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.
Files changed (75) hide show
  1. package/LICENSE +202 -0
  2. package/docs/contract.md +1 -1
  3. package/package.json +13 -1
  4. package/src/api.ts +1 -1
  5. package/.codegraph/.gitignore +0 -5
  6. package/PRD.md +0 -144
  7. package/examples/bash-card.ts +0 -1694
  8. package/gentle-shell-bridge-0.3.0.tgz +0 -0
  9. package/odd/tasks/palette-sections.md +0 -30
  10. package/odd/tasks/plugin-keymap.md +0 -32
  11. package/odd/tasks/plugin-surface-contract.md +0 -55
  12. package/test/api.test.ts +0 -395
  13. package/test/apply-on-move.test.ts +0 -477
  14. package/test/arm.test.ts +0 -220
  15. package/test/bridge-ready.test.ts +0 -309
  16. package/test/bus.test.ts +0 -165
  17. package/test/button-rows.test.ts +0 -409
  18. package/test/capture.test.ts +0 -356
  19. package/test/card-style-rows.test.ts +0 -510
  20. package/test/card-style-transform.test.ts +0 -379
  21. package/test/card-style-wiring.test.ts +0 -258
  22. package/test/card-styles-e2e.test.ts +0 -491
  23. package/test/card-styles.test.ts +0 -324
  24. package/test/card-transcript-path.test.ts +0 -178
  25. package/test/config.test.ts +0 -93
  26. package/test/e2e.test.ts +0 -443
  27. package/test/example-bash-identity.test.ts +0 -372
  28. package/test/example-bash-matrix.test.ts +0 -856
  29. package/test/example-designs.test.ts +0 -734
  30. package/test/example-neon-transform.test.ts +0 -283
  31. package/test/example-plain-style.test.ts +0 -1268
  32. package/test/extension.test.ts +0 -160
  33. package/test/fake-events.ts +0 -58
  34. package/test/fake-host.ts +0 -211
  35. package/test/fake-sidebar.ts +0 -516
  36. package/test/fake-transcript.ts +0 -486
  37. package/test/frame-telemetry.test.ts +0 -417
  38. package/test/hook.test.ts +0 -196
  39. package/test/manifest.test.ts +0 -105
  40. package/test/new-category.test.ts +0 -979
  41. package/test/notices.test.ts +0 -328
  42. package/test/plugin-integration-lines.test.ts +0 -979
  43. package/test/plugin-surface.test.ts +0 -152
  44. package/test/presence.test.ts +0 -459
  45. package/test/rail-surfaces.test.ts +0 -457
  46. package/test/rail-width.test.ts +0 -423
  47. package/test/regression.test.ts +0 -483
  48. package/test/repaint-api.test.ts +0 -200
  49. package/test/reserved-categories.test.ts +0 -361
  50. package/test/row-labels.test.ts +0 -284
  51. package/test/row-names.test.ts +0 -473
  52. package/test/row-origin.test.ts +0 -395
  53. package/test/section-interaction.test.ts +0 -364
  54. package/test/section-panel.test.ts +0 -403
  55. package/test/sections-rows.test.ts +0 -291
  56. package/test/sidebar-backend.test.ts +0 -471
  57. package/test/sidebar-e2e.test.ts +0 -331
  58. package/test/sidebar-hover.test.ts +0 -276
  59. package/test/sidebar-independent.test.ts +0 -349
  60. package/test/sidebar-render.test.ts +0 -281
  61. package/test/sidebar-sections.test.ts +0 -245
  62. package/test/status-card-styles.test.ts +0 -201
  63. package/test/status-section-frame.test.ts +0 -181
  64. package/test/status-sections.test.ts +0 -243
  65. package/test/status.test.ts +0 -209
  66. package/test/store.test.ts +0 -192
  67. package/test/surface-repaint-cost.test.ts +0 -520
  68. package/test/tool-design-chain.test.ts +0 -483
  69. package/test/tool-design-cost.test.ts +0 -473
  70. package/test/tool-design-rows.test.ts +0 -359
  71. package/test/tool-designs.test.ts +0 -745
  72. package/test/transform-pipeline.test.ts +0 -465
  73. package/test/transforms-bus.test.ts +0 -233
  74. package/test/verifier-width-regressions.test.ts +0 -164
  75. package/tsconfig.json +0 -18
@@ -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
- }