@pi-archimedes/core 2.6.3 → 2.7.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.
- package/README.md +2 -2
- package/package.json +5 -1
- package/src/editor/index.test.ts +229 -28
- package/src/editor/index.ts +52 -4
- package/src/editor/spin-quips.test.ts +106 -0
- package/src/editor/spin-quips.ts +44 -0
- package/src/settings-io.test.ts +112 -1
- package/src/settings-io.ts +67 -4
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
package/src/editor/index.test.ts
CHANGED
|
@@ -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
|
|
73
|
-
const LABEL = "
|
|
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
|
-
// `
|
|
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 + `
|
|
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( // `
|
|
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); // `⠁
|
|
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 + `
|
|
311
|
-
// (16 for the
|
|
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), `
|
|
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
|
-
|
|
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
|
-
|
|
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 ` ⠛⠛⠛⠛
|
|
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); // `
|
|
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\" (
|
|
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(
|
|
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
|
-
|
|
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 `
|
|
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(
|
package/src/editor/index.ts
CHANGED
|
@@ -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)
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/src/settings-io.test.ts
CHANGED
|
@@ -17,7 +17,7 @@ vi.mock("@earendil-works/pi-coding-agent", () => ({
|
|
|
17
17
|
}));
|
|
18
18
|
|
|
19
19
|
// Import after mocks are set up
|
|
20
|
-
const { loadConfig, saveConfig, removeConfig, isConfigEnabled, setConfigEnabled } = await import("./settings-io.js");
|
|
20
|
+
const { loadConfig, saveConfig, removeConfig, isConfigEnabled, setConfigEnabled, updateConfig } = await import("./settings-io.js");
|
|
21
21
|
|
|
22
22
|
describe("removeConfig", () => {
|
|
23
23
|
beforeEach(() => {
|
|
@@ -242,3 +242,114 @@ describe("saveConfig", () => {
|
|
|
242
242
|
expect(fs.existsSync(settingsPath + ".tmp")).toBe(false);
|
|
243
243
|
});
|
|
244
244
|
});
|
|
245
|
+
|
|
246
|
+
describe("updateConfig", () => {
|
|
247
|
+
const settingsPath = () => join(tempDir, "settings.json");
|
|
248
|
+
|
|
249
|
+
beforeEach(() => {
|
|
250
|
+
const p = settingsPath();
|
|
251
|
+
if (fs.existsSync(p)) fs.unlinkSync(p);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
afterEach(() => {
|
|
255
|
+
const p = settingsPath();
|
|
256
|
+
const tmpP = p + ".tmp";
|
|
257
|
+
try { fs.unlinkSync(p); } catch { /* ignore */ }
|
|
258
|
+
try { fs.unlinkSync(tmpP); } catch { /* ignore */ }
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it("applies mutation onto defaults when settings.json does not exist", () => {
|
|
262
|
+
const result = updateConfig(
|
|
263
|
+
"test.ns",
|
|
264
|
+
{ done: false, count: 0 },
|
|
265
|
+
(cfg) => ({ ...cfg, done: true }),
|
|
266
|
+
);
|
|
267
|
+
expect(result).toEqual({ done: true, count: 0 });
|
|
268
|
+
const data = JSON.parse(fs.readFileSync(settingsPath(), "utf-8"));
|
|
269
|
+
expect(data["test.ns"]).toEqual({ done: true, count: 0 });
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it("preserves sibling namespaces AND other keys of the target namespace", () => {
|
|
273
|
+
fs.writeFileSync(
|
|
274
|
+
settingsPath(),
|
|
275
|
+
JSON.stringify({
|
|
276
|
+
"test.ns": { done: false, extra: 99 },
|
|
277
|
+
"other.ns": { sibling: "value" },
|
|
278
|
+
}),
|
|
279
|
+
"utf-8",
|
|
280
|
+
);
|
|
281
|
+
const result = updateConfig(
|
|
282
|
+
"test.ns",
|
|
283
|
+
{ done: false, extra: 0 },
|
|
284
|
+
(cfg) => ({ ...cfg, done: true }),
|
|
285
|
+
);
|
|
286
|
+
expect(result).toEqual({ done: true, extra: 99 });
|
|
287
|
+
const data = JSON.parse(fs.readFileSync(settingsPath(), "utf-8"));
|
|
288
|
+
expect(data["test.ns"]).toEqual({ done: true, extra: 99 });
|
|
289
|
+
expect(data["other.ns"]).toEqual({ sibling: "value" });
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
it("concurrent-change detection: detects a mid-mutation external write and retries; final file contains both the external key and the mutated value", () => {
|
|
293
|
+
fs.writeFileSync(
|
|
294
|
+
settingsPath(),
|
|
295
|
+
JSON.stringify({ "test.ns": { done: false } }),
|
|
296
|
+
"utf-8",
|
|
297
|
+
);
|
|
298
|
+
|
|
299
|
+
let mutateCallCount = 0;
|
|
300
|
+
const result = updateConfig(
|
|
301
|
+
"test.ns",
|
|
302
|
+
{ done: false },
|
|
303
|
+
(cfg) => {
|
|
304
|
+
mutateCallCount += 1;
|
|
305
|
+
// On the first call, simulate a concurrent process writing an external key
|
|
306
|
+
// between the `before` read and the `after` read. This change must be
|
|
307
|
+
// detected by the optimistic re-read check, triggering a retry.
|
|
308
|
+
if (mutateCallCount === 1) {
|
|
309
|
+
const raw = JSON.parse(fs.readFileSync(settingsPath(), "utf-8"));
|
|
310
|
+
raw["concurrent.ns"] = { injected: true };
|
|
311
|
+
fs.writeFileSync(settingsPath(), JSON.stringify(raw), "utf-8");
|
|
312
|
+
}
|
|
313
|
+
return { ...cfg, done: true };
|
|
314
|
+
},
|
|
315
|
+
);
|
|
316
|
+
|
|
317
|
+
expect(mutateCallCount).toBeGreaterThan(1);
|
|
318
|
+
expect(result).toEqual({ done: true });
|
|
319
|
+
const data = JSON.parse(fs.readFileSync(settingsPath(), "utf-8"));
|
|
320
|
+
// Retry recomputed from fresh state — both the external key AND the mutated value survive
|
|
321
|
+
expect(data["test.ns"]).toEqual({ done: true });
|
|
322
|
+
expect(data["concurrent.ns"]).toEqual({ injected: true });
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
it("persistent concurrent writer: after maxAttempts the update still completes (last-writer-wins, no crash)", () => {
|
|
326
|
+
fs.writeFileSync(
|
|
327
|
+
settingsPath(),
|
|
328
|
+
JSON.stringify({ "test.ns": { done: false } }),
|
|
329
|
+
"utf-8",
|
|
330
|
+
);
|
|
331
|
+
|
|
332
|
+
let mutateCallCount = 0;
|
|
333
|
+
// mutate always writes the external key — simulates a persistent concurrent writer
|
|
334
|
+
const result = updateConfig(
|
|
335
|
+
"test.ns",
|
|
336
|
+
{ done: false },
|
|
337
|
+
(cfg) => {
|
|
338
|
+
mutateCallCount += 1;
|
|
339
|
+
// Always write a concurrent change to force re-read on every attempt
|
|
340
|
+
const raw = JSON.parse(fs.readFileSync(settingsPath(), "utf-8"));
|
|
341
|
+
raw["concurrent.ns"] = { injected: mutateCallCount };
|
|
342
|
+
fs.writeFileSync(settingsPath(), JSON.stringify(raw), "utf-8");
|
|
343
|
+
return { ...cfg, done: true };
|
|
344
|
+
},
|
|
345
|
+
3, // maxAttempts
|
|
346
|
+
);
|
|
347
|
+
|
|
348
|
+
// Must complete (not loop forever or throw)
|
|
349
|
+
expect(mutateCallCount).toBe(3); // saturated at maxAttempts
|
|
350
|
+
expect(result).toEqual({ done: true }); // flag value persisted
|
|
351
|
+
const data = JSON.parse(fs.readFileSync(settingsPath(), "utf-8"));
|
|
352
|
+
expect(data["test.ns"]).toEqual({ done: true }); // flag survived
|
|
353
|
+
expect(data["concurrent.ns"]).toBeDefined(); // siblings survived
|
|
354
|
+
});
|
|
355
|
+
});
|
package/src/settings-io.ts
CHANGED
|
@@ -4,16 +4,35 @@ import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
|
4
4
|
|
|
5
5
|
const SETTINGS_PATH = join(getAgentDir(), "settings.json");
|
|
6
6
|
|
|
7
|
-
/**
|
|
8
|
-
|
|
9
|
-
|
|
7
|
+
/**
|
|
8
|
+
* Read settings.json as a raw string. Returns null when the file is absent or
|
|
9
|
+
* unreadable (EISDIR, EACCES, etc.) — callers treat null the same as an empty
|
|
10
|
+
* file (i.e. no prior settings).
|
|
11
|
+
*/
|
|
12
|
+
function readRawSettings(): string | null {
|
|
13
|
+
if (!existsSync(SETTINGS_PATH)) return null;
|
|
10
14
|
try {
|
|
11
|
-
return
|
|
15
|
+
return readFileSync(SETTINGS_PATH, "utf-8");
|
|
16
|
+
} catch {
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Parse a raw settings string, returning empty object on null/corrupt input. */
|
|
22
|
+
function parseSettings(raw: string | null): Record<string, unknown> {
|
|
23
|
+
if (raw === null) return {};
|
|
24
|
+
try {
|
|
25
|
+
return JSON.parse(raw) as Record<string, unknown>;
|
|
12
26
|
} catch {
|
|
13
27
|
return {};
|
|
14
28
|
}
|
|
15
29
|
}
|
|
16
30
|
|
|
31
|
+
/** Read the full settings.json, returning empty object if missing/corrupt. */
|
|
32
|
+
function readSettings(): Record<string, unknown> {
|
|
33
|
+
return parseSettings(readRawSettings());
|
|
34
|
+
}
|
|
35
|
+
|
|
17
36
|
/**
|
|
18
37
|
* Load a config section from settings.json, merged with defaults.
|
|
19
38
|
*/
|
|
@@ -97,3 +116,47 @@ export function setConfigEnabled(namespace: string, enabled: boolean): void {
|
|
|
97
116
|
saveConfig(namespace, cfg);
|
|
98
117
|
}
|
|
99
118
|
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Concurrency-safe read-modify-write for one settings namespace.
|
|
122
|
+
*
|
|
123
|
+
* Uses an optimistic re-read check: the settings file is read before and after
|
|
124
|
+
* the `mutate` callback runs. If the file changed during that window (indicating
|
|
125
|
+
* a concurrent writer, e.g. another TUI session running /plugins toggle), the
|
|
126
|
+
* attempt is discarded and the loop restarts from a fresh read — up to
|
|
127
|
+
* `maxAttempts` times.
|
|
128
|
+
*
|
|
129
|
+
* Sibling namespaces always benefit from `saveConfig`’s own fresh read inside
|
|
130
|
+
* the atomic write, so they are never clobbered regardless of retries.
|
|
131
|
+
*
|
|
132
|
+
* The residual window between the final `after` check and `saveConfig`’s
|
|
133
|
+
* internal read is last-writer-wins; the optimistic check handles all
|
|
134
|
+
* detectable races (changes that happen during the `mutate` call itself).
|
|
135
|
+
*
|
|
136
|
+
* When the maximum attempt count is reached without a clean window (persistent
|
|
137
|
+
* concurrent writer), the last computed value is still written — no crash, no
|
|
138
|
+
* unbounded loop.
|
|
139
|
+
*/
|
|
140
|
+
export function updateConfig<T extends object>(
|
|
141
|
+
namespace: string,
|
|
142
|
+
defaults: T,
|
|
143
|
+
mutate: (cfg: T) => T,
|
|
144
|
+
maxAttempts = 3,
|
|
145
|
+
): T {
|
|
146
|
+
let last = { ...defaults } as T;
|
|
147
|
+
for (let attempt = 1; ; attempt++) {
|
|
148
|
+
const before = readRawSettings();
|
|
149
|
+
const parsed = parseSettings(before);
|
|
150
|
+
const current = { ...defaults, ...(parsed[namespace] ?? {}) } as T;
|
|
151
|
+
const next = mutate(current);
|
|
152
|
+
const after = readRawSettings();
|
|
153
|
+
if (before !== after && attempt < maxAttempts) {
|
|
154
|
+
// Settings file changed during our update window (concurrent writer) —
|
|
155
|
+
// recompute from the fresh state on the next iteration.
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
last = next;
|
|
159
|
+
saveConfig(namespace, next);
|
|
160
|
+
return last;
|
|
161
|
+
}
|
|
162
|
+
}
|