gentle-shell-bridge 0.3.0 → 0.3.2

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 (76) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +91 -722
  3. package/docs/contract.md +92 -35
  4. package/package.json +13 -1
  5. package/src/api.ts +1 -1
  6. package/.codegraph/.gitignore +0 -5
  7. package/PRD.md +0 -144
  8. package/examples/bash-card.ts +0 -1694
  9. package/gentle-shell-bridge-0.3.0.tgz +0 -0
  10. package/odd/tasks/palette-sections.md +0 -30
  11. package/odd/tasks/plugin-keymap.md +0 -32
  12. package/odd/tasks/plugin-surface-contract.md +0 -55
  13. package/test/api.test.ts +0 -395
  14. package/test/apply-on-move.test.ts +0 -477
  15. package/test/arm.test.ts +0 -220
  16. package/test/bridge-ready.test.ts +0 -309
  17. package/test/bus.test.ts +0 -165
  18. package/test/button-rows.test.ts +0 -409
  19. package/test/capture.test.ts +0 -356
  20. package/test/card-style-rows.test.ts +0 -510
  21. package/test/card-style-transform.test.ts +0 -379
  22. package/test/card-style-wiring.test.ts +0 -258
  23. package/test/card-styles-e2e.test.ts +0 -491
  24. package/test/card-styles.test.ts +0 -324
  25. package/test/card-transcript-path.test.ts +0 -178
  26. package/test/config.test.ts +0 -93
  27. package/test/e2e.test.ts +0 -443
  28. package/test/example-bash-identity.test.ts +0 -372
  29. package/test/example-bash-matrix.test.ts +0 -856
  30. package/test/example-designs.test.ts +0 -734
  31. package/test/example-neon-transform.test.ts +0 -283
  32. package/test/example-plain-style.test.ts +0 -1268
  33. package/test/extension.test.ts +0 -160
  34. package/test/fake-events.ts +0 -58
  35. package/test/fake-host.ts +0 -211
  36. package/test/fake-sidebar.ts +0 -516
  37. package/test/fake-transcript.ts +0 -486
  38. package/test/frame-telemetry.test.ts +0 -417
  39. package/test/hook.test.ts +0 -196
  40. package/test/manifest.test.ts +0 -105
  41. package/test/new-category.test.ts +0 -979
  42. package/test/notices.test.ts +0 -328
  43. package/test/plugin-integration-lines.test.ts +0 -979
  44. package/test/plugin-surface.test.ts +0 -152
  45. package/test/presence.test.ts +0 -459
  46. package/test/rail-surfaces.test.ts +0 -457
  47. package/test/rail-width.test.ts +0 -423
  48. package/test/regression.test.ts +0 -483
  49. package/test/repaint-api.test.ts +0 -200
  50. package/test/reserved-categories.test.ts +0 -361
  51. package/test/row-labels.test.ts +0 -284
  52. package/test/row-names.test.ts +0 -473
  53. package/test/row-origin.test.ts +0 -395
  54. package/test/section-interaction.test.ts +0 -364
  55. package/test/section-panel.test.ts +0 -403
  56. package/test/sections-rows.test.ts +0 -291
  57. package/test/sidebar-backend.test.ts +0 -471
  58. package/test/sidebar-e2e.test.ts +0 -331
  59. package/test/sidebar-hover.test.ts +0 -276
  60. package/test/sidebar-independent.test.ts +0 -349
  61. package/test/sidebar-render.test.ts +0 -281
  62. package/test/sidebar-sections.test.ts +0 -245
  63. package/test/status-card-styles.test.ts +0 -201
  64. package/test/status-section-frame.test.ts +0 -181
  65. package/test/status-sections.test.ts +0 -243
  66. package/test/status.test.ts +0 -209
  67. package/test/store.test.ts +0 -192
  68. package/test/surface-repaint-cost.test.ts +0 -520
  69. package/test/tool-design-chain.test.ts +0 -483
  70. package/test/tool-design-cost.test.ts +0 -473
  71. package/test/tool-design-rows.test.ts +0 -359
  72. package/test/tool-designs.test.ts +0 -745
  73. package/test/transform-pipeline.test.ts +0 -465
  74. package/test/transforms-bus.test.ts +0 -233
  75. package/test/verifier-width-regressions.test.ts +0 -164
  76. package/tsconfig.json +0 -18
@@ -1,160 +0,0 @@
1
- import assert from "node:assert/strict";
2
- import test from "node:test";
3
-
4
- import type { RowProvider } from "../src/types.ts";
5
- import { resetBusForTests, registerRows } from "../src/bus.ts";
6
- import { getCaptureDiagnostics, resetCaptureStateForTests } from "../src/capture.ts";
7
- import gentleShellBridge, {
8
- STATUS_COMMAND,
9
- type BridgeCommandContext,
10
- type BridgeCommandHandler,
11
- type BridgeExtensionApi,
12
- type BridgeSessionContext,
13
- type BridgeSessionHandler,
14
- } from "../src/extension.ts";
15
- import { resetHookStateForTests } from "../src/hook.ts";
16
- import { resetRowBuildStateForTests } from "../src/rows.ts";
17
- import { resetStoreStateForTests } from "../src/store.ts";
18
- import { createFakeHost } from "./fake-host.ts";
19
-
20
- const HOOK_KEY = Symbol.for("gentle-pi.customize-row-providers");
21
- const hookSlot = (): Record<symbol, unknown> => globalThis as unknown as Record<symbol, unknown>;
22
-
23
- test.beforeEach(() => {
24
- resetBusForTests();
25
- resetCaptureStateForTests();
26
- resetRowBuildStateForTests();
27
- resetHookStateForTests();
28
- resetStoreStateForTests();
29
- });
30
-
31
- test.afterEach(() => {
32
- delete hookSlot()[HOOK_KEY];
33
- resetHookStateForTests();
34
- });
35
-
36
- const labelsProvider = (id: string, category: string, labels: string[]): RowProvider => ({
37
- id,
38
- category,
39
- rows: () => labels.map((label) => ({ label, action: () => {} })),
40
- });
41
-
42
- interface FakeApi {
43
- api: BridgeExtensionApi;
44
- sessionHandlers: BridgeSessionHandler[];
45
- shutdownHandlers: BridgeSessionHandler[];
46
- commands: Array<{ name: string; description?: string; handler: BridgeCommandHandler }>;
47
- }
48
-
49
- const createFakeApi = (): FakeApi => {
50
- const sessionHandlers: BridgeSessionHandler[] = [];
51
- const shutdownHandlers: BridgeSessionHandler[] = [];
52
- const commands: Array<{ name: string; description?: string; handler: BridgeCommandHandler }> = [];
53
- const api: BridgeExtensionApi = {
54
- // The REQUIRED host package is present: `session_start` arms as before (S9/T12).
55
- getCommands: () => [{ name: "gentle:customize" }],
56
- on(event, handler) {
57
- if (event === "session_start") {
58
- sessionHandlers.push(handler);
59
- return;
60
- }
61
- assert.equal(event, "session_shutdown", `not a session event: ${String(event)}`);
62
- shutdownHandlers.push(handler);
63
- },
64
- registerCommand(name, command) {
65
- commands.push({ name, description: command.description, handler: command.handler });
66
- },
67
- };
68
- return { api, sessionHandlers, shutdownHandlers, commands };
69
- };
70
-
71
- test("registers one session_start handler, one session_shutdown handler and one command", () => {
72
- const fake = createFakeApi();
73
-
74
- gentleShellBridge(fake.api);
75
-
76
- assert.equal(fake.sessionHandlers.length, 1);
77
- assert.equal(fake.shutdownHandlers.length, 1, "shutdown is how the INTEGRATIONS row is cleared");
78
- assert.equal(fake.commands.length, 1);
79
- assert.equal(fake.commands[0].name, STATUS_COMMAND);
80
- assert.equal(typeof fake.commands[0].description, "string");
81
- assert.ok((fake.commands[0].description ?? "").length > 0);
82
- });
83
-
84
- test("a session_start without UI arms nothing", () => {
85
- const host = createFakeHost();
86
- registerRows(labelsProvider("p1", "Cards", ["Must not appear"]));
87
- const fake = createFakeApi();
88
- gentleShellBridge(fake.api);
89
-
90
- const context: BridgeSessionContext = { hasUI: false };
91
- fake.sessionHandlers[0]({}, context);
92
-
93
- assert.equal(getCaptureDiagnostics().armedTargets, 0);
94
- host.buildMenu();
95
- assert.deepEqual(host.rendered().slice(4), []);
96
- });
97
-
98
- test("two session_start events with UI arm once and inject once", () => {
99
- const host = createFakeHost();
100
- registerRows(labelsProvider("p1", "Cards", ["Once"]));
101
- const fake = createFakeApi();
102
- gentleShellBridge(fake.api);
103
- const context: BridgeSessionContext = { hasUI: true, ui: host.ui };
104
-
105
- fake.sessionHandlers[0]({}, context);
106
- fake.sessionHandlers[0]({}, context);
107
-
108
- assert.equal(getCaptureDiagnostics().armedTargets, 1, "one wrapper across two events");
109
- host.buildMenu();
110
- assert.deepEqual(
111
- host.rendered().filter((label) => label === "Once"),
112
- ["Once"],
113
- );
114
- assert.equal(host.invalidateCount(), 1);
115
- });
116
-
117
- test("loading the extension twice still arms the same ui once", () => {
118
- const host = createFakeHost();
119
- registerRows(labelsProvider("p1", "Cards", ["Once"]));
120
- const first = createFakeApi();
121
- const second = createFakeApi();
122
- gentleShellBridge(first.api);
123
- gentleShellBridge(second.api);
124
- const context: BridgeSessionContext = { hasUI: true, ui: host.ui };
125
-
126
- first.sessionHandlers[0]({}, context);
127
- second.sessionHandlers[0]({}, context);
128
-
129
- assert.equal(getCaptureDiagnostics().armedTargets, 1);
130
- host.buildMenu();
131
- assert.deepEqual(
132
- host.rendered().filter((label) => label === "Once"),
133
- ["Once"],
134
- );
135
- });
136
-
137
- test("the status command prints the formatter output", () => {
138
- registerRows(labelsProvider("loon.banner-source", "Banner", ["Row"]));
139
- const fake = createFakeApi();
140
- gentleShellBridge(fake.api);
141
- const printed: string[] = [];
142
- const context: BridgeCommandContext = {
143
- ui: { notify: (message: string) => printed.push(message) },
144
- };
145
-
146
- fake.commands[0].handler("", context);
147
-
148
- assert.equal(printed.length, 1);
149
- assert.match(printed[0], /^gentle-shell-bridge status$/m);
150
- assert.match(printed[0], /^providers: 1$/m);
151
- assert.match(printed[0], /loon\.banner-source \(Banner\)/);
152
- assert.match(printed[0], /^duplicate settings: no build yet$/m);
153
- });
154
-
155
- test("the status command does not throw when the command context has no ui", () => {
156
- const fake = createFakeApi();
157
- gentleShellBridge(fake.api);
158
-
159
- assert.doesNotThrow(() => fake.commands[0].handler("", {}));
160
- });
@@ -1,58 +0,0 @@
1
- /**
2
- * A fake `pi.events`: Pi's cross-extension bus (`docs/extensions.md:1965-1972`).
3
- *
4
- * The real bus (`dist/core/event-bus.js`) is an `EventEmitter` behind two methods: `on(channel,
5
- * handler)` returns an unsubscribe function, and `emit(channel, data)` calls every handler. The
6
- * real handler wrapper is `async`, so a handler's *synchronous* work has completed by the time
7
- * `emit` returns — this fake delivers synchronously for the same reason, which is what lets a
8
- * bridge that publishes later register its plugin's surfaces during the emit.
9
- *
10
- * It counts listeners so a test can prove a plugin that found the API at load leaves nothing
11
- * behind, and records every emit so "exactly once per process" is observable.
12
- */
13
-
14
- export type FakeEventHandler = (payload: unknown) => void;
15
-
16
- export interface FakeEmit {
17
- channel: string;
18
- payload: unknown;
19
- }
20
-
21
- export interface FakeEvents {
22
- on(channel: string, handler: FakeEventHandler): () => void;
23
- emit(channel: string, payload?: unknown): void;
24
- /** Every `emit` this bus saw, in order. */
25
- emitted(): FakeEmit[];
26
- /** Live listeners on one channel, or on every channel when omitted. */
27
- listenerCount(channel?: string): number;
28
- }
29
-
30
- export function createFakeEvents(): FakeEvents {
31
- const listeners = new Map<string, Set<FakeEventHandler>>();
32
- const emits: FakeEmit[] = [];
33
-
34
- const count = (channel: string): number => listeners.get(channel)?.size ?? 0;
35
-
36
- return {
37
- on(channel, handler) {
38
- const set = listeners.get(channel) ?? new Set<FakeEventHandler>();
39
- set.add(handler);
40
- listeners.set(channel, set);
41
- return () => {
42
- set.delete(handler);
43
- };
44
- },
45
- emit(channel, payload) {
46
- emits.push({ channel, payload });
47
- // A copy, so a listener that unsubscribes while being notified cannot skip its neighbour.
48
- for (const handler of [...(listeners.get(channel) ?? [])]) handler(payload);
49
- },
50
- emitted: () => [...emits],
51
- listenerCount: (channel) => {
52
- if (channel !== undefined) return count(channel);
53
- let total = 0;
54
- for (const set of listeners.values()) total += set.size;
55
- return total;
56
- },
57
- };
58
- }
package/test/fake-host.ts DELETED
@@ -1,211 +0,0 @@
1
- /**
2
- * A fake host for the capture backend, modelling `gentle-shell.ts:3550-3562` and
3
- * `lib/visual-customize-view.ts:387`.
4
- *
5
- * The host builds its `rows` array, derives categories from it with a single synchronous
6
- * `rows.map(...)` inside the factory — the one moment the array is reachable — and returns a
7
- * plain adapter `{ render, handleInput, invalidate, dispose }` that hides the rows. Duck-typing
8
- * `options.rows` on the adapter never matches, exactly like the real host.
9
- *
10
- * The adapter renders from the live array, so a test asserts what the user would see after an
11
- * injection, not just what the bridge captured.
12
- */
13
-
14
- import type { CustomizeRow, InlineHelpers, RowLabel } from "../src/types.ts";
15
-
16
- export type CustomFactory = (ui: unknown, theme: unknown, ...rest: unknown[]) => unknown;
17
-
18
- /** The `custom` surface Pi hands extensions; own property or inherited, depending on shape. */
19
- export interface FakeUiTarget {
20
- custom?: (factory: CustomFactory, ...rest: unknown[]) => unknown;
21
- }
22
-
23
- export interface FakeAdapter {
24
- render: () => string[];
25
- handleInput: (data: string) => unknown;
26
- invalidate: () => void;
27
- dispose: () => void;
28
- }
29
-
30
- export type FakeRow = CustomizeRow;
31
-
32
- export interface FakeHostOptions {
33
- /** The host's own menu rows. Must hold at least the capture threshold's worth of rows. */
34
- rows?: FakeRow[];
35
- /** `own` puts `custom` on the instance; `prototype` leaves the instance inheriting it. */
36
- customShape?: "own" | "prototype";
37
- /** `promise` makes the factory return a promise, as the real chain allows. */
38
- factoryResult?: "adapter" | "promise";
39
- /** Throw inside the factory after the category map ran. */
40
- throwInsideFactory?: boolean;
41
- /** Map only a non-row array inside the factory, never the rows array. */
42
- mapOnlyNonRows?: boolean;
43
- /** Theme handed to the factory as its second argument. */
44
- theme?: unknown;
45
- /** Extra factory arguments after the theme (the host passes keybindings and `close`). */
46
- extraFactoryArgs?: unknown[];
47
- }
48
-
49
- export interface FakeHost {
50
- readonly ui: FakeUiTarget;
51
- /** The factory itself, so a nesting test can re-enter a wrapped `custom` call. */
52
- readonly factory: CustomFactory;
53
- /** What Pi does: `ui.custom(factory, options)`. */
54
- buildMenu: () => unknown;
55
- /** The live array the adapter renders from (rows the host built plus anything injected). */
56
- liveRows: () => FakeRow[];
57
- /** Categories the host derived from that array in the factory's single `rows.map(...)`. */
58
- categories: () => string[];
59
- /** Labels the user would see right now. */
60
- rendered: () => string[];
61
- invalidateCount: () => number;
62
- disposeCount: () => number;
63
- factoryCallCount: () => number;
64
- /** Enter on the first visible row whose rendered label matches, including the view's repaint. */
65
- press: (label: string) => Promise<void>;
66
- /** Enter on the first visible row matching, so a test can target a choice value, not a label. */
67
- pressRow: (match: (row: FakeRow) => boolean) => Promise<void>;
68
- /**
69
- * The `●`/`○` lines the real renderer draws: consecutive rows sharing `choice.setting` become one
70
- * line (`lib/visual-customize-view.ts:583-595`), so this reads `current()` at render time.
71
- */
72
- toggleLines: () => string[];
73
- }
74
-
75
- const inlineHelpers: InlineHelpers = {
76
- input: async () => undefined,
77
- confirm: async () => true,
78
- capture: async () => undefined,
79
- };
80
-
81
- const labelText = (label: RowLabel | undefined): string => {
82
- if (typeof label === "function") return label();
83
- return label ?? "";
84
- };
85
-
86
- const isVisible = (row: FakeRow): boolean => {
87
- if (row.visible === undefined) return true;
88
- try {
89
- return row.visible();
90
- } catch {
91
- return false; // the renderer fails closed
92
- }
93
- };
94
-
95
- /** Four rows: one more than the capture threshold, spread over three categories. */
96
- const defaultRows = (): FakeRow[] => [
97
- { category: "Cards", label: "Card bash: builtin", action: () => {} },
98
- { category: "Cards", label: "Card output: builtin", action: () => {} },
99
- { category: "Banner", label: "Banner: builtin", action: () => {} },
100
- { category: "Layout", label: "Layout: builtin", action: () => {} },
101
- ];
102
-
103
- export function createFakeHost(options: FakeHostOptions = {}): FakeHost {
104
- const rows: FakeRow[] = options.rows ?? defaultRows();
105
- const theme = "theme" in options ? options.theme : { name: "fake-theme" };
106
- const extraFactoryArgs = options.extraFactoryArgs ?? [{ keybindings: "fake" }, () => {}];
107
- const customShape = options.customShape ?? "own";
108
- const factoryResult = options.factoryResult ?? "adapter";
109
-
110
- let invalidateCount = 0;
111
- let disposeCount = 0;
112
- let factoryCallCount = 0;
113
- let categories: string[] = [];
114
-
115
- const render = (): string[] => rows.filter(isVisible).map((row) => labelText(row.label));
116
-
117
- const runAction = async (row: FakeRow): Promise<void> => {
118
- await row.action?.(inlineHelpers);
119
- invalidateCount += 1; // the real view repaints after any action (visual-customize-view.ts:579)
120
- };
121
-
122
- const adapter = (): FakeAdapter => ({
123
- render,
124
- handleInput: (data: string) => (data === "enter" ? press(render()[0] ?? "") : undefined),
125
- invalidate: () => {
126
- invalidateCount += 1;
127
- },
128
- dispose: () => {
129
- disposeCount += 1;
130
- },
131
- });
132
-
133
- const pressRow = async (match: (row: FakeRow) => boolean): Promise<void> => {
134
- const row = rows.filter(isVisible).find(match);
135
- if (row === undefined) throw new Error("fake host: no visible row matched the predicate");
136
- await runAction(row);
137
- };
138
-
139
- const press = (label: string): Promise<void> =>
140
- pressRow((candidate) => labelText(candidate.label) === label);
141
-
142
- const toggleLines = (): string[] => {
143
- const visible = rows.filter(isVisible);
144
- const lines: string[] = [];
145
- let index = 0;
146
- while (index < visible.length) {
147
- const setting = visible[index].choice?.setting;
148
- if (setting === undefined) {
149
- index += 1;
150
- continue;
151
- }
152
- let end = index;
153
- while (end + 1 < visible.length && visible[end + 1].choice?.setting === setting) end += 1;
154
- const marks = visible
155
- .slice(index, end + 1)
156
- .map((row) => {
157
- const mark = row.choice?.current() === true ? "●" : "○";
158
- return `${mark} ${row.choice?.value ?? ""}`.trim();
159
- });
160
- lines.push(`${setting} ${marks.join(" ")}`);
161
- index = end + 1;
162
- }
163
- return lines;
164
- };
165
-
166
- const factory: CustomFactory = (_ui, _theme, ..._rest) => {
167
- factoryCallCount += 1;
168
- // The one reachable moment (lib/visual-customize-view.ts:387): the view derives its
169
- // categories from `options.rows` with a single synchronous `map` call inside the factory.
170
- if (options.mapOnlyNonRows === true) {
171
- [1, 2, 3].map((value) => value * 2); // a plain array of numbers: not a row array
172
- } else {
173
- categories = rows.map((row) => row.category ?? "Settings");
174
- }
175
- if (options.throwInsideFactory === true) throw new Error("factory exploded");
176
-
177
- const built = adapter();
178
- return factoryResult === "promise" ? Promise.resolve(built) : built;
179
- };
180
-
181
- const invoke = (factoryArg: CustomFactory): unknown => factoryArg(ui, theme, ...extraFactoryArgs);
182
-
183
- const ownShape: FakeUiTarget = {
184
- custom: (factoryArg: CustomFactory) => invoke(factoryArg),
185
- };
186
- const prototypeShape: FakeUiTarget = Object.create({
187
- custom(this: unknown, factoryArg: CustomFactory) {
188
- return invoke(factoryArg);
189
- },
190
- }) as FakeUiTarget;
191
-
192
- const ui: FakeUiTarget = customShape === "prototype" ? prototypeShape : ownShape;
193
-
194
- return {
195
- ui,
196
- factory,
197
- buildMenu: () => {
198
- if (typeof ui.custom !== "function") throw new Error("fake host: ui.custom disappeared");
199
- return ui.custom(factory);
200
- },
201
- liveRows: () => rows,
202
- categories: () => [...categories],
203
- rendered: render,
204
- invalidateCount: () => invalidateCount,
205
- disposeCount: () => disposeCount,
206
- factoryCallCount: () => factoryCallCount,
207
- press,
208
- pressRow,
209
- toggleLines,
210
- };
211
- }