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.
- package/extensions/context/index.ts +3 -3
- package/extensions/nvidia-nim/index.ts +103 -164
- package/extensions/whimsical/README.md +71 -50
- package/extensions/whimsical/index.ts +466 -87
- package/extensions/whimsical/messages.ts +161 -27
- package/package.json +1 -1
|
@@ -1,99 +1,480 @@
|
|
|
1
|
-
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
2
|
-
import {
|
|
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
|
|
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
|
-
|
|
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
|
|
19
|
-
if (hour >= 0 && hour < 4) return
|
|
20
|
-
return
|
|
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
|
|
24
|
-
|
|
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
|
-
|
|
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 !==
|
|
33
|
-
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
43
|
-
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
67
|
-
//
|
|
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: "
|
|
70
|
-
handler: async (args) => {
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
443
|
+
stopActiveTicker();
|
|
444
|
+
if (ctx.hasUI) ctx.ui.setWorkingMessage();
|
|
445
|
+
await saveStateToSettings();
|
|
446
|
+
return "Whimsy disabled.";
|
|
75
447
|
}
|
|
76
|
-
if (
|
|
77
|
-
state.
|
|
78
|
-
|
|
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 (
|
|
81
|
-
|
|
82
|
-
return `Whimsy mode set to: ${state.mode}`;
|
|
454
|
+
if (sub === "status") {
|
|
455
|
+
return formatStatus();
|
|
83
456
|
}
|
|
84
|
-
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
|
|
118
|
-
|
|
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
|
-
|
|
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
|
-
|
|
506
|
+
stopActiveTicker();
|
|
126
507
|
return;
|
|
127
508
|
}
|
|
128
|
-
|
|
129
|
-
const
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
-
//
|
|
139
|
-
pi.on("turn_end", async (
|
|
140
|
-
|
|
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
|
}
|