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,324 +0,0 @@
1
- /**
2
- * The card-style registry, its process-global snapshot, and the bridge-owned selection (S1, S2, S12).
3
- *
4
- * A style is a registration like a provider or a section: one id, replace-in-place, an exact
5
- * disposer, and a fresh ordered snapshot. The selection is the bridge's own state, `native` by
6
- * default, and it must be resolvable to "no such style" WITHOUT throwing even after the style that
7
- * owned it was disposed (S12) — the transformer degrades from that answer, it never guesses.
8
- */
9
-
10
- import assert from "node:assert/strict";
11
- import {
12
- existsSync,
13
- mkdirSync,
14
- mkdtempSync,
15
- readdirSync,
16
- readFileSync,
17
- rmSync,
18
- writeFileSync,
19
- } from "node:fs";
20
- import { tmpdir } from "node:os";
21
- import { join } from "node:path";
22
- import test from "node:test";
23
-
24
- import { publishBridgeApi } from "../src/api.ts";
25
- import {
26
- registerCardStyle,
27
- resetBusForTests,
28
- resetCardStylesForTests,
29
- resetSectionsForTests,
30
- snapshotCardStyles,
31
- } from "../src/bus.ts";
32
- import { CARD_STYLE_NATIVE } from "../src/card-style-line.ts";
33
- import {
34
- CARD_STYLE_STORE_ID,
35
- getCardStyleSelection,
36
- resolveCardStyleSelection,
37
- setCardStyleSelection,
38
- } from "../src/card-styles.ts";
39
- import { resetStoreStateForTests } from "../src/store.ts";
40
- import type { CardStyleRegistration } from "../src/types.ts";
41
-
42
- let configHome: string;
43
- let previousConfigHome: string | undefined;
44
-
45
- test.before(() => {
46
- configHome = mkdtempSync(join(tmpdir(), "gsb-card-styles-"));
47
- previousConfigHome = process.env.GENTLE_PI_CONFIG_HOME;
48
- process.env.GENTLE_PI_CONFIG_HOME = configHome;
49
- });
50
-
51
- test.after(() => {
52
- if (previousConfigHome === undefined) delete process.env.GENTLE_PI_CONFIG_HOME;
53
- else process.env.GENTLE_PI_CONFIG_HOME = previousConfigHome;
54
- rmSync(configHome, { recursive: true, force: true });
55
- });
56
-
57
- test.beforeEach(() => {
58
- // The selection is a real file: every test starts from nothing on disk.
59
- rmSync(join(configHome, "gentle-shell-bridge"), { recursive: true, force: true });
60
- resetBusForTests();
61
- resetSectionsForTests();
62
- resetCardStylesForTests();
63
- resetStoreStateForTests();
64
- });
65
-
66
- const frame = () => ({
67
- top: (ctx: { width: number }) => "─".repeat(ctx.width),
68
- line: (ctx: { text: string }) => ctx.text,
69
- bottom: (ctx: { width: number }) => "─".repeat(ctx.width),
70
- });
71
-
72
- const style = (id: string, extra: Partial<CardStyleRegistration> = {}): CardStyleRegistration => ({
73
- id,
74
- label: id,
75
- frame: frame(),
76
- ...extra,
77
- });
78
-
79
- const ids = (): string[] => snapshotCardStyles().map((registered) => registered.id);
80
-
81
- // ---------------------------------------------------------------------------------------------
82
- // The registry
83
- // ---------------------------------------------------------------------------------------------
84
-
85
- test("a registered style appears in the snapshot in registration order", () => {
86
- registerCardStyle(style("loon.cute"));
87
- registerCardStyle(style("loon.mono"));
88
-
89
- assert.deepEqual(ids(), ["loon.cute", "loon.mono"]);
90
- });
91
-
92
- test("a style may declare an order: ordered styles first, ascending, then the rest by registration", () => {
93
- registerCardStyle(style("late"));
94
- registerCardStyle(style("second", { order: 2 }));
95
- registerCardStyle(style("first", { order: 1 }));
96
- registerCardStyle(style("third", { order: 3 }));
97
-
98
- assert.deepEqual(ids(), ["first", "second", "third", "late"]);
99
- });
100
-
101
- test("the same id replaces in place: one entry, its position and neighbours kept", () => {
102
- registerCardStyle(style("a"));
103
- registerCardStyle(style("b", { label: "old" }));
104
- registerCardStyle(style("c"));
105
-
106
- const replacement = style("b", { label: "new" });
107
- registerCardStyle(replacement);
108
-
109
- assert.deepEqual(ids(), ["a", "b", "c"]);
110
- assert.equal(snapshotCardStyles()[1], replacement, "the later registration is the live one");
111
- });
112
-
113
- test("the disposer removes exactly its own registration, once", () => {
114
- registerCardStyle(style("a"));
115
- const disposeB = registerCardStyle(style("b"));
116
- registerCardStyle(style("c"));
117
-
118
- disposeB();
119
- disposeB();
120
-
121
- assert.deepEqual(ids(), ["a", "c"]);
122
- });
123
-
124
- test("after a same-id replacement the replaced registration's disposer is a no-op", () => {
125
- const disposeOld = registerCardStyle(style("a"));
126
- const replacement = style("a", { label: "new" });
127
- registerCardStyle(replacement);
128
-
129
- disposeOld();
130
-
131
- assert.equal(snapshotCardStyles().length, 1);
132
- assert.equal(snapshotCardStyles()[0], replacement);
133
- });
134
-
135
- test("the snapshot is a fresh copy, so a caller cannot mutate the registry", () => {
136
- registerCardStyle(style("a"));
137
-
138
- const snapshot = snapshotCardStyles() as CardStyleRegistration[];
139
- snapshot.push(style("b"));
140
- snapshot.length = 0;
141
-
142
- assert.deepEqual(ids(), ["a"]);
143
- });
144
-
145
- test("an id that cannot be a plain object key is rejected before anything is stored", () => {
146
- const rejected: unknown[] = ["", " ", "__proto__", "constructor", "prototype", 42, null, undefined, "nu\0l"];
147
-
148
- for (const id of rejected) {
149
- assert.throws(
150
- () => registerCardStyle(style(id as string)),
151
- /invalid card style id/u,
152
- `must reject ${JSON.stringify(id)}`,
153
- );
154
- }
155
- assert.deepEqual(snapshotCardStyles(), [], "a rejected style never half-registers");
156
- });
157
-
158
- test("an id the bridge or the host already owns is rejected", () => {
159
- for (const id of [CARD_STYLE_NATIVE, "neon", "float"]) {
160
- assert.throws(() => registerCardStyle(style(id)), /invalid card style id/u, `must reject ${id}`);
161
- }
162
- assert.deepEqual(snapshotCardStyles(), []);
163
- });
164
-
165
- test("the three frame functions are required", () => {
166
- for (const broken of [
167
- { ...style("a"), frame: undefined },
168
- { ...style("a"), frame: {} },
169
- { ...style("a"), frame: { ...frame(), top: "nope" } },
170
- { ...style("a"), frame: { ...frame(), line: undefined } },
171
- { ...style("a"), frame: { ...frame(), bottom: 7 } },
172
- ]) {
173
- assert.throws(() => registerCardStyle(broken as CardStyleRegistration), /frame/u);
174
- }
175
- assert.deepEqual(snapshotCardStyles(), []);
176
- });
177
-
178
- test("a label is required, because it is what the user reads on the line", () => {
179
- for (const label of ["", " ", 42, undefined]) {
180
- assert.throws(
181
- () => registerCardStyle({ ...style("a"), label } as unknown as CardStyleRegistration),
182
- /label/u,
183
- );
184
- }
185
- assert.deepEqual(snapshotCardStyles(), []);
186
- });
187
-
188
- test("a label that another style already shows is rejected (S13)", () => {
189
- registerCardStyle(style("loon.cute", { label: "cute" }));
190
-
191
- assert.throws(
192
- () => registerCardStyle(style("pix.cute", { label: "cute" })),
193
- /invalid card style label "cute"/u,
194
- "two values reading the same is ambiguous on the line",
195
- );
196
- assert.deepEqual(ids(), ["loon.cute"], "the rejected style never half-registered");
197
- });
198
-
199
- test("labels are compared case-sensitively, exactly as the host displays them (S13)", () => {
200
- registerCardStyle(style("loon.cute", { label: "cute" }));
201
-
202
- assert.doesNotThrow(
203
- () => registerCardStyle(style("pix.cute", { label: "Cute" })),
204
- "a different casing is a different string on the line, so it is allowed",
205
- );
206
- assert.deepEqual(ids(), ["loon.cute", "pix.cute"]);
207
- });
208
-
209
- test("a label equal to one of the host's own values is rejected (S13)", () => {
210
- for (const label of ["neon", "float"]) {
211
- assert.throws(
212
- () => registerCardStyle(style("loon.shadow", { label })),
213
- (error: unknown) =>
214
- error instanceof Error &&
215
- error.message.includes(`invalid card style label "${label}" for "loon.shadow"`),
216
- `a value reading ${label} is indistinguishable from the host's`,
217
- );
218
- }
219
- assert.deepEqual(snapshotCardStyles(), []);
220
- });
221
-
222
- test("re-registering the same id may keep its own label (S13)", () => {
223
- registerCardStyle(style("loon.cute", { label: "cute" }));
224
- const replacement = style("loon.cute", { label: "cute", order: 5 });
225
-
226
- assert.doesNotThrow(() => registerCardStyle(replacement));
227
-
228
- assert.equal(snapshotCardStyles()[0], replacement);
229
- });
230
-
231
- test("two loaded copies of the package share one registry and one order", async () => {
232
- registerCardStyle(style("first", { order: 1 }));
233
-
234
- const copyPath = new URL("../src/bus.ts?second-copy", import.meta.url).href;
235
- const second = (await import(copyPath)) as typeof import("../src/bus.ts");
236
- second.registerCardStyle(style("second", { order: 2 }));
237
-
238
- assert.deepEqual(ids(), ["first", "second"], "the first copy sees the second copy's style");
239
- assert.deepEqual(
240
- second.snapshotCardStyles().map((registered: CardStyleRegistration) => registered.id),
241
- ["first", "second"],
242
- "and the second copy sees the same order",
243
- );
244
- });
245
-
246
- // ---------------------------------------------------------------------------------------------
247
- // The bridge-owned selection
248
- // ---------------------------------------------------------------------------------------------
249
-
250
- test("the selection is native before anything is stored", () => {
251
- assert.equal(getCardStyleSelection(), CARD_STYLE_NATIVE);
252
- assert.deepEqual(resolveCardStyleSelection(), { kind: "native" });
253
- });
254
-
255
- test("a selection round-trips through the bridge's own file", () => {
256
- registerCardStyle(style("loon.cute"));
257
-
258
- setCardStyleSelection("loon.cute");
259
-
260
- assert.equal(getCardStyleSelection(), "loon.cute");
261
- const file = join(configHome, "gentle-shell-bridge", `${CARD_STYLE_STORE_ID}.json`);
262
- assert.ok(existsSync(file), "the bridge owns the file, not the host");
263
- const document = JSON.parse(readFileSync(file, "utf8")) as { schemaVersion: number; values: Record<string, unknown> };
264
- assert.equal(document.schemaVersion, 1);
265
- assert.equal(document.values.cardStyle, "loon.cute");
266
- assert.deepEqual(readdirSync(join(configHome, "gentle-shell-bridge")), [`${CARD_STYLE_STORE_ID}.json`]);
267
- });
268
-
269
- test("a stored selection resolves to the registered style by identity", () => {
270
- const registered = style("loon.cute", { label: "cute" });
271
- registerCardStyle(registered);
272
- setCardStyleSelection("loon.cute");
273
-
274
- const resolved = resolveCardStyleSelection();
275
- assert.equal(resolved.kind, "style");
276
- assert.equal(resolved.kind === "style" ? resolved.style : undefined, registered);
277
- });
278
-
279
- test("a selection whose style is gone resolves to missing without throwing (S12)", () => {
280
- const dispose = registerCardStyle(style("loon.cute"));
281
- setCardStyleSelection("loon.cute");
282
- dispose();
283
-
284
- assert.deepEqual(resolveCardStyleSelection(), { kind: "missing", id: "loon.cute" });
285
- assert.equal(getCardStyleSelection(), "loon.cute", "the stored value is not rewritten behind the user");
286
- });
287
-
288
- test("a selection nobody ever registered resolves to missing, not to native", () => {
289
- setCardStyleSelection("loon.never");
290
-
291
- assert.deepEqual(resolveCardStyleSelection(), { kind: "missing", id: "loon.never" });
292
- });
293
-
294
- test("a hand-edited file that is not a string falls back to native", () => {
295
- const directory = join(configHome, "gentle-shell-bridge");
296
- const file = join(directory, `${CARD_STYLE_STORE_ID}.json`);
297
- rmSync(directory, { recursive: true, force: true });
298
- mkdirSync(directory, { recursive: true });
299
- writeFileSync(file, JSON.stringify({ schemaVersion: 1, values: { cardStyle: 7 } }), "utf8");
300
-
301
- assert.equal(getCardStyleSelection(), CARD_STYLE_NATIVE);
302
- assert.deepEqual(resolveCardStyleSelection(), { kind: "native" });
303
- });
304
-
305
- // ---------------------------------------------------------------------------------------------
306
- // The published surface
307
- // ---------------------------------------------------------------------------------------------
308
-
309
- test("registerCardStyle is on the API object plugins receive", () => {
310
- const api = publishBridgeApi();
311
- const register = api.registerCardStyle;
312
-
313
- assert.equal(typeof register, "function", "the member is present, so a plugin may call it");
314
- const dispose = register?.(style("loon.cute"));
315
-
316
- assert.deepEqual(ids(), ["loon.cute"]);
317
- dispose?.();
318
- assert.deepEqual(snapshotCardStyles(), []);
319
- });
320
-
321
- test("registerCardStyle is exported from the package entry point", async () => {
322
- const entry = await import("../src/index.ts");
323
- assert.equal(entry.registerCardStyle, registerCardStyle);
324
- });
@@ -1,178 +0,0 @@
1
- /**
2
- * T1: the path and the grammar, proven against the fixture (S4, S3).
3
- *
4
- * No feature code is exercised here — there is none yet (T3 owns the transformer). What is pinned
5
- * is the two facts the transformer will be written against:
6
- *
7
- * 1. **The walk.** From the captured `tui`, the component whose `render(width)` produces the
8
- * conversation is reachable through `tui.layoutRoot` → its layout entries → the primary
9
- * `ScrollView` → its child, and through the public `tui.children[0]` → the document → its third
10
- * child. Both must land on the SAME object as the fixture's card component, and both must fail
11
- * cleanly (no throw, no invented object) when the tree is not mounted or not fullscreen.
12
- * 2. **The grammar.** The fixture's lines are what `float` actually produces: opener/header/body/
13
- * closer in that order, bars in column 1, the hint right-aligned in the dim role, and an
14
- * empty-body card folded to three rows. `neon` must be visibly different, or detection would
15
- * confuse the two.
16
- *
17
- * The real host is never imported: `test/fake-transcript.ts` documents where each shape comes from.
18
- */
19
-
20
- import assert from "node:assert/strict";
21
- import test from "node:test";
22
-
23
- import {
24
- BASH_CARD,
25
- CONVERSATION,
26
- LAYOUT_NODE,
27
- NEON_CARD,
28
- PROSE_LINE,
29
- READ_CARD,
30
- createFakeTranscriptTui,
31
- layoutNodeOf,
32
- stripAnsi,
33
- type FakeComponent,
34
- type FakeDocument,
35
- type FakeTui,
36
- } from "./fake-transcript.ts";
37
-
38
- /**
39
- * Candidate (a), as `src/card-styles.ts` documents it: `layoutRoot` is TS-`private` on Pi's
40
- * fullscreen renderer (`pi-tui/dist/tui-alt-screen.d.ts:44`), so the walk reads it through a
41
- * widened cast. Everything after that hop uses the PUBLIC layout protocol.
42
- */
43
- function conversationViaLayoutRoot(tui: FakeTui): FakeComponent | undefined {
44
- const root = (tui as { layoutRoot?: unknown }).layoutRoot;
45
- const rootNode = layoutNodeOf(root);
46
- if (rootNode === undefined) return undefined;
47
- const first = rootNode.entries?.[0]?.component;
48
- if (first === null || typeof first !== "object") return undefined;
49
- // A ScrollView is the component that owns a scrollbar and can set it (scroll-view.d.ts:24-40).
50
- const scroll = first as { scrollbar?: unknown; setScrollbar?: unknown };
51
- if (typeof scroll.setScrollbar !== "function" || !("scrollbar" in scroll)) return undefined;
52
- return layoutNodeOf(first)?.component as FakeComponent | undefined;
53
- }
54
-
55
- /** Candidate (b): public `children[0]` is the document container, in either renderer mode. */
56
- function documentViaChildren(tui: FakeTui): FakeComponent | undefined {
57
- const children = (tui as { children?: unknown }).children;
58
- if (!Array.isArray(children) || children.length === 0) return undefined;
59
- const document = children[0] as { children?: unknown };
60
- if (document === null || typeof document !== "object") return undefined;
61
- if (!Array.isArray(document.children)) return undefined;
62
- // Candidate (c): the conversation cards are the document's third child (interactive-mode.js:407).
63
- return document.children[2] as FakeComponent | undefined;
64
- }
65
-
66
- test("both documented walks reach the conversation component", () => {
67
- const tui = createFakeTranscriptTui();
68
-
69
- // The layout walk lands on the document container (the ScrollView's child).
70
- assert.equal(conversationViaLayoutRoot(tui), tui.document, "layoutRoot → ScrollView → document");
71
- // From there — and independently through `children[0]` — the card component is the third child.
72
- assert.equal(documentViaChildren(tui), tui.chat, "children[0] → document → children[2]");
73
- assert.equal(
74
- (conversationViaLayoutRoot(tui) as unknown as FakeDocument).children[2],
75
- documentViaChildren(tui),
76
- "both walks agree on the object: the exact identity a wrapper must not duplicate",
77
- );
78
- assert.equal(tui.chat.render(40).length, CONVERSATION.length, "it renders the fake conversation");
79
- });
80
-
81
- test("the transcript renders the document at the content width, not the viewport width", () => {
82
- const tui = createFakeTranscriptTui();
83
- assert.ok(tui.transcript !== undefined);
84
-
85
- const lines = tui.transcript.render(40);
86
-
87
- assert.deepEqual(tui.chat.widths, [39], "the scrollbar column is taken out before the cards see it");
88
- assert.equal(lines.length, CONVERSATION.length + 2, "header and resources rows precede the chat");
89
- assert.equal(lines[2], `${PROSE_LINE} `, "and the scrollbar column is padded back on");
90
- });
91
-
92
- test("regular mode has no layout root, so that walk finds nothing and the other still works", () => {
93
- const tui = createFakeTranscriptTui({ mode: "regular" });
94
-
95
- assert.equal("layoutRoot" in tui, false, "TuiMainScreen has no layoutRoot field at all");
96
- assert.equal(conversationViaLayoutRoot(tui), undefined);
97
- assert.equal(documentViaChildren(tui), tui.chat, "children are mounted in both modes");
98
- });
99
-
100
- test("before mounting there is no transcript by any path, and no throw", () => {
101
- const tui = createFakeTranscriptTui({ mounted: false });
102
-
103
- assert.equal(conversationViaLayoutRoot(tui), undefined);
104
- assert.equal(documentViaChildren(tui), undefined);
105
- assert.deepEqual(tui.children, []);
106
- });
107
-
108
- test("float cards are padding, header, padding, body rows, padding — in that order", () => {
109
- const rows = READ_CARD.map(stripAnsi);
110
-
111
- assert.equal(rows.length, 5);
112
- // Opener and closer are `cardBottom` with no content: the accent bar and spaces.
113
- const padding = /^ ▎ +$/u;
114
- assert.match(rows[0] ?? "", padding, "opener: margin, bar, spaces");
115
- assert.match(rows[2] ?? "", padding, "separator between header and body");
116
- assert.match(rows[4] ?? "", padding, "closer");
117
- // Header: bar, one space, glyph + title, then the hint in the dim role.
118
- // One margin column, bar, one space, glyph + title, fill, one space, hint, then the tail space,
119
- // the two-column float `topRight` and the trailing margin: four spaces after the hint.
120
- assert.match(rows[1] ?? "", /^ ▎ ✿ read +ctrl\+o {4}$/u, "title left, hint right-aligned");
121
- // Body: bar, one space, the text, then the float right rail (a space inside the margin).
122
- assert.match(rows[3] ?? "", /^ ▎ config\.ts +$/u);
123
- assert.equal(rows.every((row) => [...row].length <= 40), true, "nothing exceeds the width");
124
- });
125
-
126
- test("the hint is the only dim segment on the header row; without one the row ends in spaces", () => {
127
- assert.match(READ_CARD[1] ?? "", /\u001b\[2mctrl\+o\u001b\[39m/u, "hint painted HINT_ROLE=dim");
128
- assert.match(READ_CARD[1] ?? "", /\u001b\[95m✿ read\u001b\[39m/u, "title painted the title role");
129
-
130
- const withoutHint = stripAnsi(BASH_CARD[1] ?? "");
131
- assert.match(withoutHint, /^ ▎ ✿ bash +$/u);
132
- assert.equal(BASH_CARD[1]?.includes("\u001b[2m"), false, "no dim segment at all");
133
- });
134
-
135
- test("an empty body folds the card to three rows: padding, header, padding", () => {
136
- assert.equal(BASH_CARD.length, 3);
137
- assert.equal(stripAnsi(BASH_CARD[0] ?? ""), stripAnsi(READ_CARD[0] ?? ""), "opener is identical");
138
- assert.equal(stripAnsi(BASH_CARD[2] ?? ""), stripAnsi(READ_CARD[4] ?? ""), "closer is identical");
139
- });
140
-
141
- test("neon is a different grammar: no margin, no bar, no background", () => {
142
- const rows = NEON_CARD.map(stripAnsi);
143
-
144
- assert.match(rows[0] ?? "", /^╭─ ✿ read ─+ ctrl\+o ╮$/u);
145
- assert.match(rows[1] ?? "", /^│ config\.ts +│$/u);
146
- assert.match(rows[2] ?? "", /^╰─+╯$/u);
147
-
148
- for (const row of NEON_CARD) {
149
- assert.equal(row.includes("▎"), false, "no float accent bar in an outlined card");
150
- assert.equal(row.includes("\u001b[48;5;236m"), false, "and no tone background");
151
- }
152
- for (const row of [...READ_CARD, ...BASH_CARD]) {
153
- assert.equal(row.startsWith(" "), true, "every float row starts with the one-column margin");
154
- assert.equal(stripAnsi(row).includes("╭"), false, "and none of them carries outline chrome");
155
- }
156
- });
157
-
158
- test("the conversation interleaves card rows with rows a repaint must leave alone", () => {
159
- const lines = CONVERSATION.map(stripAnsi);
160
- const index = lines.indexOf(PROSE_LINE);
161
-
162
- assert.ok(index >= 0);
163
- assert.equal(lines[index]?.startsWith(" ▎"), false, "prose is not card chrome");
164
- assert.ok(lines.includes(""), "blank rows separate the cards");
165
- assert.equal(lines.filter((line) => line.startsWith(" ▎ ✿ ")).length, 3, "three card headers");
166
- });
167
-
168
- test("the layout protocol is what makes the walk independent of concrete classes", () => {
169
- const tui = createFakeTranscriptTui();
170
- const node = layoutNodeOf(tui.layoutRoot);
171
-
172
- assert.equal(node?.type, "vstack");
173
- assert.equal(node?.entries?.length, 2, "transcript first, dock second (chat-viewport.js:22-25)");
174
- const transcript = node?.entries?.[0]?.component as Record<symbol, unknown>;
175
- assert.equal(typeof transcript[LAYOUT_NODE], "function");
176
- assert.equal(layoutNodeOf(transcript)?.type, "scroll");
177
- assert.equal(layoutNodeOf(transcript)?.component, tui.document);
178
- });
@@ -1,93 +0,0 @@
1
- import assert from "node:assert/strict";
2
- import { homedir } from "node:os";
3
- import test from "node:test";
4
-
5
- import {
6
- getBridgeConfigHome,
7
- getConfigHome,
8
- resolveBridgeConfigHome,
9
- resolveConfigHome,
10
- } from "../src/config.ts";
11
-
12
- const HOME = "/home/tester";
13
- const CWD = "/work/repo";
14
-
15
- test("GENTLE_PI_CONFIG_HOME set is used verbatim", () => {
16
- assert.equal(
17
- resolveConfigHome({ env: { GENTLE_PI_CONFIG_HOME: "/srv/gentle" }, homeDir: HOME, cwd: CWD }),
18
- "/srv/gentle",
19
- );
20
- });
21
-
22
- test("unset GENTLE_PI_CONFIG_HOME falls back to <home>/.pi/gentle-ai", () => {
23
- assert.equal(resolveConfigHome({ env: {}, homeDir: HOME, cwd: CWD }), `${HOME}/.pi/gentle-ai`);
24
- });
25
-
26
- test("empty GENTLE_PI_CONFIG_HOME falls back to the default", () => {
27
- assert.equal(
28
- resolveConfigHome({ env: { GENTLE_PI_CONFIG_HOME: "" }, homeDir: HOME, cwd: CWD }),
29
- `${HOME}/.pi/gentle-ai`,
30
- );
31
- });
32
-
33
- test('"~" and "~/..." are expanded against the supplied home directory', () => {
34
- assert.equal(
35
- resolveConfigHome({ env: { GENTLE_PI_CONFIG_HOME: "~" }, homeDir: HOME, cwd: CWD }),
36
- HOME,
37
- );
38
- assert.equal(
39
- resolveConfigHome({ env: { GENTLE_PI_CONFIG_HOME: "~/cfg/gentle" }, homeDir: HOME, cwd: CWD }),
40
- `${HOME}/cfg/gentle`,
41
- );
42
- });
43
-
44
- test('only a bare "~" or a leading "~/" is tilde-expanded', () => {
45
- // "~name" is another user's home; we do not invent a lookup, so the value stays literal and
46
- // then falls into the relative-path rule below.
47
- assert.equal(
48
- resolveConfigHome({ env: { GENTLE_PI_CONFIG_HOME: "~weird/cfg" }, homeDir: HOME, cwd: CWD }),
49
- `${CWD}/~weird/cfg`,
50
- );
51
- });
52
-
53
- test("a relative override is resolved to an absolute path against cwd", () => {
54
- assert.equal(
55
- resolveConfigHome({ env: { GENTLE_PI_CONFIG_HOME: "cfg/gentle" }, homeDir: HOME, cwd: CWD }),
56
- `${CWD}/cfg/gentle`,
57
- );
58
- });
59
-
60
- test("redundant separators and dot segments are normalized", () => {
61
- assert.equal(
62
- resolveConfigHome({
63
- env: { GENTLE_PI_CONFIG_HOME: "/srv/./gentle//../gentle/" },
64
- homeDir: HOME,
65
- cwd: CWD,
66
- }),
67
- "/srv/gentle",
68
- );
69
- });
70
-
71
- test("the bridge directory is <config home>/gentle-shell-bridge", () => {
72
- assert.equal(resolveBridgeConfigHome("/srv/gentle"), "/srv/gentle/gentle-shell-bridge");
73
- assert.equal(
74
- resolveBridgeConfigHome("/srv/gentle/"),
75
- "/srv/gentle/gentle-shell-bridge",
76
- "a trailing separator must not produce a doubled separator",
77
- );
78
- });
79
-
80
- test("getConfigHome reads the process environment; getBridgeConfigHome appends the bridge dir", () => {
81
- const restore = process.env.GENTLE_PI_CONFIG_HOME;
82
- try {
83
- process.env.GENTLE_PI_CONFIG_HOME = "/srv/from-env";
84
- assert.equal(getConfigHome(), "/srv/from-env");
85
- assert.equal(getBridgeConfigHome(), "/srv/from-env/gentle-shell-bridge");
86
- delete process.env.GENTLE_PI_CONFIG_HOME;
87
- assert.equal(getConfigHome(), `${homedir()}/.pi/gentle-ai`);
88
- assert.equal(getBridgeConfigHome(), `${homedir()}/.pi/gentle-ai/gentle-shell-bridge`);
89
- } finally {
90
- if (restore === undefined) delete process.env.GENTLE_PI_CONFIG_HOME;
91
- else process.env.GENTLE_PI_CONFIG_HOME = restore;
92
- }
93
- });