gentle-shell-bridge 0.0.0-stage → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (112) hide show
  1. package/.codegraph/.gitignore +5 -0
  2. package/PRD.md +144 -0
  3. package/README.md +745 -2
  4. package/docs/architecture.md +981 -0
  5. package/docs/contract.md +1266 -0
  6. package/docs/state.md +251 -0
  7. package/examples/bash-card.ts +1694 -0
  8. package/gentle-shell-bridge-0.3.0.tgz +0 -0
  9. package/odd/tasks/palette-sections.md +30 -0
  10. package/odd/tasks/plugin-keymap.md +32 -0
  11. package/odd/tasks/plugin-surface-contract.md +55 -0
  12. package/package.json +35 -4
  13. package/src/api.ts +492 -0
  14. package/src/arm.ts +83 -0
  15. package/src/bus.ts +558 -0
  16. package/src/capture.ts +886 -0
  17. package/src/card-style-line.ts +112 -0
  18. package/src/card-style-rows.ts +390 -0
  19. package/src/card-style-transform.ts +862 -0
  20. package/src/card-style-wiring.ts +472 -0
  21. package/src/card-styles.ts +159 -0
  22. package/src/config.ts +71 -0
  23. package/src/extension.ts +271 -0
  24. package/src/hook.ts +310 -0
  25. package/src/index.ts +71 -0
  26. package/src/notices.ts +137 -0
  27. package/src/plugin-lines.ts +865 -0
  28. package/src/plugin-surface.ts +218 -0
  29. package/src/presence.ts +259 -0
  30. package/src/repaint.ts +351 -0
  31. package/src/row-labels.ts +146 -0
  32. package/src/row-names.ts +273 -0
  33. package/src/row-origin.ts +144 -0
  34. package/src/rows.ts +248 -0
  35. package/src/section-panel.ts +378 -0
  36. package/src/section-pointer.ts +164 -0
  37. package/src/sections-rows.ts +128 -0
  38. package/src/sidebar-render.ts +535 -0
  39. package/src/sidebar.ts +919 -0
  40. package/src/status.ts +1161 -0
  41. package/src/store.ts +279 -0
  42. package/src/tool-design-adoption.ts +263 -0
  43. package/src/tool-design-rows.ts +246 -0
  44. package/src/tool-design-wiring.ts +428 -0
  45. package/src/tool-designs.ts +402 -0
  46. package/src/transform-pipeline.ts +351 -0
  47. package/src/types.ts +442 -0
  48. package/src/visibility.ts +154 -0
  49. package/test/api.test.ts +395 -0
  50. package/test/apply-on-move.test.ts +477 -0
  51. package/test/arm.test.ts +220 -0
  52. package/test/bridge-ready.test.ts +309 -0
  53. package/test/bus.test.ts +165 -0
  54. package/test/button-rows.test.ts +409 -0
  55. package/test/capture.test.ts +356 -0
  56. package/test/card-style-rows.test.ts +510 -0
  57. package/test/card-style-transform.test.ts +379 -0
  58. package/test/card-style-wiring.test.ts +258 -0
  59. package/test/card-styles-e2e.test.ts +491 -0
  60. package/test/card-styles.test.ts +324 -0
  61. package/test/card-transcript-path.test.ts +178 -0
  62. package/test/config.test.ts +93 -0
  63. package/test/e2e.test.ts +443 -0
  64. package/test/example-bash-identity.test.ts +372 -0
  65. package/test/example-bash-matrix.test.ts +856 -0
  66. package/test/example-designs.test.ts +734 -0
  67. package/test/example-neon-transform.test.ts +283 -0
  68. package/test/example-plain-style.test.ts +1268 -0
  69. package/test/extension.test.ts +160 -0
  70. package/test/fake-events.ts +58 -0
  71. package/test/fake-host.ts +211 -0
  72. package/test/fake-sidebar.ts +516 -0
  73. package/test/fake-transcript.ts +486 -0
  74. package/test/frame-telemetry.test.ts +417 -0
  75. package/test/hook.test.ts +196 -0
  76. package/test/manifest.test.ts +105 -0
  77. package/test/new-category.test.ts +979 -0
  78. package/test/notices.test.ts +328 -0
  79. package/test/plugin-integration-lines.test.ts +979 -0
  80. package/test/plugin-surface.test.ts +152 -0
  81. package/test/presence.test.ts +459 -0
  82. package/test/rail-surfaces.test.ts +457 -0
  83. package/test/rail-width.test.ts +423 -0
  84. package/test/regression.test.ts +483 -0
  85. package/test/repaint-api.test.ts +200 -0
  86. package/test/reserved-categories.test.ts +361 -0
  87. package/test/row-labels.test.ts +284 -0
  88. package/test/row-names.test.ts +473 -0
  89. package/test/row-origin.test.ts +395 -0
  90. package/test/section-interaction.test.ts +364 -0
  91. package/test/section-panel.test.ts +403 -0
  92. package/test/sections-rows.test.ts +291 -0
  93. package/test/sidebar-backend.test.ts +471 -0
  94. package/test/sidebar-e2e.test.ts +331 -0
  95. package/test/sidebar-hover.test.ts +276 -0
  96. package/test/sidebar-independent.test.ts +349 -0
  97. package/test/sidebar-render.test.ts +281 -0
  98. package/test/sidebar-sections.test.ts +245 -0
  99. package/test/status-card-styles.test.ts +201 -0
  100. package/test/status-section-frame.test.ts +181 -0
  101. package/test/status-sections.test.ts +243 -0
  102. package/test/status.test.ts +209 -0
  103. package/test/store.test.ts +192 -0
  104. package/test/surface-repaint-cost.test.ts +520 -0
  105. package/test/tool-design-chain.test.ts +483 -0
  106. package/test/tool-design-cost.test.ts +473 -0
  107. package/test/tool-design-rows.test.ts +359 -0
  108. package/test/tool-designs.test.ts +745 -0
  109. package/test/transform-pipeline.test.ts +465 -0
  110. package/test/transforms-bus.test.ts +233 -0
  111. package/test/verifier-width-regressions.test.ts +164 -0
  112. package/tsconfig.json +18 -0
package/docs/state.md ADDED
@@ -0,0 +1,251 @@
1
+ # ADR 0001 — Stateless composition, with an optional per-plugin store
2
+
3
+ - **Status:** accepted for the extraction; implemented in `src/store.ts` and exercised by the
4
+ test suite. **Amended** by decision 4: a registered sidebar section's visibility is the one
5
+ piece of state the bridge itself owns. **Amended** by decision 5: plugins reach the bridge
6
+ through a published, versioned `globalThis` slot instead of a path or an environment variable.
7
+ **Amended** by decision 6: the selected card style is bridge-owned state, and the conversation
8
+ frames are repainted opt-in, degrading to native. **Amended** by decision 7: the repaint wrapper
9
+ became a general, ordered transform pipeline, so a plugin's rendering choice reaches cards already
10
+ on screen instead of only the next one.
11
+ - **Context:** [contract.md](contract.md), [architecture.md](architecture.md)
12
+ - **Deciders:** package author and gentle-shell maintainers.
13
+
14
+ ## Context
15
+
16
+ The bridge exists to compose rows from many plugins into one menu. A row's `●` is computed
17
+ by `current()`, which the renderer calls at render time (`lib/visual-customize-view.ts:585`),
18
+ and a toggle applies through `action()`, which the view runs on ←/→ or Enter
19
+ (`lib/visual-customize-view.ts:565`) and follows with its own repaint
20
+ (`lib/visual-customize-view.ts:579`). Persistence therefore has to sit behind the row, and
21
+ the question is *who owns it*: the bridge, each plugin, or nobody.
22
+
23
+ Two practical constraints shape the answer:
24
+
25
+ 1. Row state must outlive `/reload` to be useful, and Pi gives each extension its own copy
26
+ of any imported module (`lib/shell-card.ts:30-32`), so in-memory module state is
27
+ per-extension and per-load.
28
+ 2. Plugins already have their own config files in gentle-shell, written atomically
29
+ (gentle-shell uses temp-file plus `renameSync`, e.g. `lib/agent-profiles.ts:715`,
30
+ `lib/animation-policy.ts:47`). A bridge that owns state would need to replicate that
31
+ discipline for data it does not understand.
32
+
33
+ ## Decision
34
+
35
+ **1. The bridge is stateless by default.** It composes rows; it does not own preferences,
36
+ caches no row values, and has no opinion about a plugin's config format. Diagnostics hold
37
+ runtime facts (registrations, capture outcome, failures), not settings.
38
+
39
+ **2. The bridge also exposes an optional `createStore(pluginId)`.** A plugin that wants
40
+ zero boilerplate gets:
41
+
42
+ - path: `<gentle config home>/gentle-shell-bridge/<pluginId>.json` — namespaced by plugin,
43
+ under the directory gentle-shell already resolves (default `~/.pi/gentle-ai`,
44
+ `GENTLE_PI_CONFIG_HOME` override — `lib/gentle-shell-bundled.ts:231-239`);
45
+ - atomic writes: write a temporary file in the same directory, then rename over the
46
+ target, so a crash mid-write cannot truncate the live file;
47
+ - a schema version field (integer `schemaVersion`, currently `1`) so a future shape change
48
+ can migrate instead of guessing;
49
+ - a document shape of `{ "schemaVersion": 1, "values": { … } }`, decided during
50
+ implementation: the `values` envelope keeps a plugin key from ever colliding with
51
+ `schemaVersion`;
52
+ - `pluginId` validation before any filesystem access: one non-empty path segment, no
53
+ separators, no `.` or `..`, and a resolved path that cannot leave
54
+ `<config home>/gentle-shell-bridge`;
55
+ - read/write accessors (`get(key, fallback)`, `set(key, value)`) scoped to that one file.
56
+
57
+ **3. A plugin may keep using its own file** — gentle-shell plugins do this today, e.g.
58
+ `loon-tui.json`. The bridge never reads or writes a plugin's own file. `createStore` is a
59
+ convenience for plugins that have no file yet, not a migration target for plugins that do.
60
+
61
+ **4. One exception, and only one: a sidebar section's visibility is bridge-owned state.**
62
+ `registerSection` makes the bridge create the show/hide toggle, so the value behind that toggle
63
+ cannot belong to the plugin: there is no plugin row, no plugin-chosen setting name and no plugin
64
+ store in the flow. The bridge persists it in its own namespaced file,
65
+ `<gentle config home>/gentle-shell-bridge/bridge-sections.json`, as
66
+
67
+ ```json
68
+ { "schemaVersion": 1, "values": { "sections": { "loon.music": false } } }
69
+ ```
70
+
71
+ one key (`sections`) mapping section id → boolean, defaults taken from the section's
72
+ `defaultVisible`, non-boolean values ignored rather than coerced.
73
+
74
+ Why not the host's `visual-customization.json`: gentle-shell validates that file against a closed
75
+ `VISUAL_SECTION_KEYS` list (`lib/visual-customization-policy.ts:82`, `keysMatch` ~:112-130) and
76
+ rejects unknown keys, so a third-party section key cannot live there. The user experience is the
77
+ native one; the file deliberately is not, and that difference is documented in
78
+ [contract.md](contract.md) §11 rather than hidden.
79
+
80
+ ## Consequences
81
+
82
+ ### The correctness rule
83
+
84
+ `current()` must read the plugin's live state at render time, and `action()` must persist
85
+ first and re-render second. Concretely: `action()` writes through `createStore` (or its own
86
+ file) and returns; the view's own repaint re-evaluates `current()`, which reads the same
87
+ store, so `●` lands on the value that was just written. Inverting the order — paint, then
88
+ persist — makes the bullet lie for one frame and, on a failed write, permanently.
89
+
90
+ ```ts
91
+ choice: { setting: "Loon → Banner source", value, current: () => read() === value },
92
+ action: () => store.set("bannerSource", value), // persist; repaint follows
93
+ ```
94
+
95
+ The bridge cannot enforce this. It can only document the rule; a plugin that caches a value
96
+ at registration produces a stale `●` and the diagnostics command will not detect it.
97
+
98
+ ### Bridge-owned section visibility
99
+
100
+ This is the one place the bridge is not stateless, and the reason is structural: the bridge
101
+ generates the toggle, so only the bridge can know which value it points at. The plugin still owns
102
+ nothing — it never sees this file, never picks a key, and its section appears or disappears from
103
+ the same user gesture as a native one.
104
+
105
+ - The blast radius stays per surface: one corrupt `bridge-sections.json` disables section
106
+ visibility only. It reads as empty (defaults apply), is reported by `/bridge-status` as a store
107
+ problem, and is rewritten as a valid document by the next toggle — the same rules
108
+ `createStore` already follows for plugin files.
109
+ - A plugin that also keeps its own state is unaffected: this file is keyed by section id and the
110
+ bridge never reads anything else from it.
111
+ - The cost is honest: the bridge now owns one preference. It is a preference about the bridge's
112
+ own surface, which is why it belongs here and not in a plugin's namespace.
113
+
114
+ ### Reads, cost and limits of bridge-owned visibility
115
+
116
+ - Visibility is read once per frame by the renderer and once per `current()` call by the rows, so a
117
+ toggle shows up on the sidebar's next frame (pinned by a test), at the cost of a small file read
118
+ per read. A cache would need to be invalidated by the toggle's own write.
119
+ - There is no migration path from a future `visual-customization.json` section key; if the host ever
120
+ opens `VISUAL_SECTION_KEYS`, this file becomes redundant and can be migrated in one place.
121
+
122
+ ### Other consequences
123
+
124
+ - One plugin's corrupt or slow file cannot affect another plugin's rows or the menu.
125
+ - Two plugins writing concurrently never touch the same file, so there is no cross-plugin
126
+ write contention and no cross-plugin lock protocol to design.
127
+ - Duplicate work is accepted: N plugins may each implement a small atomic-write helper.
128
+ The alternative was N plugins contending on one file owned by the bridge.
129
+ - A malformed store file is reported through `/bridge-status` and reads fall back to the
130
+ plugin's defaults; the bridge does not repair or delete the file, and a later `set` writes
131
+ a valid document. Existing `values` are carried into that write **only** when the file was
132
+ a usable `schemaVersion: 1` document (so a partially written file keeps its other keys).
133
+ For an unknown `schemaVersion` the write stores only the keys the caller set and records
134
+ the discarded payload as a store problem: the bridge cannot know what a future schema's
135
+ payload means, so re-labelling it as schema 1 would be a lie about its format, and
136
+ silently destroying it while pretending it is valid would be worse than reporting it.
137
+ Foreign top-level keys are always dropped. A reported problem stays on record until a
138
+ write resolves it, so reading the repaired file cannot erase it from `/bridge-status`.
139
+ - A plugin that keeps its own file gets no migration help from the bridge — this is the
140
+ documented cost of the opt-in helper.
141
+ - The bridge acquires disk-write code it must test, and it does test it (round-trip,
142
+ malformed file, atomicity, path rejection on temporary directories); if `createStore`
143
+ proves to add more risk than boilerplate it removes, it can be dropped without changing
144
+ `registerRows`.
145
+
146
+ ## Alternatives rejected
147
+
148
+ **A. Bridge centralises all state in one JSON file.** One file under the gentle config home
149
+ holding every plugin's settings. Rejected: a corrupt or truncated file breaks every plugin
150
+ at once, and concurrent writers from independent plugins contend on the same file — the
151
+ exact cross-plugin coupling the bridge exists to remove. It also forces the bridge to
152
+ understand, validate and migrate data from plugins it does not control.
153
+
154
+ **B. Bridge writes nothing and offers no helper.** Maximum separation: composition only.
155
+ Rejected: every plugin re-implements atomic writes, namespace paths and version fields, and
156
+ there is no shared migration story — a plugin that gets it wrong loses user settings, and
157
+ there is no single place to fix the pattern. The duplication is real and predictable, which
158
+ is why the optional helper wins over pure minimalism.
159
+
160
+ **C. Chosen: stateless composition plus optional namespaced store.** Keeps the blast radius
161
+ per plugin (A's failure mode), keeps a fixable default for the write path (B's gap), and
162
+ leaves existing plugin files untouched.
163
+
164
+ **D. Rejected: the plugin imports the bridge by path, or the user sets
165
+ `GENTLE_SHELL_BRIDGE_MODULE`.** The worked example used to resolve the package by trying a
166
+ dynamic `import()` of candidate `src/index.ts` paths. Rejected: a path is not a contract between
167
+ two independent projects — it names one checkout on one machine, reaches into another package's
168
+ files, and breaks silently the day either repository moves. An environment variable moves the
169
+ same path into every user's shell setup, so the feature does nothing until someone is told to set
170
+ it, which contradicts "one call, zero extra steps". Neither channel carries a version gate, so
171
+ either can hand a plugin an object it cannot use with no way to notice. Removed from the example
172
+ in `f0f30fe`.
173
+
174
+ **E. Chosen: publish a frozen, versioned object on `globalThis`; consume it at `session_start`.**
175
+ The bridge publishes `{ schema, version, VERSION, registerRows, registerSection, createStore }` at
176
+ `Symbol.for("gentle-shell-bridge/api/v1")` when its module loads — first-wins, never overwritten,
177
+ never reset. A plugin registers from its own `session_start` handler: Pi completes every extension
178
+ module's async initialization before firing that event (`docs/extensions.md:213`, `:393`, `:512`),
179
+ so the slot either holds the object or the bridge is not installed. (`registerCardStyle` joined the
180
+ object in 0.2.0 and `registerTransform` in 0.3.0; the gate stays on the three original members, so an
181
+ older object is still a usable
182
+ bridge for rows, sections and the store.) A consumer gates on the schema
183
+ string, `version === 1` and the three callable members; anything else is absent, `bridgeApi()`
184
+ returns `undefined`, and the bridge warns once per process. `bridgeApi()` and `isBridgeApi(value)`
185
+ are the same handshake for a plugin that does import the package, and the exact form is quoted in
186
+ [contract.md](contract.md) §9. It follows the `pi-lens` bridge convention (stable symbol, versioned,
187
+ first-wins frozen, new major = new key) and gentle-shell's schema-string plus shape gate.
188
+
189
+ **History: a deferred-registration slot was removed.** An earlier revision of this decision added
190
+ a second `Symbol.for` slot holding initialisation callbacks, run at publish time and on every
191
+ `session_start`. It existed only to let a plugin register at module load — the one moment where
192
+ "not yet" and "never" are indistinguishable — and `session_start` already fires after every module
193
+ has loaded, so that path was redundant. Removing it also removed the only path that could defer a
194
+ plugin silently: a newer plugin against an older bridge now sees "absent", which is the intended
195
+ answer.
196
+
197
+ The cost is real and accepted: every plugin repeats the same handful of lines — a symbol lookup
198
+ and a shape gate — and copies them into its own file, because the whole point is that it cannot
199
+ import the bridge. That boilerplate lasts until Pi ships a real cross-extension registry; on that
200
+ day the inline block in each plugin becomes one call and this decision is superseded, not extended.
201
+
202
+ **F. Rejected: ask gentle-shell for a card-style registry.** A third style could be supported
203
+ properly — `CARD_STYLE` gaining members, the style policy file accepting any registered id, the
204
+ frame functions living in the host — by one upstream change. Rejected: this package exists
205
+ precisely because gentle-shell does not take every change (the sidebar work landed here for the
206
+ same reason). A design that waits for an upstream merge is not a bridge: it makes the feature's
207
+ delivery depend on a different project's review queue, and it would fork the moment the host
208
+ declined. `CARD_STYLE` stays a two-member union (`lib/shell-card.ts:33-38`), any unknown stored
209
+ value normalizes to `float` (`:43-45`), and the host keeps its own `Card style` line exactly as it
210
+ built it — the bridge only extends that line and repaints what the host already drew.
211
+
212
+ **G. Chosen: repaint the frames, opt-in, and degrade to native.** The bridge takes the `float`
213
+ frame the host drew and re-renders it with the selected style's three functions, leaving every
214
+ non-card row and every non-float card byte-identical. `native` is the default and the pass-through;
215
+ selecting a host value (`neon`/`float`) is the way back; a style that throws is a pass-through for
216
+ that frame and is reported; a disposed style resolves to `missing`, which behaves as `native`. The
217
+ selection is bridge-owned state in the bridge's own file (`bridge-card-style.json`), not the host's
218
+ `card-style.json`, for the same closed-key reason section visibility uses `bridge-sections.json`.
219
+ Costs accepted and recorded: the repaint depends on the host's `float` grammar, and a downstream
220
+ post-processor can override the frame (see [architecture.md](architecture.md) §14).
221
+
222
+ **H. Chosen: the conversation wrapper runs a general, ordered transform pipeline.** Decision G
223
+ proved that the conversation component can be repainted, but it was written as one hard-coded
224
+ feature. A `pi.registerToolRenderer` resolver cannot apply a rendering choice to rows that are
225
+ already drawn — it resolves when a row is BUILT — so that hook cannot serve a plugin that wants to
226
+ restyle history. Rather than a second wrapper, the existing one became a pipeline: the card-style
227
+ repaint runs first, then every transform registered with `registerTransform`, in order, each
228
+ receiving the previous output. The bridge keeps what it already owed the frame — clip to the host's
229
+ width, isolate a throw to the transform that made it, return the same array when nothing changed —
230
+ and reports the last frame's outcome per transform in `/bridge-status`. The alternative (a renderer
231
+ resolver plus a transform, one for new rows and one for history) was rejected because it gives one
232
+ plugin two different pictures of the same card, which is the defect the surface exists to remove.
233
+
234
+ ## Open questions
235
+
236
+ - Should `createStore` ship at all in the first release, or follow once a second plugin
237
+ needs it? — **Settled during implementation: it ships.** Deferring would have been
238
+ compatible with this decision; nothing in `registerRows` depends on the store.
239
+ - Whether migrations beyond `schemaVersion: 1` belong in the bridge or in the plugin.
240
+ - Whether a plugin that keeps its own file should be able to *declare* that path to
241
+ `/bridge-status` for diagnostics only. No read, no write — purely visibility.
242
+
243
+ ## Limits
244
+
245
+ - No file locking is specified: atomic rename prevents torn writes, not lost updates from
246
+ two processes editing the same plugin file. A plugin that runs in parallel sessions must
247
+ accept last-writer-wins or guard its own writes.
248
+ - `createStore` is implemented and tested against temporary directories, including the
249
+ malformed-file path; it has not been exercised inside a running Pi session, and it has no
250
+ cross-process locking (see the first limit).
251
+ - Secrets do not belong in these files; the store provides no redaction.