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,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
- }