pi-agent-extensions 0.2.3 → 0.3.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 CHANGED
@@ -32,6 +32,7 @@ Original repository: https://github.com/mitsuhiko/agent-stuff
32
32
  | **control** | RPC | Inter-session communication & control | ⚙️ Beta |
33
33
  | **answer** | Tool | Structured Q&A for complex queries | ⚙️ Beta |
34
34
  | **cwd_history** | Tracker | Tracks directory changes in context | ✅ Stable |
35
+ | **nvidia-nim** | Command | Nvidia NIM auth & config | ✅ Stable |
35
36
 
36
37
  ## Install
37
38
 
@@ -295,6 +296,13 @@ RPC-based session control. Allows sessions to talk to each other (e.g., a "manag
295
296
  - Flag: `--session-control`
296
297
  - Tool: `send_to_session`
297
298
 
299
+ **Nvidia NIM (`/nvidia-nim-auth`)**
300
+ Authenticate and configure Nvidia NIM as an LLM provider.
301
+ - Commands: `/nvidia-nim-auth` (alias: `/nvidia-auth`), `/nvidia-nim-models`
302
+ - Saves provider config to `~/.pi/nvidia-nim.json`
303
+ - Adds configured models to `~/.pi/agent/settings.json` `enabledModels` for scoped `/model` + Ctrl+P cycling
304
+ - Model IDs must be `org/model` (exactly one `/`), e.g. `moonshotai/kimi-k2.5` (not `nvidia/moonshotai/kimi-k2.5`)
305
+
298
306
  ## Development
299
307
 
300
308
  ```bash
@@ -0,0 +1,62 @@
1
+ # Nvidia NIM Extension
2
+
3
+ Authenticate and configure the Nvidia NIM provider for the Pi coding agent. Models you add appear directly in the `/model` picker.
4
+
5
+ ## Commands
6
+
7
+ | Command | Description |
8
+ |---------|-------------|
9
+ | `/nvidia-nim-auth` | Full setup — API key + model editor |
10
+ | `/nvidia-auth` | Alias for `/nvidia-nim-auth` |
11
+ | `/nvidia-nim-models` | Add/edit models (keeps existing API key) |
12
+
13
+ ## Usage
14
+
15
+ 1. **Get an API key** from [build.nvidia.com](https://build.nvidia.com)
16
+ 2. Run `/nvidia-nim-auth` in Pi
17
+ 3. Paste your `nvapi-...` key
18
+ 4. Add model IDs in the editor (one per line), e.g.:
19
+ ```
20
+ meta/llama-3.1-405b-instruct
21
+ deepseek-ai/deepseek-r1
22
+ moonshotai/kimi-k2.5
23
+ nvidia/llama-3.1-nemotron-70b-instruct
24
+ ```
25
+ 5. Use `/model` to switch to any registered Nvidia model (also available in scoped model list / Ctrl+P cycling)
26
+
27
+ To add more models later without re-entering your key, use `/nvidia-nim-models`.
28
+
29
+ ### Model ID format
30
+
31
+ Model IDs must be `org/model` (exactly one `/`).
32
+
33
+ ✅ Valid:
34
+ - `moonshotai/kimi-k2.5`
35
+ - `nvidia/llama-3.1-nemotron-70b-instruct`
36
+
37
+ ❌ Invalid:
38
+ - `nvidia/moonshotai/kimi-k2.5` (this is provider + org + model)
39
+
40
+ Invalid lines are ignored during parsing.
41
+
42
+ ## Configuration
43
+
44
+ Config is saved to `~/.pi/nvidia-nim.json`:
45
+
46
+ ```json
47
+ {
48
+ "apiKey": "nvapi-...",
49
+ "models": [
50
+ { "id": "meta/llama-3.1-405b-instruct", "name": "Llama 3.1 405b Instruct", "reasoning": false },
51
+ { "id": "deepseek-ai/deepseek-r1", "name": "Deepseek R1", "reasoning": true }
52
+ ]
53
+ }
54
+ ```
55
+
56
+ Models are registered via `pi.registerProvider()` on startup and after auth, so they show up in `/model` immediately.
57
+
58
+ The extension also updates `~/.pi/agent/settings.json` `enabledModels` entries for provider `nvidia`, so configured models appear in scoped `/model` and Ctrl+P cycling.
59
+
60
+ ## Browse Models
61
+
62
+ See all available Nvidia NIM models at: [build.nvidia.com/models](https://build.nvidia.com/models)
@@ -0,0 +1,250 @@
1
+ import type { ExtensionAPI, ExtensionCommandContext } from "@mariozechner/pi-coding-agent";
2
+ import type { OAuthCredentials, OAuthLoginCallbacks } from "@mariozechner/pi-ai/dist/utils/oauth/types.js";
3
+ import * as fs from "node:fs/promises";
4
+ import * as fsSync from "node:fs";
5
+ import * as path from "node:path";
6
+ import * as os from "node:os";
7
+
8
+ /** Persisted model config (API key is now managed via OAuth in ~/.pi/agent/auth.json) */
9
+ export interface NvidiaModelsConfig {
10
+ models: NvidiaModelEntry[];
11
+ }
12
+
13
+ export interface NvidiaModelEntry {
14
+ id: string;
15
+ name: string;
16
+ reasoning: boolean;
17
+ }
18
+
19
+ const NVIDIA_BASE_URL = "https://integrate.api.nvidia.com/v1";
20
+ const NVIDIA_PROVIDER_NAME = "nvidia";
21
+ const MODEL_EDITOR_TEMPLATE = `# Nvidia NIM Models — one model ID per line
22
+ # Lines starting with # are ignored.
23
+ #
24
+ # Browse available models at: https://build.nvidia.com/models
25
+ #
26
+ # Examples:
27
+ # meta/llama-3.1-405b-instruct
28
+ # nvidia/llama-3.1-nemotron-70b-instruct
29
+ # deepseek-ai/deepseek-r1
30
+ # google/gemma-2-27b-it
31
+ # mistralai/mixtral-8x22b-instruct-v0.1
32
+ # qwen/qwen2.5-72b-instruct
33
+ `;
34
+
35
+ export function getConfigPath(): string {
36
+ return path.join(os.homedir(), ".pi", "nvidia-nim.json");
37
+ }
38
+
39
+ /** Load models config from ~/.pi/nvidia-nim.json (async).
40
+ * Handles both new format (models only) and legacy format (with apiKey). */
41
+ export async function loadModelsConfig(): Promise<NvidiaModelsConfig | null> {
42
+ try {
43
+ const content = await fs.readFile(getConfigPath(), "utf-8");
44
+ const parsed = JSON.parse(content);
45
+ if (Array.isArray(parsed.models) && parsed.models.length > 0) {
46
+ return { models: parsed.models };
47
+ }
48
+ return null;
49
+ } catch {
50
+ return null;
51
+ }
52
+ }
53
+
54
+ /** Synchronous version for use during extension init (before Pi finishes loading).
55
+ * Handles both new format (models only) and legacy format (with apiKey). */
56
+ export function loadModelsConfigSync(): NvidiaModelsConfig | null {
57
+ try {
58
+ const content = fsSync.readFileSync(getConfigPath(), "utf-8");
59
+ const parsed = JSON.parse(content);
60
+ if (Array.isArray(parsed.models) && parsed.models.length > 0) {
61
+ return { models: parsed.models };
62
+ }
63
+ return null;
64
+ } catch {
65
+ return null;
66
+ }
67
+ }
68
+
69
+ export async function saveModelsConfig(config: NvidiaModelsConfig): Promise<void> {
70
+ const configPath = getConfigPath();
71
+ await fs.mkdir(path.dirname(configPath), { recursive: true });
72
+ await fs.writeFile(configPath, JSON.stringify(config, null, 2), "utf-8");
73
+ }
74
+
75
+ /** Parse model IDs from editor text, ignoring comments and blank lines.
76
+ * Model IDs must be in "org/model" format (exactly one "/").
77
+ * Lines with more than one "/" are skipped (e.g. "nvidia/moonshotai/kimi-k2.5" is invalid). */
78
+ export function parseModelLines(text: string): NvidiaModelEntry[] {
79
+ const seen = new Set<string>();
80
+ return text
81
+ .split("\n")
82
+ .map((line) => line.trim())
83
+ .filter((line) => line.length > 0 && !line.startsWith("#"))
84
+ .filter((line) => (line.match(/\//g) || []).length === 1)
85
+ .reduce<NvidiaModelEntry[]>((acc, id) => {
86
+ if (!seen.has(id)) {
87
+ seen.add(id);
88
+ acc.push({
89
+ id,
90
+ name: formatModelName(id),
91
+ reasoning: /deepseek-r1|reasoning/i.test(id),
92
+ });
93
+ }
94
+ return acc;
95
+ }, []);
96
+ }
97
+
98
+ /** Turn "meta/llama-3.1-405b-instruct" → "Llama 3.1 405B Instruct" */
99
+ function formatModelName(id: string): string {
100
+ const base = id.includes("/") ? id.split("/").pop()! : id;
101
+ return base
102
+ .replace(/[-_]/g, " ")
103
+ .replace(/\b\w/g, (c) => c.toUpperCase());
104
+ }
105
+
106
+ /**
107
+ * Fetch available model IDs from the Nvidia NIM API.
108
+ * Returns the set of valid model IDs, or null on failure.
109
+ */
110
+ export async function fetchAvailableModels(apiKey: string): Promise<Set<string> | null> {
111
+ try {
112
+ const res = await fetch(`${NVIDIA_BASE_URL}/models`, {
113
+ headers: { Authorization: `Bearer ${apiKey}` },
114
+ });
115
+ if (!res.ok) return null;
116
+ const data = (await res.json()) as { data?: { id: string }[] };
117
+ if (!data.data) return null;
118
+ return new Set(data.data.map((m) => m.id));
119
+ } catch {
120
+ return null;
121
+ }
122
+ }
123
+
124
+ /** Build the provider model descriptors from our model entries. */
125
+ function buildModelDescriptors(models: NvidiaModelEntry[]) {
126
+ return models.map((m) => ({
127
+ id: m.id,
128
+ name: m.name,
129
+ reasoning: m.reasoning,
130
+ input: ["text"] as ("text" | "image")[],
131
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
132
+ contextWindow: 128000,
133
+ maxTokens: 16384,
134
+ }));
135
+ }
136
+
137
+ /** Build the OAuth config for Nvidia NIM API key authentication. */
138
+ function buildOAuthConfig() {
139
+ return {
140
+ name: "Nvidia NIM",
141
+
142
+ async login(callbacks: OAuthLoginCallbacks): Promise<OAuthCredentials> {
143
+ const apiKey = await callbacks.onPrompt({
144
+ message: "Enter your Nvidia NIM API key (nvapi-... from build.nvidia.com):",
145
+ placeholder: "nvapi-...",
146
+ });
147
+
148
+ if (!apiKey?.trim()) {
149
+ throw new Error("API key is required. Get one at https://build.nvidia.com");
150
+ }
151
+
152
+ const key = apiKey.trim();
153
+
154
+ // Validate by hitting the models endpoint
155
+ const res = await fetch(`${NVIDIA_BASE_URL}/models`, {
156
+ headers: { Authorization: `Bearer ${key}` },
157
+ });
158
+ if (!res.ok) {
159
+ throw new Error(
160
+ `API key validation failed (HTTP ${res.status}). Check your key at build.nvidia.com`,
161
+ );
162
+ }
163
+
164
+ return {
165
+ access: key,
166
+ refresh: key, // API keys don't have refresh tokens
167
+ expires: Date.now() + 365 * 24 * 60 * 60 * 1000, // effectively never
168
+ };
169
+ },
170
+
171
+ async refreshToken(credentials: OAuthCredentials): Promise<OAuthCredentials> {
172
+ // Nvidia API keys don't expire — return as-is with extended expiry
173
+ return {
174
+ ...credentials,
175
+ expires: Date.now() + 365 * 24 * 60 * 60 * 1000,
176
+ };
177
+ },
178
+
179
+ getApiKey(credentials: OAuthCredentials): string {
180
+ return credentials.access;
181
+ },
182
+ };
183
+ }
184
+
185
+ export default function nvidiaNimExtension(pi: ExtensionAPI) {
186
+ // Load saved models synchronously at init so they're available before Pi finishes loading.
187
+ // The provider is always registered (even without models) so `/login nvidia` works.
188
+ const savedModels = loadModelsConfigSync();
189
+ const models = savedModels?.models ?? [];
190
+
191
+ pi.registerProvider(NVIDIA_PROVIDER_NAME, {
192
+ baseUrl: NVIDIA_BASE_URL,
193
+ api: "openai-completions",
194
+ models: buildModelDescriptors(models),
195
+ oauth: buildOAuthConfig(),
196
+ });
197
+
198
+ // --- /nvidia-nim-models: add/edit models ---
199
+ const modelsHandler = async (args: string | undefined, ctx: ExtensionCommandContext) => {
200
+ if (!ctx.hasUI) {
201
+ console.log("This command requires interactive mode.");
202
+ return;
203
+ }
204
+
205
+ const existing = await loadModelsConfig();
206
+ const existingModelIds = existing?.models.map((m) => m.id).join("\n") ?? "";
207
+ const prefill =
208
+ MODEL_EDITOR_TEMPLATE + "\n" + (existingModelIds || "meta/llama-3.1-405b-instruct") + "\n";
209
+
210
+ const modelText = await ctx.ui.editor("Nvidia NIM — Edit Models (one per line)", prefill);
211
+ if (!modelText?.trim()) {
212
+ ctx.ui.notify("Cancelled — models unchanged.", "info");
213
+ return;
214
+ }
215
+
216
+ const parsedModels = parseModelLines(modelText);
217
+ if (parsedModels.length === 0) {
218
+ ctx.ui.notify("No valid model IDs found. Models unchanged.", "error");
219
+ return;
220
+ }
221
+
222
+ try {
223
+ await saveModelsConfig({ models: parsedModels });
224
+
225
+ // Re-register provider with updated models (preserving OAuth config)
226
+ pi.registerProvider(NVIDIA_PROVIDER_NAME, {
227
+ baseUrl: NVIDIA_BASE_URL,
228
+ api: "openai-completions",
229
+ models: buildModelDescriptors(parsedModels),
230
+ oauth: buildOAuthConfig(),
231
+ });
232
+
233
+ const names = parsedModels.map((m) => m.id).join(", ");
234
+ ctx.ui.notify(
235
+ `Nvidia NIM models updated — ${parsedModels.length} model(s): ${names}. Use /login nvidia to authenticate.`,
236
+ "info",
237
+ );
238
+ } catch (error) {
239
+ ctx.ui.notify(
240
+ `Failed to save: ${error instanceof Error ? error.message : String(error)}`,
241
+ "error",
242
+ );
243
+ }
244
+ };
245
+
246
+ pi.registerCommand("nvidia-nim-models", {
247
+ description: "Add or edit Nvidia NIM models",
248
+ handler: modelsHandler,
249
+ });
250
+ }
@@ -1,59 +1,80 @@
1
- # Whimsical Extension
1
+ # Whimsical Extension (Chaos Mixer)
2
2
 
3
- A personality engine for the [pi coding agent](https://github.com/badlogic/pi-mono/tree/main/packages/coding-agent). Adds delightful, context-aware, and humorous loading messages to make waiting for the LLM less boring.
3
+ A stupid, nerdy, fun, Bollywood-infused loading-message extension for Pi.
4
4
 
5
- ## Features
5
+ ## What changed
6
6
 
7
- - **Context-Aware Loading Messages**: Messages change based on time of day (Morning/Night) and how long the task is taking.
8
- - **Multiple Personality Modes**: Choose your vibe, from Bollywood drama to Geek humor.
9
- - **Smart Exit**: Adds `/exit` and `/bye` commands that perform a graceful shutdown with a whimsical goodbye message (and ensure your terminal is left clean!).
10
- - **Helpful Tips**: Occasionally shows useful Pi usage tips while you wait.
7
+ Older separate modes (`classic`, `bollywood`, `geek`) are replaced by one chaos mixer with 7 weighted buckets:
11
8
 
12
- ## Usage
9
+ - **A**: Absurd Nerd Lines — grepping the void, refactoring by vibes
10
+ - **B**: Boss Progression — phase-based messages by wait duration
11
+ - **C**: Fake Compiler Panic — chaotic fake diagnostics
12
+ - **D**: Terminal Meme Lines — CLI one-liners and git jokes
13
+ - **E**: Bollywood & Hinglish — classic dialogues, movie vibes, desi dev humor
14
+ - **F**: Whimsical Verbs — Combobulating... Skedaddling... Noodling...
15
+ - **G**: Pi Tips — helpful tips for using Pi effectively
13
16
 
14
- The extension activates automatically. You can configure it using the `/whimsy` command.
17
+ Default split is `A=10 / B=10 / C=10 / D=10 / E=30 / F=15 / G=15` (Bollywood-heavy by default).
15
18
 
16
- ### Commands
19
+ Context-aware overrides still apply: morning messages (5-11 AM), late night messages (12-4 AM), and long-wait reassurance (>5s).
20
+
21
+ ## Commands
17
22
 
18
23
  | Command | Description |
19
- |---------|-------------|
20
- | `/whimsy` | Check current status and usage. |
21
- | `/whimsy <mode>` | Switch mode (chaos, bollywood, geek, classic). |
22
- | `/whimsy on` | Enable whimsical messages. |
23
- | `/whimsy off` | Disable (revert to standard "Thinking..." messages). |
24
- | `/exit` | Exit Pi gracefully with a random goodbye message. |
25
- | `/bye` | Alias for `/exit`. |
26
-
27
- ### Modes
28
-
29
- - **`chaos` (Default)**: A curated mix of everything!
30
- - 50% Bollywood/Hinglish
31
- - 30% Helpful Tips
32
- - 20% Sci-Fi/Classic Gerunds
33
- - **`bollywood`**: 100% Bollywood dialogues and Hinglish developer memes.
34
- - *"Picture abhi baaki hai mere dost..."*
35
- - *"Tareekh pe tareekh..."*
36
- - **`geek`**: Sci-Fi, Cyberpunk, and Developer humor.
37
- - *"Reticulating splines..."*
38
- - *"Downloading more RAM..."*
39
- - **`classic`**: Simple, whimsical verbs (Claude-style).
40
- - *"Schlepping..."*, *"Combobulating..."*
41
-
42
- ## Examples
43
-
44
- **Long Wait (Story Mode):**
45
- If a task takes longer than 5 seconds, the message changes to reassure you:
46
- > "Abhi hum zinda hain!" (We are still alive!)
47
- > "Sabar ka phal meetha hota hai..." (Patience pays off...)
48
-
49
- **Late Night (12 AM - 4 AM):**
50
- > "Soja beta, varna Gabbar aa jayega..."
51
- > "Burning the midnight oil..."
52
-
53
- ## Installation
54
-
55
- Included in `pi-agent-extensions`.
56
-
57
- ```bash
58
- pi install npm:pi-agent-extensions
24
+ |---|---|
25
+ | `/whimsy` | Open interactive percentage tuner window |
26
+ | `/whimsy on` | Enable whimsical messages |
27
+ | `/whimsy off` | Disable whimsical messages |
28
+ | `/whimsy status` | Show enabled state + current percentages + spinner preset |
29
+ | `/whimsy reset` | Reset to default weights + default spinner preset |
30
+ | `/exit` | Exit Pi with a weighted goodbye (uses current bucket percentages) |
31
+ | `/bye` | Alias for `/exit` |
32
+
33
+ ## Interactive tuner behavior
34
+
35
+ When you run `/whimsy`, you get a window where:
36
+
37
+ - a live spinner preview is shown using the current weights
38
+ - preview spinner animates at real-turn cadence and preview message rotates every 10 seconds
39
+
40
+ - `↑ / ↓` moves between A-G rows and the spinner row at the bottom
41
+ - `← / →` adjusts the selected bucket by **5** (or switches spinner preset when spinner row is selected)
42
+ - spinner row shows a small frame sample next to each preset name
43
+ - `Enter` saves **only if total = 100**
44
+ - `Esc` cancels
45
+
46
+ Totals are allowed to go below/above 100 while tuning, but save is blocked until total is exactly 100. A warning is shown at the bottom when invalid.
47
+
48
+ Spinner presets included: Sleek Orbit, Neon Pulse, Scanline, Chevron Flow, Matrix Glyph.
49
+
50
+ Selected spinner preset is applied to Pi's live loader spinner (not just preview).
51
+
52
+ Goodbye messages also use A-G bucketed pools and follow the same weighted split.
53
+
54
+ ## Persistence
55
+
56
+ Settings persist globally in:
57
+
58
+ - `~/.pi/agent/settings.json` under the `whimsical` key
59
+
60
+ No local project settings are used for whimsy anymore.
61
+
62
+ Example:
63
+
64
+ ```json
65
+ {
66
+ "whimsical": {
67
+ "enabled": true,
68
+ "weights": {
69
+ "A": 10,
70
+ "B": 10,
71
+ "C": 10,
72
+ "D": 10,
73
+ "E": 30,
74
+ "F": 15,
75
+ "G": 15
76
+ },
77
+ "spinnerPreset": "sleekOrbit"
78
+ }
79
+ }
59
80
  ```
@@ -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
  }
@@ -1,5 +1,100 @@
1
- export type MessageCategory = 'bollywood' | 'smart' | 'tip' | 'gerund';
1
+ // ── Bucket A: Absurd Nerd Lines ──────────────────────────────────────────────
2
+ export const ABSURD_NERD_LINES = [
3
+ "Grepping the void for meaning...",
4
+ "Refactoring by vibes and caffeine...",
5
+ "Rotating the bug 90° to see if it's a feature...",
6
+ "Converting coffee into TypeScript...",
7
+ "Asking the rubber duck for architectural guidance...",
8
+ "Recompiling my self-esteem...",
9
+ "Summoning stack traces from the 8th dimension...",
10
+ "Debugging in prod (emotionally)...",
11
+ "Aligning semicolons with lunar gravity...",
12
+ "Optimizing for memes per second...",
13
+ "Upgrading brain RAM to 128MB...",
14
+ "Consulting the ancient scrolls of Stack Overflow...",
15
+ "Introducing one more abstraction layer (for science)...",
16
+ "Measuring latency in dog years...",
17
+ "Teaching recursion to fear itself...",
18
+ "Replacing logic with pure confidence...",
19
+ "Pinning blame on cosmic rays...",
20
+ "Calibrating keyboard clack acoustics...",
21
+ "Benchmarking vibes against reality...",
22
+ "Turning it off and on with conviction...",
23
+ ];
24
+
25
+ // ── Bucket B: Boss Progression (phase-based by wait time) ────────────────────
26
+ export const BOSS_PHASE_MESSAGES = {
27
+ early: [
28
+ "🗡️ Boss Fight: Phase 1 — Pulling aggro...",
29
+ "🎮 Encounter started. Dodging obvious bugs...",
30
+ "⚔️ Warmup round: sharpening unit tests...",
31
+ "🧪 Pre-buffing with logs and false confidence...",
32
+ ],
33
+ mid: [
34
+ "🔥 Boss Fight: Phase 2 — Enrage mechanics unlocked...",
35
+ "🛡️ Raid callout: spread for stack traces!",
36
+ "💥 The bug used Critical Confusion. It's super effective.",
37
+ "🎯 Parsing attack patterns and questionable commits...",
38
+ ],
39
+ late: [
40
+ "☠️ Final Phase — clutch mode activated...",
41
+ "🚨 Enrage timer active. Casting npm install...",
42
+ "🧠 Last stand: sacrificing sleep for victory...",
43
+ "🏁 1% HP left on the boss (probably)...",
44
+ ],
45
+ };
2
46
 
47
+ // ── Bucket C: Fake Compiler Panic ────────────────────────────────────────────
48
+ export const FAKE_COMPILER_PANIC = [
49
+ "warning TS9999: vibes are not strongly typed",
50
+ "fatal: could not resolve existential dread",
51
+ "error: semicolon not found, hope not found",
52
+ "panic: undefined is not a function (of society)",
53
+ "warning: variable 'motivation' is declared but never used",
54
+ "error E420: insufficient snacks for build",
55
+ "linker error: could not link dreams.dll",
56
+ "runtime warning: coffee level below minimum threshold",
57
+ "type error: expected genius, received goblin",
58
+ "internal compiler error: lol",
59
+ "segfault (core dumped emotionally)",
60
+ "note: fixed by adding one parenthesis and prayer",
61
+ ];
62
+
63
+ // ── Bucket D: Terminal Meme Lines ────────────────────────────────────────────
64
+ export const TERMINAL_MEME_LINES = [
65
+ "sudo rm -rf stress && echo 'inner peace installed'",
66
+ "git commit -m 'fixed bug (created 4 new ones for lore)'",
67
+ "npm install luck --save-dev",
68
+ "echo 'it works' | tee production.log",
69
+ "./deploy.sh && run",
70
+ "grep -R 'WHY' .",
71
+ "git push --force-with-confidence",
72
+ "docker compose up --build --snacks",
73
+ "kubectl vibes --namespace=prod",
74
+ "chmod +x brain.sh && ./brain.sh",
75
+ "cat README.md | cry",
76
+ "top: CPU 100%, me 2%",
77
+ "git stash && run && panic",
78
+ "alias fix='turn it off && turn it on'",
79
+ "ping google.com for emotional support",
80
+ "find . -name '*bug*' -delete # legally questionable",
81
+ "echo 'ship it' && immediately regret it",
82
+ "git rebase -i destiny",
83
+ "curl localhost:3000 && pray",
84
+ "history | tail -n 5 # evidence",
85
+ "sudo apt install confidence",
86
+ "yarn add vibes",
87
+ "pnpm add chaos",
88
+ "echo 'LGTM' before reading diff",
89
+ "git blame . # me",
90
+ "kill -9 procrastination",
91
+ "mkdir final-final-real-v2",
92
+ "mv final-final-real-v2 final-final-real-v3",
93
+ "echo 'quick fix' > tech_debt.txt",
94
+ "python -c \"print('it was DNS')\"",
95
+ ];
96
+
97
+ // ── Bucket E: Bollywood & Hinglish ───────────────────────────────────────────
3
98
  export const BOLLYWOOD_MESSAGES = [
4
99
  // --- CLASSIC DIALOGUES (Reimagined for Code) ---
5
100
  "Tareekh pe tareekh, tareekh pe tareekh... par result nahi mila my lord!",
@@ -1297,6 +1392,7 @@ export const BOLLYWOOD_MESSAGES = [
1297
1392
  "Sed...",
1298
1393
  ];
1299
1394
 
1395
+ // ── Bucket F: Pi Tips ────────────────────────────────────────────────────────
1300
1396
  export const PI_TIPS = [
1301
1397
  "💡 Tip: Use /help to see all available commands",
1302
1398
  "💡 Tip: Use /sessions or /resume to switch between sessions",
@@ -1348,6 +1444,7 @@ export const PI_TIPS = [
1348
1444
  "💡 Tip: Use /name to rename the current session",
1349
1445
  ];
1350
1446
 
1447
+ // ── Context-aware messages (time of day + long wait) ─────────────────────────
1351
1448
  export const CONTEXT_MESSAGES = {
1352
1449
  // Morning (5 AM - 11 AM)
1353
1450
  morning: [
@@ -1396,6 +1493,7 @@ export const CONTEXT_MESSAGES = {
1396
1493
  ]
1397
1494
  };
1398
1495
 
1496
+ // ── Bucket G: Whimsical Verbs (Gerunds) ──────────────────────────────────────
1399
1497
  // "Gerunds" / Whimsical Verbs (The classic list, kept for variety)
1400
1498
  export const WHIMSICAL_VERBS = [
1401
1499
  "Schlepping...", "Combobulating...", "Vibing...", "Concocting...", "Spelunking...",
@@ -1438,29 +1536,65 @@ export const WHIMSICAL_VERBS = [
1438
1536
  "Shouldering...", "Bouldering...",
1439
1537
  ];
1440
1538
 
1441
- export const GOODBYE_MESSAGES = [
1442
- "Jaa Simran jaa, jee le apni zindagi...",
1443
- "Khatam... tata... bye bye!",
1444
- "Gaya... sab gaya...",
1445
- "Abhi hum zinda hain! (Just kidding, bye!)",
1446
- "Picture abhi baaki hai mere dost... (See you next time!)",
1447
- "Dosti ka ek usool hai madam... no sorry, no thank you... (Bye!)",
1448
- "Senorita, bade bade deshon mein...",
1449
- "Thappad se darr nahi lagta sahab, goodbye se lagta hai...",
1450
- "Khamosh! (Exiting...)",
1451
- "Mogambo khush hua! (Session ended)",
1452
- "Hasta la vista, baby!",
1453
- "I'll be back.",
1454
- "Live long and prosper.",
1455
- "May the Force be with you.",
1456
- "So long, and thanks for all the fish.",
1457
- "Exiting matrix...",
1458
- "Disconnecting neural link...",
1459
- "Powering down systems...",
1460
- "Shutting down core...",
1461
- "Deactivating shields...",
1462
- "Terminating process...",
1463
- "Closing wormhole...",
1464
- "Returning to base reality...",
1465
- "End of line.",
1466
- ];
1539
+ // ── Goodbye messages by bucket ───────────────────────────────────────────────
1540
+ export const GOODBYE_MESSAGES_BY_BUCKET = {
1541
+ A: [
1542
+ "🧠 Nerd goblin logged off. May your stack traces be short.",
1543
+ "📚 Returning to the sacred docs cave. BRB.",
1544
+ "🦆 Rubber duck has approved this exit.",
1545
+ "⚙️ Refactor complete. Human needs snacks now.",
1546
+ "🛰️ Leaving orbit. Keep the semicolons aligned.",
1547
+ ],
1548
+ B: [
1549
+ "🏆 Boss defeated. Loot collected. Logging out.",
1550
+ "⚔️ Raid complete. GG, no re.",
1551
+ "🛡️ Final phase cleared. Retreating to base.",
1552
+ "🎮 Match won. Queueing for sleep mode.",
1553
+ "🔥 Enrage survived. Session extracted successfully.",
1554
+ ],
1555
+ C: [
1556
+ "fatal: user exited with code 0",
1557
+ "warning: goodbye may contain sarcasm",
1558
+ "note: shutdown successful after adding one parenthesis",
1559
+ "panic resolved: touched grass",
1560
+ "compiler says: 'ship it and sleep'",
1561
+ ],
1562
+ D: [
1563
+ "git commit -m 'bye for now' && git push",
1564
+ "exit 0 # works on my machine",
1565
+ "sudo shutdown -h now (emotionally)",
1566
+ "tmux detach; coffee attach",
1567
+ "echo 'brb' | tee /dev/terminal",
1568
+ ],
1569
+ E: [
1570
+ "Jaa Simran jaa, jee le apni zindagi...",
1571
+ "Khatam... tata... bye bye!",
1572
+ "Gaya... sab gaya...",
1573
+ "Abhi hum zinda hain! (Just kidding, bye!)",
1574
+ "Picture abhi baaki hai mere dost... (See you next time!)",
1575
+ "Dosti ka ek usool hai madam... no sorry, no thank you... (Bye!)",
1576
+ "Senorita, bade bade deshon mein...",
1577
+ "Thappad se darr nahi lagta sahab, goodbye se lagta hai...",
1578
+ "Khamosh! (Exiting...)",
1579
+ "Mogambo khush hua! (Session ended)",
1580
+ ],
1581
+ F: [
1582
+ "Hasta la vista, baby!",
1583
+ "I'll be back.",
1584
+ "Live long and prosper.",
1585
+ "May the Force be with you.",
1586
+ "So long, and thanks for all the fish.",
1587
+ "Exiting matrix...",
1588
+ "Disconnecting neural link...",
1589
+ "Powering down systems...",
1590
+ "Shutting down core...",
1591
+ "End of line.",
1592
+ ],
1593
+ G: [
1594
+ "Vamoosing...",
1595
+ "Absquatulating...",
1596
+ "Skedaddling...",
1597
+ "Defenestrating...",
1598
+ "Absconding...",
1599
+ ],
1600
+ } as const;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-agent-extensions",
3
- "version": "0.2.3",
3
+ "version": "0.3.1",
4
4
  "description": "Collection of extensions for pi coding agent",
5
5
  "type": "module",
6
6
  "repository": {
@@ -45,7 +45,8 @@
45
45
  "./extensions/cwd-history/index.ts",
46
46
  "./extensions/session-breakdown/index.ts",
47
47
  "./extensions/todos/index.ts",
48
- "./extensions/whimsical/index.ts"
48
+ "./extensions/whimsical/index.ts",
49
+ "./extensions/nvidia-nim/index.ts"
49
50
  ],
50
51
  "themes": [
51
52
  "./themes/nightowl.json",