@pi-archimedes/core 2.7.3 → 2.9.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.
@@ -1,820 +0,0 @@
1
- import {
2
- describe,
3
- it,
4
- expect,
5
- vi,
6
- beforeAll,
7
- afterAll,
8
- afterEach,
9
- } from "vitest";
10
- import type {
11
- Theme,
12
- KeybindingsManager,
13
- } from "@earendil-works/pi-coding-agent";
14
- import type { TUI, EditorTheme } from "@earendil-works/pi-tui";
15
-
16
- // ── Mocks ────────────────────────────────────────────────────────────────────
17
-
18
- const widthProbe = vi.hoisted(() => ({
19
- /** Mutable width probe: default reports each char as width 1 (non-EAW), so `visibleWidth(s)` = `s.length` for ASCII. */
20
- probe: (s: string): number => s.length,
21
- }));
22
-
23
- vi.mock("@earendil-works/pi-tui", () => ({
24
- truncateToWidth: (s: string, _w: number, _pad?: string, _incl?: boolean) => s,
25
- isKeyRelease: () => false,
26
- visibleWidth: (s: string) => widthProbe.probe!(s),
27
- }));
28
-
29
- vi.mock("@earendil-works/pi-coding-agent", () => {
30
- // Minimal stand-in for pi's CustomEditor: mirrors the verified 0.85.1
31
- // behavior of reading `tui.terminal.rows` in render() and touching
32
- // `borderColor` (set from theme.borderColor in the ctor) so missing
33
- // stubs fail fast. `getAgentDir` serves config.ts's module-scope
34
- // settings path (the mock never reads or writes it).
35
- class CustomEditor {
36
- tui: TUI;
37
- borderColor: (s: string) => string;
38
- text = "";
39
- constructor(
40
- tui: TUI,
41
- editorTheme: { borderColor: (s: string) => string },
42
- ) {
43
- this.tui = tui;
44
- this.borderColor = editorTheme.borderColor;
45
- }
46
- getText(): string {
47
- return this.text;
48
- }
49
- setText(t: string): void {
50
- this.text = t;
51
- }
52
- handleInput(_data: string): void {}
53
- render(_width: number): string[] {
54
- void this.tui.terminal.rows;
55
- return [
56
- this.borderColor("┌───┐"),
57
- "input line",
58
- this.borderColor("└───┘"),
59
- ];
60
- }
61
- }
62
- return { CustomEditor, getAgentDir: () => "/nonexistent-pi-agent-dir" };
63
- });
64
-
65
- // Deterministic quip picker: `exclude === "Quip A" → "Quip B"` (the 2nd
66
- // episode never back-to-back repeats), anything else → "Quip A". The
67
- // baseline is the `vi.fn` constructor argument (NOT `.mockImplementation(...)`):
68
- // `afterEach`'s `vi.restoreAllMocks()` runs `mockReset()`, which clears the
69
- // implementation; dispatch then falls back to `state.getOriginal()`, which is
70
- // the constructor baseline for a `vi.fn(baselineFn)` (the `.mockImplementation`
71
- // form's original is the noop — `pickQuip → undefined` — and would silently
72
- // drop every default-label test after the first `afterEach`).
73
- vi.mock("./spin-quips.js", () => {
74
- const baseline = (exclude?: string): string =>
75
- exclude === "Quip A" ? "Quip B" : "Quip A";
76
- return { pickQuip: vi.fn(baseline), QUIP_ROTATION_MIN_SECS: 15, QUIP_ROTATION_MAX_SECS: 45 };
77
- });
78
-
79
- import {
80
- HephaestusEditor,
81
- SPIN_TYPE_START,
82
- SPIN_TYPE_CELLS,
83
- } from "./index.js";
84
- import { SPIN_VARIANTS } from "./spin.js";
85
- import { pickQuip } from "./spin-quips.js";
86
-
87
- /** The label the default-path tests assert on: ` Loading ` — 7 chars (9 with the leading AND trailing spaces the renderer contributes via `" " + label + " "`), the same visible width as the default "Working" (`labelFit` 16, `LABEL.length` 9). The default-path tests pass this explicitly so they stay verbatim-mode (with no `spinLabel` they would be quip-mode, where the row carries the mocked quip after the first busy tick). */
88
- const LABEL = " Loading ";
89
-
90
- // ── Helpers ──────────────────────────────────────────────────────────────────
91
-
92
- const stubTheme = {
93
- fg: (_k: string, t: string) => t,
94
- } as unknown as Theme;
95
-
96
- interface ConstructOpts {
97
- spin?: boolean;
98
- /** The `editorSpinSpeed` setting; × multiplies the style's native per-tick tempo (× 1.5 / × 1 / × 0.6), 32 ms tick floor. */
99
- spinSpeed?: "slow" | "normal" | "fast";
100
- /** The `editorSpinStyle` setting (raw setting string tolerated; a style not registered in `SPIN_VARIANTS` yet (batch 4) normalizes to typing frames — the fallback, which stays typing even though the default setting is now pendulum). */
101
- spinStyle?: string;
102
- /** Label typed after the window (empty hides it; unset → "Working"). */
103
- spinLabel?: string;
104
- /** Values consumed one per isIdle() call (idle if no values left). */
105
- idleSeq?: boolean[];
106
- onSpinInterval?: (h: ReturnType<typeof setInterval> | undefined) => void;
107
- }
108
-
109
- const createdEditors: HephaestusEditor[] = [];
110
-
111
- function makeEditor(opts: ConstructOpts = {}): {
112
- editor: HephaestusEditor;
113
- tui: ReturnType<typeof makeTui>;
114
- onSpinInterval?: ReturnType<typeof vi.fn>;
115
- } {
116
- const tui = makeTui() as unknown as TUI;
117
- const editorTheme = {
118
- borderColor: (s: string) => s,
119
- } as unknown as EditorTheme;
120
- const keybindings = {} as KeybindingsManager;
121
- const seq = opts.idleSeq ? opts.idleSeq.slice() : [];
122
- const onSpinInterval = opts.onSpinInterval
123
- ? opts.onSpinInterval
124
- : vi.fn();
125
- const editor = new HephaestusEditor(tui, editorTheme, keybindings, {
126
- getTheme: () => stubTheme,
127
- isIdle: () => (seq.length > 0 ? (seq.shift() as boolean) : true),
128
- shutdown: vi.fn(),
129
- spin: opts.spin,
130
- spinSpeed: opts.spinSpeed,
131
- spinStyle: opts.spinStyle,
132
- spinLabel: opts.spinLabel,
133
- onSpinInterval,
134
- } as any);
135
- createdEditors.push(editor);
136
- return { editor, tui, onSpinInterval: onSpinInterval as ReturnType<typeof vi.fn> };
137
- }
138
-
139
- function makeTui(): { requestRender: () => void; terminal: { rows: number } } {
140
- return { requestRender: vi.fn(), terminal: { rows: 24 } };
141
- }
142
-
143
- const tick = (ed: HephaestusEditor): void => {
144
- (ed as any).tickSpin();
145
- };
146
-
147
- // Rendered lines are wrapped in SGR sequences by wrap(); strip them so a
148
- // position-based prefix check sees pad + prefix + content.
149
- const plain = (l: string) => l.replace(/\x1b\[[0-9;]*m/g, "");
150
-
151
- // An editor that has been `step` busy ticks and reports busy to render.
152
- function busyAt(step: number, opts: ConstructOpts = {}): HephaestusEditor {
153
- const { editor } = makeEditor({
154
- spin: true,
155
- ...opts,
156
- idleSeq: Array.from({ length: step }, () => false),
157
- });
158
- for (let i = 0; i < step; i++) tick(editor);
159
- (editor as any).isIdle = () => false;
160
- return editor;
161
- }
162
-
163
- // The `┌───┐` border row is the second rendered line (first is the plain
164
- // `▁` top edge, which is un-padded; wrapped raw lines carry one PAD_X space).
165
- // Strip pad + corners and SGR; width→inner = width - 4 (PAD_X = 1).
166
- const borderRun = (lines: string[], width: number): string => {
167
- const top = plain(lines[1]!);
168
- return top.slice(2, 2 + (width - 4));
169
- };
170
-
171
- // Row shape: counting the window's cells (any char other than `─`, i.e.
172
- // stage chars, spaces, and the label chars of ` Working ` when present)
173
- // as cells, the whole row firms up to all dashes.
174
- const rowFirmsToDashes = (run: string, len: number): void => {
175
- expect(run.replace(/[^─]/g, "─")).toBe("─".repeat(len));
176
- };
177
-
178
- // Compensated width for a 60-wide box (inner = 56): the block sits at start 0
179
- // right at the corner (leading space at column 0, the 4-cell window at 1–4,
180
- // ` Loading ` with its leading AND trailing space at 5–13): the row minus the
181
- // block's 14 columns is all hard `─` — those columns replaced trailing dashes,
182
- // so the row width stays constant.
183
- const compensatedAt60 = (run: string): void => {
184
- const end = SPIN_TYPE_START + 1 + SPIN_TYPE_CELLS + LABEL.length; // 0 + 1 + 4 + 9 = 14
185
- const rest = run.slice(0, SPIN_TYPE_START) + run.slice(end);
186
- expect(rest).toBe(
187
- "─".repeat(56 - 1 - SPIN_TYPE_CELLS - LABEL.length), // 42
188
- );
189
- };
190
-
191
- // ── Setup / teardown ────────────────────────────────────────────────────────
192
-
193
- beforeAll(() => {
194
- vi.useFakeTimers();
195
- });
196
-
197
- afterAll(() => {
198
- vi.useRealTimers();
199
- });
200
-
201
- afterEach(() => {
202
- for (const ed of createdEditors) {
203
- (ed as any).dispose?.();
204
- }
205
- createdEditors.length = 0;
206
- vi.restoreAllMocks();
207
- });
208
-
209
- // ── 1. Static prefix, plain idle edge ──────────────────────────────────────
210
-
211
- describe("static prefix, plain idle edge", () => {
212
- it("tickSpin is a no-op while idle: no render", () => {
213
- const { editor, tui } = makeEditor({ spin: true });
214
- tick(editor);
215
- expect(tui.requestRender).not.toHaveBeenCalled();
216
- });
217
-
218
- it("renders the static > prefix while idle", () => {
219
- const { editor } = makeEditor({ spin: true });
220
- tick(editor);
221
- const lines = editor.render(60);
222
- // Prefix is attached to the first content line (mock's "input line" row);
223
- // wrapped lines start with an SGR, so strip it before checking the position
224
- const prefixLine = lines.find((l) => l.includes("input line"));
225
- expect(prefixLine).toBeDefined();
226
- expect(plain(prefixLine!).trimStart().startsWith("> ")).toBe(true);
227
- });
228
-
229
- it("spin=false also renders the static > prefix", () => {
230
- const { editor } = makeEditor({ spin: false });
231
- const lines = editor.render(60);
232
- const prefixLine = lines.find((l) => l.includes("input line"));
233
- expect(prefixLine).toBeDefined();
234
- expect(plain(prefixLine!).trimStart().startsWith("> ")).toBe(true);
235
- });
236
-
237
- it("prefix is the static > even while busy (chevron never animates)", () => {
238
- const editor = busyAt(4);
239
- const lines = editor.render(60);
240
- const prefixLine = lines.find((l) => l.includes("input line"));
241
- expect(prefixLine).toBeDefined();
242
- expect(plain(prefixLine!).trimStart().startsWith("> ")).toBe(true);
243
- });
244
-
245
- it("idle renders a plain edge row (no stage chars anywhere)", () => {
246
- const { editor } = makeEditor({ spin: true }); // never went busy
247
- const lines = editor.render(60);
248
- const first = plain(lines[0]!);
249
- expect(first).toBe("▁".repeat(60));
250
- });
251
-
252
- it("spin=false leaves the edge plain while busy", () => {
253
- const { editor } = makeEditor({
254
- spin: false,
255
- idleSeq: Array(4).fill(false),
256
- });
257
- for (let i = 0; i < 4; i++) tick(editor);
258
- (editor as any).isIdle = () => false;
259
- const first = plain(editor.render(60)[0]!);
260
- expect(first).toBe("▁".repeat(60));
261
- });
262
- });
263
-
264
- // ── 2. Busy/idle repaint at the render level ───────────────────────────────
265
- // (the tick state machine itself — advance, wrap, reset, repaint counts,
266
- // and the full frame table — is tested against BorderTypeSpinner in
267
- // spin.test.ts; here the edge is the plain-border repaint, which the
268
- // typing strip must NOT freeze on screen — an idle→no-op would.)
269
-
270
- describe("busy/idle repaint at the render level", () => {
271
- it("busy→idle: the plain border repaints exactly once, then idle ticks are no-ops", () => {
272
- // isIdle() returns false, false, true, true across the four ticks
273
- const { editor, tui } = makeEditor({ spin: true, spinLabel: "Loading", idleSeq: [false, false, true, true] });
274
-
275
- tick(editor); // busy #1
276
- expect(tui.requestRender).toHaveBeenCalledTimes(1);
277
-
278
- tick(editor); // busy #2
279
- expect(tui.requestRender).toHaveBeenCalledTimes(2);
280
-
281
- tick(editor); // idle after busy — reset + exactly one repaint
282
- expect(tui.requestRender).toHaveBeenCalledTimes(3);
283
-
284
- tick(editor); // idle after idle — full no-op
285
- expect(tui.requestRender).toHaveBeenCalledTimes(3);
286
- });
287
-
288
- it("busy streak wraps at the 38-step cycle: clear beat is 4 spaces + ` Loading ` (label stays up), then the block grows again", () => {
289
- // 40 busy values: 37 to reach step 37, then the wrap tick (→ 0, clear beat)
290
- // and the regrowth tick (→ step 1); the render decision uses the
291
- // overridden isIdle below, the tick machine uses the constructor sequence.
292
- // The intent is the typing cycle (38 steps, braille frames) — explicit, since the default style is now pendulum.
293
- const { editor } = makeEditor({ spin: true, spinStyle: "typing", spinLabel: "Loading", idleSeq: Array.from({ length: 40 }, () => false) });
294
- for (let i = 0; i < 37; i++) tick(editor); // s=1..37
295
- (editor as any).isIdle = () => false;
296
- tick(editor); // wraps to 0 — the clear beat
297
- const cleared = borderRun(editor.render(60), 60);
298
- expect(
299
- cleared.slice(
300
- SPIN_TYPE_START,
301
- SPIN_TYPE_START + 1 + SPIN_TYPE_CELLS + LABEL.length,
302
- ),
303
- ).toBe( // ` Loading` — the leading space + 4 clear spaces + the label
304
- " " + " " + LABEL,
305
- );
306
- tick(editor); // back to step 1 — cell 1 enters at ⠁
307
- const regrown = borderRun(editor.render(60), 60);
308
- expect(
309
- regrown.slice(
310
- SPIN_TYPE_START,
311
- SPIN_TYPE_START + 1 + SPIN_TYPE_CELLS + LABEL.length,
312
- ),
313
- ).toBe(" ⠁ " + LABEL); // `⠁ Loading`
314
-
315
- });
316
- });
317
-
318
- // ── 3. Typing strip on the top border row (serpentine line fill) ─────────
319
- // Cells fill cell-by-cell left→right, each cell walking the 8 chart-order
320
- // stages in 2-step line pairs (⠁⠉ / ⠋⠛ / ⠟⠿ / ⡿⣿): line 1 is steps 1–8,
321
- // line 2 9–16, line 3 17–24, line 4 25–32, hold 33–37, clear at the wrap (0).
322
-
323
- describe("typing strip on the top border row", () => {
324
- // 60-wide box (inner = 56): the full beat = leading space at 0 + 4-cell
325
- // window + ` Loading ` label (leading AND trailing space) when labelFit
326
- // (16 for the 7-char label) is met.
327
- const beat = (ed: HephaestusEditor): string =>
328
- borderRun(ed.render(60), 60).slice(
329
- SPIN_TYPE_START,
330
- SPIN_TYPE_START + 1 + SPIN_TYPE_CELLS + LABEL.length,
331
- );
332
-
333
- it("step 1: leading space at 0, then only cell 1 is `⠁` (dot block starts to grow), ` Loading ` label follows", () => {
334
- expect(beat(busyAt(1, { spinStyle: "typing", spinLabel: "Loading" }))).toBe(" ⠁ " + LABEL);
335
- rowFirmsToDashes(borderRun(busyAt(1, { spinStyle: "typing", spinLabel: "Loading" }).render(60), 60), 56);
336
- compensatedAt60(borderRun(busyAt(1, { spinStyle: "typing", spinLabel: "Loading" }).render(60), 60));
337
- });
338
-
339
- it("step 8 (line 1 complete): leading space at 0, all 4 cells are `⠉`, label follows", () => {
340
- const ed = busyAt(8, { spinStyle: "typing", spinLabel: "Loading" });
341
- expect(beat(ed)).toBe(" ⠉⠉⠉⠉" + LABEL);
342
- rowFirmsToDashes(borderRun(ed.render(60), 60), 56);
343
- compensatedAt60(borderRun(ed.render(60), 60));
344
- });
345
-
346
- it("step 32 (fully grown): leading space at 0, all 4 cells are `⣿`, label follows", () => {
347
- const ed = busyAt(32, { spinStyle: "typing", spinLabel: "Loading" });
348
- expect(beat(ed)).toBe(" ⣿⣿⣿⣿" + LABEL);
349
- rowFirmsToDashes(borderRun(ed.render(60), 60), 56);
350
- compensatedAt60(borderRun(ed.render(60), 60));
351
- });
352
-
353
- it("hold region (step 37): leading space at 0, full block holds — `⣿`×4 on, label still up (the hold clamp 33–38 is covered in spin.test.ts)", () => {
354
- const ed = busyAt(37, { spinStyle: "typing", spinLabel: "Loading" });
355
- expect(beat(ed)).toBe(" ⣿⣿⣿⣿" + LABEL);
356
- });
357
-
358
- it("narrow width (14, inner 10): block at the corner (leading space at 0), window at 1, no label, row dash-compensated", () => {
359
- // inner = 10 → 7 ≤ 10 < 16: window-only tier at start 0; trailing = 10 − 1 − 4 = 5.
360
- // Explicit verbatim label — the default (quip) mode would hand the seq-driven
361
- // isIdle() a second per-tick consumer (the quip block, before the border
362
- // spinner's own read) and reset the tick machine one tick early.
363
- const run = borderRun(busyAt(4, { spinStyle: "typing", spinLabel: "Loading" }).render(14), 14);
364
- expect(run.slice(0, 1)).toBe(" ");
365
- expect(run.slice(1, 5)).toBe("⠉⠉ ");
366
- expect(run.slice(5)).toBe("─".repeat(5));
367
- expect(run).not.toContain("Working");
368
- rowFirmsToDashes(run, 10);
369
- });
370
-
371
- it("label-fit tiers: window-only at inner 15 (19, 10 trailing dashes); label on at inner 16 (20 = L + 9) — verbatim beat ` ⠛⠛⠛⠛ Loading ` with 2 trailing dashes", () => {
372
- const run15 = borderRun(busyAt(16, { spinStyle: "typing", spinLabel: "Loading" }).render(19), 19);
373
- expect(run15).not.toContain("Working"); // inner 15 < 16 → window-only tier
374
- expect(run15.slice(0, 1)).toBe(" ");
375
- expect(run15.slice(1, 5)).toBe("⠛⠛⠛⠛");
376
- expect(run15.slice(5)).toBe("─".repeat(10)); // trailing = 15 − 1 − 4 = 10
377
- rowFirmsToDashes(run15, 15);
378
- const run = borderRun(busyAt(16, { spinStyle: "typing", spinLabel: "Loading" }).render(20), 20); // inner = 16 = 0 + 1 + 4 + (1 + 7 + 1) + 2 (labelFit for L = 7)
379
- expect(run.slice(0, 1)).toBe(" ");
380
- expect(run.slice(1, 5)).toBe("⠛⠛⠛⠛");
381
- expect(run.slice(5, 14)).toBe(LABEL); // ` Loading ` — leading AND trailing space
382
- expect(run.slice(14)).toBe("─".repeat(2)); // trailing = 16 − 0 − 1 − 4 − (1 + 7 + 1) = 2
383
- rowFirmsToDashes(run, 16);
384
- });
385
-
386
- it("CJK 4-char label (visible width 8 per 4-char CJK label): shows from inner 17 (= 8 + 9), absent at inner 16 (widths, not chars)", () => {
387
- widthProbe.probe = (s: string) => (s === "你好世界" ? 8 : s.length);
388
- try {
389
- const onAll = borderRun(busyAt(4, { spinStyle: "typing", spinLabel: "你好世界" }).render(21), 21); // inner 17, 13 chars
390
- const on = onAll.slice(0, 13); // the row (borderRun over-fetches the corner when chars ≠ visible cells)
391
- expect(on.slice(0, 1)).toBe(" ");
392
- expect(on.slice(1, 5)).toBe("⠉⠉ ");
393
- expect(on.slice(5, 11)).toBe(" 你好世界 "); // own leading + trailing space (the 4 CJK chars are visible-width 8)
394
- expect(on.slice(11)).toBe("─".repeat(2)); // trailing = 17 − 0 − 1 − 4 − (1 + 8 + 1) = 2 visible cells
395
- rowFirmsToDashes(on, 13); // char length: 5 + (1 + 4 + 1) + 2
396
- const offAll = borderRun(busyAt(4, { spinStyle: "typing", spinLabel: "你好世界" }).render(20), 20); // inner = 16 < 17, 16 chars
397
- const off = offAll.slice(0, 16);
398
- expect(off).not.toContain("你好世界");
399
- expect(off.slice(0, 1)).toBe(" ");
400
- expect(off.slice(1, 5)).toBe("⠉⠉ ");
401
- expect(off.slice(5)).toBe("─".repeat(11)); // window-only tier: 16 − 1 − 4 = 11
402
- rowFirmsToDashes(off, 16);
403
- } finally {
404
- widthProbe.probe = (s: string) => s.length;
405
- }
406
- });
407
-
408
- it("below the floor (width 10, inner 6): no window, no label, plain border", () => {
409
- const run = borderRun(busyAt(4, { spinStyle: "typing" }).render(10), 10);
410
- expect(run).toBe("─".repeat(6));
411
- expect(run).not.toContain("Working");
412
- });
413
-
414
- it("idle: all-dash border row — no leading space, no window, no stage chars, no label (spin on, never busy)", () => {
415
- const { editor } = makeEditor({ spin: true });
416
- const run = borderRun(editor.render(60), 60);
417
- expect(run).toBe("─".repeat(56));
418
- for (const c of ["⠁", "⠉", "⠋", "⠛", "⠟", "⠿", "⡿", "⣿", "░", "▒", "▓", "█"]) {
419
- expect(run).not.toContain(c);
420
- }
421
- expect(run).not.toContain("Working");
422
- });
423
-
424
- // ── Configurable label (archimedes.core.editorSpinLabel) ────────────────
425
-
426
- it("custom label (\"Thinking\"): the wide beat ends in ` Thinking ` (leading AND trailing space), no ` Working ` anywhere in the row", () => {
427
- const ed = busyAt(1, { spinStyle: "typing", spinLabel: "Thinking" });
428
- const run = borderRun(ed.render(60), 60);
429
- expect(run).toContain(" Thinking ");
430
- expect(run).not.toContain("Working");
431
- expect(run.slice(SPIN_TYPE_START, SPIN_TYPE_START + 1 + SPIN_TYPE_CELLS + "Thinking".length + 2)).toBe(
432
- " ⠁ " + " Thinking ",
433
- );
434
- // Compensated: trailing = 56 − 0 − 1 − 4 − (1 + 8 + 1) = 41 dashes
435
- expect(run.slice(SPIN_TYPE_START + 1 + SPIN_TYPE_CELLS + "Thinking".length + 2)).toBe("─".repeat(41));
436
- rowFirmsToDashes(run, 56);
437
- });
438
-
439
- it("custom label (\"Thinking\") survives the cycle: clear beat holds the label, regrowth keeps it (the spin machine is untouched by the label)", () => {
440
- const { editor } = makeEditor({
441
- spin: true,
442
- spinStyle: "typing",
443
- spinLabel: "Thinking",
444
- idleSeq: Array.from({ length: 40 }, () => false),
445
- });
446
- for (let i = 0; i < 37; i++) tick(editor);
447
- (editor as any).isIdle = () => false;
448
- tick(editor); // wraps to 0 — clear beat
449
- expect(borderRun(editor.render(60), 60)).toContain(" Thinking");
450
- tick(editor); // step 1
451
- expect(borderRun(editor.render(60), 60)).toContain(" ⠁ " + " Thinking ");
452
- // Label drop-off on narrow boxes follows the same width tiers as the default label (labelFit = 8 + 9 = 17 > 10)
453
- const run14 = borderRun(editor.render(14), 14);
454
- expect(run14).not.toContain("Thinking");
455
- });
456
-
457
- it("empty label (\"\"): no label text in any tier — wide (win + trailing dashes), narrow (window-only tier identical)", () => {
458
- const wide = borderRun(busyAt(4, { spinStyle: "typing", spinLabel: "" }).render(60), 60);
459
- expect(wide).not.toContain("Working");
460
- expect(wide.slice(0, 1)).toBe(" ");
461
- expect(wide.slice(1, 5)).toBe("⠉⠉ ");
462
- expect(wide.slice(5)).toBe("─".repeat(51)); // trailing = 56 − 1 − 4 = 51
463
- rowFirmsToDashes(wide, 56);
464
- const narrow = borderRun(busyAt(4, { spinStyle: "typing", spinLabel: "" }).render(14), 14);
465
- expect(narrow).not.toContain("Working");
466
- expect(narrow.slice(5)).toBe("─".repeat(5)); // window-only tier (inner 10), same as a narrow default-label box
467
- rowFirmsToDashes(narrow, 10);
468
- });
469
-
470
- it("label longer than the inner width can host (60 chars at inner 56, labelFit = 60 + 9 = 69 > 56): window-only tier even at the wide width, no negative trailing — row width constant", () => {
471
- const run = borderRun(busyAt(4, { spinStyle: "typing", spinLabel: "A".repeat(60) }).render(60), 60);
472
- expect(run).not.toContain("A");
473
- expect(run.slice(0, 1)).toBe(" ");
474
- expect(run.slice(1, 5)).toBe("⠉⠉ ");
475
- expect(run.slice(5)).toBe("─".repeat(51));
476
- rowFirmsToDashes(run, 56);
477
- });
478
-
479
- it("non-string label (corrupt config): the ctor falls back to \"Working\" → quip mode — no crash, and the first episode's quip (mock ` Quip A `) is what renders", () => {
480
- const ed = busyAt(4, { spinStyle: "typing", spinLabel: null as unknown as string });
481
- const run = borderRun(ed.render(60), 60);
482
- expect(run).toContain(" Quip A ");
483
- expect(run).not.toContain("Working");
484
- });
485
- });
486
-
487
- // ── 3b. Spin quips (default / corrupt label → per-episode quip) ─────────────
488
- // The quip block runs in `tickSpin` BEFORE the border-spinner tick (the
489
- // selection lands before the first repaint). The default "Working" (and a
490
- // non-string corrupt config, which falls back to it) → quip mode: one quip
491
- // per busy episode, `exclude` = the previous episode's. An explicit
492
- // non-empty, non-"Working" label → verbatim; `""` → hidden; `spin: false`
493
- // → the timer never exists, so the quip block is unreachable (inert).
494
-
495
- describe("spin quips (per-episode selection)", () => {
496
- it("default label (no spinLabel): the first busy pick shows (mock ` Quip A `) in a 60-col busy row — the literal ` Working ` label is NOT in the row", () => {
497
- const run = borderRun(busyAt(1, { spinStyle: "typing" }).render(60), 60);
498
- expect(run).toContain(" Quip A ");
499
- expect(run).not.toContain(" Working ");
500
- });
501
-
502
- it("busy → idle → busy: the quip survives the idle (no re-pick on idle ticks); the 2nd episode re-picks with it excluded (mock ` Quip B `)", () => {
503
- // the seq feeds isIdle() in [quip block, border spinner] order per tick:
504
- // t1 busy (false, false), t2 idle (true, true), t3 busy (false, false).
505
- const { editor } = makeEditor({
506
- spin: true,
507
- spinStyle: "typing",
508
- idleSeq: [false, false, true, true, false, false],
509
- });
510
- tick(editor); // busy episode 1 → picks `Quip A`
511
- tick(editor); // idle — the quip survives (no re-pick)
512
- expect((editor as any).spinQuip).toBe("Quip A");
513
- tick(editor); // busy episode 2 → re-picks with exclude `Quip A` → `Quip B`
514
- expect((editor as any).spinQuip).toBe("Quip B");
515
- (editor as any).isIdle = () => false;
516
- const run = borderRun(editor.render(60), 60);
517
- expect(run).toContain(" Quip B ");
518
- expect(run).not.toContain(" Quip A ");
519
- });
520
-
521
- it("empty label (empty string): no label text in any tier — hidden mode, the quip block never runs (window-only even wide)", () => {
522
- const wide = borderRun(busyAt(4, { spinStyle: "typing", spinLabel: "" }).render(60), 60);
523
- expect(wide).not.toContain("Quip");
524
- expect(wide).not.toContain("Working");
525
- expect(wide.slice(0, 1)).toBe(" ");
526
- expect(wide.slice(1, 5)).toBe("⠉⠉ ");
527
- expect(wide.slice(5)).toBe("─".repeat(51)); // trailing = 56 − 1 − 4 = 51 (no label cost)
528
- rowFirmsToDashes(wide, 56);
529
- const narrow = borderRun(busyAt(4, { spinStyle: "typing", spinLabel: "" }).render(14), 14);
530
- expect(narrow).not.toContain("Quip");
531
- expect(narrow.slice(0, 1)).toBe(" ");
532
- expect(narrow.slice(5)).toBe("─".repeat(5)); // window-only tier (inner 10)
533
- rowFirmsToDashes(narrow, 10);
534
- });
535
-
536
- it("spin=false: fully inert — even when ticked, `spinEnabled` is false so the quip block never picks; the border row stays plain (no window, no label)", () => {
537
- const { editor } = makeEditor({ spin: false, idleSeq: Array(4).fill(false) });
538
- for (let i = 0; i < 4; i++) tick(editor);
539
- (editor as any).isIdle = () => false;
540
- const run = borderRun(editor.render(60), 60);
541
- expect(run).toBe("─".repeat(56));
542
- expect(run).not.toContain("Quip");
543
- });
544
-
545
- it("20-char quip (mock one-time override): hidden at width 32 (inner 28 < 29 = 20 + 9), shown at 33 (inner 29 = 1 + 4 + (1 + 20 + 1) + 2) with the floor-held trailing", () => {
546
- // Single busy episode: ONE tick, and the seq hands BOTH per-tick isIdle()
547
- // readers (the quip block, then the border spinner) a false — the
548
- // one-time override is consumed exactly once by the quip block, and the
549
- // stored quip is what both renders see.
550
- vi.mocked(pickQuip).mockImplementationOnce(() => "TwentyCharsQuipQuiz!");
551
- const { editor } = makeEditor({
552
- spin: true,
553
- spinStyle: "typing",
554
- idleSeq: [false, false],
555
- });
556
- tick(editor);
557
- (editor as any).isIdle = () => false;
558
- const hidden = borderRun(editor.render(32), 32); // inner 28 < 29 → window-only tier
559
- expect(hidden).not.toContain("TwentyCharsQuipQuiz!");
560
- expect(hidden.slice(0, 1)).toBe(" ");
561
- expect(hidden.slice(1, 5)).toBe("⠁ ");
562
- expect(hidden.slice(5)).toBe("─".repeat(23)); // trailing = 28 − 1 − 4
563
- rowFirmsToDashes(hidden, 28);
564
- const shown = borderRun(editor.render(33), 33); // inner 29 = 0 + 1 + 4 + (1 + 20 + 1) + 2
565
- expect(shown.slice(0, 1)).toBe(" ");
566
- expect(shown.slice(1, 5)).toBe("⠁ ");
567
- expect(shown.slice(5, 27)).toBe(" TwentyCharsQuipQuiz! "); // own leading AND trailing space
568
- expect(shown.slice(27)).toBe("─".repeat(2)); // trailing = 29 − 1 − 4 − (1 + 20 + 1)
569
- rowFirmsToDashes(shown, 29);
570
- });
571
- });
572
-
573
- // ── 3c. Quip timer rotation (in-episode re-rotation, random 15–45s) ──
574
- // The in-episode rotation rides the existing spin tick (no new interval):
575
- // a counter accumulates while busy; at the per-episode random threshold
576
- // (inclusive 15–45s from the injected `quipRand`, `ceil` ticks, never
577
- // shorter than requested) the quip is re-picked (`exclude` = current, the
578
- // same one-re-roll contract). A new episode always re-picks + resets.
579
- // Idle freezes the counter (no rotation across gaps; the label persists).
580
- // Forced periods go through `as any` field overrides (no setting reaches
581
- // them: `SPIN_INTERVALS` max 80 ms × 1.5 (slow) = 120 ms); they must be
582
- // applied before the first busy tick (the window is drawn lazily there).
583
-
584
- describe("spin quips (in-episode timer rotation)", () => {
585
- it("window threshold: 300 ms (divides 15000/30000/45000) → exact 15 / 30 / 45 s for the forced rand 0 / 0.5 / 1, within the ceil-form bounds", () => {
586
- const draws: Array<[number, number]> = [[0, 50], [0.5, 100], [0.999, 150]];
587
- for (const [r, ticksExact] of draws) {
588
- const { editor } = makeEditor({ spin: true, spinStyle: "typing", idleSeq: [false] });
589
- (editor as any).spinTickMs = 300;
590
- (editor as any).quipRand = () => r;
591
- tick(editor); // episode start → window drawn
592
- const ticks = (editor as any).spinQuipEveryTicks as number;
593
- expect(ticks, `rand ${r}`).toBe(ticksExact);
594
- }
595
- });
596
-
597
- it("window threshold: 2000 ms + rand 1 → ceil(22.5) = 23 ticks = 46 s — the ceil-form bounds hold (never shorter than the request; at most one tick past the max)", () => {
598
- const { editor } = makeEditor({ spin: true, spinStyle: "typing", idleSeq: [false] });
599
- (editor as any).spinTickMs = 2000;
600
- (editor as any).quipRand = () => 1;
601
- tick(editor);
602
- const ticks = (editor as any).spinQuipEveryTicks as number;
603
- expect(ticks).toBe(23);
604
- const seconds = (ticks * 2000) / 1000;
605
- expect(seconds).toBeGreaterThanOrEqual(15);
606
- expect(seconds).toBeLessThan(45 + 2000 / 1000);
607
- });
608
-
609
- it("one long busy episode (T = 50, N = 60 ticks @ 300 ms, T < N < 2T): exactly one re-pick (at tick 50, `exclude` = the previous), counter reset, next window re-drawn, border shows the rotation's quip", () => {
610
- const { editor } = makeEditor({
611
- spin: true,
612
- spinStyle: "typing",
613
- // The seq feeds isIdle() in [quip block, border spinner] order — two readers per tick.
614
- idleSeq: Array(120).fill(false),
615
- });
616
- (editor as any).spinTickMs = 300;
617
- (editor as any).quipRand = () => 0;
618
- for (let i = 0; i < 60; i++) tick(editor);
619
- expect(pickQuip).toHaveBeenCalledTimes(2); // episode pick + the one rotation
620
- expect(pickQuip).toHaveBeenNthCalledWith(2, "Quip A", expect.any(Function));
621
- expect((editor as any).spinQuip).toBe("Quip B");
622
- expect((editor as any).spinQuipTicks).toBe(9); // 60 − 50, reset after the re-pick
623
- expect((editor as any).spinQuipEveryTicks).toBe(50); // re-drawn (rand 0)
624
- (editor as any).isIdle = () => false; // render reads isIdle() itself (the seq is exhausted)
625
- const run = borderRun(editor.render(60), 60);
626
- expect(run).toContain(" Quip B ");
627
- });
628
-
629
- it("while idle: the counter is frozen (no cumulative rotation across the gap, 44 + 30 idle < never reached), the pick count doesn't move, and the label survives; the next episode re-picks + resets", () => {
630
- const { editor } = makeEditor({
631
- spin: true,
632
- spinStyle: "typing",
633
- // Two readers per tick: 45 busy ticks = 90 falses, 30 idle ticks = 60 trues, 2 busy ticks = 4 falses.
634
- idleSeq: [...Array(90).fill(false), ...Array(60).fill(true), false, false, false, false],
635
- });
636
- (editor as any).spinTickMs = 300;
637
- (editor as any).quipRand = () => 0;
638
- for (let i = 0; i < 45; i++) tick(editor); // busy → ticks 44 (< T = 50)
639
- expect((editor as any).spinQuipTicks).toBe(44);
640
- for (let i = 0; i < 30; i++) tick(editor); // idle → frozen
641
- expect((editor as any).spinQuipTicks).toBe(44);
642
- expect(pickQuip).toHaveBeenCalledTimes(1);
643
- expect((editor as any).spinQuip).toBe("Quip A");
644
- for (let i = 0; i < 2; i++) tick(editor); // busy again → new episode: pick #2 + reset
645
- expect(pickQuip).toHaveBeenCalledTimes(2);
646
- expect(pickQuip).toHaveBeenNthCalledWith(2, "Quip A", expect.any(Function));
647
- expect((editor as any).spinQuip).toBe("Quip B");
648
- expect((editor as any).spinQuipTicks).toBe(1);
649
- });
650
-
651
- it("real (unmocked) rand: 10 consecutive episode windows all within [15 s, 45 s + 1 tick period)", () => {
652
- for (let i = 0; i < 10; i++) {
653
- const { editor } = makeEditor({ spin: true, spinStyle: "typing", idleSeq: [false, true] });
654
- tick(editor); // episode start — the default `Math.random` quipRand draws the window
655
- const ticks = (editor as any).spinQuipEveryTicks as number;
656
- const ms = (editor as any).spinTickMs as number;
657
- const seconds = (ticks * ms) / 1000;
658
- expect(seconds, `window ${i + 1}`).toBeGreaterThanOrEqual(15);
659
- expect(seconds, `window ${i + 1}`).toBeLessThan(45 + ms / 1000);
660
- tick(editor); // idle — end of episode
661
- }
662
- });
663
- });
664
-
665
- // ── 4. Timer lifecycle & gating ────────────────────────────────────────────
666
-
667
- describe("timer lifecycle & gating", () => {
668
- it("spin=false: no interval is set up", () => {
669
- const setSpy = vi.spyOn(globalThis, "setInterval");
670
- const before = setSpy.mock.calls.length;
671
- makeEditor({ spin: false });
672
- expect(setSpy.mock.calls.length).toBe(before);
673
- });
674
-
675
- it("spin=true (default ctor): one 32ms interval — the default pendulum 12ms native tempo × the normal multiplier, clamped at the 32ms tick floor (12 × 1 = 12 < 32 → 32); onSpinInterval receives the handle", () => {
676
- const setSpy = vi.spyOn(globalThis, "setInterval");
677
- const { editor, onSpinInterval } = makeEditor({ spin: true });
678
- expect(setSpy).toHaveBeenCalledTimes(1);
679
- expect(setSpy.mock.calls[0]![1]).toBe(32); // 12 clamped at the 32ms floor
680
- expect(onSpinInterval).toHaveBeenCalledTimes(1);
681
- const handle = onSpinInterval!.mock.calls[0]![0];
682
- expect(handle).not.toBeUndefined();
683
- expect(handle).toBe((editor as any).spinTimer);
684
- });
685
-
686
- it("spinSpeed \"slow\" (the typing 80 ms × 1.5): the interval period is the mapped 120ms", () => {
687
- const setSpy = vi.spyOn(globalThis, "setInterval");
688
- makeEditor({ spin: true, spinSpeed: "slow", spinStyle: "typing" });
689
- expect(setSpy).toHaveBeenCalledTimes(1);
690
- expect(setSpy.mock.calls[0]![1]).toBe(120);
691
- });
692
-
693
- it("spinStyle \"wave-rows\" (ported — batch 2): a never-ticked busy box shows the wave-rows step-0 window (⢦⣠⠞⠙), NOT the typing fallback (⠁)", () => {
694
- // hold-0 port styles run the full source loop: step 0 is the real first frame (no blank beat).
695
- // Explicit verbatim label — this box is never ticked, so its row carries the
696
- // label as-is (a quip-mode box would flash the default "Working" for one
697
- // un-ticked render before the first pick).
698
- const { editor } = makeEditor({ spin: true, spinStyle: "wave-rows", spinLabel: "Loading" });
699
- (editor as any).isIdle = () => false;
700
- const wave0 = SPIN_VARIANTS["wave-rows"]!.compute(0);
701
- const cellStr = wave0
702
- .map((m) => (m === 0 ? " " : String.fromCharCode(0x2800 + m)))
703
- .join("");
704
- const run = borderRun(editor.render(60), 60);
705
- expect(run).toContain(" " + cellStr + LABEL);
706
- expect(run).not.toContain(" ⠁ "); // not the typing fallback
707
- });
708
-
709
- it("spinStyle \"marquee\" + normal speed: the interval period is the marquee 55ms native tempo × the normal multiplier", () => {
710
- const setSpy = vi.spyOn(globalThis, "setInterval");
711
- makeEditor({ spin: true, spinStyle: "marquee" });
712
- expect(setSpy).toHaveBeenCalledTimes(1);
713
- expect(setSpy.mock.calls[0]![1]).toBe(55);
714
- });
715
-
716
- it("spinStyle \"pendulum\" + normal speed (12ms native): the period clamps at the 32ms tick floor (12 × 1 = 12 < 32 → 32)", () => {
717
- const setSpy = vi.spyOn(globalThis, "setInterval");
718
- makeEditor({ spin: true, spinStyle: "pendulum" });
719
- expect(setSpy).toHaveBeenCalledTimes(1);
720
- expect(setSpy.mock.calls[0]![1]).toBe(32);
721
- });
722
-
723
- it("spinSpeed \"fast\" with style \"diagonal-swipe\" (30ms native): the period clamps at the 32ms tick floor (30 × 0.6 = 18 → 32)", () => {
724
- const setSpy = vi.spyOn(globalThis, "setInterval");
725
- makeEditor({ spin: true, spinSpeed: "fast", spinStyle: "diagonal-swipe" });
726
- expect(setSpy).toHaveBeenCalledTimes(1);
727
- expect(setSpy.mock.calls[0]![1]).toBe(32);
728
- });
729
-
730
- it("spinStyle \"rain\" + normal speed (40ms native, ported — batch 4): the interval period is the rain 40ms native tempo × the normal multiplier", () => {
731
- const setSpy = vi.spyOn(globalThis, "setInterval");
732
- makeEditor({ spin: true, spinStyle: "rain" });
733
- expect(setSpy).toHaveBeenCalledTimes(1);
734
- expect(setSpy.mock.calls[0]![1]).toBe(40);
735
- });
736
-
737
- it("spinStyle \"sparkle\" + fast (40ms native, ported — batch 4): the period is 40 × 0.6 = 24 → clamps at the 32ms tick floor (24 → 32)", () => {
738
- const setSpy = vi.spyOn(globalThis, "setInterval");
739
- makeEditor({ spin: true, spinSpeed: "fast", spinStyle: "sparkle" });
740
- expect(setSpy).toHaveBeenCalledTimes(1);
741
- expect(setSpy.mock.calls[0]![1]).toBe(32);
742
- });
743
-
744
- it("hand-edited speed string (out of the union, e.g. `turbo`): the multiplier resolves to ×1 (never a NaN hot timer)", () => {
745
- const setSpy = vi.spyOn(globalThis, "setInterval");
746
- makeEditor({ spin: true, spinSpeed: "turbo" as any, spinStyle: "typing" });
747
- expect(setSpy).toHaveBeenCalledTimes(1);
748
- expect(setSpy.mock.calls[0]![1]).toBe(80); // 80 × (undefined ?? 1)
749
- });
750
-
751
- it("dispose clears the interval and notifies onSpinInterval(undefined)", () => {
752
- const { editor, onSpinInterval } = makeEditor({ spin: true });
753
- const clearSpy = vi.spyOn(globalThis, "clearInterval");
754
- editor.dispose();
755
- expect(clearSpy).toHaveBeenCalledTimes(1);
756
- expect((editor as any).spinTimer).toBeUndefined();
757
- expect(onSpinInterval).toHaveBeenLastCalledWith(undefined);
758
- });
759
-
760
- it("dispose with spin=false is a no-op (no interval, no notification)", () => {
761
- const clearSpy = vi.spyOn(globalThis, "clearInterval");
762
- const { editor, onSpinInterval } = makeEditor({ spin: false });
763
- editor.dispose();
764
- expect(clearSpy).not.toHaveBeenCalled();
765
- expect(onSpinInterval).not.toHaveBeenCalled();
766
- });
767
-
768
- it("fake 80ms ticks drive render while the agent is busy", () => {
769
- const { editor, tui } = makeEditor({ spin: true, spinStyle: "typing", idleSeq: [false] });
770
- (editor as any).isIdle = () => false;
771
- vi.advanceTimersByTime(80);
772
- expect(tui.requestRender).toHaveBeenCalledTimes(1);
773
- });
774
-
775
- it("fast tick (48ms — editorSpinSpeed \"fast\"): the mapped period drives the render", () => {
776
- const { editor, tui } = makeEditor({ spin: true, spinSpeed: "fast", spinStyle: "typing", idleSeq: [false] });
777
- (editor as any).isIdle = () => false;
778
- vi.advanceTimersByTime(48);
779
- expect(tui.requestRender).toHaveBeenCalledTimes(1);
780
- });
781
- });
782
-
783
- // ── 5. EAW fallback (⣿ reports width 2 → stage set flips to shading) ──────
784
-
785
- describe("EAW terminal fallback", () => {
786
- it("⣿ reports width 2: the window uses the shade set (░→█) in the same cell-wise order, no braille in the row, the ` Loading ` label stays (ASCII-safe)", () => {
787
- const braille = ["⠁", "⠉", "⠋", "⠛", "⠟", "⠿", "⡿", "⣿"];
788
- widthProbe.probe = (s: string) => (s === "⣿" ? 2 : s.length);
789
- try {
790
- const expectWindow = (s: number, window: string) => {
791
- const run = borderRun(busyAt(s, { spinStyle: "typing", spinLabel: "Loading" }).render(60), 60);
792
- expect(run.slice(SPIN_TYPE_START, SPIN_TYPE_START + 1)).toBe(" ");
793
- expect(
794
- run.slice(
795
- SPIN_TYPE_START + 1,
796
- SPIN_TYPE_START + 1 + SPIN_TYPE_CELLS,
797
- ),
798
- ).toBe(window);
799
- expect(
800
- run.slice(
801
- SPIN_TYPE_START + 1 + SPIN_TYPE_CELLS,
802
- SPIN_TYPE_START + 1 + SPIN_TYPE_CELLS + LABEL.length,
803
- ),
804
- ).toBe(LABEL);
805
- for (const c of braille) expect(run).not.toContain(c);
806
- rowFirmsToDashes(run, 56);
807
- compensatedAt60(run);
808
- };
809
- expectWindow(1, "░ ");
810
- expectWindow(3, "░░ ");
811
- expectWindow(8, "░░░░");
812
- expectWindow(17, "▒░░░");
813
- expectWindow(19, "▒▒░░");
814
- expectWindow(26, "█▒▒▒");
815
- expectWindow(32, "████");
816
- } finally {
817
- widthProbe.probe = (s: string) => s.length;
818
- }
819
- });
820
- });