@pi-archimedes/core 2.7.3 → 2.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,379 +0,0 @@
1
- import {
2
- CustomEditor,
3
- type Theme,
4
- type KeybindingsManager,
5
- } from "@earendil-works/pi-coding-agent";
6
- import {
7
- type TUI,
8
- type EditorTheme,
9
- truncateToWidth,
10
- isKeyRelease,
11
- visibleWidth,
12
- } from "@earendil-works/pi-tui";
13
-
14
- import {
15
- RESET,
16
- PAD_X,
17
- PI_STR,
18
- PI_WIDTH,
19
- PI_SYMBOL_COL,
20
- AUTOCOMPLETE_CURSOR,
21
- HINT_MARGIN_RIGHT,
22
- resolvePalette,
23
- } from "../chrome.js";
24
- import { isParentBorder, formatKey } from "../text.js";
25
- import { SPIN_INTERVALS, BorderTypeSpinner } from "./spin.js";
26
- import { pickQuip, QUIP_ROTATION_MAX_SECS, QUIP_ROTATION_MIN_SECS } from "./spin-quips.js";
27
- import { SPIN_SPEED_MULT, type CoreConfig, type SpinnerStyle } from "../config.js";
28
-
29
- const DOUBLE_PRESS_WINDOW_MS = 500;
30
-
31
- /** The 4-cell window inside the `┌───┐` border row — both stage sets are 1 wide per stage char, so the window stays 4 chars wide. */
32
- export const SPIN_TYPE_CELLS = BorderTypeSpinner.CELLS;
33
- /** Window start (in cells) inside the `┌───┐` border row, after `┌` — position 0, the block sits directly after the corner; the window's leading padding space is the first cell after it (`┌␠⠛⠛⠛⠛ …`). */
34
- export const SPIN_TYPE_START = 0;
35
-
36
- export class HephaestusEditor extends CustomEditor {
37
- private readonly piKeybindings: KeybindingsManager;
38
- private readonly getTheme: () => Theme;
39
- private readonly isIdle: () => boolean;
40
- private readonly shutdown: () => void;
41
- private hintTimer: ReturnType<typeof setTimeout> | undefined;
42
- private hintMessage: string | undefined;
43
- private pendingQuitUntil = 0;
44
-
45
- private readonly spinEnabled: boolean;
46
- /** Label typed after the 4-cell window while busy (the `editorSpinLabel` setting): an empty string hides it. */
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;
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. */
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;
64
- private readonly onSpinInterval:
65
- | ((interval: ReturnType<typeof setInterval> | undefined) => void)
66
- | undefined;
67
- private spinTimer: ReturnType<typeof setInterval> | undefined;
68
-
69
- constructor(
70
- tui: TUI,
71
- editorTheme: EditorTheme,
72
- keybindings: KeybindingsManager,
73
- {
74
- getTheme,
75
- isIdle,
76
- shutdown,
77
- spin = false,
78
- spinSpeed = "normal",
79
- spinStyle = "pendulum",
80
- spinLabel = "Working",
81
- onSpinInterval,
82
- }: {
83
- getTheme: () => Theme;
84
- isIdle: () => boolean;
85
- shutdown: () => void;
86
- /** Type a 4-cell spinner window into the editor's top border while the agent is busy (the animation mechanism lives in `BorderTypeSpinner`, `./spin.js`): the style-configured 4-cell window (the gallery-derived styles in `SPIN_VARIANTS` — the 2×4 braille dot block (⠁ → ⣿, Unicode chart order, `typing`) grows cell-by-cell left→right — each cell walking the 8 stages in 2-step line pairs (⠁⠉/⠋⠛/⠟⠿/⡿⣿) — then holds, clears, repeats (in EAW terminals the stage set falls back to the width-1 shading ░ → █; raw setting strings are tolerated — unknown names normalize to typing frames). */
87
- spin?: boolean;
88
- /** Tick period = the style's native per-tick tempo (`SPIN_INTERVALS[normalizeSpinnerStyle(spinStyle)]`) × the `editorSpinSpeed` multiplier (1.5 / 1 / 0.6), clamped at the 32 ms tick floor (the floor also caps a 30 ms native style under `fast` at 32). */
89
- spinSpeed?: CoreConfig["editorSpinSpeed"];
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). */
91
- spinStyle?: SpinnerStyle | string;
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. */
93
- spinLabel?: string;
94
- /** Lets an out-of-editor scope (core index.ts session hooks) clear the timer. */
95
- onSpinInterval?: (
96
- interval: ReturnType<typeof setInterval> | undefined,
97
- ) => void;
98
- },
99
- ) {
100
- super(tui, editorTheme, keybindings);
101
- this.piKeybindings = keybindings;
102
- this.getTheme = getTheme;
103
- this.isIdle = isIdle;
104
- this.shutdown = shutdown;
105
- this.onSpinInterval = onSpinInterval;
106
- this.spinEnabled = spin;
107
- // Non-string values (corrupt config) fall back to "Working" before `spinLabel` reaches `visibleWidth(label)`.
108
- const safeSpinLabel =
109
- typeof spinLabel === "string" ? spinLabel : "Working";
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";
118
- this.borderSpinner = spin ? new BorderTypeSpinner(this.isIdle, spinStyle) : undefined;
119
- if (spin) {
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).
121
- const nativeMs = SPIN_INTERVALS[spinStyle as SpinnerStyle];
122
- this.spinTickMs = Math.max(
123
- 32,
124
- (nativeMs ?? SPIN_INTERVALS["typing"]) * (SPIN_SPEED_MULT[spinSpeed] ?? 1),
125
- );
126
- this.spinTimer = setInterval(() => this.tickSpin(), this.spinTickMs);
127
- this.onSpinInterval?.(this.spinTimer);
128
- }
129
- }
130
-
131
- /** Clears the spinner timer and drops the module handle; idempotent. Pi 0.85.1 never calls dispose on a replaced editor — module-scope reaping is the operative safety net. */
132
- dispose(): void {
133
- if (this.spinTimer) {
134
- clearInterval(this.spinTimer);
135
- this.spinTimer = undefined;
136
- this.onSpinInterval?.(undefined);
137
- }
138
- }
139
-
140
- // ── Prompt spin ───────────────────────────────────────
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). */
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
- }
162
- if (this.borderSpinner && this.borderSpinner.tick()) {
163
- this.tui.requestRender();
164
- }
165
- }
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
-
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. */
174
- private typeStrip(): string {
175
- const p = resolvePalette(this.getTheme());
176
- if (!this.borderSpinner) return " ".repeat(SPIN_TYPE_CELLS);
177
- return this.borderSpinner.frame().split("").map(
178
- (c) => (c === " " ? " " : p.spin(c)),
179
- ).join("");
180
- }
181
-
182
- // ── Quit hint ─────────────────────────────────────────────
183
-
184
- private clearHint(resetWindow = true): void {
185
- clearTimeout(this.hintTimer);
186
- this.hintTimer = undefined;
187
- this.hintMessage = undefined;
188
- if (resetWindow) this.pendingQuitUntil = 0;
189
- this.tui.requestRender();
190
- }
191
-
192
- private showHint(message: string): void {
193
- this.clearHint(false);
194
- this.hintMessage = message;
195
- this.tui.requestRender();
196
- this.hintTimer = setTimeout(() => {
197
- this.hintMessage = undefined;
198
- this.hintTimer = undefined;
199
- this.pendingQuitUntil = 0;
200
- this.tui.requestRender();
201
- }, DOUBLE_PRESS_WINDOW_MS);
202
- }
203
-
204
- // ── Input ─────────────────────────────────────────────────
205
-
206
- override handleInput(data: string): void {
207
- if (isKeyRelease(data)) {
208
- super.handleInput(data);
209
- return;
210
- }
211
-
212
- if (!this.piKeybindings.matches(data, "app.clear")) {
213
- this.clearHint();
214
- super.handleInput(data);
215
- return;
216
- }
217
-
218
- const now = Date.now();
219
-
220
- if (this.getText().length > 0) {
221
- this.clearHint();
222
- this.pendingQuitUntil = now + DOUBLE_PRESS_WINDOW_MS;
223
- this.setText("");
224
- return;
225
- }
226
-
227
- if (!this.isIdle()) {
228
- this.clearHint();
229
- super.handleInput(data);
230
- return;
231
- }
232
-
233
- if (this.pendingQuitUntil > 0 && now <= this.pendingQuitUntil) {
234
- this.clearHint();
235
- this.shutdown();
236
- return;
237
- }
238
-
239
- this.pendingQuitUntil = now + DOUBLE_PRESS_WINDOW_MS;
240
- this.showHint(
241
- `${formatKey(this.piKeybindings.getKeys("app.clear")[0])} to quit`,
242
- );
243
- }
244
-
245
- // ── Render ────────────────────────────────────────────────
246
-
247
- override render(width: number): string[] {
248
- try {
249
- const p = resolvePalette(this.getTheme());
250
- const cw = width - PAD_X * 2;
251
- const inner = cw - 2;
252
- const rightPad = 1;
253
- const superLines = super.render(cw - PI_WIDTH - rightPad);
254
-
255
- let bottomIdx = superLines.length - 1;
256
- for (let i = superLines.length - 1; i >= 1; i--) {
257
- if (isParentBorder(superLines[i]!)) {
258
- bottomIdx = i;
259
- }
260
- }
261
- const contentLines = superLines.slice(1, bottomIdx);
262
- const autoLines = superLines.slice(bottomIdx + 1).map(
263
- (line) =>
264
- " ".repeat(PI_SYMBOL_COL) +
265
- truncateToWidth(
266
- line.replace("→", AUTOCOMPLETE_CURSOR),
267
- cw - PI_SYMBOL_COL,
268
- "",
269
- true,
270
- ),
271
- );
272
-
273
- // Top border row: while busy, a 4-cell window at start 0 replaces a
274
- // segment of the `─` border and the block sits directly after `┌` —
275
- // the window's leading padding space is the first cell after the
276
- // corner, then the window; the config label (`editorSpinLabel`,
277
- // default "Working") follows the window typed as `Working ` — one
278
- // space before AND after the label — when the box is wide enough
279
- // (inner >= 0 + 1 + 4 + 1 + labelW + 1 + 2 = labelW + 9, where
280
- // labelW = visibleWidth(label) — 9 for the default label; a CJK
281
- // label's visible width exceeds its char length — a margin of 2
282
- // after the trailing space; empty label or too-narrow
283
- // box → window only, not standalone), plain when too narrow to fit
284
- // (inner < 7, the minimum busy row is start 0 + 1 leading space +
285
- // 4 cells + 2 trailing margin). The window fills cell-by-cell
286
- // left→right, each cell walking the 8 chart-order stages in 2-step
287
- // line pairs (⠁⠉ / ⠋⠛ / ⠟⠿ / ⡿⣿; EAW: the width-1 shading ░ → █), so
288
- // the 2×4 dot block grows across the window line by line, followed
289
- // by the label (shown when the box hosts it; over-long labels never
290
- // sink the row — Math.max keeps the trailing ≥ 0 and the
291
- // window-only tier handles them), then holds — on the empty clear
292
- // step (step 38) the window cells are spaces and the label stays
293
- // up (the border line breaks there); the leading space + window +
294
- // label's columns replace trailing dashes, so the row width stays
295
- // constant (the trailing run shortens accordingly: inner − 0 − 1 −
296
- // 4 − (1 + labelW + 1) when the label shows, inner − 1 − 4
297
- // otherwise).
298
- const borderRun = (() => {
299
- const busy = this.spinEnabled && !this.isIdle();
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;
302
- // Visible width (not `.length`): a 4-char CJK label is 8 cells wide — `label.length` would short the row.
303
- const labelW = visibleWidth(label);
304
- const labelShown =
305
- busy &&
306
- label !== "" &&
307
- inner >=
308
- SPIN_TYPE_START + 1 + SPIN_TYPE_CELLS + 1 + labelW + 1 + 2; // labelFit = 0 + 1 (left padding) + 4 (window) + (" " + label + " " = 1 + labelW + 1) + 2 (trailing margin) = labelW + 9
309
- if (!busy || inner < SPIN_TYPE_START + 1 + SPIN_TYPE_CELLS + 2) { // inner < 7 → plain (no window, no label)
310
- return p.frame("─".repeat(inner));
311
- }
312
- const start = SPIN_TYPE_START; // 0 — the block sits directly after `┌` whenever it shows
313
- // Label cost = its own leading space + the label + its own trailing
314
- // space; the Math.max floor means an over-long label can never
315
- // sink the trailing below zero (those boxes degraded to
316
- // window-only above).
317
- const labelCost = labelShown ? 1 + labelW + 1 : 0;
318
- return (
319
- p.frame("─".repeat(start)) +
320
- " " +
321
- this.typeStrip() +
322
- (labelShown ? p.time(" " + label + " ") : "") +
323
- p.frame(
324
- "─".repeat(
325
- Math.max(0, inner - start - 1 - SPIN_TYPE_CELLS - labelCost),
326
- ),
327
- )
328
- );
329
- })();
330
- const topLine = p.frame("┌") + borderRun + p.frame("┐");
331
- const botLine =
332
- p.frame("└") + p.frame("─".repeat(inner)) + p.frame("┘");
333
-
334
- const piPrefix = p.prefix(PI_STR);
335
-
336
- const midLines = contentLines.map((line, i) => {
337
- if (i !== 0) {
338
- return (
339
- " ".repeat(PI_WIDTH) +
340
- truncateToWidth(line, cw - PI_WIDTH, "", true)
341
- );
342
- }
343
-
344
- if (this.hintMessage) {
345
- const hint =
346
- p.hint(this.hintMessage) + " ".repeat(HINT_MARGIN_RIGHT);
347
- return (
348
- piPrefix +
349
- truncateToWidth(
350
- line,
351
- cw - PI_WIDTH - visibleWidth(hint),
352
- "",
353
- true,
354
- ) +
355
- hint
356
- );
357
- }
358
- return piPrefix + truncateToWidth(line, cw - PI_WIDTH, "", true);
359
- });
360
-
361
- const spacer = autoLines.length > 0 ? [" ".repeat(cw)] : [];
362
- const raw = [topLine, ...midLines, ...spacer, ...autoLines, botLine];
363
-
364
- const pad = " ".repeat(PAD_X);
365
- const wrap = (line: string): string => {
366
- const patched = line.replaceAll(RESET, RESET + p.panelBg);
367
- return p.panelBg + pad + patched + pad + RESET;
368
- };
369
-
370
- const topEdge = p.panelEdge + "▁".repeat(width) + RESET;
371
- const botEdge = p.panelEdge + "▔".repeat(width) + RESET;
372
-
373
- return [topEdge, ...raw.map(wrap), botEdge];
374
- } catch (e) {
375
- console.error("HephaestusEditor render error:", e);
376
- throw e;
377
- }
378
- }
379
- }
@@ -1,106 +0,0 @@
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
- }
@@ -1,44 +0,0 @@
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
- }