pi-agent-extensions 0.3.0 → 0.3.2

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,99 +1,480 @@
1
- import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
2
- import { BOLLYWOOD_MESSAGES, CONTEXT_MESSAGES, PI_TIPS, WHIMSICAL_VERBS, GOODBYE_MESSAGES } from "./messages.js";
1
+ import type { ExtensionAPI, ExtensionCommandContext } from "@mariozechner/pi-coding-agent";
2
+ import { Key, Loader, matchesKey } from "@mariozechner/pi-tui";
3
+ import * as fs from "node:fs/promises";
4
+ import * as os from "node:os";
5
+ import * as path from "node:path";
6
+ import {
7
+ ABSURD_NERD_LINES,
8
+ BOLLYWOOD_MESSAGES,
9
+ BOSS_PHASE_MESSAGES,
10
+ CONTEXT_MESSAGES,
11
+ FAKE_COMPILER_PANIC,
12
+ GOODBYE_MESSAGES_BY_BUCKET,
13
+ PI_TIPS,
14
+ TERMINAL_MEME_LINES,
15
+ WHIMSICAL_VERBS,
16
+ } from "./messages.js";
3
17
 
4
- type WhimsyMode = 'chaos' | 'classic' | 'bollywood' | 'geek';
18
+ type ChaosBucket = "A" | "B" | "C" | "D" | "E" | "F" | "G";
19
+
20
+ const ALL_BUCKETS: ChaosBucket[] = ["A", "B", "C", "D", "E", "F", "G"];
21
+
22
+ const SPINNER_PRESETS = {
23
+ sleekOrbit: ["◜", "◠", "◝", "◞", "◡", "◟"],
24
+ neonPulse: ["∙∙●∙∙", "∙●∙●∙", "●∙∙∙●", "∙●∙●∙"],
25
+ scanline: ["▏", "▎", "▍", "▌", "▋", "▊", "▉", "█", "▉", "▊", "▋", "▌", "▍", "▎"],
26
+ chevronFlow: [">>>", ">>·", ">··", "···", "·<<", "<<<"],
27
+ matrixGlyph: ["┆", "╎", "┊", "╏", "┋"],
28
+ } as const;
29
+
30
+ type SpinnerPresetId = keyof typeof SPINNER_PRESETS;
31
+
32
+ const SPINNER_PRESET_ORDER: SpinnerPresetId[] = [
33
+ "sleekOrbit",
34
+ "neonPulse",
35
+ "scanline",
36
+ "chevronFlow",
37
+ "matrixGlyph",
38
+ ];
39
+
40
+ const SPINNER_PRESET_LABELS: Record<SpinnerPresetId, string> = {
41
+ sleekOrbit: "Sleek Orbit",
42
+ neonPulse: "Neon Pulse",
43
+ scanline: "Scanline",
44
+ chevronFlow: "Chevron Flow",
45
+ matrixGlyph: "Matrix Glyph",
46
+ };
5
47
 
6
48
  interface WhimsyState {
7
- mode: WhimsyMode;
8
49
  enabled: boolean;
50
+ chaosWeights: Record<ChaosBucket, number>;
51
+ spinnerPreset: SpinnerPresetId;
52
+ }
53
+
54
+ interface PersistedWhimsyConfig {
55
+ enabled?: boolean;
56
+ weights?: Partial<Record<ChaosBucket, number>>;
57
+ spinnerPreset?: string;
9
58
  }
10
59
 
60
+ interface TunerResult {
61
+ weights: Record<ChaosBucket, number>;
62
+ spinnerPreset: SpinnerPresetId;
63
+ }
64
+
65
+ const BUCKET_META: Array<{ key: ChaosBucket; title: string; description: string }> = [
66
+ { key: "A", title: "Absurd Nerd Lines", description: "Grepping the void, refactoring by vibes" },
67
+ { key: "B", title: "Boss Progression", description: "Phase-based messages by wait duration" },
68
+ { key: "C", title: "Fake Compiler Panic", description: "Chaotic fake diagnostics" },
69
+ { key: "D", title: "Terminal Meme Lines", description: "CLI one-liners and git jokes" },
70
+ { key: "E", title: "Bollywood & Hinglish", description: "Dialogues, movie vibes, desi dev humor" },
71
+ { key: "F", title: "Pi Tips", description: "Helpful tips for using Pi effectively" },
72
+ { key: "G", title: "Whimsical Verbs", description: "Combobulating... Skedaddling... Noodling..." },
73
+ ];
74
+
75
+ const DEFAULT_WEIGHTS: Record<ChaosBucket, number> = {
76
+ A: 10, B: 10, C: 10, D: 10, E: 30, F: 15, G: 15,
77
+ };
78
+ const DEFAULT_SPINNER_PRESET: SpinnerPresetId = "sleekOrbit";
79
+
11
80
  const state: WhimsyState = {
12
- mode: 'chaos', // Default: The mix you requested
13
81
  enabled: true,
82
+ chaosWeights: { ...DEFAULT_WEIGHTS },
83
+ spinnerPreset: DEFAULT_SPINNER_PRESET,
14
84
  };
15
85
 
16
- function getTimeContext(): 'morning' | 'night' | 'day' {
86
+ let loadedGlobalState = false;
87
+ let loaderSpinnerPatched = false;
88
+
89
+ const MIN_WORKING_MESSAGE_INTERVAL_MS = 10_000;
90
+ const SPINNER_FRAME_INTERVAL_MS = 100;
91
+
92
+ let activeWhimsyTicker: ReturnType<typeof setInterval> | null = null;
93
+ let activeTurnStartedAtMs = Date.now();
94
+ let nextWorkingMessageAtMs = Date.now();
95
+ let currentWorkingMessage = "";
96
+
97
+ function pick<T>(arr: readonly T[]): T {
98
+ return arr[Math.floor(Math.random() * arr.length)];
99
+ }
100
+
101
+ function getSpinnerFrames(preset: SpinnerPresetId): readonly string[] {
102
+ return SPINNER_PRESETS[preset];
103
+ }
104
+
105
+ function patchGlobalLoaderSpinner(): void {
106
+ if (loaderSpinnerPatched) return;
107
+
108
+ const proto = Loader.prototype as any;
109
+ const originalUpdateDisplay = proto.updateDisplay;
110
+ if (typeof originalUpdateDisplay !== "function") return;
111
+
112
+ proto.updateDisplay = function patchedUpdateDisplay(this: any, ...args: unknown[]) {
113
+ const frames = getSpinnerFrames(state.spinnerPreset);
114
+ if (Array.isArray(frames) && frames.length > 0) {
115
+ this.frames = [...frames];
116
+ const current = Number(this.currentFrame ?? 0);
117
+ this.currentFrame = Number.isFinite(current) ? current % this.frames.length : 0;
118
+ }
119
+ return originalUpdateDisplay.apply(this, args);
120
+ };
121
+
122
+ loaderSpinnerPatched = true;
123
+ }
124
+
125
+ function formatWeights(weights: Record<ChaosBucket, number>): string {
126
+ return ALL_BUCKETS.map((k) => `${k}=${weights[k]}%`).join(" ");
127
+ }
128
+
129
+ function formatStatus(): string {
130
+ return `Whimsy ${state.enabled ? "on" : "off"} | ${formatWeights(state.chaosWeights)} | spinner=${SPINNER_PRESET_LABELS[state.spinnerPreset]}`;
131
+ }
132
+
133
+ function getTimeContext(): "morning" | "night" | "day" {
17
134
  const hour = new Date().getHours();
18
- if (hour >= 5 && hour < 11) return 'morning';
19
- if (hour >= 0 && hour < 4) return 'night';
20
- return 'day';
135
+ if (hour >= 5 && hour < 11) return "morning";
136
+ if (hour >= 0 && hour < 4) return "night";
137
+ return "day";
21
138
  }
22
139
 
23
- function pickMessage(mode: WhimsyMode, durationSeconds: number = 0): string {
24
- // 1. Check for Long Wait (Overrides everything else if waiting > 5s)
140
+ function pickBossProgression(durationSeconds: number): string {
141
+ if (durationSeconds < 5) return pick(BOSS_PHASE_MESSAGES.early);
142
+ if (durationSeconds < 15) return pick(BOSS_PHASE_MESSAGES.mid);
143
+ return pick(BOSS_PHASE_MESSAGES.late);
144
+ }
145
+
146
+ function chooseWeightedBucket(weights: Record<ChaosBucket, number>): ChaosBucket {
147
+ const roll = Math.random() * 100;
148
+ let cumulative = 0;
149
+ for (const bucket of ALL_BUCKETS) {
150
+ cumulative += weights[bucket];
151
+ if (roll < cumulative) return bucket;
152
+ }
153
+ return "E"; // fallback
154
+ }
155
+
156
+ function pickMessageForBucket(bucket: ChaosBucket, durationSeconds: number): string {
157
+ switch (bucket) {
158
+ case "A": return pick(ABSURD_NERD_LINES);
159
+ case "B": return pickBossProgression(durationSeconds);
160
+ case "C": return pick(FAKE_COMPILER_PANIC);
161
+ case "D": return pick(TERMINAL_MEME_LINES);
162
+ case "E": return pick(BOLLYWOOD_MESSAGES);
163
+ case "F": return pick(PI_TIPS);
164
+ case "G": return pick(WHIMSICAL_VERBS);
165
+ }
166
+ }
167
+
168
+ function pickWorkingMessageFor(weights: Record<ChaosBucket, number>, durationSeconds: number): string {
169
+ // Context-aware overrides: time of day and long waits
25
170
  if (durationSeconds > 5 && Math.random() > 0.5) {
26
- const longMsgs = CONTEXT_MESSAGES.longWait;
27
- return longMsgs[Math.floor(Math.random() * longMsgs.length)];
171
+ return pick(CONTEXT_MESSAGES.longWait);
28
172
  }
29
173
 
30
- // 2. Check for Time Context (Morning/Night special messages)
31
174
  const timeContext = getTimeContext();
32
- if (timeContext !== 'day' && Math.random() > 0.7) {
33
- const timeMsgs = CONTEXT_MESSAGES[timeContext];
34
- return timeMsgs[Math.floor(Math.random() * timeMsgs.length)];
175
+ if (timeContext !== "day" && Math.random() > 0.7) {
176
+ return pick(CONTEXT_MESSAGES[timeContext]);
35
177
  }
36
178
 
37
- // 3. Mode-based Selection
38
- if (mode === 'classic') {
39
- return WHIMSICAL_VERBS[Math.floor(Math.random() * WHIMSICAL_VERBS.length)];
179
+ const selected = chooseWeightedBucket(weights);
180
+ return pickMessageForBucket(selected, durationSeconds);
181
+ }
182
+
183
+ function pickGoodbyeMessage(): string {
184
+ const selected = chooseWeightedBucket(state.chaosWeights);
185
+ return pick(GOODBYE_MESSAGES_BY_BUCKET[selected]);
186
+ }
187
+
188
+ function adjustWeightsByStep(
189
+ weights: Record<ChaosBucket, number>,
190
+ selected: ChaosBucket,
191
+ delta: 5 | -5,
192
+ ): boolean {
193
+ const next = weights[selected] + delta;
194
+ if (next < 0) return false;
195
+ weights[selected] = next;
196
+ return true;
197
+ }
198
+
199
+ function sanitizeWeights(raw?: Partial<Record<ChaosBucket, number>>): Record<ChaosBucket, number> {
200
+ if (!raw) return { ...DEFAULT_WEIGHTS };
201
+
202
+ const out: Record<ChaosBucket, number> = { A: 0, B: 0, C: 0, D: 0, E: 0, F: 0, G: 0 };
203
+
204
+ for (const key of ALL_BUCKETS) {
205
+ const v = Number(raw[key] ?? 0);
206
+ if (!Number.isFinite(v) || v < 0 || v % 5 !== 0) return { ...DEFAULT_WEIGHTS };
207
+ out[key] = v;
40
208
  }
41
-
42
- if (mode === 'bollywood') {
43
- return BOLLYWOOD_MESSAGES[Math.floor(Math.random() * BOLLYWOOD_MESSAGES.length)];
209
+
210
+ const total = ALL_BUCKETS.reduce((sum, k) => sum + out[k], 0);
211
+ if (total !== 100) return { ...DEFAULT_WEIGHTS };
212
+ return out;
213
+ }
214
+
215
+ function sanitizeSpinnerPreset(raw?: string): SpinnerPresetId {
216
+ if (!raw) return DEFAULT_SPINNER_PRESET;
217
+ return (SPINNER_PRESET_ORDER as string[]).includes(raw) ? (raw as SpinnerPresetId) : DEFAULT_SPINNER_PRESET;
218
+ }
219
+
220
+ function cycleSpinnerPreset(current: SpinnerPresetId, direction: -1 | 1): SpinnerPresetId {
221
+ const index = SPINNER_PRESET_ORDER.indexOf(current);
222
+ const next = (index + direction + SPINNER_PRESET_ORDER.length) % SPINNER_PRESET_ORDER.length;
223
+ return SPINNER_PRESET_ORDER[next];
224
+ }
225
+
226
+ function getSettingsPath(): string {
227
+ return path.join(os.homedir(), ".pi", "agent", "settings.json");
228
+ }
229
+
230
+ async function loadStateFromSettings(): Promise<void> {
231
+ const settingsPath = getSettingsPath();
232
+ try {
233
+ const text = await fs.readFile(settingsPath, "utf-8");
234
+ const parsed = JSON.parse(text) as Record<string, unknown>;
235
+ const whimsical = (parsed?.whimsical ?? {}) as PersistedWhimsyConfig;
236
+
237
+ state.enabled = typeof whimsical.enabled === "boolean" ? whimsical.enabled : true;
238
+ state.chaosWeights = sanitizeWeights(whimsical.weights);
239
+ state.spinnerPreset = sanitizeSpinnerPreset(whimsical.spinnerPreset);
240
+ } catch {
241
+ state.enabled = true;
242
+ state.chaosWeights = { ...DEFAULT_WEIGHTS };
243
+ state.spinnerPreset = DEFAULT_SPINNER_PRESET;
44
244
  }
45
-
46
- if (mode === 'geek') {
47
- // Re-use gerunds + tips for now as "geek" substitute + some custom logic could go here
48
- return PI_TIPS[Math.floor(Math.random() * PI_TIPS.length)];
245
+ }
246
+
247
+ async function saveStateToSettings(): Promise<void> {
248
+ const settingsPath = getSettingsPath();
249
+ const dir = path.dirname(settingsPath);
250
+
251
+ let parsed: Record<string, unknown> = {};
252
+ try {
253
+ const text = await fs.readFile(settingsPath, "utf-8");
254
+ parsed = text.trim() ? (JSON.parse(text) as Record<string, unknown>) : {};
255
+ } catch {
256
+ parsed = {};
49
257
  }
50
258
 
51
- // CHAOS MODE (The requested 50/30/20 mix)
52
- const roll = Math.random();
53
-
54
- if (roll < 0.5) {
55
- // 50% Bollywood
56
- return BOLLYWOOD_MESSAGES[Math.floor(Math.random() * BOLLYWOOD_MESSAGES.length)];
57
- } else if (roll < 0.8) {
58
- // 30% Tips
59
- return PI_TIPS[Math.floor(Math.random() * PI_TIPS.length)];
60
- } else {
61
- // 20% Classic/Smart (Gerunds or Context)
62
- return WHIMSICAL_VERBS[Math.floor(Math.random() * WHIMSICAL_VERBS.length)];
259
+ parsed.whimsical = {
260
+ enabled: state.enabled,
261
+ weights: { ...state.chaosWeights },
262
+ spinnerPreset: state.spinnerPreset,
263
+ } satisfies PersistedWhimsyConfig;
264
+
265
+ await fs.mkdir(dir, { recursive: true });
266
+ await fs.writeFile(settingsPath, JSON.stringify(parsed, null, 2), "utf-8");
267
+ }
268
+
269
+ async function ensureStateLoaded(): Promise<void> {
270
+ if (loadedGlobalState) return;
271
+ await loadStateFromSettings();
272
+ loadedGlobalState = true;
273
+ }
274
+
275
+ function stopActiveTicker(): void {
276
+ if (activeWhimsyTicker) {
277
+ clearInterval(activeWhimsyTicker);
278
+ activeWhimsyTicker = null;
63
279
  }
64
280
  }
65
281
 
66
- export default function (pi: ExtensionAPI) {
67
- // Register Command
282
+ function renderWorkingLine(): string {
283
+ // Interactive mode already renders its own spinner glyph.
284
+ // Return message-only text to avoid double spinners.
285
+ return currentWorkingMessage;
286
+ }
287
+
288
+ async function openWeightsTuner(ctx: ExtensionCommandContext) {
289
+ if (!ctx.hasUI) return null;
290
+
291
+ return ctx.ui.custom<TunerResult | null>((tui, theme, _kb, done) => {
292
+ const workingWeights = { ...state.chaosWeights };
293
+ let workingSpinnerPreset: SpinnerPresetId = state.spinnerPreset;
294
+ let selectedRow = 0; // 0-6 buckets, 7 spinner row
295
+ let warning = "";
296
+
297
+ const previewStartedAt = Date.now();
298
+ let previewSpinnerIndex = 0;
299
+ let nextPreviewMessageAt = Date.now() + MIN_WORKING_MESSAGE_INTERVAL_MS;
300
+ let previewMessage = pickWorkingMessageFor(workingWeights, 0);
301
+
302
+ const previewTicker = setInterval(() => {
303
+ previewSpinnerIndex += 1;
304
+ const now = Date.now();
305
+ if (now >= nextPreviewMessageAt) {
306
+ const elapsed = (now - previewStartedAt) / 1000;
307
+ previewMessage = pickWorkingMessageFor(workingWeights, elapsed);
308
+ nextPreviewMessageAt = now + MIN_WORKING_MESSAGE_INTERVAL_MS;
309
+ }
310
+ tui.requestRender();
311
+ }, SPINNER_FRAME_INTERVAL_MS);
312
+
313
+ const finish = (result: TunerResult | null) => {
314
+ clearInterval(previewTicker);
315
+ done(result);
316
+ };
317
+
318
+ function totalWeight(): number {
319
+ return ALL_BUCKETS.reduce((sum, k) => sum + workingWeights[k], 0);
320
+ }
321
+
322
+ function currentPreviewFrame(): string {
323
+ const frames = getSpinnerFrames(workingSpinnerPreset);
324
+ return frames[previewSpinnerIndex % frames.length];
325
+ }
326
+
327
+ function render(width: number): string[] {
328
+ const lines: string[] = [];
329
+ const hr = theme.fg("accent", "─".repeat(Math.max(8, width)));
330
+ const add = (line: string) => lines.push(line);
331
+ const total = totalWeight();
332
+ const canSave = total === 100;
333
+
334
+ add(hr);
335
+ add(theme.fg("accent", theme.bold(" Whimsy Chaos Mixer")));
336
+ add(theme.fg("muted", " ↑/↓ move • ←/→ adjust • Enter save (only when total=100) • Esc cancel"));
337
+ add("");
338
+
339
+ for (let i = 0; i < BUCKET_META.length; i++) {
340
+ const bucket = BUCKET_META[i];
341
+ const focused = i === selectedRow;
342
+ const prefix = focused ? theme.fg("accent", "> ") : " ";
343
+ const title = `${bucket.key}. ${bucket.title}`;
344
+ const pct = `${workingWeights[bucket.key]}%`;
345
+ const main = focused ? theme.fg("accent", `${title.padEnd(30)} ${pct}`) : `${title.padEnd(30)} ${pct}`;
346
+ add(prefix + main);
347
+ add(` ${theme.fg("dim", bucket.description)}`);
348
+ }
349
+
350
+ add("");
351
+ const spinnerFocused = selectedRow === BUCKET_META.length;
352
+ const spinnerPrefix = spinnerFocused ? theme.fg("accent", "> ") : " ";
353
+ const presetLabel = SPINNER_PRESET_LABELS[workingSpinnerPreset];
354
+ const sampleFrames = getSpinnerFrames(workingSpinnerPreset).slice(0, 4).join(" ");
355
+ const spinnerLine = `Spinner Preset: ${presetLabel} [${sampleFrames}]`;
356
+ add(spinnerPrefix + (spinnerFocused ? theme.fg("accent", spinnerLine) : spinnerLine));
357
+ add(` ${theme.fg("dim", "Use ←/→ on this row to switch between 5 presets")}`);
358
+
359
+ add("");
360
+ add(theme.fg("muted", " Preview"));
361
+ add(` ${theme.fg("accent", currentPreviewFrame())} ${theme.fg("text", previewMessage)}`);
362
+
363
+ add("");
364
+ add(canSave ? theme.fg("text", ` Total: ${total}%`) : theme.fg("warning", ` Total: ${total}%`));
365
+ if (!canSave) add(theme.fg("warning", " ⚠ Total must be exactly 100% to save."));
366
+ if (warning) add(theme.fg("warning", ` ⚠ ${warning}`));
367
+ add(hr);
368
+
369
+ return lines;
370
+ }
371
+
372
+ function handleInput(data: string) {
373
+ if (matchesKey(data, Key.up)) {
374
+ warning = "";
375
+ selectedRow = Math.max(0, selectedRow - 1);
376
+ tui.requestRender();
377
+ return;
378
+ }
379
+ if (matchesKey(data, Key.down)) {
380
+ warning = "";
381
+ selectedRow = Math.min(BUCKET_META.length, selectedRow + 1);
382
+ tui.requestRender();
383
+ return;
384
+ }
385
+ if (matchesKey(data, Key.left)) {
386
+ warning = "";
387
+ if (selectedRow < BUCKET_META.length) {
388
+ adjustWeightsByStep(workingWeights, BUCKET_META[selectedRow].key, -5);
389
+ } else {
390
+ workingSpinnerPreset = cycleSpinnerPreset(workingSpinnerPreset, -1);
391
+ }
392
+ tui.requestRender();
393
+ return;
394
+ }
395
+ if (matchesKey(data, Key.right)) {
396
+ warning = "";
397
+ if (selectedRow < BUCKET_META.length) {
398
+ adjustWeightsByStep(workingWeights, BUCKET_META[selectedRow].key, 5);
399
+ } else {
400
+ workingSpinnerPreset = cycleSpinnerPreset(workingSpinnerPreset, 1);
401
+ }
402
+ tui.requestRender();
403
+ return;
404
+ }
405
+ if (matchesKey(data, Key.enter)) {
406
+ if (totalWeight() !== 100) {
407
+ warning = "Cannot save until total equals 100%.";
408
+ tui.requestRender();
409
+ return;
410
+ }
411
+ finish({ weights: workingWeights, spinnerPreset: workingSpinnerPreset });
412
+ return;
413
+ }
414
+ if (matchesKey(data, Key.escape)) {
415
+ finish(null);
416
+ }
417
+ }
418
+
419
+ return {
420
+ render,
421
+ invalidate: () => undefined,
422
+ handleInput,
423
+ };
424
+ });
425
+ }
426
+
427
+ export default function whimsicalExtension(pi: ExtensionAPI) {
428
+ patchGlobalLoaderSpinner();
429
+
68
430
  pi.registerCommand("whimsy", {
69
- description: "Configure whimsical loading messages",
70
- handler: async (args) => {
71
- const subCommand = args[0];
72
- if (subCommand === 'off') {
431
+ description: "Open chaos mixer + spinner tuner",
432
+ handler: async (args, ctx) => {
433
+ await ensureStateLoaded();
434
+ const sub = (args[0] ?? "").toLowerCase();
435
+
436
+ if (sub === "on") {
437
+ state.enabled = true;
438
+ await saveStateToSettings();
439
+ return "Whimsy enabled.";
440
+ }
441
+ if (sub === "off") {
73
442
  state.enabled = false;
74
- return "Whimsical messages disabled.";
443
+ stopActiveTicker();
444
+ if (ctx.hasUI) ctx.ui.setWorkingMessage();
445
+ await saveStateToSettings();
446
+ return "Whimsy disabled.";
75
447
  }
76
- if (subCommand === 'on') {
77
- state.enabled = true;
78
- return "Whimsical messages enabled.";
448
+ if (sub === "reset") {
449
+ state.chaosWeights = { ...DEFAULT_WEIGHTS };
450
+ state.spinnerPreset = DEFAULT_SPINNER_PRESET;
451
+ await saveStateToSettings();
452
+ return `Whimsy reset: ${formatStatus()}`;
79
453
  }
80
- if (['chaos', 'classic', 'bollywood', 'geek'].includes(subCommand)) {
81
- state.mode = subCommand as WhimsyMode;
82
- return `Whimsy mode set to: ${state.mode}`;
454
+ if (sub === "status") {
455
+ return formatStatus();
83
456
  }
84
- return "Usage: /whimsy [chaos|classic|bollywood|geek|on|off]";
85
- }
457
+
458
+ if (!ctx.hasUI) {
459
+ return `${formatStatus()}\nUse interactive mode and run /whimsy to tune weights + spinner.`;
460
+ }
461
+
462
+ const tuned = await openWeightsTuner(ctx);
463
+ if (!tuned) return "Whimsy unchanged.";
464
+
465
+ state.chaosWeights = tuned.weights;
466
+ state.spinnerPreset = tuned.spinnerPreset;
467
+ await saveStateToSettings();
468
+ return `Whimsy updated: ${formatStatus()}`;
469
+ },
86
470
  });
87
471
 
88
- // Register /exit and /bye
89
472
  pi.registerCommand("exit", {
90
473
  description: "Exit Pi with a whimsical goodbye",
91
474
  handler: async (_args, ctx) => {
92
- const msg = GOODBYE_MESSAGES[Math.floor(Math.random() * GOODBYE_MESSAGES.length)];
93
- if (ctx.hasUI) {
94
- ctx.ui.notify(`👋 ${msg}`, "info");
95
- }
96
- // Use setImmediate to ensure shutdown happens after command handler completes
475
+ await ensureStateLoaded();
476
+ const msg = pickGoodbyeMessage();
477
+ if (ctx.hasUI) ctx.ui.notify(`👋 ${msg}`, "info");
97
478
  setImmediate(() => ctx.shutdown());
98
479
  },
99
480
  });
@@ -101,45 +482,43 @@ export default function (pi: ExtensionAPI) {
101
482
  pi.registerCommand("bye", {
102
483
  description: "Exit Pi with a whimsical goodbye (alias)",
103
484
  handler: async (_args, ctx) => {
104
- const msg = GOODBYE_MESSAGES[Math.floor(Math.random() * GOODBYE_MESSAGES.length)];
105
- if (ctx.hasUI) {
106
- ctx.ui.notify(`👋 ${msg}`, "info");
107
- }
108
- // Use setImmediate to ensure shutdown happens after command handler completes
485
+ await ensureStateLoaded();
486
+ const msg = pickGoodbyeMessage();
487
+ if (ctx.hasUI) ctx.ui.notify(`👋 ${msg}`, "info");
109
488
  setImmediate(() => ctx.shutdown());
110
489
  },
111
490
  });
112
491
 
113
- // Turn Start Logic
114
492
  pi.on("turn_start", async (_event, ctx) => {
493
+ await ensureStateLoaded();
115
494
  if (!state.enabled) return;
116
495
 
117
- // Initial Message
118
- ctx.ui.setWorkingMessage(pickMessage(state.mode));
496
+ stopActiveTicker();
497
+
498
+ activeTurnStartedAtMs = Date.now();
499
+ nextWorkingMessageAtMs = activeTurnStartedAtMs + MIN_WORKING_MESSAGE_INTERVAL_MS;
500
+ currentWorkingMessage = pickWorkingMessageFor(state.chaosWeights, 0);
501
+
502
+ ctx.ui.setWorkingMessage(renderWorkingLine());
119
503
 
120
- // Dynamic Updates for Long Turns
121
- const startTime = Date.now();
122
- // Update every 10 seconds to give time to read long messages
123
- const interval = setInterval(() => {
504
+ activeWhimsyTicker = setInterval(() => {
124
505
  if (!state.enabled) {
125
- clearInterval(interval);
506
+ stopActiveTicker();
126
507
  return;
127
508
  }
128
-
129
- const elapsed = (Date.now() - startTime) / 1000;
130
- ctx.ui.setWorkingMessage(pickMessage(state.mode, elapsed));
131
- }, 10000);
132
-
133
- // Store interval in a weak map or similar if we needed to clear it externally,
134
- // but turn_end is sufficient.
135
- (ctx as any)._whimsyInterval = interval;
509
+
510
+ const now = Date.now();
511
+ if (now >= nextWorkingMessageAtMs) {
512
+ const elapsed = (now - activeTurnStartedAtMs) / 1000;
513
+ currentWorkingMessage = pickWorkingMessageFor(state.chaosWeights, elapsed);
514
+ nextWorkingMessageAtMs = now + MIN_WORKING_MESSAGE_INTERVAL_MS;
515
+ ctx.ui.setWorkingMessage(renderWorkingLine());
516
+ }
517
+ }, SPINNER_FRAME_INTERVAL_MS);
136
518
  });
137
519
 
138
- // Turn End Logic
139
- pi.on("turn_end", async (_event, ctx) => {
140
- ctx.ui.setWorkingMessage(); // Reset
141
- if ((ctx as any)._whimsyInterval) {
142
- clearInterval((ctx as any)._whimsyInterval);
143
- }
520
+ // Keep running until the next turn_start replaces cadence.
521
+ pi.on("turn_end", async () => {
522
+ // no-op by design
144
523
  });
145
524
  }