pi-powerline-footer 0.2.16 → 0.2.18
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 +22 -0
- package/README.md +47 -1
- package/index.ts +84 -85
- package/package.json +1 -1
- package/working-vibes.ts +350 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,28 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [0.2.18] - 2026-01-28
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
- **Race condition in vibe generation** — Fixed bug where stale vibe generations could overwrite newer ones by capturing AbortController in local variable
|
|
9
|
+
|
|
10
|
+
## [0.2.17] - 2026-01-28
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- **Working Vibes** — AI-generated themed loading messages that match your preferred style
|
|
14
|
+
- Set a theme with `/vibe star trek` and loading messages become "Running diagnostics..." instead of "Working..."
|
|
15
|
+
- Configure via `settings.json`: `"workingVibe": "pirate"` for nautical-themed messages
|
|
16
|
+
- Supports any theme: star trek, pirate, zen, noir, cowboy, etc.
|
|
17
|
+
- Shows "Channeling {theme}..." placeholder, then updates when AI responds (within 3s timeout)
|
|
18
|
+
- **Auto-refresh on tool calls** — Generates new vibes during long tasks (rate-limited, default 30s)
|
|
19
|
+
- Configurable refresh interval via `workingVibeRefreshInterval` (in seconds)
|
|
20
|
+
- Custom prompt templates via `workingVibePrompt` with `{theme}` and `{task}` variables
|
|
21
|
+
- Uses claude-haiku-4-5 by default (~$0.000015/generation), configurable via `/vibe model` or `workingVibeModel` setting
|
|
22
|
+
|
|
23
|
+
### Fixed
|
|
24
|
+
- **Event handlers now use correct events** — Replaced non-existent `stream_start`/`stream_end` with `agent_start`/`agent_end`
|
|
25
|
+
- **Removed duplicate powerline bar** — Footer no longer renders redundant status during streaming
|
|
26
|
+
|
|
5
27
|
## [0.2.16] - 2026-01-28
|
|
6
28
|
|
|
7
29
|
### Fixed
|
package/README.md
CHANGED
|
@@ -1,11 +1,17 @@
|
|
|
1
|
+
<p>
|
|
2
|
+
<img src="banner.png" alt="pi-powerline-footer" width="1100">
|
|
3
|
+
</p>
|
|
4
|
+
|
|
1
5
|
# pi-powerline-footer
|
|
2
6
|
|
|
3
|
-
|
|
7
|
+
Customizes the default [pi](https://github.com/badlogic/pi-mono) editor with a powerline-style status bar, welcome overlay, and AI-generated "vibes" for loading messages. Inspired by [Powerlevel10k](https://github.com/romkatv/powerlevel10k) and [oh-my-pi](https://github.com/can1357/oh-my-pi).
|
|
4
8
|
|
|
5
9
|
<img width="1261" height="817" alt="Image" src="https://github.com/user-attachments/assets/4cc43320-3fb8-4503-b857-69dffa7028f2" />
|
|
6
10
|
|
|
7
11
|
## Features
|
|
8
12
|
|
|
13
|
+
**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.
|
|
14
|
+
|
|
9
15
|
**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
16
|
|
|
11
17
|
**Rounded box design** — Status renders directly in the editor's top border, not as a separate footer.
|
|
@@ -43,6 +49,46 @@ Activates automatically. Toggle with `/powerline`, switch presets with `/powerli
|
|
|
43
49
|
|
|
44
50
|
**Environment:** `POWERLINE_NERD_FONTS=1` to force Nerd Fonts, `=0` for ASCII.
|
|
45
51
|
|
|
52
|
+
## Working Vibes
|
|
53
|
+
|
|
54
|
+
Transform boring "Working..." messages into themed phrases that match your style:
|
|
55
|
+
|
|
56
|
+
```
|
|
57
|
+
/vibe star trek → "Running diagnostics...", "Engaging warp drive..."
|
|
58
|
+
/vibe pirate → "Hoisting the sails...", "Charting course..."
|
|
59
|
+
/vibe zen → "Breathing deeply...", "Finding balance..."
|
|
60
|
+
/vibe noir → "Following the trail...", "Checking the angles..."
|
|
61
|
+
/vibe → Shows current theme and model
|
|
62
|
+
/vibe off → Disables (back to "Working...")
|
|
63
|
+
/vibe model → Shows current model
|
|
64
|
+
/vibe model openai/gpt-4o-mini → Use a different model for generation
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
### Configuration
|
|
68
|
+
|
|
69
|
+
In `~/.pi/agent/settings.json`:
|
|
70
|
+
|
|
71
|
+
```json
|
|
72
|
+
{
|
|
73
|
+
"workingVibe": "star trek", // Theme phrase
|
|
74
|
+
"workingVibeModel": "anthropic/claude-haiku-4-5", // Optional: model to use (default)
|
|
75
|
+
"workingVibeFallback": "Working", // Optional: fallback message
|
|
76
|
+
"workingVibeRefreshInterval": 30, // Optional: seconds between refreshes (default 30)
|
|
77
|
+
"workingVibePrompt": "Generate a {theme} loading message for: {task}" // Optional: custom prompt template
|
|
78
|
+
}
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
**Prompt template variables:**
|
|
82
|
+
- `{theme}` — the current vibe theme (e.g., "star trek", "mafia")
|
|
83
|
+
- `{task}` — the current task hint (user prompt or tool action, truncated to 100 chars)
|
|
84
|
+
|
|
85
|
+
**How it works:**
|
|
86
|
+
1. When you send a message, shows "Channeling {theme}..." placeholder
|
|
87
|
+
2. AI generates a themed message in the background (3s timeout)
|
|
88
|
+
3. Message updates to the themed version (e.g., "Engaging warp drive...")
|
|
89
|
+
4. During long tasks, refreshes on tool calls (rate-limited, default 30s)
|
|
90
|
+
5. Cost: ~$0.000015 per generation (60 tokens @ haiku pricing)
|
|
91
|
+
|
|
46
92
|
## Thinking Level Display
|
|
47
93
|
|
|
48
94
|
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("
|
|
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 (
|
|
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("
|
|
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
|
|
@@ -330,6 +325,51 @@ export default function powerlineFooter(pi: ExtensionAPI) {
|
|
|
330
325
|
},
|
|
331
326
|
});
|
|
332
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
|
+
|
|
333
373
|
function buildSegmentContext(ctx: any, width: number, theme: Theme): SegmentContext {
|
|
334
374
|
const presetDef = getPreset(config.preset);
|
|
335
375
|
const colors: ColorScheme = presetDef.colors ?? getDefaultColors();
|
|
@@ -520,59 +560,18 @@ export default function powerlineFooter(pi: ExtensionAPI) {
|
|
|
520
560
|
return editor;
|
|
521
561
|
});
|
|
522
562
|
|
|
523
|
-
// Set up footer data provider access
|
|
524
|
-
|
|
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) => {
|
|
525
566
|
footerDataRef = footerData;
|
|
526
567
|
tuiRef = tui; // Store TUI reference for re-renders on git branch changes
|
|
527
568
|
const unsub = footerData.onBranchChange(() => tui.requestRender());
|
|
528
569
|
|
|
529
570
|
return {
|
|
530
571
|
dispose: unsub,
|
|
531
|
-
invalidate() {
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
render(width: number): string[] {
|
|
535
|
-
if (!currentCtx) return [];
|
|
536
|
-
|
|
537
|
-
const presetDef = getPreset(config.preset);
|
|
538
|
-
const segmentCtx = buildSegmentContext(currentCtx, width, theme);
|
|
539
|
-
const lines: string[] = [];
|
|
540
|
-
|
|
541
|
-
// During streaming, show primary status in footer (editor hidden)
|
|
542
|
-
if (isStreaming) {
|
|
543
|
-
const statusContent = buildStatusContent(segmentCtx, presetDef);
|
|
544
|
-
if (statusContent) {
|
|
545
|
-
const statusWidth = visibleWidth(statusContent);
|
|
546
|
-
if (statusWidth <= width) {
|
|
547
|
-
lines.push(statusContent + " ".repeat(width - statusWidth));
|
|
548
|
-
} else {
|
|
549
|
-
// Truncate by removing segments until it fits
|
|
550
|
-
// Start from leftSegments.length to try "just leftSegments" when rightSegments exists
|
|
551
|
-
let truncatedContent = "";
|
|
552
|
-
let foundFit = false;
|
|
553
|
-
for (let numSegments = presetDef.leftSegments.length; numSegments >= 1; numSegments--) {
|
|
554
|
-
const limitedPreset = {
|
|
555
|
-
...presetDef,
|
|
556
|
-
leftSegments: presetDef.leftSegments.slice(0, numSegments),
|
|
557
|
-
rightSegments: [],
|
|
558
|
-
};
|
|
559
|
-
truncatedContent = buildStatusContent(segmentCtx, limitedPreset);
|
|
560
|
-
const truncWidth = visibleWidth(truncatedContent);
|
|
561
|
-
if (truncWidth <= width - 1) {
|
|
562
|
-
truncatedContent += "…";
|
|
563
|
-
foundFit = true;
|
|
564
|
-
break;
|
|
565
|
-
}
|
|
566
|
-
}
|
|
567
|
-
// Only push if we found a fit, otherwise skip (don't crash on very narrow terminals)
|
|
568
|
-
if (foundFit) {
|
|
569
|
-
lines.push(truncatedContent);
|
|
570
|
-
}
|
|
571
|
-
}
|
|
572
|
-
}
|
|
573
|
-
}
|
|
574
|
-
|
|
575
|
-
return lines;
|
|
572
|
+
invalidate() {},
|
|
573
|
+
render(): string[] {
|
|
574
|
+
return []; // Status is in editor top border, not footer
|
|
576
575
|
},
|
|
577
576
|
};
|
|
578
577
|
});
|
|
@@ -666,7 +665,7 @@ export default function powerlineFooter(pi: ExtensionAPI) {
|
|
|
666
665
|
// Small delay to let pi-mono finish initialization
|
|
667
666
|
setTimeout(() => {
|
|
668
667
|
// Skip overlay if:
|
|
669
|
-
// 1. Dismissal was explicitly requested (
|
|
668
|
+
// 1. Dismissal was explicitly requested (agent_start/user_message fired)
|
|
670
669
|
// 2. Agent is already streaming
|
|
671
670
|
// 3. Session already has assistant messages (agent already responded)
|
|
672
671
|
if (welcomeOverlayShouldDismiss || isStreaming) {
|
package/package.json
CHANGED
package/working-vibes.ts
ADDED
|
@@ -0,0 +1,350 @@
|
|
|
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 and create new controller
|
|
232
|
+
// Capture in local variable to avoid race condition with subsequent calls
|
|
233
|
+
const controller = new AbortController();
|
|
234
|
+
currentGeneration?.abort();
|
|
235
|
+
currentGeneration = controller;
|
|
236
|
+
|
|
237
|
+
// Create timeout signal (3 seconds)
|
|
238
|
+
const timeoutSignal = AbortSignal.timeout(config.timeout);
|
|
239
|
+
const combinedSignal = AbortSignal.any([
|
|
240
|
+
controller.signal,
|
|
241
|
+
timeoutSignal,
|
|
242
|
+
]);
|
|
243
|
+
|
|
244
|
+
try {
|
|
245
|
+
const vibe = await generateVibe(
|
|
246
|
+
{ theme: config.theme!, userPrompt: prompt },
|
|
247
|
+
combinedSignal,
|
|
248
|
+
);
|
|
249
|
+
|
|
250
|
+
// Only update if still streaming and THIS generation wasn't aborted
|
|
251
|
+
if (isStreaming && !controller.signal.aborted) {
|
|
252
|
+
setWorkingMessage(vibe);
|
|
253
|
+
}
|
|
254
|
+
} catch (error) {
|
|
255
|
+
// AbortError is expected on timeout/cancel - don't log as error
|
|
256
|
+
if (error instanceof Error && error.name === "AbortError") {
|
|
257
|
+
console.debug("[working-vibes] Generation aborted");
|
|
258
|
+
} else {
|
|
259
|
+
console.debug("[working-vibes] Generation failed:", error);
|
|
260
|
+
}
|
|
261
|
+
// Fallback already showing, no action needed
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
266
|
+
// Exported Functions (called from index.ts)
|
|
267
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
268
|
+
|
|
269
|
+
export function initVibeManager(ctx: ExtensionContext): void {
|
|
270
|
+
extensionCtx = ctx;
|
|
271
|
+
config = loadConfig(); // Refresh config in case settings changed
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
export function getVibeTheme(): string | null {
|
|
275
|
+
return config.theme;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
export function setVibeTheme(theme: string | null): void {
|
|
279
|
+
config = { ...config, theme };
|
|
280
|
+
saveConfig();
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
export function getVibeModel(): string {
|
|
284
|
+
return config.modelSpec;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
export function setVibeModel(modelSpec: string): void {
|
|
288
|
+
config = { ...config, modelSpec };
|
|
289
|
+
saveModelConfig();
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
export function onVibeBeforeAgentStart(
|
|
293
|
+
prompt: string,
|
|
294
|
+
setWorkingMessage: (msg?: string) => void,
|
|
295
|
+
): void {
|
|
296
|
+
// Skip if no theme configured or no extensionCtx
|
|
297
|
+
if (!config.theme || !extensionCtx) return;
|
|
298
|
+
|
|
299
|
+
// Queue themed placeholder BEFORE agent_start creates the loader
|
|
300
|
+
// This sets pendingWorkingMessage which is applied when loader is created
|
|
301
|
+
setWorkingMessage(`Channeling ${config.theme}...`);
|
|
302
|
+
|
|
303
|
+
// Mark vibe generation time for rate limiting
|
|
304
|
+
lastVibeTime = Date.now();
|
|
305
|
+
|
|
306
|
+
// Async: generate and update (fire-and-forget, don't await)
|
|
307
|
+
generateAndUpdate(prompt, setWorkingMessage);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
export function onVibeAgentStart(): void {
|
|
311
|
+
isStreaming = true;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
export function onVibeToolCall(
|
|
315
|
+
toolName: string,
|
|
316
|
+
toolInput: Record<string, unknown>,
|
|
317
|
+
setWorkingMessage: (msg?: string) => void,
|
|
318
|
+
): void {
|
|
319
|
+
// Skip if no theme, not streaming, or no extensionCtx
|
|
320
|
+
if (!config.theme || !extensionCtx || !isStreaming) return;
|
|
321
|
+
|
|
322
|
+
// Rate limit: skip if not enough time has passed
|
|
323
|
+
const now = Date.now();
|
|
324
|
+
if (now - lastVibeTime < config.refreshInterval) return;
|
|
325
|
+
|
|
326
|
+
// Build context hint from tool name and input
|
|
327
|
+
let hint = `using ${toolName} tool`;
|
|
328
|
+
if (toolName === "read" && toolInput.path) {
|
|
329
|
+
hint = `reading file: ${toolInput.path}`;
|
|
330
|
+
} else if (toolName === "write" && toolInput.path) {
|
|
331
|
+
hint = `writing file: ${toolInput.path}`;
|
|
332
|
+
} else if (toolName === "edit" && toolInput.path) {
|
|
333
|
+
hint = `editing file: ${toolInput.path}`;
|
|
334
|
+
} else if (toolName === "bash" && toolInput.command) {
|
|
335
|
+
const cmd = String(toolInput.command).slice(0, 40);
|
|
336
|
+
hint = `running command: ${cmd}`;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// Update time and generate new vibe
|
|
340
|
+
lastVibeTime = now;
|
|
341
|
+
generateAndUpdate(hint, setWorkingMessage);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
export function onVibeAgentEnd(setWorkingMessage: (msg?: string) => void): void {
|
|
345
|
+
isStreaming = false;
|
|
346
|
+
// Cancel any in-flight generation
|
|
347
|
+
currentGeneration?.abort();
|
|
348
|
+
// Reset to pi's default working message
|
|
349
|
+
setWorkingMessage(undefined);
|
|
350
|
+
}
|