pi-powerline-footer 0.2.14 → 0.2.17

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/CHANGELOG.md CHANGED
@@ -2,6 +2,37 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.2.17] - 2026-01-28
6
+
7
+ ### Added
8
+ - **Working Vibes** — AI-generated themed loading messages that match your preferred style
9
+ - Set a theme with `/vibe star trek` and loading messages become "Running diagnostics..." instead of "Working..."
10
+ - Configure via `settings.json`: `"workingVibe": "pirate"` for nautical-themed messages
11
+ - Supports any theme: star trek, pirate, zen, noir, cowboy, etc.
12
+ - Shows "Channeling {theme}..." placeholder, then updates when AI responds (within 3s timeout)
13
+ - **Auto-refresh on tool calls** — Generates new vibes during long tasks (rate-limited, default 30s)
14
+ - Configurable refresh interval via `workingVibeRefreshInterval` (in seconds)
15
+ - Custom prompt templates via `workingVibePrompt` with `{theme}` and `{task}` variables
16
+ - Uses claude-haiku-4-5 by default (~$0.000015/generation), configurable via `/vibe model` or `workingVibeModel` setting
17
+
18
+ ### Fixed
19
+ - **Event handlers now use correct events** — Replaced non-existent `stream_start`/`stream_end` with `agent_start`/`agent_end`
20
+ - **Removed duplicate powerline bar** — Footer no longer renders redundant status during streaming
21
+
22
+ ## [0.2.16] - 2026-01-28
23
+
24
+ ### Fixed
25
+ - **Model and path colors restored** — Fixed color regression from v0.2.13 theme refactor:
26
+ - Model segment now uses original pink (`#d787af`) instead of white/gray (`text`)
27
+ - Path segment now uses original cyan (`#00afaf`) instead of muted gray
28
+
29
+ ## [0.2.15] - 2026-01-27
30
+
31
+ ### Added
32
+ - **Status notifications above editor** — Extension status messages that look like notifications (e.g., `[pi-annotate] Received: CANCEL`) now appear on a separate line above the editor input
33
+ - Notification-style statuses (starting with `[`) appear above editor
34
+ - Compact statuses (e.g., `MCP: 6 servers`) remain in the powerline bar
35
+
5
36
  ## [0.2.14] - 2026-01-26
6
37
 
7
38
  ### Fixed
package/README.md CHANGED
@@ -6,6 +6,8 @@ A powerline-style status bar and welcome header extension for [pi](https://githu
6
6
 
7
7
  ## Features
8
8
 
9
+ **Working Vibes** — AI-generated themed loading messages. Set `/vibe star trek` and your "Working..." becomes "Running diagnostics..." or "Engaging warp drive...". Supports any theme: pirate, zen, noir, cowboy, etc.
10
+
9
11
  **Welcome overlay** — Branded splash screen shown as centered overlay on startup. Shows gradient logo, model info, keyboard tips, loaded AGENTS.md/extensions/skills/templates counts, and recent sessions. Auto-dismisses after 30 seconds or on any key press.
10
12
 
11
13
  **Rounded box design** — Status renders directly in the editor's top border, not as a separate footer.
@@ -23,10 +25,10 @@ A powerline-style status bar and welcome header extension for [pi](https://githu
23
25
  ## Installation
24
26
 
25
27
  ```bash
26
- npx pi-powerline-footer
28
+ pi install npm:pi-powerline-footer
27
29
  ```
28
30
 
29
- This copies the extension to `~/.pi/agent/extensions/powerline-footer/`. Restart pi to activate.
31
+ Restart pi to activate.
30
32
 
31
33
  ## Usage
32
34
 
@@ -43,6 +45,46 @@ Activates automatically. Toggle with `/powerline`, switch presets with `/powerli
43
45
 
44
46
  **Environment:** `POWERLINE_NERD_FONTS=1` to force Nerd Fonts, `=0` for ASCII.
45
47
 
48
+ ## Working Vibes
49
+
50
+ Transform boring "Working..." messages into themed phrases that match your style:
51
+
52
+ ```
53
+ /vibe star trek → "Running diagnostics...", "Engaging warp drive..."
54
+ /vibe pirate → "Hoisting the sails...", "Charting course..."
55
+ /vibe zen → "Breathing deeply...", "Finding balance..."
56
+ /vibe noir → "Following the trail...", "Checking the angles..."
57
+ /vibe → Shows current theme and model
58
+ /vibe off → Disables (back to "Working...")
59
+ /vibe model → Shows current model
60
+ /vibe model openai/gpt-4o-mini → Use a different model for generation
61
+ ```
62
+
63
+ ### Configuration
64
+
65
+ In `~/.pi/agent/settings.json`:
66
+
67
+ ```json
68
+ {
69
+ "workingVibe": "star trek", // Theme phrase
70
+ "workingVibeModel": "anthropic/claude-haiku-4-5", // Optional: model to use (default)
71
+ "workingVibeFallback": "Working", // Optional: fallback message
72
+ "workingVibeRefreshInterval": 30, // Optional: seconds between refreshes (default 30)
73
+ "workingVibePrompt": "Generate a {theme} loading message for: {task}" // Optional: custom prompt template
74
+ }
75
+ ```
76
+
77
+ **Prompt template variables:**
78
+ - `{theme}` — the current vibe theme (e.g., "star trek", "mafia")
79
+ - `{task}` — the current task hint (user prompt or tool action, truncated to 100 chars)
80
+
81
+ **How it works:**
82
+ 1. When you send a message, shows "Channeling {theme}..." placeholder
83
+ 2. AI generates a themed message in the background (3s timeout)
84
+ 3. Message updates to the themed version (e.g., "Engaging warp drive...")
85
+ 4. During long tasks, refreshes on tool calls (rate-limited, default 30s)
86
+ 5. Cost: ~$0.000015 per generation (60 tokens @ haiku pricing)
87
+
46
88
  ## Thinking Level Display
47
89
 
48
90
  The thinking segment shows live updates when you change thinking level:
package/index.ts CHANGED
@@ -12,6 +12,17 @@ import { getGitStatus, invalidateGitStatus, invalidateGitBranch } from "./git-st
12
12
  import { ansi, getFgAnsiCode } from "./colors.js";
13
13
  import { WelcomeComponent, WelcomeHeader, discoverLoadedCounts, getRecentSessions } from "./welcome.js";
14
14
  import { getDefaultColors } from "./theme.js";
15
+ import {
16
+ initVibeManager,
17
+ onVibeBeforeAgentStart,
18
+ onVibeAgentStart,
19
+ onVibeAgentEnd,
20
+ onVibeToolCall,
21
+ getVibeTheme,
22
+ setVibeTheme,
23
+ getVibeModel,
24
+ setVibeModel,
25
+ } from "./working-vibes.js";
15
26
 
16
27
  // ═══════════════════════════════════════════════════════════════════════════
17
28
  // Configuration
@@ -56,33 +67,6 @@ function renderSegmentWithWidth(
56
67
  return { content: rendered.content, width: visibleWidth(rendered.content), visible: true };
57
68
  }
58
69
 
59
- /** Build status content from a list of segment IDs */
60
- function buildStatusContentFromSegments(
61
- segmentIds: StatusLineSegmentId[],
62
- ctx: SegmentContext,
63
- presetDef: ReturnType<typeof getPreset>
64
- ): string {
65
- const separatorDef = getSeparator(presetDef.separator);
66
- const sepAnsi = getFgAnsiCode("sep");
67
-
68
- // Collect visible segment contents
69
- const parts: string[] = [];
70
- for (const segId of segmentIds) {
71
- const rendered = renderSegment(segId, ctx);
72
- if (rendered.visible && rendered.content) {
73
- parts.push(rendered.content);
74
- }
75
- }
76
-
77
- if (parts.length === 0) {
78
- return "";
79
- }
80
-
81
- // Build content with powerline separators (no background)
82
- const sep = separatorDef.left;
83
- return " " + parts.join(` ${sepAnsi}${sep}${ansi.reset} `) + ansi.reset + " ";
84
- }
85
-
86
70
  /** Build content string from pre-rendered parts */
87
71
  function buildContentFromParts(
88
72
  parts: string[],
@@ -156,12 +140,6 @@ function computeResponsiveLayout(
156
140
  };
157
141
  }
158
142
 
159
- /** Build primary status content (for top border) - legacy, used during streaming */
160
- function buildStatusContent(ctx: SegmentContext, presetDef: ReturnType<typeof getPreset>): string {
161
- const allSegments = [...presetDef.leftSegments, ...presetDef.rightSegments];
162
- return buildStatusContentFromSegments(allSegments, ctx, presetDef);
163
- }
164
-
165
143
  // ═══════════════════════════════════════════════════════════════════════════
166
144
  // Extension
167
145
  // ═══════════════════════════════════════════════════════════════════════════
@@ -193,6 +171,9 @@ export default function powerlineFooter(pi: ExtensionAPI) {
193
171
  getThinkingLevelFn = () => ctx.getThinkingLevel();
194
172
  }
195
173
 
174
+ // Initialize vibe manager (needs modelRegistry from ctx)
175
+ initVibeManager(ctx);
176
+
196
177
  if (enabled && ctx.hasUI) {
197
178
  setupCustomEditor(ctx);
198
179
  // quietStartup: true → compact header, otherwise → full overlay
@@ -246,16 +227,27 @@ export default function powerlineFooter(pi: ExtensionAPI) {
246
227
  }
247
228
  });
248
229
 
230
+ // Generate themed working message before agent starts (has access to user's prompt)
231
+ pi.on("before_agent_start", async (event, ctx) => {
232
+ if (ctx.hasUI) {
233
+ onVibeBeforeAgentStart(event.prompt, ctx.ui.setWorkingMessage);
234
+ }
235
+ });
236
+
249
237
  // Track streaming state (footer only shows status during streaming)
250
238
  // Also dismiss welcome when agent starts responding (handles `p "command"` case)
251
- pi.on("stream_start", async (_event, ctx) => {
239
+ pi.on("agent_start", async (_event, ctx) => {
252
240
  isStreaming = true;
241
+ onVibeAgentStart();
253
242
  dismissWelcome(ctx);
254
243
  });
255
244
 
256
- // Also dismiss on tool calls (agent is working)
257
- pi.on("tool_call", async (_event, ctx) => {
245
+ // Also dismiss on tool calls (agent is working) + refresh vibe if rate limit allows
246
+ pi.on("tool_call", async (event, ctx) => {
258
247
  dismissWelcome(ctx);
248
+ if (ctx.hasUI) {
249
+ onVibeToolCall(event.toolName, event.input, ctx.ui.setWorkingMessage);
250
+ }
259
251
  });
260
252
 
261
253
  // Helper to dismiss welcome overlay/header
@@ -273,8 +265,11 @@ export default function powerlineFooter(pi: ExtensionAPI) {
273
265
  }
274
266
  }
275
267
 
276
- pi.on("stream_end", async () => {
268
+ pi.on("agent_end", async (_event, ctx) => {
277
269
  isStreaming = false;
270
+ if (ctx.hasUI) {
271
+ onVibeAgentEnd(ctx.ui.setWorkingMessage); // working-vibes internal state + reset message
272
+ }
278
273
  });
279
274
 
280
275
  // Dismiss welcome overlay/header on first user message
@@ -301,6 +296,7 @@ export default function powerlineFooter(pi: ExtensionAPI) {
301
296
  ctx.ui.setFooter(undefined);
302
297
  ctx.ui.setHeader(undefined);
303
298
  ctx.ui.setWidget("powerline-secondary", undefined);
299
+ ctx.ui.setWidget("powerline-status", undefined);
304
300
  footerDataRef = null;
305
301
  tuiRef = null;
306
302
  // Clear layout cache
@@ -329,6 +325,51 @@ export default function powerlineFooter(pi: ExtensionAPI) {
329
325
  },
330
326
  });
331
327
 
328
+ // Command to set working message theme
329
+ pi.registerCommand("vibe", {
330
+ description: "Set working message theme. Usage: /vibe [theme|off|model [provider/model]]",
331
+ handler: async (args, ctx) => {
332
+ const parts = args?.trim().split(/\s+/) || [];
333
+ const subcommand = parts[0]?.toLowerCase();
334
+
335
+ // No args: show current status
336
+ if (!args || !args.trim()) {
337
+ const theme = getVibeTheme();
338
+ const model = getVibeModel();
339
+ ctx.ui.notify(`Vibe: ${theme || "off"} | Model: ${model}`, "info");
340
+ return;
341
+ }
342
+
343
+ // /vibe model [spec] - show or set model
344
+ if (subcommand === "model") {
345
+ const modelSpec = parts.slice(1).join(" ");
346
+ if (!modelSpec) {
347
+ ctx.ui.notify(`Current vibe model: ${getVibeModel()}`, "info");
348
+ return;
349
+ }
350
+ // Validate format (provider/modelId)
351
+ if (!modelSpec.includes("/")) {
352
+ ctx.ui.notify("Invalid model format. Use: provider/modelId (e.g., anthropic/claude-haiku-4-5)", "error");
353
+ return;
354
+ }
355
+ setVibeModel(modelSpec);
356
+ ctx.ui.notify(`Vibe model set to: ${modelSpec}`, "info");
357
+ return;
358
+ }
359
+
360
+ // /vibe off - disable
361
+ if (subcommand === "off") {
362
+ setVibeTheme(null);
363
+ ctx.ui.notify("Vibe disabled", "info");
364
+ return;
365
+ }
366
+
367
+ // /vibe <theme> - set theme (preserve original casing)
368
+ setVibeTheme(args.trim());
369
+ ctx.ui.notify(`Vibe set to: ${args.trim()}`, "info");
370
+ },
371
+ });
372
+
332
373
  function buildSegmentContext(ctx: any, width: number, theme: Theme): SegmentContext {
333
374
  const presetDef = getPreset(config.preset);
334
375
  const colors: ColorScheme = presetDef.colors ?? getDefaultColors();
@@ -519,66 +560,25 @@ export default function powerlineFooter(pi: ExtensionAPI) {
519
560
  return editor;
520
561
  });
521
562
 
522
- // Set up footer data provider access via a minimal footer
523
- ctx.ui.setFooter((tui: any, theme: Theme, footerData: ReadonlyFooterDataProvider) => {
563
+ // Set up footer data provider access (needed for git branch, extension statuses)
564
+ // Note: We don't render status here - it's already in the editor's top border
565
+ ctx.ui.setFooter((tui: any, _theme: Theme, footerData: ReadonlyFooterDataProvider) => {
524
566
  footerDataRef = footerData;
525
567
  tuiRef = tui; // Store TUI reference for re-renders on git branch changes
526
568
  const unsub = footerData.onBranchChange(() => tui.requestRender());
527
569
 
528
570
  return {
529
571
  dispose: unsub,
530
- invalidate() {
531
- // No cache to clear - render is always fresh
532
- },
533
- render(width: number): string[] {
534
- if (!currentCtx) return [];
535
-
536
- const presetDef = getPreset(config.preset);
537
- const segmentCtx = buildSegmentContext(currentCtx, width, theme);
538
- const lines: string[] = [];
539
-
540
- // During streaming, show primary status in footer (editor hidden)
541
- if (isStreaming) {
542
- const statusContent = buildStatusContent(segmentCtx, presetDef);
543
- if (statusContent) {
544
- const statusWidth = visibleWidth(statusContent);
545
- if (statusWidth <= width) {
546
- lines.push(statusContent + " ".repeat(width - statusWidth));
547
- } else {
548
- // Truncate by removing segments until it fits
549
- // Start from leftSegments.length to try "just leftSegments" when rightSegments exists
550
- let truncatedContent = "";
551
- let foundFit = false;
552
- for (let numSegments = presetDef.leftSegments.length; numSegments >= 1; numSegments--) {
553
- const limitedPreset = {
554
- ...presetDef,
555
- leftSegments: presetDef.leftSegments.slice(0, numSegments),
556
- rightSegments: [],
557
- };
558
- truncatedContent = buildStatusContent(segmentCtx, limitedPreset);
559
- const truncWidth = visibleWidth(truncatedContent);
560
- if (truncWidth <= width - 1) {
561
- truncatedContent += "…";
562
- foundFit = true;
563
- break;
564
- }
565
- }
566
- // Only push if we found a fit, otherwise skip (don't crash on very narrow terminals)
567
- if (foundFit) {
568
- lines.push(truncatedContent);
569
- }
570
- }
571
- }
572
- }
573
-
574
- return lines;
572
+ invalidate() {},
573
+ render(): string[] {
574
+ return []; // Status is in editor top border, not footer
575
575
  },
576
576
  };
577
577
  });
578
578
 
579
579
  // Set up secondary row as a widget below editor (above sub bar)
580
580
  // Shows overflow segments when top bar is too narrow
581
- ctx.ui.setWidget("powerline-secondary", (tui: any, theme: Theme) => {
581
+ ctx.ui.setWidget("powerline-secondary", (_tui: any, theme: Theme) => {
582
582
  return {
583
583
  dispose() {},
584
584
  invalidate() {},
@@ -601,6 +601,37 @@ export default function powerlineFooter(pi: ExtensionAPI) {
601
601
  },
602
602
  };
603
603
  }, { placement: "belowEditor" });
604
+
605
+ // Set up status notifications widget above editor
606
+ // Shows extension status messages that look like notifications (e.g., "[pi-annotate] Received: CANCEL")
607
+ // Compact statuses (e.g., "MCP: 6 servers") stay in the powerline bar via extension_statuses segment
608
+ ctx.ui.setWidget("powerline-status", () => {
609
+ return {
610
+ dispose() {},
611
+ invalidate() {},
612
+ render(width: number): string[] {
613
+ if (!currentCtx || !footerDataRef) return [];
614
+
615
+ const statuses = footerDataRef.getExtensionStatuses();
616
+ if (!statuses || statuses.size === 0) return [];
617
+
618
+ // Collect notification-style statuses (those starting with "[extensionName]")
619
+ const notifications: string[] = [];
620
+ for (const value of statuses.values()) {
621
+ if (value && value.trimStart().startsWith('[')) {
622
+ // Account for leading space when checking width
623
+ const lineContent = ` ${value}`;
624
+ const contentWidth = visibleWidth(lineContent);
625
+ if (contentWidth <= width) {
626
+ notifications.push(lineContent);
627
+ }
628
+ }
629
+ }
630
+
631
+ return notifications;
632
+ },
633
+ };
634
+ }, { placement: "aboveEditor" });
604
635
  });
605
636
  }
606
637
 
@@ -634,7 +665,7 @@ export default function powerlineFooter(pi: ExtensionAPI) {
634
665
  // Small delay to let pi-mono finish initialization
635
666
  setTimeout(() => {
636
667
  // Skip overlay if:
637
- // 1. Dismissal was explicitly requested (stream_start/user_message fired)
668
+ // 1. Dismissal was explicitly requested (agent_start/user_message fired)
638
669
  // 2. Agent is already streaming
639
670
  // 3. Session already has assistant messages (agent already responded)
640
671
  if (welcomeOverlayShouldDismiss || isStreaming) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-powerline-footer",
3
- "version": "0.2.14",
3
+ "version": "0.2.17",
4
4
  "description": "Powerline-style status bar extension for pi coding agent",
5
5
  "type": "module",
6
6
  "bin": {
package/segments.ts CHANGED
@@ -389,10 +389,13 @@ const extensionStatusesSegment: StatusLineSegment = {
389
389
  const statuses = ctx.extensionStatuses;
390
390
  if (!statuses || statuses.size === 0) return { content: "", visible: false };
391
391
 
392
- // Join all extension statuses with a separator
392
+ // Join compact statuses with a separator
393
+ // Notification-style statuses (starting with "[") are shown above the editor instead
393
394
  const parts: string[] = [];
394
- for (const [_key, value] of statuses) {
395
- if (value) parts.push(value);
395
+ for (const value of statuses.values()) {
396
+ if (value && !value.trimStart().startsWith('[')) {
397
+ parts.push(value);
398
+ }
396
399
  }
397
400
 
398
401
  if (parts.length === 0) return { content: "", visible: false };
package/theme.ts CHANGED
@@ -16,8 +16,8 @@ import type { ColorScheme, ColorValue, SemanticColor } from "./types.js";
16
16
  // Default color scheme (uses pi theme colors)
17
17
  const DEFAULT_COLORS: Required<ColorScheme> = {
18
18
  pi: "accent",
19
- model: "text",
20
- path: "muted",
19
+ model: "#d787af", // Pink/mauve (matching original colors.ts)
20
+ path: "#00afaf", // Teal/cyan (matching original colors.ts)
21
21
  git: "success",
22
22
  gitDirty: "warning",
23
23
  gitClean: "success",
@@ -0,0 +1,348 @@
1
+ // working-vibes.ts
2
+ // AI-generated contextual working messages that match a user's preferred theme/vibe.
3
+ // Uses module-level state (matching powerline-footer pattern).
4
+
5
+ import { complete, type Context } from "@mariozechner/pi-ai";
6
+ import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
7
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
8
+ import { join } from "node:path";
9
+
10
+ // ═══════════════════════════════════════════════════════════════════════════
11
+ // Constants
12
+ // ═══════════════════════════════════════════════════════════════════════════
13
+
14
+ const DEFAULT_MODEL = "anthropic/claude-haiku-4-5";
15
+
16
+ const DEFAULT_PROMPT = `Generate a 2-4 word "{theme}" themed loading message ending in "...".
17
+
18
+ Task: {task}
19
+
20
+ The message should hint at what's being done, but in theme vocabulary.
21
+ Examples for "mafia" theme: "Checking the ledger...", "Consulting the family...", "Making arrangements..."
22
+ Examples for "star trek" theme: "Scanning sensors...", "Analyzing data...", "Running diagnostics..."
23
+
24
+ Output only the message, nothing else.`;
25
+
26
+ // ═══════════════════════════════════════════════════════════════════════════
27
+ // Types
28
+ // ═══════════════════════════════════════════════════════════════════════════
29
+
30
+ interface VibeConfig {
31
+ theme: string | null; // null = disabled
32
+ modelSpec: string; // default: "anthropic/claude-haiku-4-5"
33
+ fallback: string; // default: "Working"
34
+ timeout: number; // default: 3000ms
35
+ refreshInterval: number; // default: 30000ms (30s)
36
+ promptTemplate: string; // template with {theme} and {task} placeholders
37
+ }
38
+
39
+ interface VibeGenContext {
40
+ theme: string;
41
+ userPrompt: string; // from event.prompt in before_agent_start
42
+ }
43
+
44
+ // ═══════════════════════════════════════════════════════════════════════════
45
+ // Module-level State
46
+ // ═══════════════════════════════════════════════════════════════════════════
47
+
48
+ let config: VibeConfig = loadConfig();
49
+ let extensionCtx: ExtensionContext | null = null;
50
+ let currentGeneration: AbortController | null = null;
51
+ let isStreaming = false;
52
+ let lastVibeTime = 0;
53
+
54
+ // ═══════════════════════════════════════════════════════════════════════════
55
+ // Configuration Management
56
+ // ═══════════════════════════════════════════════════════════════════════════
57
+
58
+ function getSettingsPath(): string {
59
+ const homeDir = process.env.HOME || process.env.USERPROFILE || "";
60
+ return join(homeDir, ".pi", "agent", "settings.json");
61
+ }
62
+
63
+ function loadConfig(): VibeConfig {
64
+ const settingsPath = getSettingsPath();
65
+
66
+ let settings: Record<string, unknown> = {};
67
+ try {
68
+ if (existsSync(settingsPath)) {
69
+ settings = JSON.parse(readFileSync(settingsPath, "utf-8"));
70
+ }
71
+ } catch {}
72
+
73
+ // Handle "off" in settings.json (same as null/disabled)
74
+ const rawTheme = typeof settings.workingVibe === "string" ? settings.workingVibe : null;
75
+ const theme = rawTheme?.toLowerCase() === "off" ? null : rawTheme;
76
+
77
+ return {
78
+ theme,
79
+ modelSpec: typeof settings.workingVibeModel === "string" ? settings.workingVibeModel : DEFAULT_MODEL,
80
+ fallback: typeof settings.workingVibeFallback === "string" ? settings.workingVibeFallback : "Working",
81
+ timeout: 3000,
82
+ refreshInterval: typeof settings.workingVibeRefreshInterval === "number"
83
+ ? settings.workingVibeRefreshInterval * 1000 // config is in seconds
84
+ : 30000, // default 30s
85
+ promptTemplate: typeof settings.workingVibePrompt === "string" ? settings.workingVibePrompt : DEFAULT_PROMPT,
86
+ };
87
+ }
88
+
89
+ function saveConfig(): void {
90
+ const settingsPath = getSettingsPath();
91
+
92
+ try {
93
+ let settings: Record<string, unknown> = {};
94
+ if (existsSync(settingsPath)) {
95
+ settings = JSON.parse(readFileSync(settingsPath, "utf-8"));
96
+ }
97
+
98
+ if (config.theme === null) {
99
+ delete settings.workingVibe;
100
+ } else {
101
+ settings.workingVibe = config.theme;
102
+ }
103
+
104
+ writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
105
+ } catch (error) {
106
+ console.debug("[working-vibes] Failed to save settings:", error);
107
+ }
108
+ }
109
+
110
+ function saveModelConfig(): void {
111
+ const settingsPath = getSettingsPath();
112
+
113
+ try {
114
+ let settings: Record<string, unknown> = {};
115
+ if (existsSync(settingsPath)) {
116
+ settings = JSON.parse(readFileSync(settingsPath, "utf-8"));
117
+ }
118
+
119
+ // Only save if different from default
120
+ if (config.modelSpec === DEFAULT_MODEL) {
121
+ delete settings.workingVibeModel;
122
+ } else {
123
+ settings.workingVibeModel = config.modelSpec;
124
+ }
125
+
126
+ writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
127
+ } catch (error) {
128
+ console.debug("[working-vibes] Failed to save model settings:", error);
129
+ }
130
+ }
131
+
132
+ // ═══════════════════════════════════════════════════════════════════════════
133
+ // Prompt Building & Response Parsing (Pure Functions)
134
+ // ═══════════════════════════════════════════════════════════════════════════
135
+
136
+ function buildVibePrompt(ctx: VibeGenContext): string {
137
+ // Truncate user prompt to save tokens (most context in first 100 chars)
138
+ const task = ctx.userPrompt.slice(0, 100);
139
+
140
+ // Use configured template with variable substitution
141
+ return config.promptTemplate
142
+ .replace(/\{theme\}/g, ctx.theme)
143
+ .replace(/\{task\}/g, task);
144
+ }
145
+
146
+ function parseVibeResponse(response: string, fallback: string): string {
147
+ if (!response) return `${fallback}...`;
148
+
149
+ // Take only the first line (AI sometimes adds explanations)
150
+ let vibe = response.trim().split('\n')[0].trim();
151
+
152
+ // Remove quotes if model wrapped the response
153
+ vibe = vibe.replace(/^["']|["']$/g, "");
154
+
155
+ // Ensure ellipsis
156
+ if (!vibe.endsWith("...")) {
157
+ vibe = vibe.replace(/\.+$/, "") + "...";
158
+ }
159
+
160
+ // Enforce length limit (50 chars max)
161
+ if (vibe.length > 50) {
162
+ vibe = vibe.slice(0, 47) + "...";
163
+ }
164
+
165
+ // Final validation
166
+ if (!vibe || vibe === "...") {
167
+ return `${fallback}...`;
168
+ }
169
+
170
+ return vibe;
171
+ }
172
+
173
+ // ═══════════════════════════════════════════════════════════════════════════
174
+ // AI Generation
175
+ // ═══════════════════════════════════════════════════════════════════════════
176
+
177
+ async function generateVibe(
178
+ ctx: VibeGenContext,
179
+ signal: AbortSignal,
180
+ ): Promise<string> {
181
+ if (!extensionCtx) {
182
+ return `${config.fallback}...`;
183
+ }
184
+
185
+ // Parse model spec (provider/modelId format, where modelId may contain slashes)
186
+ const slashIndex = config.modelSpec.indexOf("/");
187
+ if (slashIndex === -1) {
188
+ return `${config.fallback}...`;
189
+ }
190
+ const provider = config.modelSpec.slice(0, slashIndex);
191
+ const modelId = config.modelSpec.slice(slashIndex + 1);
192
+ if (!provider || !modelId) {
193
+ return `${config.fallback}...`;
194
+ }
195
+
196
+ // Resolve model from registry
197
+ const model = extensionCtx.modelRegistry.find(provider, modelId);
198
+ if (!model) {
199
+ console.debug(`[working-vibes] Model not found: ${config.modelSpec}`);
200
+ return `${config.fallback}...`;
201
+ }
202
+
203
+ // Get API key
204
+ const apiKey = await extensionCtx.modelRegistry.getApiKey(model);
205
+ if (!apiKey) {
206
+ console.debug(`[working-vibes] No API key for provider: ${provider}`);
207
+ return `${config.fallback}...`;
208
+ }
209
+
210
+ // Build minimal context (just a user message, no system prompt or tools)
211
+ const aiContext: Context = {
212
+ messages: [{
213
+ role: "user",
214
+ content: [{ type: "text", text: buildVibePrompt(ctx) }],
215
+ timestamp: Date.now(),
216
+ }],
217
+ };
218
+
219
+ // Call model with timeout
220
+ const response = await complete(model, aiContext, { apiKey, signal });
221
+
222
+ // Extract and parse response
223
+ const textContent = response.content.find(c => c.type === "text");
224
+ return parseVibeResponse(textContent?.text || "", config.fallback);
225
+ }
226
+
227
+ async function generateAndUpdate(
228
+ prompt: string,
229
+ setWorkingMessage: (msg?: string) => void,
230
+ ): Promise<void> {
231
+ // Cancel any in-flight generation
232
+ currentGeneration?.abort();
233
+ currentGeneration = new AbortController();
234
+
235
+ // Create timeout signal (3 seconds)
236
+ const timeoutSignal = AbortSignal.timeout(config.timeout);
237
+ const combinedSignal = AbortSignal.any([
238
+ currentGeneration.signal,
239
+ timeoutSignal,
240
+ ]);
241
+
242
+ try {
243
+ const vibe = await generateVibe(
244
+ { theme: config.theme!, userPrompt: prompt },
245
+ combinedSignal,
246
+ );
247
+
248
+ // Only update if still streaming and not aborted
249
+ if (isStreaming && !currentGeneration.signal.aborted) {
250
+ setWorkingMessage(vibe);
251
+ }
252
+ } catch (error) {
253
+ // AbortError is expected on timeout/cancel - don't log as error
254
+ if (error instanceof Error && error.name === "AbortError") {
255
+ console.debug("[working-vibes] Generation aborted");
256
+ } else {
257
+ console.debug("[working-vibes] Generation failed:", error);
258
+ }
259
+ // Fallback already showing, no action needed
260
+ }
261
+ }
262
+
263
+ // ═══════════════════════════════════════════════════════════════════════════
264
+ // Exported Functions (called from index.ts)
265
+ // ═══════════════════════════════════════════════════════════════════════════
266
+
267
+ export function initVibeManager(ctx: ExtensionContext): void {
268
+ extensionCtx = ctx;
269
+ config = loadConfig(); // Refresh config in case settings changed
270
+ }
271
+
272
+ export function getVibeTheme(): string | null {
273
+ return config.theme;
274
+ }
275
+
276
+ export function setVibeTheme(theme: string | null): void {
277
+ config = { ...config, theme };
278
+ saveConfig();
279
+ }
280
+
281
+ export function getVibeModel(): string {
282
+ return config.modelSpec;
283
+ }
284
+
285
+ export function setVibeModel(modelSpec: string): void {
286
+ config = { ...config, modelSpec };
287
+ saveModelConfig();
288
+ }
289
+
290
+ export function onVibeBeforeAgentStart(
291
+ prompt: string,
292
+ setWorkingMessage: (msg?: string) => void,
293
+ ): void {
294
+ // Skip if no theme configured or no extensionCtx
295
+ if (!config.theme || !extensionCtx) return;
296
+
297
+ // Queue themed placeholder BEFORE agent_start creates the loader
298
+ // This sets pendingWorkingMessage which is applied when loader is created
299
+ setWorkingMessage(`Channeling ${config.theme}...`);
300
+
301
+ // Mark vibe generation time for rate limiting
302
+ lastVibeTime = Date.now();
303
+
304
+ // Async: generate and update (fire-and-forget, don't await)
305
+ generateAndUpdate(prompt, setWorkingMessage);
306
+ }
307
+
308
+ export function onVibeAgentStart(): void {
309
+ isStreaming = true;
310
+ }
311
+
312
+ export function onVibeToolCall(
313
+ toolName: string,
314
+ toolInput: Record<string, unknown>,
315
+ setWorkingMessage: (msg?: string) => void,
316
+ ): void {
317
+ // Skip if no theme, not streaming, or no extensionCtx
318
+ if (!config.theme || !extensionCtx || !isStreaming) return;
319
+
320
+ // Rate limit: skip if not enough time has passed
321
+ const now = Date.now();
322
+ if (now - lastVibeTime < config.refreshInterval) return;
323
+
324
+ // Build context hint from tool name and input
325
+ let hint = `using ${toolName} tool`;
326
+ if (toolName === "read" && toolInput.path) {
327
+ hint = `reading file: ${toolInput.path}`;
328
+ } else if (toolName === "write" && toolInput.path) {
329
+ hint = `writing file: ${toolInput.path}`;
330
+ } else if (toolName === "edit" && toolInput.path) {
331
+ hint = `editing file: ${toolInput.path}`;
332
+ } else if (toolName === "bash" && toolInput.command) {
333
+ const cmd = String(toolInput.command).slice(0, 40);
334
+ hint = `running command: ${cmd}`;
335
+ }
336
+
337
+ // Update time and generate new vibe
338
+ lastVibeTime = now;
339
+ generateAndUpdate(hint, setWorkingMessage);
340
+ }
341
+
342
+ export function onVibeAgentEnd(setWorkingMessage: (msg?: string) => void): void {
343
+ isStreaming = false;
344
+ // Cancel any in-flight generation
345
+ currentGeneration?.abort();
346
+ // Reset to pi's default working message
347
+ setWorkingMessage(undefined);
348
+ }