@pi-archimedes/core 2.6.3 → 2.7.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.
package/README.md CHANGED
@@ -30,7 +30,7 @@ After installing Pi, choose one installation command above, then `cd` into your
30
30
 
31
31
  - **Splash screen** — an animated greeting when the session launches, in one of nine reveal styles (`diagonal`, `top-right`, `bottom-left`, `bottom-right`, `center-out`, `wave`, `horizontal`, `vertical`, `vertical-up`).
32
32
  - **Framed editor** — your input in a clean bordered frame, with a double-press guard on the quit key (`Ctrl+C` by default) so a stray keystroke doesn't end the session.
33
- - **Working spinner on the border** — one of ten animating styles (`pendulum`, `typing`, `pulse`, `marquee`, `wave-rows`, `columns`, `cascade`, `diagonal-swipe`, `rain`, `sparkle`) traces the editor frame while the agent works, replacing Pi's native "Working" line.
33
+ - **Working spinner on the border** — one of ten animating styles (`pendulum`, `typing`, `pulse`, `marquee`, `wave-rows`, `columns`, `cascade`, `diagonal-swipe`, `rain`, `sparkle`) traces the editor frame while the agent works, replacing Pi's native "Working" line. When `editorSpinLabel` is at its default, the label switches to a random quip per busy episode — re-picked on a subtle random 15–45 s timer while long episodes continue; set a custom value to pin a label (an empty value still hides it).
34
34
  - **Thinking blocks** — chain-of-thought output gets a consistent label, colour, and layout; `codeUnindent` strips the common indentation so code in reasoning reads flush.
35
35
  - **The bus** (`@pi-archimedes/core/bus`) — a global pub/sub event system: `COST_UPDATE`, `ASK_REQUEST`, `TODOS_UPDATE`, `TODOS_CLEAR`… subagent costs flow to the footer through it, subagent todos to the task board, subagent questions to the ask UI.
36
36
  - **Shared utilities** — text truncation and width measurement, colour formatting, settings I/O, and startup profiling.
@@ -44,7 +44,7 @@ Settings live in `~/.pi/agent/settings.json` under `archimedes.core`. The file i
44
44
  | `editorSpinBorder` | bool | `true` | Show the animated border spinner while the agent works |
45
45
  | `editorSpinStyle` | string | `pendulum` | One of the ten spinner styles |
46
46
  | `editorSpinSpeed` | string | `normal` | `slow`, `normal`, or `fast` |
47
- | `editorSpinLabel` | string | `Working` | Label shown alongside the border spinner |
47
+ | `editorSpinLabel` | string | `Working` | Label shown alongside the border spinner; the default is replaced by a random quip per busy episode (re-picked on a subtle random 15–45 s timer while long episodes continue) — set a custom value to pin it (empty still hides it) |
48
48
  | `animationStyle` | string | `vertical-up` | Splash-screen reveal style (the nine styles above) |
49
49
  | `labelText` | string | `Thinking...` | Prefix before thinking blocks |
50
50
  | `labelColor` | string | `255,215,0` | RGB string for the thinking label |
package/package.json CHANGED
@@ -1,6 +1,10 @@
1
1
  {
2
2
  "name": "@pi-archimedes/core",
3
- "version": "2.6.3",
3
+ "version": "2.7.0",
4
+ "repository": {
5
+ "type": "git",
6
+ "url": "https://github.com/danielcherubini/pi-archimedes.git"
7
+ },
4
8
  "type": "module",
5
9
  "keywords": [
6
10
  "pi-package"
@@ -62,15 +62,30 @@ vi.mock("@earendil-works/pi-coding-agent", () => {
62
62
  return { CustomEditor, getAgentDir: () => "/nonexistent-pi-agent-dir" };
63
63
  });
64
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
+
65
79
  import {
66
80
  HephaestusEditor,
67
81
  SPIN_TYPE_START,
68
82
  SPIN_TYPE_CELLS,
69
83
  } from "./index.js";
70
84
  import { SPIN_VARIANTS } from "./spin.js";
85
+ import { pickQuip } from "./spin-quips.js";
71
86
 
72
- /** The default label (no `spinLabel` in the ctor options), including the leading AND trailing spaces the renderer contributes (`" " + label + " "`). */
73
- const LABEL = " Working ";
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 ";
74
89
 
75
90
  // ── Helpers ──────────────────────────────────────────────────────────────────
76
91
 
@@ -162,7 +177,7 @@ const rowFirmsToDashes = (run: string, len: number): void => {
162
177
 
163
178
  // Compensated width for a 60-wide box (inner = 56): the block sits at start 0
164
179
  // right at the corner (leading space at column 0, the 4-cell window at 1–4,
165
- // ` Working ` with its leading AND trailing space at 5–13): the row minus the
180
+ // ` Loading ` with its leading AND trailing space at 5–13): the row minus the
166
181
  // block's 14 columns is all hard `─` — those columns replaced trailing dashes,
167
182
  // so the row width stays constant.
168
183
  const compensatedAt60 = (run: string): void => {
@@ -255,7 +270,7 @@ describe("static prefix, plain idle edge", () => {
255
270
  describe("busy/idle repaint at the render level", () => {
256
271
  it("busy→idle: the plain border repaints exactly once, then idle ticks are no-ops", () => {
257
272
  // isIdle() returns false, false, true, true across the four ticks
258
- const { editor, tui } = makeEditor({ spin: true, idleSeq: [false, false, true, true] });
273
+ const { editor, tui } = makeEditor({ spin: true, spinLabel: "Loading", idleSeq: [false, false, true, true] });
259
274
 
260
275
  tick(editor); // busy #1
261
276
  expect(tui.requestRender).toHaveBeenCalledTimes(1);
@@ -270,12 +285,12 @@ describe("busy/idle repaint at the render level", () => {
270
285
  expect(tui.requestRender).toHaveBeenCalledTimes(3);
271
286
  });
272
287
 
273
- it("busy streak wraps at the 38-step cycle: clear beat is 4 spaces + ` Working ` (label stays up), then the block grows again", () => {
288
+ it("busy streak wraps at the 38-step cycle: clear beat is 4 spaces + ` Loading ` (label stays up), then the block grows again", () => {
274
289
  // 40 busy values: 37 to reach step 37, then the wrap tick (→ 0, clear beat)
275
290
  // and the regrowth tick (→ step 1); the render decision uses the
276
291
  // overridden isIdle below, the tick machine uses the constructor sequence.
277
292
  // The intent is the typing cycle (38 steps, braille frames) — explicit, since the default style is now pendulum.
278
- const { editor } = makeEditor({ spin: true, spinStyle: "typing", idleSeq: Array.from({ length: 40 }, () => false) });
293
+ const { editor } = makeEditor({ spin: true, spinStyle: "typing", spinLabel: "Loading", idleSeq: Array.from({ length: 40 }, () => false) });
279
294
  for (let i = 0; i < 37; i++) tick(editor); // s=1..37
280
295
  (editor as any).isIdle = () => false;
281
296
  tick(editor); // wraps to 0 — the clear beat
@@ -285,7 +300,7 @@ describe("busy/idle repaint at the render level", () => {
285
300
  SPIN_TYPE_START,
286
301
  SPIN_TYPE_START + 1 + SPIN_TYPE_CELLS + LABEL.length,
287
302
  ),
288
- ).toBe( // ` Working` — the leading space + 4 clear spaces + the label
303
+ ).toBe( // ` Loading` — the leading space + 4 clear spaces + the label
289
304
  " " + " " + LABEL,
290
305
  );
291
306
  tick(editor); // back to step 1 — cell 1 enters at ⠁
@@ -295,7 +310,7 @@ describe("busy/idle repaint at the render level", () => {
295
310
  SPIN_TYPE_START,
296
311
  SPIN_TYPE_START + 1 + SPIN_TYPE_CELLS + LABEL.length,
297
312
  ),
298
- ).toBe(" ⠁ " + LABEL); // `⠁ Working`
313
+ ).toBe(" ⠁ " + LABEL); // `⠁ Loading`
299
314
 
300
315
  });
301
316
  });
@@ -307,41 +322,45 @@ describe("busy/idle repaint at the render level", () => {
307
322
 
308
323
  describe("typing strip on the top border row", () => {
309
324
  // 60-wide box (inner = 56): the full beat = leading space at 0 + 4-cell
310
- // window + ` Working ` label (leading AND trailing space) when labelFit
311
- // (16 for the default label) is met.
325
+ // window + ` Loading ` label (leading AND trailing space) when labelFit
326
+ // (16 for the 7-char label) is met.
312
327
  const beat = (ed: HephaestusEditor): string =>
313
328
  borderRun(ed.render(60), 60).slice(
314
329
  SPIN_TYPE_START,
315
330
  SPIN_TYPE_START + 1 + SPIN_TYPE_CELLS + LABEL.length,
316
331
  );
317
332
 
318
- it("step 1: leading space at 0, then only cell 1 is `⠁` (dot block starts to grow), ` Working ` label follows", () => {
319
- expect(beat(busyAt(1, { spinStyle: "typing" }))).toBe(" ⠁ " + LABEL);
320
- rowFirmsToDashes(borderRun(busyAt(1, { spinStyle: "typing" }).render(60), 60), 56);
321
- compensatedAt60(borderRun(busyAt(1, { spinStyle: "typing" }).render(60), 60));
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));
322
337
  });
323
338
 
324
339
  it("step 8 (line 1 complete): leading space at 0, all 4 cells are `⠉`, label follows", () => {
325
- const ed = busyAt(8, { spinStyle: "typing" });
340
+ const ed = busyAt(8, { spinStyle: "typing", spinLabel: "Loading" });
326
341
  expect(beat(ed)).toBe(" ⠉⠉⠉⠉" + LABEL);
327
342
  rowFirmsToDashes(borderRun(ed.render(60), 60), 56);
328
343
  compensatedAt60(borderRun(ed.render(60), 60));
329
344
  });
330
345
 
331
346
  it("step 32 (fully grown): leading space at 0, all 4 cells are `⣿`, label follows", () => {
332
- const ed = busyAt(32, { spinStyle: "typing" });
347
+ const ed = busyAt(32, { spinStyle: "typing", spinLabel: "Loading" });
333
348
  expect(beat(ed)).toBe(" ⣿⣿⣿⣿" + LABEL);
334
349
  rowFirmsToDashes(borderRun(ed.render(60), 60), 56);
335
350
  compensatedAt60(borderRun(ed.render(60), 60));
336
351
  });
337
352
 
338
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)", () => {
339
- expect(beat(busyAt(37, { spinStyle: "typing" }))).toBe(" ⣿⣿⣿⣿" + LABEL);
354
+ const ed = busyAt(37, { spinStyle: "typing", spinLabel: "Loading" });
355
+ expect(beat(ed)).toBe(" ⣿⣿⣿⣿" + LABEL);
340
356
  });
341
357
 
342
358
  it("narrow width (14, inner 10): block at the corner (leading space at 0), window at 1, no label, row dash-compensated", () => {
343
- // inner = 10 → 7 ≤ 10 < 16: window-only tier at start 0; trailing = 10 − 1 − 4 = 5
344
- const run = borderRun(busyAt(4, { spinStyle: "typing" }).render(14), 14);
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);
345
364
  expect(run.slice(0, 1)).toBe(" ");
346
365
  expect(run.slice(1, 5)).toBe("⠉⠉ ");
347
366
  expect(run.slice(5)).toBe("─".repeat(5));
@@ -349,17 +368,17 @@ describe("typing strip on the top border row", () => {
349
368
  rowFirmsToDashes(run, 10);
350
369
  });
351
370
 
352
- it("label-fit tiers: window-only at inner 15 (19, 10 trailing dashes); label on at inner 16 (20 = L + 9) — verbatim beat ` ⠛⠛⠛⠛ Working ` with 2 trailing dashes", () => {
353
- const run15 = borderRun(busyAt(16, { spinStyle: "typing" }).render(19), 19);
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);
354
373
  expect(run15).not.toContain("Working"); // inner 15 < 16 → window-only tier
355
374
  expect(run15.slice(0, 1)).toBe(" ");
356
375
  expect(run15.slice(1, 5)).toBe("⠛⠛⠛⠛");
357
376
  expect(run15.slice(5)).toBe("─".repeat(10)); // trailing = 15 − 1 − 4 = 10
358
377
  rowFirmsToDashes(run15, 15);
359
- const run = borderRun(busyAt(16, { spinStyle: "typing" }).render(20), 20); // inner = 16 = 0 + 1 + 4 + (1 + 7 + 1) + 2 (labelFit for L = 7)
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)
360
379
  expect(run.slice(0, 1)).toBe(" ");
361
380
  expect(run.slice(1, 5)).toBe("⠛⠛⠛⠛");
362
- expect(run.slice(5, 14)).toBe(LABEL); // ` Working ` — leading AND trailing space
381
+ expect(run.slice(5, 14)).toBe(LABEL); // ` Loading ` — leading AND trailing space
363
382
  expect(run.slice(14)).toBe("─".repeat(2)); // trailing = 16 − 0 − 1 − 4 − (1 + 7 + 1) = 2
364
383
  rowFirmsToDashes(run, 16);
365
384
  });
@@ -457,10 +476,189 @@ describe("typing strip on the top border row", () => {
457
476
  rowFirmsToDashes(run, 56);
458
477
  });
459
478
 
460
- it("non-string label (corrupt config): the ctor falls back to \"Working\" (never throws in visibleWidth)", () => {
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", () => {
461
480
  const ed = busyAt(4, { spinStyle: "typing", spinLabel: null as unknown as string });
462
481
  const run = borderRun(ed.render(60), 60);
463
- expect(run).toContain(LABEL);
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
+ }
464
662
  });
465
663
  });
466
664
 
@@ -494,7 +692,10 @@ describe("timer lifecycle & gating", () => {
494
692
 
495
693
  it("spinStyle \"wave-rows\" (ported — batch 2): a never-ticked busy box shows the wave-rows step-0 window (⢦⣠⠞⠙), NOT the typing fallback (⠁)", () => {
496
694
  // hold-0 port styles run the full source loop: step 0 is the real first frame (no blank beat).
497
- const { editor } = makeEditor({ spin: true, spinStyle: "wave-rows" });
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" });
498
699
  (editor as any).isIdle = () => false;
499
700
  const wave0 = SPIN_VARIANTS["wave-rows"]!.compute(0);
500
701
  const cellStr = wave0
@@ -582,12 +783,12 @@ describe("timer lifecycle & gating", () => {
582
783
  // ── 5. EAW fallback (⣿ reports width 2 → stage set flips to shading) ──────
583
784
 
584
785
  describe("EAW terminal fallback", () => {
585
- it("⣿ reports width 2: the window uses the shade set (░→█) in the same cell-wise order, no braille in the row, the ` Working ` label stays (ASCII-safe)", () => {
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)", () => {
586
787
  const braille = ["⠁", "⠉", "⠋", "⠛", "⠟", "⠿", "⡿", "⣿"];
587
788
  widthProbe.probe = (s: string) => (s === "⣿" ? 2 : s.length);
588
789
  try {
589
790
  const expectWindow = (s: number, window: string) => {
590
- const run = borderRun(busyAt(s, { spinStyle: "typing" }).render(60), 60);
791
+ const run = borderRun(busyAt(s, { spinStyle: "typing", spinLabel: "Loading" }).render(60), 60);
591
792
  expect(run.slice(SPIN_TYPE_START, SPIN_TYPE_START + 1)).toBe(" ");
592
793
  expect(
593
794
  run.slice(
@@ -23,6 +23,7 @@ import {
23
23
  } from "../chrome.js";
24
24
  import { isParentBorder, formatKey } from "../text.js";
25
25
  import { SPIN_INTERVALS, BorderTypeSpinner } from "./spin.js";
26
+ import { pickQuip, QUIP_ROTATION_MAX_SECS, QUIP_ROTATION_MIN_SECS } from "./spin-quips.js";
26
27
  import { SPIN_SPEED_MULT, type CoreConfig, type SpinnerStyle } from "../config.js";
27
28
 
28
29
  const DOUBLE_PRESS_WINDOW_MS = 500;
@@ -44,8 +45,22 @@ export class HephaestusEditor extends CustomEditor {
44
45
  private readonly spinEnabled: boolean;
45
46
  /** Label typed after the 4-cell window while busy (the `editorSpinLabel` setting): an empty string hides it. */
46
47
  private readonly spinLabel: string;
48
+ /** Immutable label mode, derived in the ctor from the RAW `spinLabel`: `""` (a string) → `"hidden"` (no quip; legacy precedence); non-string (corrupt config — `safeSpinLabel` fell back to `"Working"`) → `"quip"`; `safeSpinLabel === "Working"` (default or hand-typed, indistinguishable) → `"quip"`; any other non-empty string → `"verbatim"` (the config label wins). */
49
+ private readonly labelMode: "hidden" | "quip" | "verbatim";
50
+ /** The current busy episode's quip: `undefined` until the first idle→busy tick (the default `"Working"` then shows for at most one tick — approved), survives idle ticks, and is re-picked on the next busy transition with the previous quip excluded. */
51
+ private spinQuip: string | undefined;
52
+ /** Quip-mode busy-episode tracker — deliberately starts `false`, never reads `isIdle()` at construction, so an already-busy agent gets a quip on its first tick (transition detection needs `false` beforehand). */
53
+ private wasBusy = false;
47
54
  /** The border-row spin spinner (mechanism in `BorderTypeSpinner`): the `spinStyle` (raw setting strings normalize — unknown names fall back to typing frames) — created only when `spin` is on, so the off path stays fully inert. The tick period is the style's native tempo × the `spinSpeed` multiplier, 32 ms floor. */
48
55
  private readonly borderSpinner: BorderTypeSpinner | undefined;
56
+ /** The quip rotation tick period in ms — set to the style tempo when `spin` is on; the 32 ms default is inert (`rotationThresholdTicks()` is only reached on busy ticks, which imply spin is on). */
57
+ private readonly spinTickMs: number = 32;
58
+ /** In-episode quip rotation — the counter accumulates busy ticks; at the per-episode random threshold it re-picks the quip. Frozen while idle (no re-rotation across gaps); a new episode re-picks + resets. */
59
+ private spinQuipTicks = 0;
60
+ /** The current rotation window's threshold in ticks — a per-episode draw (see `rotationThresholdTicks()`); the `0` default is never read before the idle→busy branch assigns it (the counter only increments after that branch has run). */
61
+ private spinQuipEveryTicks = 0;
62
+ /** The injected random source for the in-episode quip rotation window (inclusive 15–45 s); tests override via `as any`, the same seam as `isIdle` — the constructor gains no signature change. */
63
+ private quipRand: () => number = Math.random;
49
64
  private readonly onSpinInterval:
50
65
  | ((interval: ReturnType<typeof setInterval> | undefined) => void)
51
66
  | undefined;
@@ -74,7 +89,7 @@ export class HephaestusEditor extends CustomEditor {
74
89
  spinSpeed?: CoreConfig["editorSpinSpeed"];
75
90
  /** The `editorSpinStyle` setting — the default style, from the config default (pendulum); raw setting strings are tolerated, but unknown names still normalize to typing frames (the normalizer's fallback, kept distinct from the default). */
76
91
  spinStyle?: SpinnerStyle | string;
77
- /** Label typed after the window while busy (the `editorSpinLabel` setting); an empty string hides it. Non-string values (corrupt config) fall back to "Working". */
92
+ /** Label typed after the window while busy (the `editorSpinLabel` setting): an empty string hides it. The default `"Working"` (and a hand-typed `"Working"`, indistinguishable from it) is replaced by a random quip picked once per busy episode, re-picked on a subtle random 15–45 s timer while a long episode continues; any other non-empty string is shown verbatim. Non-string values (corrupt config) fall back to `"Working"` — i.e. quip mode. */
78
93
  spinLabel?: string;
79
94
  /** Lets an out-of-editor scope (core index.ts session hooks) clear the timer. */
80
95
  onSpinInterval?: (
@@ -93,15 +108,22 @@ export class HephaestusEditor extends CustomEditor {
93
108
  const safeSpinLabel =
94
109
  typeof spinLabel === "string" ? spinLabel : "Working";
95
110
  this.spinLabel = safeSpinLabel;
111
+ // Immutable label mode from the RAW option (the `typeof` check reads the raw value, so it also distinguishes a corrupt non-string from a hand-typed "Working").
112
+ this.labelMode =
113
+ typeof spinLabel === "string" && spinLabel === ""
114
+ ? "hidden"
115
+ : safeSpinLabel === "Working"
116
+ ? "quip"
117
+ : "verbatim";
96
118
  this.borderSpinner = spin ? new BorderTypeSpinner(this.isIdle, spinStyle) : undefined;
97
119
  if (spin) {
98
120
  // The style's native per-tick tempo × the `editorSpinSpeed` multiplier, 32 ms tick floor (the floor also caps a 30 ms native style under `fast` at 32); unknown raw strings fall back to the typing native (the normalize fallback).
99
121
  const nativeMs = SPIN_INTERVALS[spinStyle as SpinnerStyle];
100
- const spinTickMs = Math.max(
122
+ this.spinTickMs = Math.max(
101
123
  32,
102
124
  (nativeMs ?? SPIN_INTERVALS["typing"]) * (SPIN_SPEED_MULT[spinSpeed] ?? 1),
103
125
  );
104
- this.spinTimer = setInterval(() => this.tickSpin(), spinTickMs);
126
+ this.spinTimer = setInterval(() => this.tickSpin(), this.spinTickMs);
105
127
  this.onSpinInterval?.(this.spinTimer);
106
128
  }
107
129
  }
@@ -117,12 +139,37 @@ export class HephaestusEditor extends CustomEditor {
117
139
 
118
140
  // ── Prompt spin ───────────────────────────────────────
119
141
 
142
+ /** Advances the spin; in quip mode the selection (per-episode pick, and the in-episode re-pick at the computed window's threshold) lands BEFORE the border-spinner repaint. While idle, the rotation counter is frozen (the label remains; no re-rotation across the gap). */
120
143
  private tickSpin(): void {
144
+ if (this.labelMode === "quip") {
145
+ const busy = this.spinEnabled && !this.isIdle();
146
+ if (busy && !this.wasBusy) {
147
+ // Episode start: fresh quip + reset the rotation window.
148
+ this.spinQuip = pickQuip(this.spinQuip, this.quipRand);
149
+ this.spinQuipTicks = 0;
150
+ this.spinQuipEveryTicks = this.rotationThresholdTicks();
151
+ } else if (busy) {
152
+ this.spinQuipTicks += 1;
153
+ if (this.spinQuipTicks >= this.spinQuipEveryTicks) {
154
+ // In-episode re-rotation: re-pick (excludes the current), reset the counter, draw the next window.
155
+ this.spinQuip = pickQuip(this.spinQuip, this.quipRand);
156
+ this.spinQuipTicks = 0;
157
+ this.spinQuipEveryTicks = this.rotationThresholdTicks();
158
+ }
159
+ }
160
+ this.wasBusy = busy;
161
+ }
121
162
  if (this.borderSpinner && this.borderSpinner.tick()) {
122
163
  this.tui.requestRender();
123
164
  }
124
165
  }
125
166
 
167
+ /** The random 15–45 s window (inclusive, from the injected `quipRand`) in whole ticks — `ceil`'d so the window is never shorter than requested (at most one tick over the max). */
168
+ private rotationThresholdTicks(): number {
169
+ const seconds = QUIP_ROTATION_MIN_SECS + Math.floor(this.quipRand() * (QUIP_ROTATION_MAX_SECS - QUIP_ROTATION_MIN_SECS + 1));
170
+ return Math.ceil((seconds * 1000) / this.spinTickMs);
171
+ }
172
+
126
173
  /** The 4-cell window that replaces a segment of the border (mechanism in `BorderTypeSpinner`): cells fill cell-by-cell left→right, the current step's stage chars rendered in the spin (accent) palette (⠁⠉ / ⠋⠛ / ⠟⠿ / ⡿⣿; EAW: the width-1 shading ░ → █) — the 2×4 dot block grows across the window — the not-yet-reached cells are plain spaces (the border line breaks) — plain " " (U+0020). The empty clear step (step 0) is all spaces; hold steps clamp to the last (fully grown) frame. The window sits one leading space after `┌` at start 0 (right at the corner), and the label follows it typed as `" " + spinLabel + " "` — its own leading and trailing spaces, with the trailing one providing the margin to the trailing dashes. */
127
174
  private typeStrip(): string {
128
175
  const p = resolvePalette(this.getTheme());
@@ -250,7 +297,8 @@ export class HephaestusEditor extends CustomEditor {
250
297
  // otherwise).
251
298
  const borderRun = (() => {
252
299
  const busy = this.spinEnabled && !this.isIdle();
253
- const label = this.spinLabel;
300
+ // Quip mode: the current episode's quip replaces the default label (`spinQuip` is `undefined` before the first busy tick, so at most one tick shows the default `"Working"` — approved); `hidden` / `verbatim` render the config label as-is.
301
+ const label = this.labelMode === "quip" ? (this.spinQuip ?? this.spinLabel) : this.spinLabel;
254
302
  // Visible width (not `.length`): a 4-char CJK label is 8 cells wide — `label.length` would short the row.
255
303
  const labelW = visibleWidth(label);
256
304
  const labelShown =
@@ -0,0 +1,106 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { SPIN_QUIPS, pickQuip, QUIP_ROTATION_MIN_SECS, QUIP_ROTATION_MAX_SECS } from "./spin-quips.js";
3
+
4
+ // ── SPIN_QUIPS pool invariants ───────────────────────────────────────────────
5
+
6
+ describe("SPIN_QUIPS", () => {
7
+ /** A mutable copy — the frozen `SPIN_QUIPS` is asserted read-only below. */
8
+ const pool = [...SPIN_QUIPS];
9
+
10
+ it("has 20–60 entries", () => {
11
+ expect(SPIN_QUIPS.length).toBeGreaterThanOrEqual(20);
12
+ expect(SPIN_QUIPS.length).toBeLessThanOrEqual(60);
13
+ });
14
+
15
+ it("entries are 1–64 chars", () => {
16
+ for (const q of pool) {
17
+ expect(q.length, JSON.stringify(q)).toBeGreaterThanOrEqual(1);
18
+ expect(q.length, JSON.stringify(q)).toBeLessThanOrEqual(64);
19
+ }
20
+ });
21
+
22
+ it("entries are printable ASCII only (U+0020–U+007E)", () => {
23
+ for (const q of pool) {
24
+ const ok = [...q].every((c) => {
25
+ const code = c.charCodeAt(0);
26
+ return code >= 0x20 && code <= 0x7e;
27
+ });
28
+ expect(ok, JSON.stringify(q)).toBe(true);
29
+ }
30
+ });
31
+
32
+ it("entries have no leading or trailing space", () => {
33
+ for (const q of pool) {
34
+ expect(q.startsWith(" "), JSON.stringify(q)).toBe(false);
35
+ expect(q.endsWith(" "), JSON.stringify(q)).toBe(false);
36
+ }
37
+ });
38
+
39
+ it("entries have no double (consecutive) spaces", () => {
40
+ for (const q of pool) {
41
+ expect(q.includes(" "), JSON.stringify(q)).toBe(false);
42
+ }
43
+ });
44
+
45
+ it("entries are all unique", () => {
46
+ expect(new Set(pool).size).toBe(pool.length);
47
+ });
48
+
49
+ it("is frozen", () => {
50
+ expect(Object.isFrozen(SPIN_QUIPS)).toBe(true);
51
+ });
52
+ });
53
+
54
+ // ── Quip rotation constants ──────────────────────────────────────────────────
55
+
56
+ describe("QUIP_ROTATION_* constants", () => {
57
+ it("are the inclusive 15 s / 45 s bounds, min < max, integers", () => {
58
+ expect(QUIP_ROTATION_MIN_SECS).toBe(15);
59
+ expect(QUIP_ROTATION_MAX_SECS).toBe(45);
60
+ expect(QUIP_ROTATION_MIN_SECS).toBeLessThan(QUIP_ROTATION_MAX_SECS);
61
+ expect(Number.isInteger(QUIP_ROTATION_MIN_SECS)).toBe(true);
62
+ expect(Number.isInteger(QUIP_ROTATION_MAX_SECS)).toBe(true);
63
+ });
64
+ });
65
+
66
+ // ── pickQuip ─────────────────────────────────────────────────────────────────
67
+
68
+ describe("pickQuip", () => {
69
+ it("rand = () => 0 (no exclude) → the first pool entry", () => {
70
+ expect(pickQuip(undefined, () => 0)).toBe(SPIN_QUIPS[0]!);
71
+ });
72
+
73
+ it("exclude = first pick → exactly one restricted re-roll: the first non-excluded entry, and rand is consumed twice", () => {
74
+ const rand = vi.fn().mockReturnValue(0);
75
+ const first = SPIN_QUIPS[0]!;
76
+ const quip = pickQuip(first, rand);
77
+ // a second draw really happened (not a duplicate of the first)…
78
+ expect(rand).toHaveBeenCalledTimes(2);
79
+ expect(quip).not.toBe(first);
80
+ // …and it is the first pool entry ≠ exclude (filtered pick, rand 0).
81
+ expect(quip).toBe(SPIN_QUIPS[1]!);
82
+ expect(poolHas(quip)).toBe(true);
83
+ });
84
+
85
+ it("exclude given but the first pick misses it → no re-roll (rand consumed once)", () => {
86
+ const rand = vi.fn().mockReturnValue(0);
87
+ const quip = pickQuip(SPIN_QUIPS[1]!, rand);
88
+ expect(rand).toHaveBeenCalledTimes(1);
89
+ expect(quip).toBe(SPIN_QUIPS[0]!);
90
+ });
91
+
92
+ it("exclude not in the pool → single draw, never a re-roll", () => {
93
+ const rand = vi.fn().mockReturnValue(0);
94
+ expect(pickQuip("not in the pool", rand)).toBe(SPIN_QUIPS[0]!);
95
+ expect(rand).toHaveBeenCalledTimes(1);
96
+ });
97
+
98
+ it("default rand returns an actual pool member (smoke)", () => {
99
+ expect(poolHas(pickQuip())).toBe(true);
100
+ });
101
+ });
102
+
103
+ /** Whether `q` is a pool member. */
104
+ function poolHas(q: string): boolean {
105
+ return SPIN_QUIPS.includes(q);
106
+ }
@@ -0,0 +1,44 @@
1
+ /** The spinner quip pool — fun (≤ 64 visible chars, printable-ASCII-only) status labels shown instead of the default "Working" per busy episode (see plan-037). Mixed nerdy: compute/OS, CS/programmer folklore, math/physics. Invariants (20–60 entries, 1–64 chars, printable ASCII, no edge/double spaces, unique, frozen) are enforced by `spin-quips.test.ts`. */
2
+ export const SPIN_QUIPS: readonly string[] = Object.freeze([
3
+ "Working...",
4
+ "Compiling a list of excuses for the next stand-up...",
5
+ "Containerizing the 'it works on my machine' anomaly...",
6
+ "Consulting a 10-year-old Stack Overflow thread...",
7
+ "Explaining the entire architecture to a rubber duck...",
8
+ "Force-pushing a rebase onto objective reality...",
9
+ "Pretending the linter warnings are just polite suggestions...",
10
+ "Wrangling a regex that no mortal can actually comprehend...",
11
+ "Slipping a $20 bill to the CPU's branch predictor...",
12
+ "Waiting for a cosmic ray to flip exactly the right bit...",
13
+ "Convincing floating-point math that 0.1 + 0.2 equals 0.3...",
14
+ "Quantizing the vibes from FP32 all the way down to INT4...",
15
+ "Performing evasive maneuvers to dodge a thread deadlock...",
16
+ "Politely asking the borrow checker for permission to live...",
17
+ "Deep-sea mining in the codebase for a missing semicolon...",
18
+ "Blaming a mysterious DNS issue like we always do...",
19
+ "Downloading more RAM from a highly suspicious website...",
20
+ "Collapsing quantum wavefunctions just by looking at them...",
21
+ "Hunting for rogue gravitons inside the server rack...",
22
+ "Paging memory to disk because we refused to optimize...",
23
+ "Optimizing the optimizer so it can optimize faster...",
24
+ "Searching the entire keyboard for the legendary 'Any' key...",
25
+ "Reticulating multi-dimensional splines...",
26
+ "Running gradient ascent until we reach enlightenment...",
27
+ "Pruning decision trees before autumn sets in...",
28
+ "Desugaring the syntax until it's completely flavorless...",
29
+ "Checking for starvation in the dining philosophers...",
30
+ "Negotiating a peace treaty between competing threads..."
31
+ ]);
32
+
33
+ /** Uniformly pick a quip; `exclude` (the previous episode's quip) is never returned — if the first draw lands on it, exactly one re-roll restricted to entries ≠ `exclude` (no loops). `rand` is injectable for deterministic tests. */
34
+ /** In-episode quip rotation bounds — the quip is re-picked on a random window drawn inclusive between these seconds (from the editor's injected random source), only while a busy episode continues; a new episode always re-picks and resets the window. */
35
+ export const QUIP_ROTATION_MIN_SECS = 15;
36
+ export const QUIP_ROTATION_MAX_SECS = 45;
37
+
38
+ export function pickQuip(exclude?: string, rand: () => number = Math.random): string {
39
+ const pick = (customPool: readonly string[]): string =>
40
+ customPool[Math.floor(rand() * customPool.length)]!;
41
+ const first = pick(SPIN_QUIPS);
42
+ if (exclude === undefined || first !== exclude) return first;
43
+ return pick(SPIN_QUIPS.filter((q) => q !== exclude));
44
+ }