gentle-shell-bridge 0.3.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/docs/contract.md +1 -1
- package/package.json +13 -1
- package/src/api.ts +1 -1
- package/.codegraph/.gitignore +0 -5
- package/PRD.md +0 -144
- package/examples/bash-card.ts +0 -1694
- package/gentle-shell-bridge-0.3.0.tgz +0 -0
- package/odd/tasks/palette-sections.md +0 -30
- package/odd/tasks/plugin-keymap.md +0 -32
- package/odd/tasks/plugin-surface-contract.md +0 -55
- package/test/api.test.ts +0 -395
- package/test/apply-on-move.test.ts +0 -477
- package/test/arm.test.ts +0 -220
- package/test/bridge-ready.test.ts +0 -309
- package/test/bus.test.ts +0 -165
- package/test/button-rows.test.ts +0 -409
- package/test/capture.test.ts +0 -356
- package/test/card-style-rows.test.ts +0 -510
- package/test/card-style-transform.test.ts +0 -379
- package/test/card-style-wiring.test.ts +0 -258
- package/test/card-styles-e2e.test.ts +0 -491
- package/test/card-styles.test.ts +0 -324
- package/test/card-transcript-path.test.ts +0 -178
- package/test/config.test.ts +0 -93
- package/test/e2e.test.ts +0 -443
- package/test/example-bash-identity.test.ts +0 -372
- package/test/example-bash-matrix.test.ts +0 -856
- package/test/example-designs.test.ts +0 -734
- package/test/example-neon-transform.test.ts +0 -283
- package/test/example-plain-style.test.ts +0 -1268
- package/test/extension.test.ts +0 -160
- package/test/fake-events.ts +0 -58
- package/test/fake-host.ts +0 -211
- package/test/fake-sidebar.ts +0 -516
- package/test/fake-transcript.ts +0 -486
- package/test/frame-telemetry.test.ts +0 -417
- package/test/hook.test.ts +0 -196
- package/test/manifest.test.ts +0 -105
- package/test/new-category.test.ts +0 -979
- package/test/notices.test.ts +0 -328
- package/test/plugin-integration-lines.test.ts +0 -979
- package/test/plugin-surface.test.ts +0 -152
- package/test/presence.test.ts +0 -459
- package/test/rail-surfaces.test.ts +0 -457
- package/test/rail-width.test.ts +0 -423
- package/test/regression.test.ts +0 -483
- package/test/repaint-api.test.ts +0 -200
- package/test/reserved-categories.test.ts +0 -361
- package/test/row-labels.test.ts +0 -284
- package/test/row-names.test.ts +0 -473
- package/test/row-origin.test.ts +0 -395
- package/test/section-interaction.test.ts +0 -364
- package/test/section-panel.test.ts +0 -403
- package/test/sections-rows.test.ts +0 -291
- package/test/sidebar-backend.test.ts +0 -471
- package/test/sidebar-e2e.test.ts +0 -331
- package/test/sidebar-hover.test.ts +0 -276
- package/test/sidebar-independent.test.ts +0 -349
- package/test/sidebar-render.test.ts +0 -281
- package/test/sidebar-sections.test.ts +0 -245
- package/test/status-card-styles.test.ts +0 -201
- package/test/status-section-frame.test.ts +0 -181
- package/test/status-sections.test.ts +0 -243
- package/test/status.test.ts +0 -209
- package/test/store.test.ts +0 -192
- package/test/surface-repaint-cost.test.ts +0 -520
- package/test/tool-design-chain.test.ts +0 -483
- package/test/tool-design-cost.test.ts +0 -473
- package/test/tool-design-rows.test.ts +0 -359
- package/test/tool-designs.test.ts +0 -745
- package/test/transform-pipeline.test.ts +0 -465
- package/test/transforms-bus.test.ts +0 -233
- package/test/verifier-width-regressions.test.ts +0 -164
- package/tsconfig.json +0 -18
package/examples/bash-card.ts
DELETED
|
@@ -1,1694 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Worked consumer of `gentle-shell-bridge`: ONE Pi extension that contributes a tool DESIGN, a card
|
|
3
|
-
* STYLE, a sidebar section, and a demo row provider, and nothing else.
|
|
4
|
-
*
|
|
5
|
-
* A. The bash design — through the bridge, as a `registerToolDesign` registration. The host draws a
|
|
6
|
-
* minimal bash card with just the `$` prompt as its glyph (`quiet-tools.ts:521-525`: *"Minimal
|
|
7
|
-
* cards title bash with the bare command; the card glyph supplies the `$` prompt"*), so the card
|
|
8
|
-
* heads with a mark the user cannot change from `/gentle:customize`. This file registers the
|
|
9
|
-
* design the user asked for: the SAME card, headed with the word `bash` instead of that mark.
|
|
10
|
-
*
|
|
11
|
-
* A design is not a card renderer and not a second grammar. The bridge registers Pi's ONE
|
|
12
|
-
* `registerToolRenderer` resolver, builds the HOST's own component, and hands `apply` the rows
|
|
13
|
-
* that card produced:
|
|
14
|
-
*
|
|
15
|
-
* ```text
|
|
16
|
-
* apply(lines, { width, tool, theme, part }) → lines
|
|
17
|
-
* ```
|
|
18
|
-
*
|
|
19
|
-
* So the frame is the host's, by construction: with `Card style = neon` the card stays neon, with
|
|
20
|
-
* `float` it stays float, and every byte this file does not deliberately change — ANSI, padding,
|
|
21
|
-
* hint, rail — comes back exactly as drawn. Pi resolves renderers when it builds a card, and the
|
|
22
|
-
* bridge reads the live selection on every render pass, so choosing the design repaints the cards
|
|
23
|
-
* already on screen, not only the next one.
|
|
24
|
-
*
|
|
25
|
-
* The design changes exactly two things, both on the heading row: the `$` mark becomes `bash`, and
|
|
26
|
-
* the frame's fill loses the three columns the longer mark added, so the row keeps the width the
|
|
27
|
-
* host made it. When the fill cannot pay — the command the host already collapsed fills the row —
|
|
28
|
-
* the command's OWN abbreviation loses the three columns instead, at a grapheme boundary; a
|
|
29
|
-
* command the host shows in full is never shortened. Every other row is the byte-for-byte host
|
|
30
|
-
* row. `test/example-bash-matrix.test.ts`
|
|
31
|
-
* drives that claim over the whole matrix the user named (S11): `float`, `neon` and a style ADDED
|
|
32
|
-
* through `registerCardStyle`, crossed with card content `minimal` and `default` — where the host's
|
|
33
|
-
* resolver calls `next()` and pi's native renderer draws rows that look nothing like a gentle-shell
|
|
34
|
-
* card. The table of what the host draws in each cell, and the source lines each fixture came from,
|
|
35
|
-
* is the comment block above the fixtures in `test/fake-transcript.ts`.
|
|
36
|
-
*
|
|
37
|
-
* Finding the heading never parses the frame's chrome: the design takes the component's first row
|
|
38
|
-
* that carries content and the host's `$` mark as its first non-decoration character, and refuses
|
|
39
|
-
* two shapes that cannot be a heading — a mark after a blank row (pi's native result component
|
|
40
|
-
* opens with a `Spacer` row) and a mark on the outline frame's side or closing rule. The limits
|
|
41
|
-
* that rule leaves are stated in the section header below and in README.md; the one shape it cannot
|
|
42
|
-
* prove is drawn exactly as the host drew it and reported, instead of being guessed at.
|
|
43
|
-
*
|
|
44
|
-
* `default` is not a design: the customize line of every tool starts with the reserved value
|
|
45
|
-
* `native`, the host's own card, so installing this file changes no rendering until the user picks
|
|
46
|
-
* the `bash heading` value.
|
|
47
|
-
*
|
|
48
|
-
* B. The card style — through the bridge. `registerCardStyle` adds one value, `plain`, to the
|
|
49
|
-
* `Card style` line `/gentle:customize` already draws: `○ neon ○ float ○ plain`. Choosing it
|
|
50
|
-
* makes the bridge repaint every tool card the host drew with this file's frame — a single
|
|
51
|
-
* gutter character per row, the heading as plain text, no box — and choosing `neon` or `float`
|
|
52
|
-
* puts the host's own rendering back. It is a style for TOOL cards in general: a `read` card is
|
|
53
|
-
* repainted exactly like a `bash` one. Nothing here parses a card: the bridge measures, extracts
|
|
54
|
-
* the heading and the hint, and hands them over. Because the design runs FIRST and this style
|
|
55
|
-
* repaints AFTER, the word `bash` the design put on a bash heading is what `plain` draws — and a
|
|
56
|
-
* card content the host draws outside the float grammar (`default`) is passed through exactly,
|
|
57
|
-
* because a style can only repaint what the host framed.
|
|
58
|
-
*
|
|
59
|
-
* C. The sidebar section — through the bridge. `registerSection` adds a `CARD BASH` card to
|
|
60
|
-
* gentle-shell's fullscreen sidebar, drawn by the host's own frame, and the same registration
|
|
61
|
-
* gives it a `Sections` toggle in `/gentle:customize` with no row, setting or store written by
|
|
62
|
-
* this file. It reports the LIVE design state: which value is selected for `bash` (the header's
|
|
63
|
-
* right-aligned meta, which is also the one surface that survives a collapse), whether this
|
|
64
|
-
* load's design reached the bus (`resolver registered` / `resolver absent`), and whether the
|
|
65
|
-
* design could recognise the frames it was handed (`unrecognised none`, or the reason — also
|
|
66
|
-
* delivered once through `ctx.ui.notify`).
|
|
67
|
-
*
|
|
68
|
-
* The card behaves like a host card (N10–N14): clicking the HEADER folds the section (the bridge
|
|
69
|
-
* owns that, and persists it) and the header's right-aligned meta is the live selection — printed
|
|
70
|
-
* once, so the value never appears twice. The design is chosen on the `Cards` line, so the report
|
|
71
|
-
* rows carry no `onClick`; the section's ONE action row is the demo below.
|
|
72
|
-
*
|
|
73
|
-
* The section also shows the DEMO below: the toggle's stored value as a plain
|
|
74
|
-
* `demo toggle on|off` row, and a CLICKABLE `demo presses` action row whose right-aligned meta
|
|
75
|
-
* is the button's press count. Clicking it increments the count once and repaints the section
|
|
76
|
-
* (and hover paints it with the host's own role), so both the plugin's state and the pointer
|
|
77
|
-
* handling are visible without a debug build.
|
|
78
|
-
*
|
|
79
|
-
* D. The demo category — through the bridge, as a `registerRows` registration. ONE provider (stable
|
|
80
|
-
* id, replaced in place on `/reload`) adds a category no host code had to know about — `Card
|
|
81
|
-
* bash` — with exactly two rows as the user reads them: a `Demo toggle` (two consecutive rows
|
|
82
|
-
* sharing one `choice.setting`, values `on`/`off`, persisted default `off`) and a plain `Demo
|
|
83
|
-
* button` row with an `action` and no `choice`. Neither does anything real; they exist only to
|
|
84
|
-
* prove the surface works, and their effect is visible in the section above. Their state lives in
|
|
85
|
-
* the example's OWN store (`bash-card`), never in the bridge's selection file.
|
|
86
|
-
*
|
|
87
|
-
* The selection lives in the BRIDGE's own store (`bridge-tool-designs`), which is what makes it
|
|
88
|
-
* survive a reload and belong to the user rather than to this example. The example only READS it,
|
|
89
|
-
* through the published `createStore`, to describe the state; it never writes another owner's file.
|
|
90
|
-
*
|
|
91
|
-
* E. The handshake — a runtime meeting, not a dependency. The file and the bridge agree on one stable
|
|
92
|
-
* `globalThis` symbol, so there is no path, no environment variable and no import (this repository
|
|
93
|
-
* depends on neither Pi nor the bridge). Two load orders really happen, so both work: the example
|
|
94
|
-
* reads the symbol in its own `session_start` handler, AND listens on Pi's cross-extension bus for
|
|
95
|
-
* `"gentle-shell-bridge:ready"` when the symbol is not there yet. Whichever happens first registers
|
|
96
|
-
* the surfaces; a guard makes the second a no-op, and the returned disposer removes every one.
|
|
97
|
-
*
|
|
98
|
-
* A bridge that publishes an incompatible API is treated as absent, and the example registers nothing.
|
|
99
|
-
* It says so once through `ctx.ui.notify` — the only channel Pi renders inside its TUI; nothing here
|
|
100
|
-
* writes to stdout/stderr. A bridge object older than `registerToolDesign` keeps the section and
|
|
101
|
-
* says `resolver absent`; one older than `registerCardStyle` says `card style absent`; the members
|
|
102
|
-
* are optional, so a surface the bridge never promised is never warned about.
|
|
103
|
-
*
|
|
104
|
-
* Loading: copy this file into `~/.pi/agent/extensions/` (or point Pi's `packages` entry at this
|
|
105
|
-
* repository) and `/reload`. The `bash` line defaults to the host's own card.
|
|
106
|
-
*
|
|
107
|
-
* Nothing here imports `@earendil-works/pi-coding-agent` or `@earendil-works/pi-tui`: every host
|
|
108
|
-
* shape this file touches is declared structurally below, the way `src/extension.ts` declares the
|
|
109
|
-
* extension API it uses.
|
|
110
|
-
*/
|
|
111
|
-
|
|
112
|
-
// ---------------------------------------------------------------------------------------------
|
|
113
|
-
// What this file contributes
|
|
114
|
-
// ---------------------------------------------------------------------------------------------
|
|
115
|
-
|
|
116
|
-
/** The tool name the design is declared for (S7: a name, never a bash-specific branch). */
|
|
117
|
-
const TOOL_NAME = "bash";
|
|
118
|
-
/** Stable across loads and unique per tool: `/reload` replaces this design, never stacks one. */
|
|
119
|
-
const DESIGN_ID = "bash-card.bash-heading";
|
|
120
|
-
/**
|
|
121
|
-
* The value the user reads on the `Cards` line. The line's NAME is derived from `bash`, so the menu
|
|
122
|
-
* reads `bash ○ native ○ bash heading` — a short value, in the host's own vocabulary.
|
|
123
|
-
*/
|
|
124
|
-
const DESIGN_LABEL = "bash heading";
|
|
125
|
-
/**
|
|
126
|
-
* This plugin's INTEGRATIONS row. Required for a file with no owning `package.json` above it: the
|
|
127
|
-
* bridge refuses to invent a name, so a copied single-file extension names itself here. The first
|
|
128
|
-
* registration fixes the name for the identity; the section below inherits it.
|
|
129
|
-
*/
|
|
130
|
-
const PLUGIN_NAME = "Card bash";
|
|
131
|
-
|
|
132
|
-
/**
|
|
133
|
-
* The bridge's own selection file, mirrored the way the API symbol is (this file cannot import the
|
|
134
|
-
* bridge, not even for a constant). The example READS it to describe the live state; the value is
|
|
135
|
-
* written by the bridge's own `Cards` row, so the user's choice has one owner.
|
|
136
|
-
*/
|
|
137
|
-
const DESIGNS_STORE_ID = "bridge-tool-designs";
|
|
138
|
-
const DESIGNS_STORE_KEY = "selections";
|
|
139
|
-
/** The reserved value meaning "the host's own card" (`TOOL_DESIGN_NATIVE` in the bridge). */
|
|
140
|
-
const NATIVE_VALUE = "native";
|
|
141
|
-
|
|
142
|
-
/** Stable across loads, so `/reload` replaces this section instead of stacking it. */
|
|
143
|
-
const SECTION_ID = "bash-card.status";
|
|
144
|
-
/** The card heading, drawn by the host's own frame like `AGENTS` / `STATS`. */
|
|
145
|
-
const SECTION_TITLE = "CARD BASH";
|
|
146
|
-
/**
|
|
147
|
-
* What the `Sections` toggle reads. Passed explicitly: the bridge derives `card bash` from the title
|
|
148
|
-
* anyway, but a reader should copy a label, never the dotted id above.
|
|
149
|
-
*/
|
|
150
|
-
const SECTION_ROW_LABEL = "card bash";
|
|
151
|
-
/** The section's one action row; the button's count rides it as the right-aligned meta. */
|
|
152
|
-
const SECTION_PRESSES_LABEL = "demo presses";
|
|
153
|
-
|
|
154
|
-
/**
|
|
155
|
-
* The DEMO category: one provider, one new `Card bash` category, exactly two rows as the user reads
|
|
156
|
-
* them — a toggle (two consecutive rows sharing one `choice.setting`, as the host draws toggles) and
|
|
157
|
-
* a plain action row. They exist only to prove a plugin can put its own toggle and button in a
|
|
158
|
-
* category of its own, so the effect is kept trivial and made VISIBLE in the section below.
|
|
159
|
-
*/
|
|
160
|
-
const DEMO_PROVIDER_ID = "bash-card.demo";
|
|
161
|
-
const DEMO_CATEGORY = "Card bash";
|
|
162
|
-
/** The example's OWN store, not the bridge's design-selection file. */
|
|
163
|
-
const EXAMPLE_STORE_ID = "bash-card";
|
|
164
|
-
const DEMO_TOGGLE_SETTING = "Demo toggle";
|
|
165
|
-
const DEMO_BUTTON_LABEL = "Demo button";
|
|
166
|
-
const DEMO_TOGGLE_KEY = "demoToggle";
|
|
167
|
-
const DEMO_PRESSES_KEY = "demoPresses";
|
|
168
|
-
|
|
169
|
-
/**
|
|
170
|
-
* The two surfaces that turn this example into a plugin's own quick action: one keymapped action the
|
|
171
|
-
* user rebinds in `/gentle:customize` → Keymaps, and one command-palette item in a section of the
|
|
172
|
-
* example's own. Both name the SAME command, so neither can point at something that is not there.
|
|
173
|
-
*/
|
|
174
|
-
const BASH_CARD_COMMAND = "bash-card:state";
|
|
175
|
-
const BASH_CARD_KEYMAP: KeymapRegistrationLike = {
|
|
176
|
-
id: "bash-card.state",
|
|
177
|
-
label: "Bash card: report state",
|
|
178
|
-
defaultKey: "alt+b",
|
|
179
|
-
command: BASH_CARD_COMMAND,
|
|
180
|
-
};
|
|
181
|
-
/**
|
|
182
|
-
* The palette section this example opens. A section the host's catalog does not already have is
|
|
183
|
-
* added after the catalog's own groups, which is what "a new category in Commands" means here: the
|
|
184
|
-
* host keeps its order and this file only names the page.
|
|
185
|
-
*/
|
|
186
|
-
const BASH_CARD_PALETTE_SECTION = "Panel";
|
|
187
|
-
const BASH_CARD_PALETTE_ITEM: CommandContributionLike = {
|
|
188
|
-
id: "bash-card.state",
|
|
189
|
-
label: "Bash card · report state",
|
|
190
|
-
command: BASH_CARD_COMMAND,
|
|
191
|
-
section: BASH_CARD_PALETTE_SECTION,
|
|
192
|
-
};
|
|
193
|
-
|
|
194
|
-
/**
|
|
195
|
-
* The OTHER shape of the same registration: an item that joins a section the host already has.
|
|
196
|
-
* `Diagnostics` is one of the catalog's own pages, so this proves both cases at once — the example's
|
|
197
|
-
* own page (Panel) and an existing one — with a second real command behind it.
|
|
198
|
-
*/
|
|
199
|
-
const BASH_CARD_KEY_COMMAND = "bash-card:key";
|
|
200
|
-
const BASH_CARD_DIAGNOSTICS_ITEM: CommandContributionLike = {
|
|
201
|
-
id: "bash-card.key",
|
|
202
|
-
label: "Bash card · key and surfaces",
|
|
203
|
-
command: BASH_CARD_KEY_COMMAND,
|
|
204
|
-
section: "Diagnostics",
|
|
205
|
-
};
|
|
206
|
-
|
|
207
|
-
// ---------------------------------------------------------------------------------------------
|
|
208
|
-
// Host shapes, declared structurally
|
|
209
|
-
// ---------------------------------------------------------------------------------------------
|
|
210
|
-
|
|
211
|
-
interface BridgeStoreLike {
|
|
212
|
-
get<T>(key: string, fallback: T): T;
|
|
213
|
-
set(key: string, value: unknown): void;
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
/** One row a provider contributes, structurally (`CustomizeRow` in `src/types.ts`). */
|
|
217
|
-
interface CustomizeRowLike {
|
|
218
|
-
category?: string;
|
|
219
|
-
label?: string;
|
|
220
|
-
choice?: { setting: string; value: string; current(): boolean };
|
|
221
|
-
preview?(): { title: string; sample: string };
|
|
222
|
-
action?(): void | Promise<void>;
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
/**
|
|
226
|
-
* A provider of customize rows (`RowProvider` in `src/types.ts`). Its `category` is the default for
|
|
227
|
-
* rows that omit one, and a category the host does not already have simply becomes a new one: the
|
|
228
|
-
* host derives its category list from the rows it is handed.
|
|
229
|
-
*/
|
|
230
|
-
interface RowProviderLike {
|
|
231
|
-
id: string;
|
|
232
|
-
category: string;
|
|
233
|
-
name?: string;
|
|
234
|
-
rows(): CustomizeRowLike[];
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
/**
|
|
238
|
-
* What a card style's `top` receives (`src/types.ts`, `CardFrameTopContext`): the width the bridge
|
|
239
|
-
* measured and the heading it extracted. A style never parses a card; it only draws.
|
|
240
|
-
*/
|
|
241
|
-
interface CardFrameTopLike {
|
|
242
|
-
width: number;
|
|
243
|
-
title?: string;
|
|
244
|
-
hint?: string;
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
/** What a style's `line` receives: one body row's text, already stripped of the host's frame. */
|
|
248
|
-
interface CardFrameLineLike {
|
|
249
|
-
text: string;
|
|
250
|
-
width: number;
|
|
251
|
-
first: boolean;
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
/** What a style's `bottom` receives. */
|
|
255
|
-
interface CardFrameBottomLike {
|
|
256
|
-
width: number;
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
/** The three functions a style draws with (`src/types.ts`, `CardFrame`). */
|
|
260
|
-
interface CardFrameLike {
|
|
261
|
-
top(ctx: CardFrameTopLike): string;
|
|
262
|
-
line(ctx: CardFrameLineLike): string;
|
|
263
|
-
bottom(ctx: CardFrameBottomLike): string;
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
/**
|
|
267
|
-
* One card style as the bridge accepts it (`src/types.ts`, `CardStyleRegistration`). The id is the
|
|
268
|
-
* stored selection key and stays internal; `label` is the short value the user reads on the line.
|
|
269
|
-
*/
|
|
270
|
-
interface CardStyleLike {
|
|
271
|
-
id: string;
|
|
272
|
-
name?: string;
|
|
273
|
-
label: string;
|
|
274
|
-
rowLabel?: string;
|
|
275
|
-
frame: CardFrameLike;
|
|
276
|
-
preview?(): { title: string; sample: string };
|
|
277
|
-
order?: number;
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
/**
|
|
281
|
-
* One tool design as the bridge accepts it (`src/types.ts`, `ToolDesignRegistration`). The host's
|
|
282
|
-
* rows go in, the rows to draw come out; `apply` never sees Pi and never owns the frame.
|
|
283
|
-
*/
|
|
284
|
-
interface ToolDesignLike {
|
|
285
|
-
tool: string;
|
|
286
|
-
id: string;
|
|
287
|
-
/** The plugin's INTEGRATIONS row, honoured on the first registration only. */
|
|
288
|
-
name?: string;
|
|
289
|
-
label: string;
|
|
290
|
-
description?: string;
|
|
291
|
-
preview?(): { title: string; sample: string };
|
|
292
|
-
apply(lines: readonly string[], ctx: ToolDesignContextLike): string[];
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
/** What a design is handed on every render pass (`src/types.ts`, `ToolDesignContext`). */
|
|
296
|
-
interface ToolDesignContextLike {
|
|
297
|
-
/** The width the host asked its own component to render at; the row it returned is that wide. */
|
|
298
|
-
width: number;
|
|
299
|
-
/** The tool name this design was declared for, never anything the card draws. */
|
|
300
|
-
tool: string;
|
|
301
|
-
/** The host's opaque theme object. This file never reads a colour from it. */
|
|
302
|
-
theme?: unknown;
|
|
303
|
-
/**
|
|
304
|
-
* Which half of the card this pass is drawing (S12): `renderCall` or `renderResult`. OPTIONAL here
|
|
305
|
-
* because a bridge published before the field existed hands a context without it; this file then
|
|
306
|
-
* treats the part as UNKNOWN and keeps its previous behaviour (the row grammar decides), so an
|
|
307
|
-
* older bridge decorates exactly what it decorated before.
|
|
308
|
-
*/
|
|
309
|
-
part?: "call" | "result";
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
/**
|
|
313
|
-
* One structured section row (`SectionRow` in `src/types.ts`): an icon, a semantic tone, a
|
|
314
|
-
* right-aligned live `meta` and an optional `onClick`. `tone` names one of the host's own roles
|
|
315
|
-
* (`text`, `muted`, `dim`, `accent`, `success`, `error`, `warning`), never a colour.
|
|
316
|
-
*/
|
|
317
|
-
interface SectionRowLike {
|
|
318
|
-
label: string;
|
|
319
|
-
meta?: string;
|
|
320
|
-
glyph?: string;
|
|
321
|
-
tone?: "text" | "muted" | "dim" | "accent" | "success" | "error" | "warning";
|
|
322
|
-
onClick?: () => void;
|
|
323
|
-
}
|
|
324
|
-
|
|
325
|
-
/**
|
|
326
|
-
* The section contract (`docs/contract.md` §11), structural like every host shape here. A section
|
|
327
|
-
* writes content only: no `choice`, no `action`, no `preview` — the bridge contributes the toggle,
|
|
328
|
-
* the header, the collapse control and the pointer handling.
|
|
329
|
-
*/
|
|
330
|
-
interface SidebarSectionLike {
|
|
331
|
-
id: string;
|
|
332
|
-
title: string;
|
|
333
|
-
rowLabel?: string;
|
|
334
|
-
meta?(ctx: { theme: unknown; configHome: string }): string | undefined;
|
|
335
|
-
/** A plain string is drawn byte for byte; a structured row is drawn like a native one. */
|
|
336
|
-
lines(ctx: { theme: unknown; configHome: string }): Array<string | SectionRowLike>;
|
|
337
|
-
defaultVisible?: boolean;
|
|
338
|
-
order?: number;
|
|
339
|
-
}
|
|
340
|
-
|
|
341
|
-
/**
|
|
342
|
-
* The API object `gentle-shell-bridge` publishes (`src/api.ts`, `BridgeApi`): the members this
|
|
343
|
-
* example calls, structurally. Nothing here imports the package, so the shape has to be declared.
|
|
344
|
-
*
|
|
345
|
-
* `registerToolDesign` is OPTIONAL by contract: the published object is first-wins, so a process
|
|
346
|
-
* running an older copy can hand back an object without it. A consumer gates with `typeof
|
|
347
|
-
* api.registerToolDesign === "function"` and keeps the section working. The same applies to
|
|
348
|
-
* `registerRows`, `registerCardStyle` and `requestRepaint`: an older bridge keeps the design, the
|
|
349
|
-
* plain style and the section, and the example degrades to no demo category rather than throwing.
|
|
350
|
-
*/
|
|
351
|
-
interface BridgeApiLike {
|
|
352
|
-
readonly schema: string;
|
|
353
|
-
readonly version: number;
|
|
354
|
-
registerSection(section: SidebarSectionLike): () => void;
|
|
355
|
-
registerToolDesign?(design: ToolDesignLike): () => void;
|
|
356
|
-
registerCardStyle?(style: CardStyleLike): () => void;
|
|
357
|
-
registerRows?(provider: RowProviderLike): () => void;
|
|
358
|
-
registerKeymap?(registration: KeymapRegistrationLike): () => void;
|
|
359
|
-
registerCommands?(contribution: CommandContributionLike | readonly CommandContributionLike[]): () => void;
|
|
360
|
-
resolveKeymap?(id: string): { key?: string; unavailable?: string };
|
|
361
|
-
requestRepaint?(): void;
|
|
362
|
-
createStore(pluginId: string): BridgeStoreLike;
|
|
363
|
-
}
|
|
364
|
-
|
|
365
|
-
/** One action for the host's Keymaps page: the row is the bridge's, the binding is ours. */
|
|
366
|
-
interface KeymapRegistrationLike {
|
|
367
|
-
id: string;
|
|
368
|
-
label: string;
|
|
369
|
-
defaultKey?: string;
|
|
370
|
-
command?: string;
|
|
371
|
-
}
|
|
372
|
-
|
|
373
|
-
/** One item for the host's command palette; `section` names an existing section or opens one. */
|
|
374
|
-
interface CommandContributionLike {
|
|
375
|
-
id: string;
|
|
376
|
-
label: string;
|
|
377
|
-
command: string;
|
|
378
|
-
section?: string;
|
|
379
|
-
}
|
|
380
|
-
|
|
381
|
-
/** The part of Pi's `session_start` context this file reads. */
|
|
382
|
-
interface SessionContextLike {
|
|
383
|
-
hasUI?: boolean;
|
|
384
|
-
ui?: unknown;
|
|
385
|
-
}
|
|
386
|
-
|
|
387
|
-
/** The one channel Pi gives an extension for text that is not a row: the in-TUI notification. */
|
|
388
|
-
interface NoticeUiLike {
|
|
389
|
-
notify?: (message: string, level?: "info" | "warning" | "error") => void;
|
|
390
|
-
}
|
|
391
|
-
|
|
392
|
-
/** The part of `ExtensionAPI` this extension uses. */
|
|
393
|
-
interface ExtensionApiLike {
|
|
394
|
-
/** `session_start` carries the UI context, so this is where a buffered notice is delivered. */
|
|
395
|
-
on?(event: "session_start", handler: (event: unknown, ctx: SessionContextLike) => void): void;
|
|
396
|
-
/** A real command is what makes the palette item and the keymap row honest. */
|
|
397
|
-
registerCommand?(name: string, spec: { description?: string; handler: (args: unknown, ctx: SessionContextLike) => void | Promise<void> }): void;
|
|
398
|
-
/** Pi reads extension shortcuts at startup and on `/reload`; the plugin owns its own binding. */
|
|
399
|
-
registerShortcut?(key: string, spec: { description?: string; handler: (ctx: SessionContextLike) => void | Promise<void> }): void;
|
|
400
|
-
/** Pi's cross-extension bus (`docs/extensions.md:1965-1972`); absent on a bare harness. */
|
|
401
|
-
events?: unknown;
|
|
402
|
-
}
|
|
403
|
-
|
|
404
|
-
/** The one member of `pi.events` this file needs: subscribe, and keep the unsubscribe if handed. */
|
|
405
|
-
type EventUnsubscribe = () => void;
|
|
406
|
-
|
|
407
|
-
interface ExtensionEventsLike {
|
|
408
|
-
on(channel: string, handler: (payload: unknown) => void): EventUnsubscribe | void;
|
|
409
|
-
}
|
|
410
|
-
|
|
411
|
-
// ---------------------------------------------------------------------------------------------
|
|
412
|
-
// The design: `bash` where the host prints its bare `$` mark
|
|
413
|
-
// ---------------------------------------------------------------------------------------------
|
|
414
|
-
//
|
|
415
|
-
// WHAT THE HOST REALLY DRAWS (S11/T17; the fixtures in `test/fake-transcript.ts` carry the source
|
|
416
|
-
// lines, and `test/example-bash-matrix.test.ts` drives every cell):
|
|
417
|
-
//
|
|
418
|
-
// | card style | card content | the heading row the host draws |
|
|
419
|
-
// | ---------- | ------------ | ------------------------------------------------------- |
|
|
420
|
-
// | `float` | `minimal` | ` ▎ $ npm test (timeout 30s) …… ctrl+o to expand ` |
|
|
421
|
-
// | `neon` | `minimal` | `╭─ $ npm test (timeout 30s) ────── ctrl+o to expand ╮` |
|
|
422
|
-
// | any | `default` | `$ npm test (timeout 30s) …padding…` (pi's native row) |
|
|
423
|
-
//
|
|
424
|
-
// So the mark is `$` in every cell, and only the amount of frame around it changes. The design
|
|
425
|
-
// therefore keys on the MARK and on its POSITION, never on a frame's shape, colour or fill:
|
|
426
|
-
//
|
|
427
|
-
// 1. the heading is the component's first row that carries a letter, a digit or the mark;
|
|
428
|
-
// 2. the mark must be the first non-decoration character on that row, followed by a space — a `$`
|
|
429
|
-
// inside a command or an output line (`PATH=$HOME`, `$(date)`) is never a prompt;
|
|
430
|
-
// 3. the rows before it, when there are any, must be frame decoration and must NOT include a blank
|
|
431
|
-
// row: pi's native RESULT component starts with a blank `Spacer` row (`Spacer(1)` in
|
|
432
|
-
// `dist/core/tools/renderers/bash.js`), and a bash result is not a heading — that one row is what
|
|
433
|
-
// tells the two components apart in the `default` cell;
|
|
434
|
-
// 4. the row must not begin with the outline frame's SIDE or CLOSING rule (`│`, `╰`): a heading is
|
|
435
|
-
// always on the card's top, so a `$` on a body row of the `neon` card (`cat script.sh` printing a
|
|
436
|
-
// prompt) is output, not a heading.
|
|
437
|
-
//
|
|
438
|
-
// In the `float` cell the top row and a body row share both chrome and position, so this row grammar
|
|
439
|
-
// alone could not tell a heading from output — which is why the bridge says WHICH part of the card it
|
|
440
|
-
// is drawing (`ctx.part`, S12). The design acts on the CALL only: everything the result component
|
|
441
|
-
// draws is returned byte-identical, so a script that echoes `$ …` keeps its output intact.
|
|
442
|
-
//
|
|
443
|
-
// The word replaces the mark and the row's padding or rule fill gives back the columns it adds, so
|
|
444
|
-
// the row keeps the width the host made it. When there is no fill to take them from — a heading whose
|
|
445
|
-
// command already fills the row — the design takes them from the HOST'S OWN ABBREVIATION instead:
|
|
446
|
-
// the host collapses a long command with `…`, and only that already-shortened text loses three more
|
|
447
|
-
// columns, at a grapheme boundary, keeping the ellipsis. A command the host shows in FULL is never
|
|
448
|
-
// shortened (a design must not hide what a command does): such a row is returned byte for byte and
|
|
449
|
-
// reported once, through the same two channels the rest of this file uses (`ctx.ui.notify` and the
|
|
450
|
-
// CARD BASH section), instead of silently drawing nothing the way the first iterations did.
|
|
451
|
-
//
|
|
452
|
-
// The three limits this rule leaves, all of them stated in README.md and pinned by
|
|
453
|
-
// `test/example-bash-matrix.test.ts`: the fill is the row's WIDEST run, so a command carrying a wider
|
|
454
|
-
// run of spaces than the frame's fill would have those shortened instead; a heading is only looked
|
|
455
|
-
// for in the component's first content row, so a chrome that adds a second row above the heading is
|
|
456
|
-
// reported as unrecognised rather than guessed at; and an abbreviation whose graphemes cannot pay
|
|
457
|
-
// exactly three columns (a run of two-column glyphs, for example) is refused rather than widened or
|
|
458
|
-
// half-cut. Nothing here parses the frame, and the result component is never touched at all
|
|
459
|
-
// (`ctx.part === "result"` returns the host's bytes).
|
|
460
|
-
|
|
461
|
-
/** The heading mark the host prints, and the word the design puts in its place. */
|
|
462
|
-
const MARK = "$";
|
|
463
|
-
const WORD = "bash";
|
|
464
|
-
/** Columns the replacement adds, which the row's own fill has to give back. */
|
|
465
|
-
const WORD_COLUMNS = WORD.length - MARK.length;
|
|
466
|
-
|
|
467
|
-
/** The outline frame's side and closing rules: a `$` on such a row is output, never a heading. */
|
|
468
|
-
const FRAME_BODY_STARTS: ReadonlySet<string> = new Set(["│", "╰"]);
|
|
469
|
-
|
|
470
|
-
/** One visible character of a row and where it starts in the raw string (ANSI skipped). */
|
|
471
|
-
interface VisibleChar {
|
|
472
|
-
char: string;
|
|
473
|
-
raw: number;
|
|
474
|
-
}
|
|
475
|
-
|
|
476
|
-
/**
|
|
477
|
-
* One escape sequence the design must not see as text: an SGR colour run or an OSC-8 hyperlink.
|
|
478
|
-
* Sticky, so it is anchored exactly where the reader is looking.
|
|
479
|
-
*/
|
|
480
|
-
const ESCAPE = /\u001b\[[0-9;]*m|\u001b\]8;;[^\u0007\u001b]*(?:\u0007|\u001b\\)/uy;
|
|
481
|
-
|
|
482
|
-
/**
|
|
483
|
-
* The row's visible characters with their raw offsets. SGR sequences and OSC-8 hyperlinks are
|
|
484
|
-
* skipped, so `raw` always points at the character a reader sees — which is what lets the design
|
|
485
|
-
* splice the mark without touching a single escape sequence.
|
|
486
|
-
*/
|
|
487
|
-
function visibleChars(line: string): VisibleChar[] {
|
|
488
|
-
const chars: VisibleChar[] = [];
|
|
489
|
-
let index = 0;
|
|
490
|
-
while (index < line.length) {
|
|
491
|
-
if (line[index] === "\u001b") {
|
|
492
|
-
ESCAPE.lastIndex = index;
|
|
493
|
-
const escape = ESCAPE.exec(line);
|
|
494
|
-
if (escape !== null) {
|
|
495
|
-
index += escape[0].length;
|
|
496
|
-
continue;
|
|
497
|
-
}
|
|
498
|
-
}
|
|
499
|
-
const codePoint = line.codePointAt(index);
|
|
500
|
-
const char = codePoint === undefined ? line[index] : String.fromCodePoint(codePoint);
|
|
501
|
-
chars.push({ char, raw: index });
|
|
502
|
-
index += char.length;
|
|
503
|
-
}
|
|
504
|
-
return chars;
|
|
505
|
-
}
|
|
506
|
-
|
|
507
|
-
/** `true` for a character that makes a row content rather than frame: a letter or a digit. */
|
|
508
|
-
function isWordCharacter(char: string): boolean {
|
|
509
|
-
return /[\p{L}\p{N}]/u.test(char);
|
|
510
|
-
}
|
|
511
|
-
|
|
512
|
-
/** The columns a reader sees: the row's visible characters, ANSI ignored. */
|
|
513
|
-
function visibleWidth(line: string): number {
|
|
514
|
-
let width = 0;
|
|
515
|
-
for (const cluster of graphemes(visibleChars(line))) width += cluster.width;
|
|
516
|
-
return width;
|
|
517
|
-
}
|
|
518
|
-
|
|
519
|
-
/** The segmenter's shape, declared locally so the example needs no lib newer than its host. */
|
|
520
|
-
interface GraphemeSegmenterLike {
|
|
521
|
-
segment(input: string): Iterable<{ segment: string; index: number }>;
|
|
522
|
-
}
|
|
523
|
-
|
|
524
|
-
/** The shared grapheme segmenter: one cluster is one thing the terminal draws, never half of it. */
|
|
525
|
-
// SAFETY: every runtime the host supports (Node >= 22.18, `package.json` engines) ships
|
|
526
|
-
// `Intl.Segmenter`; the standard lib this example is checked against older than ES2022 does not
|
|
527
|
-
// declare it, so the constructor is read through a locally declared shape instead of a newer lib.
|
|
528
|
-
const GRAPHEMES = new (
|
|
529
|
-
Intl as unknown as {
|
|
530
|
-
Segmenter: new (
|
|
531
|
-
locales?: string,
|
|
532
|
-
options?: { granularity: "grapheme" },
|
|
533
|
-
) => GraphemeSegmenterLike;
|
|
534
|
-
}
|
|
535
|
-
).Segmenter(undefined, { granularity: "grapheme" });
|
|
536
|
-
|
|
537
|
-
/** Marks, format characters and controls occupy no cell of their own. */
|
|
538
|
-
const ZERO_WIDTH_CLUSTER = new RegExp(
|
|
539
|
-
"^(?:\\p{Default_Ignorable_Code_Point}|\\p{Control}|\\p{Mark}|\\p{Surrogate})+$",
|
|
540
|
-
"v",
|
|
541
|
-
);
|
|
542
|
-
/** Leading non-printing code points of a cluster, stripped before its base character is measured. */
|
|
543
|
-
const LEADING_NON_PRINTING = new RegExp(
|
|
544
|
-
"^[\\p{Default_Ignorable_Code_Point}\\p{Control}\\p{Format}\\p{Mark}\\p{Surrogate}]+",
|
|
545
|
-
"v",
|
|
546
|
-
);
|
|
547
|
-
|
|
548
|
-
/**
|
|
549
|
-
* The columns one grapheme cluster occupies, mirroring pi-tui's `visibleWidth` model closely enough
|
|
550
|
-
* for the abbreviations the host produces: ASCII is one column, a tab is three, a cluster of marks
|
|
551
|
-
* is zero, and an East Asian wide or emoji base is two. The design never needs the full Unicode
|
|
552
|
-
* table, because it only measures text the host itself already clipped.
|
|
553
|
-
*/
|
|
554
|
-
function graphemeColumns(segment: string): number {
|
|
555
|
-
if (segment.length === 1) {
|
|
556
|
-
const code = segment.charCodeAt(0);
|
|
557
|
-
if (code === 0x09) return 3;
|
|
558
|
-
if (code >= 0x20 && code <= 0x7e) return 1;
|
|
559
|
-
}
|
|
560
|
-
if (ZERO_WIDTH_CLUSTER.test(segment)) return 0;
|
|
561
|
-
const base = segment.replace(LEADING_NON_PRINTING, "");
|
|
562
|
-
const code = base.codePointAt(0);
|
|
563
|
-
if (code === undefined) return 0;
|
|
564
|
-
if (code >= 0x1f1e6 && code <= 0x1f1ff) return 2;
|
|
565
|
-
return isWideCodePoint(code) ? 2 : 1;
|
|
566
|
-
}
|
|
567
|
-
|
|
568
|
-
/** The East Asian wide/fullwidth and emoji blocks a terminal charges two columns for. */
|
|
569
|
-
function isWideCodePoint(code: number): boolean {
|
|
570
|
-
return (
|
|
571
|
-
(code >= 0x1100 && code <= 0x115f) ||
|
|
572
|
-
(code >= 0x2329 && code <= 0x232a) ||
|
|
573
|
-
(code >= 0x2e80 && code <= 0xa4cf && code !== 0x303f) ||
|
|
574
|
-
(code >= 0xac00 && code <= 0xd7a3) ||
|
|
575
|
-
(code >= 0xf900 && code <= 0xfaff) ||
|
|
576
|
-
(code >= 0xfe10 && code <= 0xfe19) ||
|
|
577
|
-
(code >= 0xfe30 && code <= 0xfe6f) ||
|
|
578
|
-
(code >= 0xff00 && code <= 0xff60) ||
|
|
579
|
-
(code >= 0xffe0 && code <= 0xffe6) ||
|
|
580
|
-
(code >= 0x1f000 && code <= 0x1fbff) ||
|
|
581
|
-
(code >= 0x20000 && code <= 0x3fffd)
|
|
582
|
-
);
|
|
583
|
-
}
|
|
584
|
-
|
|
585
|
-
/** One grapheme cluster of a row, with the visible characters it is made of and its column cost. */
|
|
586
|
-
interface Grapheme {
|
|
587
|
-
chars: VisibleChar[];
|
|
588
|
-
width: number;
|
|
589
|
-
}
|
|
590
|
-
|
|
591
|
-
/**
|
|
592
|
-
* The row's grapheme clusters, in order. `visibleChars` already dropped every escape sequence, so a
|
|
593
|
-
* cluster is never cut through an SGR run; the raw offsets on each character are what a caller
|
|
594
|
-
* deletes from.
|
|
595
|
-
*/
|
|
596
|
-
function graphemes(chars: readonly VisibleChar[]): Grapheme[] {
|
|
597
|
-
const text = chars.map((entry) => entry.char).join("");
|
|
598
|
-
const byOffset = new Map<number, number>();
|
|
599
|
-
let offset = 0;
|
|
600
|
-
for (const [index, entry] of chars.entries()) {
|
|
601
|
-
byOffset.set(offset, index);
|
|
602
|
-
offset += entry.char.length;
|
|
603
|
-
}
|
|
604
|
-
byOffset.set(offset, chars.length);
|
|
605
|
-
|
|
606
|
-
const clusters: Grapheme[] = [];
|
|
607
|
-
for (const part of GRAPHEMES.segment(text)) {
|
|
608
|
-
const start = byOffset.get(part.index);
|
|
609
|
-
const end = byOffset.get(part.index + part.segment.length);
|
|
610
|
-
if (start === undefined || end === undefined || end <= start) continue;
|
|
611
|
-
clusters.push({ chars: chars.slice(start, end), width: graphemeColumns(part.segment) });
|
|
612
|
-
}
|
|
613
|
-
return clusters;
|
|
614
|
-
}
|
|
615
|
-
|
|
616
|
-
/**
|
|
617
|
-
* Why a frame the design was handed is not a bash heading. Each reason carries a short label (the
|
|
618
|
-
* CARD BASH row the user reads) and a full sentence (the one-time notice).
|
|
619
|
-
*/
|
|
620
|
-
const UNRECOGNISED = {
|
|
621
|
-
/** The mark sits on a row of the outlined frame's body, not on the card's top. */
|
|
622
|
-
bodyRow: {
|
|
623
|
-
short: "outline body row",
|
|
624
|
-
detail: "the mark is on an outline body row, never on the card's top",
|
|
625
|
-
},
|
|
626
|
-
/** A blank row comes first: pi's native result component opens with one. */
|
|
627
|
-
blankFirst: {
|
|
628
|
-
short: "blank row first",
|
|
629
|
-
detail: "a blank row comes before the mark, as pi's native result component draws",
|
|
630
|
-
},
|
|
631
|
-
/** The word's columns cannot be taken back without growing the row. */
|
|
632
|
-
noRoom: {
|
|
633
|
-
short: "no fill",
|
|
634
|
-
detail:
|
|
635
|
-
`the row has no padding or rule fill to take the word's ${WORD_COLUMNS} columns from, and the ` +
|
|
636
|
-
"command is shown in full (the design never shortens a command the host did not abbreviate)",
|
|
637
|
-
},
|
|
638
|
-
} as const;
|
|
639
|
-
|
|
640
|
-
/** One reason a frame was refused. */
|
|
641
|
-
type Unrecognised = (typeof UNRECOGNISED)[keyof typeof UNRECOGNISED];
|
|
642
|
-
|
|
643
|
-
/** The card's heading mark: which row it is on, where it starts, and how many columns it costs. */
|
|
644
|
-
interface HeadingSite {
|
|
645
|
-
row: number;
|
|
646
|
-
/** Raw offset of the mark in the row, so the word replaces the mark and nothing else. */
|
|
647
|
-
raw: number;
|
|
648
|
-
/** Visible columns the replacement adds, taken back from the row's fill. */
|
|
649
|
-
extra: number;
|
|
650
|
-
}
|
|
651
|
-
|
|
652
|
-
/** What the design could make of one component's rows. Every outcome is a reported answer. */
|
|
653
|
-
type Recognition =
|
|
654
|
-
| { kind: "heading"; site: HeadingSite }
|
|
655
|
-
| { kind: "none" }
|
|
656
|
-
| { kind: "unrecognised"; reason: Unrecognised };
|
|
657
|
-
|
|
658
|
-
/**
|
|
659
|
-
* Where the heading is, or why it cannot be proved. The rule is in the header of this section:
|
|
660
|
-
* content position first, then the mark, then the two shapes that prove the row is not a heading.
|
|
661
|
-
*/
|
|
662
|
-
function headingSite(lines: readonly string[]): Recognition {
|
|
663
|
-
let row = -1;
|
|
664
|
-
let chars: VisibleChar[] = [];
|
|
665
|
-
for (const [index, line] of lines.entries()) {
|
|
666
|
-
const candidates = visibleChars(line);
|
|
667
|
-
if (!candidates.some((entry) => isWordCharacter(entry.char) || entry.char === MARK)) continue;
|
|
668
|
-
row = index;
|
|
669
|
-
chars = candidates;
|
|
670
|
-
break;
|
|
671
|
-
}
|
|
672
|
-
// No content at all (a padding row, a closing rule): there is nothing to recognise, and nothing
|
|
673
|
-
// to report — this is what a card's last component looks like when the command printed nothing.
|
|
674
|
-
if (row < 0) return { kind: "none" };
|
|
675
|
-
|
|
676
|
-
const markIndex = chars.findIndex((entry) => entry.char === MARK);
|
|
677
|
-
// Content without the host's mark: another tool's card, or a bash result. Silently the host's.
|
|
678
|
-
if (markIndex < 0) return { kind: "none" };
|
|
679
|
-
const mark = chars[markIndex];
|
|
680
|
-
if (mark === undefined) return { kind: "none" };
|
|
681
|
-
|
|
682
|
-
// A `$` on the outline's side or closing rule is output that happens to start with a prompt.
|
|
683
|
-
const first = chars.find((entry) => entry.char.trim() !== "");
|
|
684
|
-
if (first !== undefined && FRAME_BODY_STARTS.has(first.char)) {
|
|
685
|
-
return { kind: "unrecognised", reason: UNRECOGNISED.bodyRow };
|
|
686
|
-
}
|
|
687
|
-
|
|
688
|
-
// A blank row before the mark means this component is not the card's top: pi's native result
|
|
689
|
-
// component opens with a `Spacer` row, so its first output row sits exactly where a heading sits.
|
|
690
|
-
for (const previous of lines.slice(0, row)) {
|
|
691
|
-
if (visibleChars(previous).every((entry) => entry.char.trim() === "")) {
|
|
692
|
-
return { kind: "unrecognised", reason: UNRECOGNISED.blankFirst };
|
|
693
|
-
}
|
|
694
|
-
}
|
|
695
|
-
|
|
696
|
-
// The mark must be the row's first non-decoration character (frame padding, a bar, a rule), and a
|
|
697
|
-
// prompt rather than a shell expansion: `$HOME`, `$(date)` and `a$b` are not the mark.
|
|
698
|
-
const before = chars
|
|
699
|
-
.slice(0, markIndex)
|
|
700
|
-
.map((entry) => entry.char)
|
|
701
|
-
.join("");
|
|
702
|
-
if (!/^[^\p{L}\p{N}]*$/u.test(before)) return { kind: "none" };
|
|
703
|
-
const after = chars[markIndex + 1];
|
|
704
|
-
if (after !== undefined && after.char !== " ") return { kind: "none" };
|
|
705
|
-
|
|
706
|
-
return { kind: "heading", site: { row, raw: mark.raw, extra: WORD_COLUMNS } };
|
|
707
|
-
}
|
|
708
|
-
|
|
709
|
-
/**
|
|
710
|
-
* Give back the columns the longer mark added, out of the row's own fill: `float`'s space fill,
|
|
711
|
-
* `neon`'s rule fill, pi's trailing padding. The fill is the row's WIDEST run of spaces or rules,
|
|
712
|
-
* which is what the host paints it with in every cell (`cardTop`'s ` ${fill}` and `cardLine`'s
|
|
713
|
-
* padding), and a run no wider than the columns needed is not a fill at all: `undefined` means the
|
|
714
|
-
* row must not be touched. Between equally wide runs the one with no letter or digit beside it wins,
|
|
715
|
-
* so the command text is the last thing this design would shorten; that preference is the documented
|
|
716
|
-
* second limit — a command carrying a wider run of spaces than the frame's fill would have those
|
|
717
|
-
* shortened instead.
|
|
718
|
-
*/
|
|
719
|
-
function shortenFill(line: string, columns: number): string | undefined {
|
|
720
|
-
const chars = visibleChars(line);
|
|
721
|
-
interface Run {
|
|
722
|
-
start: number;
|
|
723
|
-
length: number;
|
|
724
|
-
/** No letter or digit on either side: unmistakably padding, never part of the words. */
|
|
725
|
-
bounded: boolean;
|
|
726
|
-
}
|
|
727
|
-
const runs: Run[] = [];
|
|
728
|
-
for (let index = 0; index < chars.length; index += 1) {
|
|
729
|
-
const char = chars[index]?.char;
|
|
730
|
-
if (char !== " " && char !== "─") continue;
|
|
731
|
-
let end = index;
|
|
732
|
-
// A fill is one CONTIGUOUS raw run: the host closes the styled run it belongs to before the
|
|
733
|
-
// padding, the hint and the rule that follow, so a space on the other side of an escape sequence
|
|
734
|
-
// belongs to a different part of the row and is never merged into it.
|
|
735
|
-
while (
|
|
736
|
-
end + 1 < chars.length &&
|
|
737
|
-
chars[end + 1]?.char === char &&
|
|
738
|
-
chars[end + 1]?.raw === (chars[end]?.raw ?? 0) + 1
|
|
739
|
-
) {
|
|
740
|
-
end += 1;
|
|
741
|
-
}
|
|
742
|
-
const before = chars[index - 1]?.char;
|
|
743
|
-
const after = chars[end + 1]?.char;
|
|
744
|
-
runs.push({
|
|
745
|
-
start: index,
|
|
746
|
-
length: end - index + 1,
|
|
747
|
-
bounded:
|
|
748
|
-
(before === undefined || !isWordCharacter(before)) &&
|
|
749
|
-
(after === undefined || !isWordCharacter(after)),
|
|
750
|
-
});
|
|
751
|
-
index = end;
|
|
752
|
-
}
|
|
753
|
-
if (runs.length === 0) return undefined;
|
|
754
|
-
const widest = Math.max(...runs.map((run) => run.length));
|
|
755
|
-
// As wide as the columns needed (or narrower) would empty the fill: the row keeps its bytes.
|
|
756
|
-
if (widest <= columns) return undefined;
|
|
757
|
-
const candidates = runs.filter((run) => run.length === widest);
|
|
758
|
-
const chosen = candidates.find((run) => run.bounded) ?? candidates[0];
|
|
759
|
-
if (chosen === undefined) return undefined;
|
|
760
|
-
|
|
761
|
-
let shortened = line;
|
|
762
|
-
for (let index = chosen.start + chosen.length - 1; index >= chosen.start + chosen.length - columns; index -= 1) {
|
|
763
|
-
const at = chars[index]?.raw;
|
|
764
|
-
if (at === undefined) return undefined;
|
|
765
|
-
shortened = `${shortened.slice(0, at)}${shortened.slice(at + 1)}`;
|
|
766
|
-
}
|
|
767
|
-
return shortened;
|
|
768
|
-
}
|
|
769
|
-
|
|
770
|
-
/**
|
|
771
|
-
* Take the word's extra columns out of the command text ITSELF, but only when the host has already
|
|
772
|
-
* abbreviated it: the command ends at the host's `…`, immediately before the optional muted
|
|
773
|
-
* ` (timeout Ns)` suffix (or, with no suffix, before at least the frame's fill). A command the host
|
|
774
|
-
* shows in full is never shortened — a design must not hide what a command does.
|
|
775
|
-
*
|
|
776
|
-
* The three columns are removed from the END of the visible command, one grapheme cluster at a
|
|
777
|
-
* time, never inside an escape sequence and never through a cluster boundary. When whole clusters
|
|
778
|
-
* cannot pay exactly three columns (a wide glyph costs two, for example) the row is refused rather
|
|
779
|
-
* than widened or half-cut; `undefined` means "do not touch this row".
|
|
780
|
-
*/
|
|
781
|
-
function shortenAbbreviation(line: string, site: HeadingSite): string | undefined {
|
|
782
|
-
const chars = visibleChars(line);
|
|
783
|
-
const markIndex = chars.findIndex((entry) => entry.char === MARK);
|
|
784
|
-
if (markIndex < 0) return undefined;
|
|
785
|
-
|
|
786
|
-
let commandStart = markIndex + 1;
|
|
787
|
-
while (chars[commandStart]?.char === " ") commandStart += 1;
|
|
788
|
-
|
|
789
|
-
let ellipsis = -1;
|
|
790
|
-
for (let index = commandStart; index < chars.length; index += 1) {
|
|
791
|
-
if (chars[index]?.char === "…") {
|
|
792
|
-
ellipsis = index;
|
|
793
|
-
break;
|
|
794
|
-
}
|
|
795
|
-
}
|
|
796
|
-
if (ellipsis <= commandStart) return undefined;
|
|
797
|
-
|
|
798
|
-
// Prove the ellipsis is the HOST's abbreviation and not text the command carries: the host puts
|
|
799
|
-
// either its timeout suffix immediately after it, or its fill (at least two columns) before the
|
|
800
|
-
// hint. Anything else is a `…` shown in full, which must not be touched.
|
|
801
|
-
const tail = chars
|
|
802
|
-
.slice(ellipsis + 1)
|
|
803
|
-
.map((entry) => entry.char)
|
|
804
|
-
.join("");
|
|
805
|
-
const timeout = /^ \(timeout \d+s\)/u.exec(tail);
|
|
806
|
-
if (timeout === null) {
|
|
807
|
-
if (!/^ {2,}(?:\S|$)/u.test(tail)) return undefined;
|
|
808
|
-
} else if (!/^ *(?:\S|$)/u.test(tail.slice(timeout[0].length))) {
|
|
809
|
-
return undefined;
|
|
810
|
-
}
|
|
811
|
-
|
|
812
|
-
const clusters = graphemes(chars.slice(commandStart, ellipsis));
|
|
813
|
-
const removed: VisibleChar[] = [];
|
|
814
|
-
let needed = site.extra;
|
|
815
|
-
let index = clusters.length - 1;
|
|
816
|
-
while (needed > 0 && index >= 0) {
|
|
817
|
-
const cluster = clusters[index];
|
|
818
|
-
if (cluster === undefined || cluster.width > needed) return undefined;
|
|
819
|
-
needed -= cluster.width;
|
|
820
|
-
removed.push(...cluster.chars);
|
|
821
|
-
index -= 1;
|
|
822
|
-
}
|
|
823
|
-
// Fewer columns than needed, or every grapheme removed: the command (or its ellipsis) would not
|
|
824
|
-
// survive, so the host's row stands.
|
|
825
|
-
if (needed !== 0 || index < 0) return undefined;
|
|
826
|
-
|
|
827
|
-
let shortened = line;
|
|
828
|
-
for (const entry of [...removed].sort((left, right) => right.raw - left.raw)) {
|
|
829
|
-
shortened = `${shortened.slice(0, entry.raw)}${shortened.slice(entry.raw + entry.char.length)}`;
|
|
830
|
-
}
|
|
831
|
-
return shortened;
|
|
832
|
-
}
|
|
833
|
-
|
|
834
|
-
/**
|
|
835
|
-
* Every reason a frame was drawn unchanged, in the order it was first seen, deduplicated: the same
|
|
836
|
-
* card is re-rendered on every frame, so a count would grow without telling the user anything.
|
|
837
|
-
*/
|
|
838
|
-
const unrecognisedFrames: Unrecognised[] = [];
|
|
839
|
-
|
|
840
|
-
/**
|
|
841
|
-
* Record (once) that a frame the design was handed was left exactly as the host drew it. Both
|
|
842
|
-
* channels this example owns are used: the TUI notice (delivered on the next `session_start`, and
|
|
843
|
-
* only once per reason) and the live CARD BASH row below.
|
|
844
|
-
*/
|
|
845
|
-
function recordUnrecognised(reason: Unrecognised): void {
|
|
846
|
-
if (unrecognisedFrames.some((entry) => entry.short === reason.short)) return;
|
|
847
|
-
unrecognisedFrames.push(reason);
|
|
848
|
-
recordNotice(
|
|
849
|
-
`[bash-card] one bash card frame was left exactly as the host drew it: ${reason.detail}. The ` +
|
|
850
|
-
"design only rewrites a heading it can prove, and never a row it cannot keep the width of; the " +
|
|
851
|
-
"CARD BASH sidebar section reports this live.",
|
|
852
|
-
);
|
|
853
|
-
}
|
|
854
|
-
|
|
855
|
-
/** The live report the CARD BASH section shows: `none`, or the last reason a frame was refused. */
|
|
856
|
-
function unrecognisedText(): string {
|
|
857
|
-
const last = unrecognisedFrames[unrecognisedFrames.length - 1];
|
|
858
|
-
return last?.short ?? "none";
|
|
859
|
-
}
|
|
860
|
-
|
|
861
|
-
/**
|
|
862
|
-
* The design's whole behaviour: prove where the heading is, swap the mark for the word, keep the
|
|
863
|
-
* row's width. Rows the design has nothing to do with — a body row, a closing rule, another tool's
|
|
864
|
-
* card — are returned as they came, byte for byte; a frame it cannot prove is returned unchanged and
|
|
865
|
-
* reported once.
|
|
866
|
-
*/
|
|
867
|
-
function applyBashHeading(lines: readonly string[], ctx: ToolDesignContextLike): string[] {
|
|
868
|
-
// The tool name is the bridge's, and it is the same one this file declared; a design that
|
|
869
|
-
// received another tool's rows would be a bridge bug, and drawing them unchanged is the safe
|
|
870
|
-
// answer rather than rewriting a card the design was not for.
|
|
871
|
-
if (ctx.tool !== TOOL_NAME) return [...lines];
|
|
872
|
-
|
|
873
|
-
// Only the CALL component heads a card (S12). The host draws the call and the result with the same
|
|
874
|
-
// chrome, so in the `float` cell a result body row that begins with `$ ` sits exactly where a
|
|
875
|
-
// heading sits — and real output does begin with `$ ` when a script echoes its commands. The
|
|
876
|
-
// bridge wraps the two renderers separately and says which one this is, so output is never
|
|
877
|
-
// rewritten. A context with NO `part` comes from an older bridge and is UNKNOWN, not a result:
|
|
878
|
-
// the previous behaviour stands and the row grammar below decides.
|
|
879
|
-
if (ctx.part === "result") return [...lines];
|
|
880
|
-
|
|
881
|
-
const recognition = headingSite(lines);
|
|
882
|
-
if (recognition.kind === "unrecognised") {
|
|
883
|
-
recordUnrecognised(recognition.reason);
|
|
884
|
-
return [...lines];
|
|
885
|
-
}
|
|
886
|
-
if (recognition.kind === "none") return [...lines];
|
|
887
|
-
|
|
888
|
-
const { site } = recognition;
|
|
889
|
-
const row = lines[site.row];
|
|
890
|
-
if (row === undefined) return [...lines];
|
|
891
|
-
|
|
892
|
-
const replaced = `${row.slice(0, site.raw)}${WORD}${row.slice(site.raw + MARK.length)}`;
|
|
893
|
-
const hostWidth = visibleWidth(row);
|
|
894
|
-
|
|
895
|
-
// First the host's own fill pays, exactly as before. Only when it cannot — the row's command
|
|
896
|
-
// already fills it — is the HOST'S OWN ABBREVIATION shortened by the same three columns (S14).
|
|
897
|
-
const balanced = shortenFill(replaced, site.extra);
|
|
898
|
-
if (balanced !== undefined && visibleWidth(balanced) === hostWidth) {
|
|
899
|
-
return [...lines.slice(0, site.row), balanced, ...lines.slice(site.row + 1)];
|
|
900
|
-
}
|
|
901
|
-
|
|
902
|
-
// The abbreviation is measured on the HOST'S row, where the mark is still in place; the removal
|
|
903
|
-
// is after the mark, so the word can replace it at the same raw offset afterwards.
|
|
904
|
-
const abbreviated = shortenAbbreviation(row, site);
|
|
905
|
-
if (abbreviated !== undefined) {
|
|
906
|
-
const headed = `${abbreviated.slice(0, site.raw)}${WORD}${abbreviated.slice(site.raw + MARK.length)}`;
|
|
907
|
-
if (visibleWidth(headed) === hostWidth) {
|
|
908
|
-
return [...lines.slice(0, site.row), headed, ...lines.slice(site.row + 1)];
|
|
909
|
-
}
|
|
910
|
-
}
|
|
911
|
-
|
|
912
|
-
// Growing the row would break the frame the host drew, so the host's row stands.
|
|
913
|
-
recordUnrecognised(UNRECOGNISED.noRoom);
|
|
914
|
-
return [...lines];
|
|
915
|
-
}
|
|
916
|
-
|
|
917
|
-
/** A literal depiction for `/gentle:customize`'s preview, every row the same 25 columns. */
|
|
918
|
-
const DESIGN_PREVIEW = {
|
|
919
|
-
title: `Cards · ${DESIGN_LABEL}`,
|
|
920
|
-
sample: [
|
|
921
|
-
"╭─ bash npm test ───────╮",
|
|
922
|
-
"│ PASS 42 tests │",
|
|
923
|
-
"╰───────────────────────╯",
|
|
924
|
-
].join("\n"),
|
|
925
|
-
};
|
|
926
|
-
|
|
927
|
-
/** The one registration this file sends. The caller decides whether the surface exists. */
|
|
928
|
-
function bashHeadingDesign(): ToolDesignLike {
|
|
929
|
-
return {
|
|
930
|
-
tool: TOOL_NAME,
|
|
931
|
-
id: DESIGN_ID,
|
|
932
|
-
name: PLUGIN_NAME,
|
|
933
|
-
label: DESIGN_LABEL,
|
|
934
|
-
description: "heads the card with bash instead of the host's $ mark",
|
|
935
|
-
preview: () => ({ ...DESIGN_PREVIEW }),
|
|
936
|
-
apply: applyBashHeading,
|
|
937
|
-
};
|
|
938
|
-
}
|
|
939
|
-
|
|
940
|
-
// ---------------------------------------------------------------------------------------------
|
|
941
|
-
// The card style: one gutter character per row, for tool cards in general (S17)
|
|
942
|
-
// ---------------------------------------------------------------------------------------------
|
|
943
|
-
//
|
|
944
|
-
// Restored from the version this file had before the design rewrite (`5c0c123:examples/bash-card.ts`
|
|
945
|
-
// lines 344-415): the SAME id, so a stored selection still resolves, the same label and the same
|
|
946
|
-
// literal preview. Nothing about it is bash-specific — the bridge hands a style the heading, the
|
|
947
|
-
// hint and the body text it extracted from any tool card, and the style only decides what chrome to
|
|
948
|
-
// draw around them.
|
|
949
|
-
|
|
950
|
-
/** Stable across loads and unique on the line: `/reload` replaces this value, never adds a second. */
|
|
951
|
-
const CARD_STYLE_ID = "bash-card.plain";
|
|
952
|
-
/**
|
|
953
|
-
* What the user reads on the host's own `Card style` line. Short, and it collides with neither the
|
|
954
|
-
* host's values (`neon`, `float`), the bridge's `native`, nor another style's label — the bridge
|
|
955
|
-
* rejects a duplicate label at registration (`docs/contract.md` §12).
|
|
956
|
-
*/
|
|
957
|
-
const CARD_STYLE_LABEL = "plain";
|
|
958
|
-
/**
|
|
959
|
-
* What the style's row reads in `/gentle:customize`. Its id (`bash-card.plain`) stays internal: it
|
|
960
|
-
* is the replacement and selection key, and it would read as a dotted key in the menu.
|
|
961
|
-
*/
|
|
962
|
-
const CARD_STYLE_ROW_LABEL = "plain";
|
|
963
|
-
|
|
964
|
-
/** One character of gutter per row: no box, no border glyphs, cheap to read. */
|
|
965
|
-
const CARD_STYLE_GUTTER = "\u258c";
|
|
966
|
-
|
|
967
|
-
/** Width-clips one line with a trailing ellipsis, so a row never overflows the width it was given. */
|
|
968
|
-
function clipLine(text: string, width: number): string {
|
|
969
|
-
if (width <= 0) return "";
|
|
970
|
-
if (visibleWidth(text) <= width) return text;
|
|
971
|
-
if (width === 1) return "\u2026";
|
|
972
|
-
return `${Array.from(text).slice(0, width - 1).join("")}\u2026`;
|
|
973
|
-
}
|
|
974
|
-
|
|
975
|
-
/**
|
|
976
|
-
* The frame. Three functions, each handed a width and text the BRIDGE already measured and
|
|
977
|
-
* extracted; nothing here looks for a title in a line or strips a frame. Every row goes through
|
|
978
|
-
* `clipLine`, so the style can never emit a line wider than the width it was given.
|
|
979
|
-
*
|
|
980
|
-
* A card in this style at width 40:
|
|
981
|
-
*
|
|
982
|
-
* ```text
|
|
983
|
-
* \u258c read ctrl+o
|
|
984
|
-
* \u258c config.ts
|
|
985
|
-
* \u258c
|
|
986
|
-
* ```
|
|
987
|
-
*/
|
|
988
|
-
const PLAIN_CARD_FRAME: CardFrameLike = {
|
|
989
|
-
top: (ctx) => {
|
|
990
|
-
const head = `${CARD_STYLE_GUTTER} ${ctx.title ?? ""}`.replace(/\s+$/u, "");
|
|
991
|
-
const hint = ctx.hint;
|
|
992
|
-
if (hint === undefined) return clipLine(head, ctx.width);
|
|
993
|
-
// Right-align the hint when there is room for it and at least one space between; otherwise the
|
|
994
|
-
// heading wins and the hint is dropped rather than making the row ambiguous.
|
|
995
|
-
const gap = ctx.width - visibleWidth(head) - visibleWidth(hint) - 2;
|
|
996
|
-
if (gap < 1) return clipLine(head, ctx.width);
|
|
997
|
-
return clipLine(`${head}${" ".repeat(gap)} ${hint}`, ctx.width);
|
|
998
|
-
},
|
|
999
|
-
line: (ctx) => {
|
|
1000
|
-
const row = ctx.text === "" ? CARD_STYLE_GUTTER : `${CARD_STYLE_GUTTER} ${ctx.text}`;
|
|
1001
|
-
return clipLine(row, ctx.width);
|
|
1002
|
-
},
|
|
1003
|
-
// A bare gutter closes the card: the style has no bottom border to draw, and the row costs one
|
|
1004
|
-
// column instead of a rule across the frame.
|
|
1005
|
-
bottom: (ctx) => (ctx.width > 0 ? CARD_STYLE_GUTTER : ""),
|
|
1006
|
-
};
|
|
1007
|
-
|
|
1008
|
-
/** A literal depiction for `/gentle:customize`'s preview, drawn by the row's `preview()`.
|
|
1009
|
-
* Exactly as restored: the same two sample rows and the same closing gutter the user saw before. */
|
|
1010
|
-
const CARD_STYLE_PREVIEW = {
|
|
1011
|
-
title: "Cards \u00b7 plain",
|
|
1012
|
-
sample: [
|
|
1013
|
-
`${CARD_STYLE_GUTTER} read ctrl+o`,
|
|
1014
|
-
`${CARD_STYLE_GUTTER} config.ts`,
|
|
1015
|
-
`${CARD_STYLE_GUTTER}`,
|
|
1016
|
-
].join("\n"),
|
|
1017
|
-
};
|
|
1018
|
-
|
|
1019
|
-
/**
|
|
1020
|
-
* The registration this file sends, or nothing. A style draws what it is given: the bridge owns
|
|
1021
|
-
* detection, the heading and the width.
|
|
1022
|
-
*/
|
|
1023
|
-
function bashCardStyle(): CardStyleLike {
|
|
1024
|
-
return {
|
|
1025
|
-
id: CARD_STYLE_ID,
|
|
1026
|
-
label: CARD_STYLE_LABEL,
|
|
1027
|
-
// A label a user reads, passed explicitly so a reader copies the shape; the bridge would derive
|
|
1028
|
-
// `plain` from the label anyway, and the id never appears in a row.
|
|
1029
|
-
rowLabel: CARD_STYLE_ROW_LABEL,
|
|
1030
|
-
// After any style the host or another plugin leaves unordered; the host's own values come
|
|
1031
|
-
// first regardless (`docs/contract.md` §12).
|
|
1032
|
-
order: 30,
|
|
1033
|
-
frame: PLAIN_CARD_FRAME,
|
|
1034
|
-
preview: () => ({ ...CARD_STYLE_PREVIEW }),
|
|
1035
|
-
};
|
|
1036
|
-
}
|
|
1037
|
-
|
|
1038
|
-
// ---------------------------------------------------------------------------------------------
|
|
1039
|
-
// Reading the bridge's selection: one function, so the header and the row can never disagree
|
|
1040
|
-
// ---------------------------------------------------------------------------------------------
|
|
1041
|
-
|
|
1042
|
-
/**
|
|
1043
|
-
* The value selected for `bash` right now, read LIVE so the card follows the menu without a reload.
|
|
1044
|
-
* Anything unreadable, malformed or missing reads as `native` — the host's own card — which is what
|
|
1045
|
-
* the bridge itself would draw.
|
|
1046
|
-
*/
|
|
1047
|
-
function readSelection(store: BridgeStoreLike | undefined): string {
|
|
1048
|
-
if (store === undefined) return NATIVE_VALUE;
|
|
1049
|
-
try {
|
|
1050
|
-
const selections: unknown = store.get<unknown>(DESIGNS_STORE_KEY, {});
|
|
1051
|
-
if (typeof selections !== "object" || selections === null) return NATIVE_VALUE;
|
|
1052
|
-
const selected: unknown = (selections as Record<string, unknown>)[TOOL_NAME];
|
|
1053
|
-
return typeof selected === "string" && selected.trim() !== "" ? selected : NATIVE_VALUE;
|
|
1054
|
-
} catch {
|
|
1055
|
-
return NATIVE_VALUE;
|
|
1056
|
-
}
|
|
1057
|
-
}
|
|
1058
|
-
|
|
1059
|
-
/**
|
|
1060
|
-
* What a user reads for one stored value. `native` is the host's card; this file's own design is its
|
|
1061
|
-
* label; a value another plugin owns reads as `other design` — an id is internal and never a row.
|
|
1062
|
-
*/
|
|
1063
|
-
function selectionText(selected: string): string {
|
|
1064
|
-
if (selected === NATIVE_VALUE) return NATIVE_VALUE;
|
|
1065
|
-
if (selected === DESIGN_ID) return DESIGN_LABEL;
|
|
1066
|
-
return "other design";
|
|
1067
|
-
}
|
|
1068
|
-
|
|
1069
|
-
// ---------------------------------------------------------------------------------------------
|
|
1070
|
-
// The demo category: one toggle and one button, and the visible effect in the section
|
|
1071
|
-
// ---------------------------------------------------------------------------------------------
|
|
1072
|
-
|
|
1073
|
-
/** The two values the demo toggle offers; the default is `off`. */
|
|
1074
|
-
type DemoToggle = "on" | "off";
|
|
1075
|
-
|
|
1076
|
-
/** Live read from the example's OWN store; anything unreadable is the default, never a crash. */
|
|
1077
|
-
function readDemoToggle(store: BridgeStoreLike | undefined): DemoToggle {
|
|
1078
|
-
if (store === undefined) return "off";
|
|
1079
|
-
try {
|
|
1080
|
-
return store.get<unknown>(DEMO_TOGGLE_KEY, "off") === "on" ? "on" : "off";
|
|
1081
|
-
} catch {
|
|
1082
|
-
return "off";
|
|
1083
|
-
}
|
|
1084
|
-
}
|
|
1085
|
-
|
|
1086
|
-
/** The button's press count, read live; anything malformed reads as zero. */
|
|
1087
|
-
function readDemoPresses(store: BridgeStoreLike | undefined): number {
|
|
1088
|
-
if (store === undefined) return 0;
|
|
1089
|
-
try {
|
|
1090
|
-
const stored = store.get<unknown>(DEMO_PRESSES_KEY, 0);
|
|
1091
|
-
return typeof stored === "number" && Number.isFinite(stored) && stored > 0 ? Math.floor(stored) : 0;
|
|
1092
|
-
} catch {
|
|
1093
|
-
return 0;
|
|
1094
|
-
}
|
|
1095
|
-
}
|
|
1096
|
-
|
|
1097
|
-
/** Persist one demo value. A store that refuses costs the demo its memory, never the action. */
|
|
1098
|
-
function writeDemo(store: BridgeStoreLike | undefined, key: string, value: unknown): void {
|
|
1099
|
-
if (store === undefined) return;
|
|
1100
|
-
try {
|
|
1101
|
-
store.set(key, value);
|
|
1102
|
-
} catch {
|
|
1103
|
-
// The user's next frame reads the old value, which is the honest result of a failed write.
|
|
1104
|
-
}
|
|
1105
|
-
}
|
|
1106
|
-
|
|
1107
|
-
/** Ask the bridge to repaint when the published object offers it; an older bridge simply does not. */
|
|
1108
|
-
function requestSurfaceRepaint(request: (() => void) | undefined): void {
|
|
1109
|
-
if (request === undefined) return;
|
|
1110
|
-
try {
|
|
1111
|
-
request();
|
|
1112
|
-
} catch {
|
|
1113
|
-
// A failed repaint cannot undo the value the user just changed.
|
|
1114
|
-
}
|
|
1115
|
-
}
|
|
1116
|
-
|
|
1117
|
-
/** Short literal depictions for the two demo rows, every line well under 40 columns. */
|
|
1118
|
-
const DEMO_TOGGLE_PREVIEW = {
|
|
1119
|
-
title: "Card bash · Demo toggle",
|
|
1120
|
-
sample: ["Demo toggle: on / off", "The sidebar shows the value"].join("\n"),
|
|
1121
|
-
};
|
|
1122
|
-
const DEMO_BUTTON_PREVIEW = {
|
|
1123
|
-
title: "Card bash · Demo button",
|
|
1124
|
-
sample: ["Demo button: one press", "The counter goes up"].join("\n"),
|
|
1125
|
-
};
|
|
1126
|
-
|
|
1127
|
-
/**
|
|
1128
|
-
* The example's ONE row provider: a brand-new `Card bash` category with two rows as the user reads
|
|
1129
|
-
* them — a two-value toggle and a plain button. Neither does anything real: the toggle is stored,
|
|
1130
|
-
* and the button counts its presses, so the user can see both work in the section below.
|
|
1131
|
-
*/
|
|
1132
|
-
function demoProvider(store: BridgeStoreLike | undefined, repaint: (() => void) | undefined): RowProviderLike {
|
|
1133
|
-
const toggle = (value: DemoToggle): CustomizeRowLike => ({
|
|
1134
|
-
category: DEMO_CATEGORY,
|
|
1135
|
-
label: DEMO_TOGGLE_SETTING,
|
|
1136
|
-
choice: {
|
|
1137
|
-
setting: DEMO_TOGGLE_SETTING,
|
|
1138
|
-
value,
|
|
1139
|
-
current: () => readDemoToggle(store) === value,
|
|
1140
|
-
},
|
|
1141
|
-
preview: () => ({ ...DEMO_TOGGLE_PREVIEW }),
|
|
1142
|
-
action: () => {
|
|
1143
|
-
writeDemo(store, DEMO_TOGGLE_KEY, value);
|
|
1144
|
-
requestSurfaceRepaint(repaint);
|
|
1145
|
-
},
|
|
1146
|
-
});
|
|
1147
|
-
|
|
1148
|
-
return {
|
|
1149
|
-
id: DEMO_PROVIDER_ID,
|
|
1150
|
-
category: DEMO_CATEGORY,
|
|
1151
|
-
rows: () => [
|
|
1152
|
-
toggle("on"),
|
|
1153
|
-
toggle("off"),
|
|
1154
|
-
{
|
|
1155
|
-
category: DEMO_CATEGORY,
|
|
1156
|
-
label: DEMO_BUTTON_LABEL,
|
|
1157
|
-
preview: () => ({ ...DEMO_BUTTON_PREVIEW }),
|
|
1158
|
-
action: () => {
|
|
1159
|
-
writeDemo(store, DEMO_PRESSES_KEY, readDemoPresses(store) + 1);
|
|
1160
|
-
requestSurfaceRepaint(repaint);
|
|
1161
|
-
},
|
|
1162
|
-
},
|
|
1163
|
-
],
|
|
1164
|
-
};
|
|
1165
|
-
}
|
|
1166
|
-
|
|
1167
|
-
// ---------------------------------------------------------------------------------------------
|
|
1168
|
-
// The sidebar section: the live design state
|
|
1169
|
-
// ---------------------------------------------------------------------------------------------
|
|
1170
|
-
|
|
1171
|
-
/**
|
|
1172
|
-
* Live state for the card: the value selected for `bash`, and whether this load's design reached the
|
|
1173
|
-
* bridge. A function rather than a boolean, so the row stays honest if registration came later than
|
|
1174
|
-
* this section's creation.
|
|
1175
|
-
*/
|
|
1176
|
-
function bashDesignSection(
|
|
1177
|
-
store: BridgeStoreLike | undefined,
|
|
1178
|
-
demoStore: BridgeStoreLike | undefined,
|
|
1179
|
-
repaint: (() => void) | undefined,
|
|
1180
|
-
designRegistered: () => boolean,
|
|
1181
|
-
cardStyleRegistered: () => boolean,
|
|
1182
|
-
): SidebarSectionLike {
|
|
1183
|
-
const selectedLabel = (): string => selectionText(readSelection(store));
|
|
1184
|
-
return {
|
|
1185
|
-
id: SECTION_ID,
|
|
1186
|
-
title: SECTION_TITLE,
|
|
1187
|
-
// The row label, not the id: the menu reads `card bash ● shown ○ hidden`, next to the host's
|
|
1188
|
-
// own `stats` / `agents` lines.
|
|
1189
|
-
rowLabel: SECTION_ROW_LABEL,
|
|
1190
|
-
// The right-aligned meta: the same value the `Cards` row writes, so the two surfaces cannot
|
|
1191
|
-
// disagree. Read per frame, which is what makes it LIVE (N14). This is the ONE place the
|
|
1192
|
-
// selection is printed; a second body row above the reports only repeated it.
|
|
1193
|
-
meta: () => selectedLabel(),
|
|
1194
|
-
lines: () => [
|
|
1195
|
-
// A plain string, inert on purpose: it reports whether this load's design reached the bus.
|
|
1196
|
-
// The per-CARD mount count is the bridge's own diagnostic (`/bridge-status`), not a plugin's.
|
|
1197
|
-
`resolver ${designRegistered() ? "registered" : "absent"}`,
|
|
1198
|
-
// The same answer for the restored `plain` style, so an older bridge cannot look like it
|
|
1199
|
-
// accepted a surface it never had: `absent` is the honest state, never an assumed `registered`.
|
|
1200
|
-
`card style ${cardStyleRegistered() ? "registered" : "absent"}`,
|
|
1201
|
-
// The live answer to "did the design recognise the cards it was handed?". `none` until a frame
|
|
1202
|
-
// is refused, then the reason, once — a card is re-rendered on every pass, so a count would say
|
|
1203
|
-
// nothing. The same reason is delivered once through `ctx.ui.notify` (`recordUnrecognised`).
|
|
1204
|
-
`unrecognised ${unrecognisedText()}`,
|
|
1205
|
-
// The demo's toggle value: a plain string row, rebuilt every frame, so the user sees the change
|
|
1206
|
-
// without opening a file.
|
|
1207
|
-
`demo toggle ${readDemoToggle(demoStore)}`,
|
|
1208
|
-
// The section's ONE action row (N12): the count is the right-aligned meta, and a click
|
|
1209
|
-
// increments the example's own store ONCE and asks the bridge to repaint the section. The
|
|
1210
|
-
// bridge draws it like the host's own clickable rows — and paints it with the host's hover
|
|
1211
|
-
// role — so the live pointer path can be verified without a debug build.
|
|
1212
|
-
{
|
|
1213
|
-
label: SECTION_PRESSES_LABEL,
|
|
1214
|
-
meta: String(readDemoPresses(demoStore)),
|
|
1215
|
-
onClick: () => {
|
|
1216
|
-
writeDemo(demoStore, DEMO_PRESSES_KEY, readDemoPresses(demoStore) + 1);
|
|
1217
|
-
requestSurfaceRepaint(repaint);
|
|
1218
|
-
},
|
|
1219
|
-
},
|
|
1220
|
-
],
|
|
1221
|
-
// Visible on install: a section needs no extra step from the plugin or the user.
|
|
1222
|
-
defaultVisible: true,
|
|
1223
|
-
// After any section the host or another plugin leaves unordered; the host's own parts come
|
|
1224
|
-
// first regardless.
|
|
1225
|
-
order: 20,
|
|
1226
|
-
};
|
|
1227
|
-
}
|
|
1228
|
-
|
|
1229
|
-
// ---------------------------------------------------------------------------------------------
|
|
1230
|
-
// Reaching gentle-shell-bridge: a runtime handshake, not a path
|
|
1231
|
-
// ---------------------------------------------------------------------------------------------
|
|
1232
|
-
//
|
|
1233
|
-
// A Pi extension lives outside every package, so it cannot resolve a bare specifier, and an absolute
|
|
1234
|
-
// path is not a contract between two independent projects. So the bridge and the plugin meet on
|
|
1235
|
-
// `globalThis`, the one channel Pi sanctions (`docs/extensions.md:1965-1972`), behind one stable
|
|
1236
|
-
// symbol:
|
|
1237
|
-
//
|
|
1238
|
-
// Symbol.for("gentle-shell-bridge/api/v1") → the frozen, versioned API object
|
|
1239
|
-
//
|
|
1240
|
-
// Registration happens in this file's own `session_start` handler, AND on the bridge's
|
|
1241
|
-
// announcement. Pi does not guarantee that the package loaded first: global extensions load and
|
|
1242
|
-
// receive `session_start` before a configured package's module publishes, which is exactly the
|
|
1243
|
-
// order the real TUI reported. So when the symbol is absent the example subscribes to
|
|
1244
|
-
// `"gentle-shell-bridge:ready"` and registers when it fires; when the symbol is present there is
|
|
1245
|
-
// nothing to wait for and no listener is taken. Each path calls the same guarded registration.
|
|
1246
|
-
//
|
|
1247
|
-
// The constant and the gate MIRROR `src/api.ts` and are inlined on purpose: this file must not
|
|
1248
|
-
// import the bridge, not even to say hello. Keep them in sync — a breaking change is a new `v2` key,
|
|
1249
|
-
// never a mutation of this one.
|
|
1250
|
-
|
|
1251
|
-
/** The published API object's key. */
|
|
1252
|
-
const BRIDGE_API_KEY = Symbol.for("gentle-shell-bridge/api/v1");
|
|
1253
|
-
|
|
1254
|
-
/**
|
|
1255
|
-
* The channel the bridge announces itself on. The string MIRRORS `src/api.ts`: this file cannot
|
|
1256
|
-
* import it, so the two are kept in sync by hand. The announcement is a notification, never a
|
|
1257
|
-
* registry: it says "the object is published now", and this file reads the symbol again.
|
|
1258
|
-
*/
|
|
1259
|
-
const BRIDGE_READY_EVENT = "gentle-shell-bridge:ready";
|
|
1260
|
-
|
|
1261
|
-
function bridgeHost(): Record<symbol, unknown> {
|
|
1262
|
-
// SAFETY: `globalThis` accepts symbol keys; every value read through this view is gated by
|
|
1263
|
-
// `isBridgeApi` before use.
|
|
1264
|
-
return globalThis as unknown as Record<symbol, unknown>;
|
|
1265
|
-
}
|
|
1266
|
-
|
|
1267
|
-
/**
|
|
1268
|
-
* The shape gate: the schema string, `version === 1` and the members this example calls. Anything
|
|
1269
|
-
* else — another library's object, a future major version, a partial object — is treated as ABSENT,
|
|
1270
|
-
* never as an error, exactly as the bridge's own `isBridgeApi` does.
|
|
1271
|
-
*/
|
|
1272
|
-
function isBridgeApi(value: unknown): value is BridgeApiLike {
|
|
1273
|
-
if (typeof value !== "object" || value === null) return false;
|
|
1274
|
-
const candidate = value as Record<string, unknown>;
|
|
1275
|
-
return (
|
|
1276
|
-
candidate.schema === "gentle-shell-bridge/api" &&
|
|
1277
|
-
candidate.version === 1 &&
|
|
1278
|
-
typeof candidate.registerSection === "function" &&
|
|
1279
|
-
typeof candidate.createStore === "function"
|
|
1280
|
-
);
|
|
1281
|
-
}
|
|
1282
|
-
|
|
1283
|
-
/**
|
|
1284
|
-
* The two notices for "no published object yet", which are honestly DIFFERENT states:
|
|
1285
|
-
*
|
|
1286
|
-
* - with a bus, the bridge may still load and announce itself, so the example is waiting and says
|
|
1287
|
-
* what will register when the announcement arrives;
|
|
1288
|
-
* - without one, nothing can arrive in this process, so this load adds nothing and says that.
|
|
1289
|
-
*
|
|
1290
|
-
* Neither claims the bridge is not installed unless nothing can arrive.
|
|
1291
|
-
*/
|
|
1292
|
-
const NO_SIGNAL_NOTICE =
|
|
1293
|
-
"[bash-card] no compatible gentle-shell-bridge is installed, and this Pi has no cross-extension " +
|
|
1294
|
-
"event bus (`pi.events`) for one to announce itself on, so nothing can arrive later: this load " +
|
|
1295
|
-
"adds nothing — no bash design, no plain card style, no demo category and no CARD BASH sidebar " +
|
|
1296
|
-
"section. A slot that holds another object is treated as absent, never used.";
|
|
1297
|
-
|
|
1298
|
-
const WAITING_NOTICE =
|
|
1299
|
-
"[bash-card] no compatible gentle-shell-bridge API is published yet, so this load is waiting for " +
|
|
1300
|
-
'the "gentle-shell-bridge:ready" announcement the bridge emits when it publishes. If a bridge is ' +
|
|
1301
|
-
"installed it registers the bash design for the `bash` tool and the CARD BASH sidebar section " +
|
|
1302
|
-
"when the announcement arrives — together with the example's plain card style and its demo " +
|
|
1303
|
-
"category; if it is not installed, or the published object is incompatible, " +
|
|
1304
|
-
"nothing arrives and nothing registers. A slot that holds another object is treated as absent, " +
|
|
1305
|
-
"never used.";
|
|
1306
|
-
|
|
1307
|
-
// ---------------------------------------------------------------------------------------------
|
|
1308
|
-
// The host's cross-extension bus: how a later bridge reaches this load
|
|
1309
|
-
// ---------------------------------------------------------------------------------------------
|
|
1310
|
-
|
|
1311
|
-
/**
|
|
1312
|
-
* The host's event bus, when this Pi has one and it looks usable. Reading `pi.events` is guarded
|
|
1313
|
-
* because it is host state: a getter that throws must cost the announcement, not the load.
|
|
1314
|
-
*/
|
|
1315
|
-
function extensionEvents(pi: ExtensionApiLike): ExtensionEventsLike | undefined {
|
|
1316
|
-
let events: unknown;
|
|
1317
|
-
try {
|
|
1318
|
-
events = pi.events;
|
|
1319
|
-
} catch {
|
|
1320
|
-
return undefined;
|
|
1321
|
-
}
|
|
1322
|
-
if (typeof events !== "object" || events === null) return undefined;
|
|
1323
|
-
const candidate = events as { on?: unknown };
|
|
1324
|
-
return typeof candidate.on === "function" ? (candidate as ExtensionEventsLike) : undefined;
|
|
1325
|
-
}
|
|
1326
|
-
|
|
1327
|
-
// ---------------------------------------------------------------------------------------------
|
|
1328
|
-
// The in-TUI diagnostics channel
|
|
1329
|
-
// ---------------------------------------------------------------------------------------------
|
|
1330
|
-
|
|
1331
|
-
/**
|
|
1332
|
-
* A message that must reach Pi's TUI but cannot yet: at module load no context exists. Notices are
|
|
1333
|
-
* NEVER printed — Pi renders its own diagnostics inside the TUI, and text written to the terminal at
|
|
1334
|
-
* startup lands outside the report the user reads.
|
|
1335
|
-
*/
|
|
1336
|
-
interface PendingNotice {
|
|
1337
|
-
message: string;
|
|
1338
|
-
/** Drop it once the surfaces register: "waiting" stops being true at that moment. */
|
|
1339
|
-
untilRegistered: boolean;
|
|
1340
|
-
}
|
|
1341
|
-
|
|
1342
|
-
const pendingNotices: PendingNotice[] = [];
|
|
1343
|
-
/** How many a session already showed; a delivered message is never delivered twice. */
|
|
1344
|
-
let deliveredNotices = 0;
|
|
1345
|
-
|
|
1346
|
-
function recordNotice(message: string, options: { untilRegistered?: boolean } = {}): void {
|
|
1347
|
-
if (pendingNotices.some((entry) => entry.message === message)) return;
|
|
1348
|
-
pendingNotices.push({ message, untilRegistered: options.untilRegistered === true });
|
|
1349
|
-
}
|
|
1350
|
-
|
|
1351
|
-
/**
|
|
1352
|
-
* Forget a notice a later registration made untrue. Already-delivered messages stay in the buffer
|
|
1353
|
-
* (so they are never shown twice); every other pending message keeps its order.
|
|
1354
|
-
*/
|
|
1355
|
-
function withdrawWaitingNotice(): void {
|
|
1356
|
-
const stillPending = pendingNotices
|
|
1357
|
-
.slice(deliveredNotices)
|
|
1358
|
-
.filter((entry) => !entry.untilRegistered);
|
|
1359
|
-
pendingNotices.length = deliveredNotices;
|
|
1360
|
-
pendingNotices.push(...stillPending);
|
|
1361
|
-
}
|
|
1362
|
-
|
|
1363
|
-
/**
|
|
1364
|
-
* Deliver every pending notice through `ctx.ui.notify`. A context without a `notify` delivers
|
|
1365
|
-
* nothing, and a `notify` that throws leaves the remaining messages pending for the next session.
|
|
1366
|
-
* `settled` means the surfaces are registered, so a "waiting" notice must not be shown.
|
|
1367
|
-
*/
|
|
1368
|
-
function flushNotices(ui: unknown, settled: boolean): void {
|
|
1369
|
-
const surface = typeof ui === "object" && ui !== null ? (ui as NoticeUiLike) : undefined;
|
|
1370
|
-
const notify = surface?.notify;
|
|
1371
|
-
if (typeof notify !== "function") return;
|
|
1372
|
-
if (settled) withdrawWaitingNotice();
|
|
1373
|
-
while (deliveredNotices < pendingNotices.length) {
|
|
1374
|
-
try {
|
|
1375
|
-
notify.call(surface, pendingNotices[deliveredNotices].message, "warning");
|
|
1376
|
-
} catch {
|
|
1377
|
-
return;
|
|
1378
|
-
}
|
|
1379
|
-
deliveredNotices += 1;
|
|
1380
|
-
}
|
|
1381
|
-
}
|
|
1382
|
-
|
|
1383
|
-
// ---------------------------------------------------------------------------------------------
|
|
1384
|
-
// Extension entry
|
|
1385
|
-
// ---------------------------------------------------------------------------------------------
|
|
1386
|
-
|
|
1387
|
-
export default function bashCardExample(pi: ExtensionApiLike): () => void {
|
|
1388
|
-
// Holds whether each optional surface was there at registration. `false` means the bridge is
|
|
1389
|
-
// older than that surface: the section still registers and states which one is absent.
|
|
1390
|
-
const state: {
|
|
1391
|
-
designRegistered: boolean;
|
|
1392
|
-
cardStyleRegistered: boolean;
|
|
1393
|
-
keymapRegistered: boolean;
|
|
1394
|
-
paletteRegistered: boolean;
|
|
1395
|
-
} = {
|
|
1396
|
-
designRegistered: false,
|
|
1397
|
-
cardStyleRegistered: false,
|
|
1398
|
-
keymapRegistered: false,
|
|
1399
|
-
paletteRegistered: false,
|
|
1400
|
-
};
|
|
1401
|
-
|
|
1402
|
-
const disposers: Array<() => void> = [];
|
|
1403
|
-
let closed = false;
|
|
1404
|
-
let bridgeRegistered = false;
|
|
1405
|
-
// The bus this Pi offers, when it offers one: the only channel a later-loading bridge can use.
|
|
1406
|
-
const events = extensionEvents(pi);
|
|
1407
|
-
let unsubscribeReady: EventUnsubscribe | undefined;
|
|
1408
|
-
|
|
1409
|
-
/** Stop listening for the announcement. Anything that throws here cannot cost the load. */
|
|
1410
|
-
const stopListening = (): void => {
|
|
1411
|
-
const off = unsubscribeReady;
|
|
1412
|
-
unsubscribeReady = undefined;
|
|
1413
|
-
if (off === undefined) return;
|
|
1414
|
-
try {
|
|
1415
|
-
off();
|
|
1416
|
-
} catch {
|
|
1417
|
-
// A disposer that throws cannot resurrect the subscription.
|
|
1418
|
-
}
|
|
1419
|
-
};
|
|
1420
|
-
|
|
1421
|
-
/** Every surface belongs to one registration lifetime: disposing must not leave half of them. */
|
|
1422
|
-
const dispose = (): void => {
|
|
1423
|
-
closed = true;
|
|
1424
|
-
stopListening();
|
|
1425
|
-
for (const registered of disposers.splice(0, disposers.length)) {
|
|
1426
|
-
try {
|
|
1427
|
-
registered();
|
|
1428
|
-
} catch {
|
|
1429
|
-
// A disposer that throws must not strand the others.
|
|
1430
|
-
}
|
|
1431
|
-
}
|
|
1432
|
-
};
|
|
1433
|
-
|
|
1434
|
-
/** Register the bridge surfaces once. A stable id each, so a `/reload` replaces, not stacks. */
|
|
1435
|
-
const registerWithBridge = (api: BridgeApiLike): void => {
|
|
1436
|
-
if (closed || bridgeRegistered) return;
|
|
1437
|
-
|
|
1438
|
-
// The selection is the BRIDGE's: this file opens the published store to READ the value the
|
|
1439
|
-
// `Cards` row writes. A store that refuses to answer only costs the section its live text.
|
|
1440
|
-
let store: BridgeStoreLike | undefined;
|
|
1441
|
-
try {
|
|
1442
|
-
store = api.createStore(DESIGNS_STORE_ID);
|
|
1443
|
-
} catch (error) {
|
|
1444
|
-
recordNotice(
|
|
1445
|
-
`[bash-card] could not read the bridge's design selections: ` +
|
|
1446
|
-
`${error instanceof Error ? error.message : String(error)}`,
|
|
1447
|
-
);
|
|
1448
|
-
}
|
|
1449
|
-
|
|
1450
|
-
// The demo's OWN store, under this example's plugin id. It is not the bridge's selection file:
|
|
1451
|
-
// a plugin's state belongs to the plugin. A store that refuses to open only costs the demo its
|
|
1452
|
-
// memory, never the section or the design.
|
|
1453
|
-
let demoStore: BridgeStoreLike | undefined;
|
|
1454
|
-
try {
|
|
1455
|
-
demoStore = api.createStore(EXAMPLE_STORE_ID);
|
|
1456
|
-
} catch (error) {
|
|
1457
|
-
recordNotice(
|
|
1458
|
-
`[bash-card] could not open the example's own store: ` +
|
|
1459
|
-
`${error instanceof Error ? error.message : String(error)}`,
|
|
1460
|
-
);
|
|
1461
|
-
}
|
|
1462
|
-
|
|
1463
|
-
// The design first, so its explicit `name` fixes the plugin's one INTEGRATIONS row before the
|
|
1464
|
-
// section's registration inherits it. Optional by contract: an object published by an older copy
|
|
1465
|
-
// passes the gate without it, and then only the section registers. Its own failure is reported
|
|
1466
|
-
// and never costs the section: one surface refusing is not the plugin going silent (the bridge
|
|
1467
|
-
// records the rejection in `/bridge-status` as well).
|
|
1468
|
-
const registerDesign = api.registerToolDesign;
|
|
1469
|
-
if (typeof registerDesign === "function") {
|
|
1470
|
-
try {
|
|
1471
|
-
disposers.push(registerDesign.call(api, bashHeadingDesign()));
|
|
1472
|
-
state.designRegistered = true;
|
|
1473
|
-
} catch (error) {
|
|
1474
|
-
recordNotice(
|
|
1475
|
-
`[bash-card] the bridge refused the bash design: ` +
|
|
1476
|
-
`${error instanceof Error ? error.message : String(error)}`,
|
|
1477
|
-
);
|
|
1478
|
-
}
|
|
1479
|
-
}
|
|
1480
|
-
|
|
1481
|
-
// The card style: the same optional discipline as the design. An older object without
|
|
1482
|
-
// `registerCardStyle` keeps the design, the section and the demo category, and the section says
|
|
1483
|
-
// `card style absent` instead of claiming a surface that never registered.
|
|
1484
|
-
const registerCardStyle = api.registerCardStyle;
|
|
1485
|
-
if (typeof registerCardStyle === "function") {
|
|
1486
|
-
try {
|
|
1487
|
-
disposers.push(registerCardStyle.call(api, bashCardStyle()));
|
|
1488
|
-
state.cardStyleRegistered = true;
|
|
1489
|
-
} catch (error) {
|
|
1490
|
-
recordNotice(
|
|
1491
|
-
`[bash-card] the bridge refused the plain card style: ` +
|
|
1492
|
-
`${error instanceof Error ? error.message : String(error)}`,
|
|
1493
|
-
);
|
|
1494
|
-
}
|
|
1495
|
-
}
|
|
1496
|
-
|
|
1497
|
-
// The demo provider: ONE stable id, so a `/reload` replaces it in place instead of stacking a
|
|
1498
|
-
// second category. `registerRows` is optional by contract, so an older bridge keeps the design
|
|
1499
|
-
// and the section and simply contributes no demo category.
|
|
1500
|
-
const registerRows = api.registerRows;
|
|
1501
|
-
if (typeof registerRows === "function") {
|
|
1502
|
-
try {
|
|
1503
|
-
disposers.push(registerRows.call(api, demoProvider(demoStore, api.requestRepaint)));
|
|
1504
|
-
} catch (error) {
|
|
1505
|
-
recordNotice(
|
|
1506
|
-
`[bash-card] the bridge refused the demo category: ` +
|
|
1507
|
-
`${error instanceof Error ? error.message : String(error)}`,
|
|
1508
|
-
);
|
|
1509
|
-
}
|
|
1510
|
-
}
|
|
1511
|
-
|
|
1512
|
-
// The keymap: one action the user rebinds in `/gentle:customize` → Keymaps. The bridge publishes
|
|
1513
|
-
// the row; THIS file owns the binding, because Pi reads extension shortcuts at startup and on
|
|
1514
|
-
// `/reload`. The key comes from the shell through `resolveKeymap`, so the row and the real
|
|
1515
|
-
// binding cannot disagree — a row claiming `alt+b` while nothing listens is worse than no row.
|
|
1516
|
-
const registerKeymap = api.registerKeymap;
|
|
1517
|
-
if (typeof registerKeymap === "function") {
|
|
1518
|
-
try {
|
|
1519
|
-
disposers.push(registerKeymap.call(api, BASH_CARD_KEYMAP));
|
|
1520
|
-
state.keymapRegistered = true;
|
|
1521
|
-
} catch (error) {
|
|
1522
|
-
recordNotice(
|
|
1523
|
-
`[bash-card] the bridge refused the keymap action: ` +
|
|
1524
|
-
`${error instanceof Error ? error.message : String(error)}`,
|
|
1525
|
-
);
|
|
1526
|
-
}
|
|
1527
|
-
}
|
|
1528
|
-
|
|
1529
|
-
// The palette: a section of this example's own, listed only while the command is registered —
|
|
1530
|
-
// the host filters on that, so a missing command never leaves a dead row in the menu.
|
|
1531
|
-
const registerCommands = api.registerCommands;
|
|
1532
|
-
if (typeof registerCommands === "function") {
|
|
1533
|
-
try {
|
|
1534
|
-
disposers.push(registerCommands.call(api, [BASH_CARD_PALETTE_ITEM, BASH_CARD_DIAGNOSTICS_ITEM]));
|
|
1535
|
-
state.paletteRegistered = true;
|
|
1536
|
-
} catch (error) {
|
|
1537
|
-
recordNotice(
|
|
1538
|
-
`[bash-card] the bridge refused the palette item: ` +
|
|
1539
|
-
`${error instanceof Error ? error.message : String(error)}`,
|
|
1540
|
-
);
|
|
1541
|
-
}
|
|
1542
|
-
}
|
|
1543
|
-
|
|
1544
|
-
bindKeymapShortcut(api);
|
|
1545
|
-
|
|
1546
|
-
disposers.push(
|
|
1547
|
-
api.registerSection(
|
|
1548
|
-
bashDesignSection(
|
|
1549
|
-
store,
|
|
1550
|
-
demoStore,
|
|
1551
|
-
api.requestRepaint,
|
|
1552
|
-
() => state.designRegistered,
|
|
1553
|
-
() => state.cardStyleRegistered,
|
|
1554
|
-
),
|
|
1555
|
-
),
|
|
1556
|
-
);
|
|
1557
|
-
bridgeRegistered = true;
|
|
1558
|
-
};
|
|
1559
|
-
|
|
1560
|
-
/** The key this extension last bound, so a second `session_start` never stacks a handler. */
|
|
1561
|
-
let boundKey: string | undefined;
|
|
1562
|
-
|
|
1563
|
-
/** What the command prints, the palette item runs and the shortcut triggers: one honest report. */
|
|
1564
|
-
const reportState = (ctx: SessionContextLike): void => {
|
|
1565
|
-
const ui = ctx?.ui as NoticeUiLike | undefined;
|
|
1566
|
-
if (typeof ui?.notify !== "function") return;
|
|
1567
|
-
const facts = [
|
|
1568
|
-
`design ${state.designRegistered ? "ready" : "absent"}`,
|
|
1569
|
-
`card style ${state.cardStyleRegistered ? "ready" : "absent"}`,
|
|
1570
|
-
`keymap ${state.keymapRegistered ? "ready" : "absent"}`,
|
|
1571
|
-
`palette ${state.paletteRegistered ? "ready" : "absent"}`,
|
|
1572
|
-
`key ${boundKey ?? "none"}`,
|
|
1573
|
-
];
|
|
1574
|
-
ui.notify(`[bash-card] ${facts.join(" · ")}`, "info");
|
|
1575
|
-
};
|
|
1576
|
-
|
|
1577
|
-
/**
|
|
1578
|
-
* Bind the key the shell says this action uses, with this extension's own `registerShortcut` — the
|
|
1579
|
-
* bridge never binds for a plugin. Re-binding is skipped when the key did not move, and the
|
|
1580
|
-
* `unavailable` reason (an older shell) is reported once instead of a shortcut that silently is not
|
|
1581
|
-
* there.
|
|
1582
|
-
*/
|
|
1583
|
-
const bindKeymapShortcut = (api: BridgeApiLike): void => {
|
|
1584
|
-
if (typeof pi.registerShortcut !== "function") return;
|
|
1585
|
-
const resolved = typeof api.resolveKeymap === "function" ? api.resolveKeymap.call(api, BASH_CARD_KEYMAP.id) : undefined;
|
|
1586
|
-
const key = resolved?.key;
|
|
1587
|
-
if (key === undefined) {
|
|
1588
|
-
if (resolved?.unavailable !== undefined) recordNotice(`[bash-card] no key bound: ${resolved.unavailable}`);
|
|
1589
|
-
return;
|
|
1590
|
-
}
|
|
1591
|
-
if (key === boundKey) return;
|
|
1592
|
-
try {
|
|
1593
|
-
pi.registerShortcut(key, {
|
|
1594
|
-
description: BASH_CARD_KEYMAP.label,
|
|
1595
|
-
handler: (ctx: SessionContextLike) => reportState(ctx),
|
|
1596
|
-
});
|
|
1597
|
-
boundKey = key;
|
|
1598
|
-
} catch (error) {
|
|
1599
|
-
recordNotice(
|
|
1600
|
-
`[bash-card] ${key} was refused by Pi: ${error instanceof Error ? error.message : String(error)}`,
|
|
1601
|
-
);
|
|
1602
|
-
}
|
|
1603
|
-
};
|
|
1604
|
-
|
|
1605
|
-
// The command is the anchor of both surfaces: the palette item names it and the keymap action runs
|
|
1606
|
-
// it, so neither can survive the other's absence as a row that does nothing.
|
|
1607
|
-
if (typeof pi.registerCommand === "function") {
|
|
1608
|
-
pi.registerCommand(BASH_CARD_COMMAND, {
|
|
1609
|
-
description: "Report what the bash-card example registered, and the key bound to its action.",
|
|
1610
|
-
handler: async (_args: unknown, ctx: SessionContextLike) => reportState(ctx),
|
|
1611
|
-
});
|
|
1612
|
-
// The second command exists to prove the OTHER palette shape: an item that joins a section the
|
|
1613
|
-
// host already owns. It says where the key comes from, which is the one thing a row cannot show.
|
|
1614
|
-
pi.registerCommand(BASH_CARD_KEY_COMMAND, {
|
|
1615
|
-
description: "Report the key bound to the bash-card action and where to rebind it.",
|
|
1616
|
-
handler: async (_args: unknown, ctx: SessionContextLike) => {
|
|
1617
|
-
const ui = ctx?.ui as NoticeUiLike | undefined;
|
|
1618
|
-
if (typeof ui?.notify !== "function") return;
|
|
1619
|
-
ui.notify(
|
|
1620
|
-
boundKey === undefined
|
|
1621
|
-
? `[bash-card] no key bound. ${state.keymapRegistered ? "Open /gentle:customize → Keymaps and assign one." : "This shell does not read plugin keymaps."}`
|
|
1622
|
-
: `[bash-card] ${boundKey} → ${BASH_CARD_KEYMAP.label}. Rebind it in /gentle:customize → Keymaps.`,
|
|
1623
|
-
"info",
|
|
1624
|
-
);
|
|
1625
|
-
},
|
|
1626
|
-
});
|
|
1627
|
-
}
|
|
1628
|
-
|
|
1629
|
-
/**
|
|
1630
|
-
* Register through the published object, if there is one. Three outcomes, because "absent" and
|
|
1631
|
-
* "present but unusable" need different notices: the design registration is the one call that can
|
|
1632
|
-
* fail while the API is right there.
|
|
1633
|
-
*/
|
|
1634
|
-
const tryRegister = (): "registered" | "absent" | "failed" => {
|
|
1635
|
-
if (closed || bridgeRegistered) return "registered";
|
|
1636
|
-
const published = bridgeHost()[BRIDGE_API_KEY];
|
|
1637
|
-
if (!isBridgeApi(published)) return "absent";
|
|
1638
|
-
try {
|
|
1639
|
-
registerWithBridge(published);
|
|
1640
|
-
} catch (error) {
|
|
1641
|
-
recordNotice(
|
|
1642
|
-
`[bash-card] the published bridge refused to register its section: ` +
|
|
1643
|
-
`${error instanceof Error ? error.message : String(error)}`,
|
|
1644
|
-
);
|
|
1645
|
-
return "failed";
|
|
1646
|
-
}
|
|
1647
|
-
return bridgeRegistered ? "registered" : "failed";
|
|
1648
|
-
};
|
|
1649
|
-
|
|
1650
|
-
/**
|
|
1651
|
-
* The bridge published after this file loaded. Registration is the same guarded call
|
|
1652
|
-
* `session_start` makes, so an announcement and a later session cannot both register, and a
|
|
1653
|
-
* "waiting" notice the user has not seen yet is withdrawn because it is no longer true.
|
|
1654
|
-
*/
|
|
1655
|
-
const onBridgeReady = (): void => {
|
|
1656
|
-
// The announcement only says "look again": the symbol is the authority.
|
|
1657
|
-
if (tryRegister() === "absent") return;
|
|
1658
|
-
withdrawWaitingNotice();
|
|
1659
|
-
stopListening();
|
|
1660
|
-
};
|
|
1661
|
-
|
|
1662
|
-
// The symbol first. If the bridge loaded before this file there is nothing to wait for, so no
|
|
1663
|
-
// listener is taken at all. Otherwise the announcement is the only way a later bridge is heard.
|
|
1664
|
-
if (events !== undefined && !isBridgeApi(bridgeHost()[BRIDGE_API_KEY])) {
|
|
1665
|
-
try {
|
|
1666
|
-
const off = events.on(BRIDGE_READY_EVENT, onBridgeReady);
|
|
1667
|
-
if (typeof off === "function") unsubscribeReady = off;
|
|
1668
|
-
} catch {
|
|
1669
|
-
// No subscription: the `session_start` attempt below still tries the symbol.
|
|
1670
|
-
}
|
|
1671
|
-
}
|
|
1672
|
-
|
|
1673
|
-
// Pi renders diagnostics inside the TUI, so every message this example produces is delivered
|
|
1674
|
-
// there — never on stdout/stderr — and the handler does nothing when the context has no UI.
|
|
1675
|
-
const onSessionStart = (_event: unknown, ctx: SessionContextLike): void => {
|
|
1676
|
-
if (closed || ctx.hasUI !== true) return;
|
|
1677
|
-
|
|
1678
|
-
if (tryRegister() === "absent") {
|
|
1679
|
-
// Two honest states: with a bus the bridge may still announce itself, without one nothing can
|
|
1680
|
-
// arrive in this process. The notice says which, and the waiting one is withdrawn if it
|
|
1681
|
-
// happens to register before the next session shows it.
|
|
1682
|
-
if (events === undefined) recordNotice(NO_SIGNAL_NOTICE);
|
|
1683
|
-
else recordNotice(WAITING_NOTICE, { untilRegistered: true });
|
|
1684
|
-
}
|
|
1685
|
-
|
|
1686
|
-
flushNotices(ctx.ui, bridgeRegistered);
|
|
1687
|
-
};
|
|
1688
|
-
if (typeof pi.on === "function") pi.on("session_start", onSessionStart);
|
|
1689
|
-
|
|
1690
|
-
// Nothing depends on this return value — the stable ids are what make a `/reload` replace rather
|
|
1691
|
-
// than stack — it exists so a programmatic caller can tear every surface down together, and it
|
|
1692
|
-
// drops the announcement listener with them.
|
|
1693
|
-
return dispose;
|
|
1694
|
-
}
|