pi-crew 0.9.27 → 0.9.29
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 +15 -0
- package/dist/build-meta.json +164 -115
- package/dist/index.mjs +986 -555
- package/dist/index.mjs.map +4 -4
- package/docs/REVIEW-FINDINGS-2026-07-CORE.md +139 -0
- package/docs/REVIEW-FINDINGS-2026-07.md +125 -0
- package/docs/bugs/bug-quota-display-truncation.md +223 -0
- package/docs/stories/README.md +3 -1
- package/docs/stories/US-DEPS-major-upgrade.md +62 -0
- package/package.json +4 -4
- package/src/agents/agent-config.ts +2 -0
- package/src/agents/discover-agents.ts +4 -0
- package/src/config/config.ts +4 -0
- package/src/extension/crew-vibes/config.ts +1 -1
- package/src/extension/crew-vibes/figures.ts +1 -1
- package/src/extension/crew-vibes/footer.ts +292 -0
- package/src/extension/crew-vibes/index.ts +74 -70
- package/src/extension/crew-vibes/provider-usage.ts +119 -53
- package/src/extension/team-tool.ts +11 -0
- package/src/prompt/prompt-runtime.ts +65 -0
- package/src/runtime/child-pi.ts +58 -43
- package/src/runtime/cross-extension-rpc.ts +1 -1
- package/src/runtime/pi-args.ts +2 -0
- package/src/runtime/post-exit-stdio-guard.ts +22 -4
- package/src/runtime/stale-reconciler.ts +9 -4
- package/src/runtime/task-runner.ts +1 -0
- package/src/runtime/team-runner.ts +11 -16
- package/src/state/atomic-write.ts +46 -19
- package/src/state/event-log.ts +77 -24
- package/src/state/mailbox.ts +6 -3
- package/src/ui/pi-ui-compat.ts +11 -0
- package/src/utils/conflict-detect.ts +9 -3
- package/src/utils/paths.ts +7 -1
- package/src/worktree/cleanup.ts +19 -0
- package/src/worktree/worktree-manager.ts +145 -34
|
@@ -61,7 +61,7 @@ const PUA_CREW_FRAMES: readonly string[] = [
|
|
|
61
61
|
* - `"pua"`: runner-pose glyphs from crew-vibes.ttf, requires font install
|
|
62
62
|
*/
|
|
63
63
|
export function crewFrames(style: "braille" | "pua" = "pua"): readonly string[] {
|
|
64
|
-
// Web terminals (gotty, wetty) cannot render PUA glyphs —
|
|
64
|
+
// Web terminals (gotty, wetty) cannot render PUA glyphs — fall back to braille
|
|
65
65
|
if (style === "pua" && isWebTerminal()) return BRAILLE_FRAMES;
|
|
66
66
|
return style === "pua" ? PUA_CREW_FRAMES : BRAILLE_FRAMES;
|
|
67
67
|
}
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
import { isAbsolute, relative, resolve, sep } from "node:path";
|
|
2
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { requestRenderTarget } from "../../ui/pi-ui-compat.ts";
|
|
4
|
+
import { asCrewTheme, type CrewTheme } from "../../ui/theme-adapter.ts";
|
|
5
|
+
import { truncateToWidth, visibleWidth } from "../../utils/visual.ts";
|
|
6
|
+
import type { CrewVibesConfig } from "./config.ts";
|
|
7
|
+
import type { ProviderUsage } from "./provider-usage.ts";
|
|
8
|
+
import { formatCount, getCapacityUsage, renderCapacity, renderProviderUsage } from "./render.ts";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Custom footer replacement for crew-vibes.
|
|
12
|
+
*
|
|
13
|
+
* Why this exists: pi joins ALL `setStatus()` entries onto ONE line and
|
|
14
|
+
* right-truncates when the line overflows. The provider quota lives at the
|
|
15
|
+
* tail, so it gets chopped to "W..." whenever the `pi-crew` widget status grows
|
|
16
|
+
* during sub-agent runs. Five prior attempts (right-align padding, dynamic
|
|
17
|
+
* padding, separate keys) all failed because an extension using `setStatus`
|
|
18
|
+
* cannot see the widget's width, other extensions' widths, or pi's real render
|
|
19
|
+
* width. `setFooter` sidesteps all of that: `render(width)` receives the REAL
|
|
20
|
+
* width and we own the line layout, so the meters get a dedicated line that is
|
|
21
|
+
* never truncated by the join.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/** Live values the footer reads on each render (owned by the extension). */
|
|
25
|
+
export interface CrewVibesFooterSource {
|
|
26
|
+
getConfig(): CrewVibesConfig;
|
|
27
|
+
getQuotaUsage(): ProviderUsage | null;
|
|
28
|
+
getThinkingLevel(): string | undefined;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface CrewVibesFooterDeps {
|
|
32
|
+
tui: unknown;
|
|
33
|
+
theme: unknown;
|
|
34
|
+
footerData: unknown;
|
|
35
|
+
ctx: ExtensionContext;
|
|
36
|
+
source: CrewVibesFooterSource;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
interface FooterData {
|
|
40
|
+
getGitBranch(): string | null;
|
|
41
|
+
getExtensionStatuses(): ReadonlyMap<string, string>;
|
|
42
|
+
getAvailableProviderCount(): number;
|
|
43
|
+
onBranchChange(cb: () => void): () => void;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
interface FooterComponent {
|
|
47
|
+
render(width: number): string[];
|
|
48
|
+
invalidate(): void;
|
|
49
|
+
dispose(): void;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function num(value: unknown): number {
|
|
53
|
+
return typeof value === "number" && Number.isFinite(value) ? value : 0;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Mirror pi's footer cwd formatting: collapse the home prefix to `~`. */
|
|
57
|
+
function formatCwdForFooter(cwd: string, home: string | undefined): string {
|
|
58
|
+
if (!home) return cwd;
|
|
59
|
+
const resolvedCwd = resolve(cwd);
|
|
60
|
+
const resolvedHome = resolve(home);
|
|
61
|
+
const rel = relative(resolvedHome, resolvedCwd);
|
|
62
|
+
const inside = rel === "" || (rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel));
|
|
63
|
+
if (!inside) return cwd;
|
|
64
|
+
return rel === "" ? "~" : `~${sep}${rel}`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Mirror pi's status sanitizer (collapse control chars + runs of spaces). */
|
|
68
|
+
function sanitizeStatusText(text: string): string {
|
|
69
|
+
return text
|
|
70
|
+
.replace(/[\r\n\t]/g, " ")
|
|
71
|
+
.replace(/ +/g, " ")
|
|
72
|
+
.trim();
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function asFooterData(value: unknown): FooterData | undefined {
|
|
76
|
+
if (!value || typeof value !== "object") return undefined;
|
|
77
|
+
const record = value as Record<string, unknown>;
|
|
78
|
+
if (
|
|
79
|
+
typeof record.getGitBranch !== "function" ||
|
|
80
|
+
typeof record.getExtensionStatuses !== "function" ||
|
|
81
|
+
typeof record.getAvailableProviderCount !== "function" ||
|
|
82
|
+
typeof record.onBranchChange !== "function"
|
|
83
|
+
) {
|
|
84
|
+
return undefined;
|
|
85
|
+
}
|
|
86
|
+
return value as FooterData;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
interface SessionTotals {
|
|
90
|
+
input: number;
|
|
91
|
+
output: number;
|
|
92
|
+
cacheRead: number;
|
|
93
|
+
cacheWrite: number;
|
|
94
|
+
cost: number;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function computeTotals(entries: unknown[]): SessionTotals {
|
|
98
|
+
const totals: SessionTotals = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0 };
|
|
99
|
+
for (const entry of entries) {
|
|
100
|
+
const rec = entry as {
|
|
101
|
+
type?: string;
|
|
102
|
+
message?: { role?: string; usage?: Record<string, unknown> & { cost?: { total?: unknown } } };
|
|
103
|
+
};
|
|
104
|
+
if (rec?.type !== "message" || rec.message?.role !== "assistant") continue;
|
|
105
|
+
const usage = rec.message.usage;
|
|
106
|
+
if (!usage) continue;
|
|
107
|
+
totals.input += num(usage.input);
|
|
108
|
+
totals.output += num(usage.output);
|
|
109
|
+
totals.cacheRead += num(usage.cacheRead);
|
|
110
|
+
totals.cacheWrite += num(usage.cacheWrite);
|
|
111
|
+
totals.cost += num(usage.cost?.total);
|
|
112
|
+
}
|
|
113
|
+
return totals;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
class CrewVibesFooter implements FooterComponent {
|
|
117
|
+
private readonly theme: CrewTheme;
|
|
118
|
+
private readonly footerData: FooterData | undefined;
|
|
119
|
+
private readonly ctx: ExtensionContext;
|
|
120
|
+
private readonly source: CrewVibesFooterSource;
|
|
121
|
+
private readonly tui: unknown;
|
|
122
|
+
private readonly unsubscribeBranch: () => void;
|
|
123
|
+
|
|
124
|
+
constructor(deps: CrewVibesFooterDeps) {
|
|
125
|
+
this.theme = asCrewTheme(deps.theme);
|
|
126
|
+
this.footerData = asFooterData(deps.footerData);
|
|
127
|
+
this.ctx = deps.ctx;
|
|
128
|
+
this.source = deps.source;
|
|
129
|
+
this.tui = deps.tui;
|
|
130
|
+
this.unsubscribeBranch = this.footerData ? this.footerData.onBranchChange(() => requestRenderTarget(this.tui)) : () => {};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
invalidate(): void {}
|
|
134
|
+
|
|
135
|
+
dispose(): void {
|
|
136
|
+
this.unsubscribeBranch();
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
private buildPwdLine(width: number): string {
|
|
140
|
+
const sm = this.ctx.sessionManager;
|
|
141
|
+
let pwd = formatCwdForFooter(sm.getCwd(), process.env.HOME || process.env.USERPROFILE);
|
|
142
|
+
const branch = this.footerData?.getGitBranch();
|
|
143
|
+
if (branch) pwd = `${pwd} (${branch})`;
|
|
144
|
+
const sessionName = sm.getSessionName?.();
|
|
145
|
+
if (sessionName) pwd = `${pwd} • ${sessionName}`;
|
|
146
|
+
return truncateToWidth(this.theme.fg("dim", pwd), width, this.theme.fg("dim", "..."));
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
private buildStatsLine(width: number): string {
|
|
150
|
+
const theme = this.theme;
|
|
151
|
+
const model = this.ctx.model as { id?: string; provider?: string; reasoning?: unknown; contextWindow?: number } | undefined;
|
|
152
|
+
const totals = computeTotals(this.ctx.sessionManager.getEntries() as unknown[]);
|
|
153
|
+
const contextUsage = this.ctx.getContextUsage?.();
|
|
154
|
+
const contextWindow = contextUsage?.contextWindow ?? model?.contextWindow ?? 0;
|
|
155
|
+
const percentValue = contextUsage?.percent ?? 0;
|
|
156
|
+
const percentKnown = contextUsage?.percent !== null && contextUsage?.percent !== undefined;
|
|
157
|
+
|
|
158
|
+
const statsParts: string[] = [];
|
|
159
|
+
if (totals.input) statsParts.push(`↑${formatCount(totals.input)}`);
|
|
160
|
+
if (totals.output) statsParts.push(`↓${formatCount(totals.output)}`);
|
|
161
|
+
if (totals.cacheRead) statsParts.push(`R${formatCount(totals.cacheRead)}`);
|
|
162
|
+
if (totals.cacheWrite) statsParts.push(`W${formatCount(totals.cacheWrite)}`);
|
|
163
|
+
|
|
164
|
+
const usingSubscription = !!(model && this.ctx.modelRegistry?.isUsingOAuth?.(this.ctx.model as never));
|
|
165
|
+
if (totals.cost || usingSubscription) {
|
|
166
|
+
statsParts.push(`$${totals.cost.toFixed(3)}${usingSubscription ? " (sub)" : ""}`);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// User choice: always show the "(auto)" compaction indicator (pi's default);
|
|
170
|
+
// the toggle state is not observable by extensions.
|
|
171
|
+
const autoIndicator = " (auto)";
|
|
172
|
+
const contextDisplay = percentKnown
|
|
173
|
+
? `${percentValue.toFixed(1)}%/${formatCount(contextWindow)}${autoIndicator}`
|
|
174
|
+
: `?/${formatCount(contextWindow)}${autoIndicator}`;
|
|
175
|
+
const contextColored =
|
|
176
|
+
percentValue > 90
|
|
177
|
+
? theme.fg("error", contextDisplay)
|
|
178
|
+
: percentValue > 70
|
|
179
|
+
? theme.fg("warning", contextDisplay)
|
|
180
|
+
: contextDisplay;
|
|
181
|
+
statsParts.push(contextColored);
|
|
182
|
+
|
|
183
|
+
let statsLeft = statsParts.join(" ");
|
|
184
|
+
let statsLeftWidth = visibleWidth(statsLeft);
|
|
185
|
+
if (statsLeftWidth > width) {
|
|
186
|
+
statsLeft = truncateToWidth(statsLeft, width, "...");
|
|
187
|
+
statsLeftWidth = visibleWidth(statsLeft);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const minPadding = 2;
|
|
191
|
+
const modelName = model?.id || "no-model";
|
|
192
|
+
let rightSideWithoutProvider = modelName;
|
|
193
|
+
if (model?.reasoning) {
|
|
194
|
+
// Prefer the authoritative thinking level from session state
|
|
195
|
+
// (buildSessionContext), which reflects the current effective level
|
|
196
|
+
// including the model default — not just the last thinking_level_select
|
|
197
|
+
// event (which only fires on manual switch).
|
|
198
|
+
let level = this.source.getThinkingLevel();
|
|
199
|
+
try {
|
|
200
|
+
const ctx = this.ctx.sessionManager as { buildSessionContext?: () => { thinkingLevel?: string } };
|
|
201
|
+
if (typeof ctx.buildSessionContext === "function") {
|
|
202
|
+
const resolved = ctx.buildSessionContext()?.thinkingLevel;
|
|
203
|
+
if (resolved) level = resolved;
|
|
204
|
+
}
|
|
205
|
+
} catch {
|
|
206
|
+
// buildSessionContext may throw on empty/early sessions — fall back.
|
|
207
|
+
}
|
|
208
|
+
const finalLevel = level || "off";
|
|
209
|
+
rightSideWithoutProvider = finalLevel === "off" ? `${modelName} • thinking off` : `${modelName} • ${finalLevel}`;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
let rightSide = rightSideWithoutProvider;
|
|
213
|
+
const providerCount = this.footerData?.getAvailableProviderCount() ?? 0;
|
|
214
|
+
if (providerCount > 1 && model?.provider) {
|
|
215
|
+
rightSide = `(${model.provider}) ${rightSideWithoutProvider}`;
|
|
216
|
+
if (statsLeftWidth + minPadding + visibleWidth(rightSide) > width) rightSide = rightSideWithoutProvider;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const rightSideWidth = visibleWidth(rightSide);
|
|
220
|
+
let statsLine: string;
|
|
221
|
+
if (statsLeftWidth + minPadding + rightSideWidth <= width) {
|
|
222
|
+
statsLine = statsLeft + " ".repeat(width - statsLeftWidth - rightSideWidth) + rightSide;
|
|
223
|
+
} else {
|
|
224
|
+
const availableForRight = width - statsLeftWidth - minPadding;
|
|
225
|
+
if (availableForRight > 0) {
|
|
226
|
+
const truncatedRight = truncateToWidth(rightSide, availableForRight, "");
|
|
227
|
+
const padding = " ".repeat(Math.max(0, width - statsLeftWidth - visibleWidth(truncatedRight)));
|
|
228
|
+
statsLine = statsLeft + padding + truncatedRight;
|
|
229
|
+
} else {
|
|
230
|
+
statsLine = statsLeft;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const dimStatsLeft = theme.fg("dim", statsLeft);
|
|
235
|
+
const dimRemainder = theme.fg("dim", statsLine.slice(statsLeft.length));
|
|
236
|
+
return dimStatsLeft + dimRemainder;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
private buildStatusLine(width: number): string | undefined {
|
|
240
|
+
if (!this.footerData) return undefined;
|
|
241
|
+
const statuses = this.footerData.getExtensionStatuses();
|
|
242
|
+
if (statuses.size === 0) return undefined;
|
|
243
|
+
const joined = Array.from(statuses.entries())
|
|
244
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
245
|
+
.map(([, text]) => sanitizeStatusText(text))
|
|
246
|
+
.join(" ");
|
|
247
|
+
if (!joined) return undefined;
|
|
248
|
+
return truncateToWidth(joined, width, this.theme.fg("dim", "..."));
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
private rightAlign(text: string, width: number): string {
|
|
252
|
+
const w = visibleWidth(text);
|
|
253
|
+
if (w >= width) return truncateToWidth(text, width, "…");
|
|
254
|
+
return " ".repeat(width - w) + text;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** Capacity + provider quota. Uses the REAL render width, so the quota is
|
|
258
|
+
* never chopped. When both do not fit on one line, wrap to two lines
|
|
259
|
+
* (capacity above, quota right-aligned below) per the chosen behavior. */
|
|
260
|
+
private buildMeterLines(width: number): string[] {
|
|
261
|
+
const config = this.source.getConfig();
|
|
262
|
+
if (!config.enabled) return [];
|
|
263
|
+
const capText = config.capacity.enabled ? renderCapacity(this.theme, config.capacity, getCapacityUsage(this.ctx)) : undefined;
|
|
264
|
+
const quotaText = config.capacity.providerUsage ? renderProviderUsage(this.theme, this.source.getQuotaUsage()) : undefined;
|
|
265
|
+
|
|
266
|
+
if (!capText && !quotaText) return [];
|
|
267
|
+
if (capText && !quotaText) return [truncateToWidth(capText, width, "…")];
|
|
268
|
+
if (!capText && quotaText) return [this.rightAlign(quotaText, width)];
|
|
269
|
+
|
|
270
|
+
const cap = capText as string;
|
|
271
|
+
const quota = quotaText as string;
|
|
272
|
+
const capWidth = visibleWidth(cap);
|
|
273
|
+
const quotaWidth = visibleWidth(quota);
|
|
274
|
+
if (capWidth + 1 + quotaWidth <= width) {
|
|
275
|
+
const pad = Math.max(1, width - capWidth - quotaWidth);
|
|
276
|
+
return [cap + " ".repeat(pad) + quota];
|
|
277
|
+
}
|
|
278
|
+
return [truncateToWidth(cap, width, "…"), this.rightAlign(quota, width)];
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
render(width: number): string[] {
|
|
282
|
+
const lines = [this.buildPwdLine(width), this.buildStatsLine(width)];
|
|
283
|
+
const statusLine = this.buildStatusLine(width);
|
|
284
|
+
if (statusLine) lines.push(statusLine);
|
|
285
|
+
lines.push(...this.buildMeterLines(width));
|
|
286
|
+
return lines;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
export function createCrewVibesFooter(deps: CrewVibesFooterDeps): FooterComponent {
|
|
291
|
+
return new CrewVibesFooter(deps);
|
|
292
|
+
}
|
|
@@ -1,24 +1,20 @@
|
|
|
1
1
|
import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { requestRender, setFooter } from "../../ui/pi-ui-compat.ts";
|
|
2
3
|
import { type CrewVibesConfig, loadConfig, PROVIDER_STATUS_ID, saveConfig } from "./config.ts";
|
|
3
|
-
import { isWebTerminal } from "./font-detect.ts";
|
|
4
|
-
import { fetchProviderUsage, clearProviderUsageCache } from "./provider-usage.ts";
|
|
5
4
|
import { intervalForSpeed } from "./figures.ts";
|
|
5
|
+
import { type CrewVibesFooterSource, createCrewVibesFooter } from "./footer.ts";
|
|
6
|
+
import { clearProviderUsageCache, fetchProviderUsage, type ProviderUsage } from "./provider-usage.ts";
|
|
6
7
|
import {
|
|
7
8
|
asCrewTheme,
|
|
8
9
|
clearVibesStatus,
|
|
9
10
|
crewIndicatorFrames,
|
|
10
11
|
formatSpeed,
|
|
11
12
|
getCapacityUsage,
|
|
12
|
-
renderCapacity,
|
|
13
|
-
renderProviderUsage,
|
|
14
13
|
renderSpeedFooter,
|
|
15
14
|
renderWorkingMessage,
|
|
16
|
-
setCapacityStatus,
|
|
17
|
-
setProviderStatus,
|
|
18
15
|
setSpeedStatus,
|
|
19
16
|
} from "./render.ts";
|
|
20
17
|
import { SpeedAnimator, SpeedTracker } from "./speed.ts";
|
|
21
|
-
import { CAT_FRAMES } from "./cat-frames.ts";
|
|
22
18
|
|
|
23
19
|
export const CREW_VIBES_STATUS_KEY = "pi-crew-vibes";
|
|
24
20
|
|
|
@@ -51,37 +47,41 @@ export function registerCrewVibes(pi: ExtensionAPI): void {
|
|
|
51
47
|
let footerTimer: ReturnType<typeof setInterval> | undefined;
|
|
52
48
|
let capacityTimer: ReturnType<typeof setInterval> | undefined;
|
|
53
49
|
let providerTimer: ReturnType<typeof setInterval> | undefined;
|
|
54
|
-
let
|
|
55
|
-
let
|
|
50
|
+
let lastProviderUsage: ProviderUsage | null = null;
|
|
51
|
+
let currentProvider: string | undefined;
|
|
52
|
+
let currentThinkingLevel: string | undefined;
|
|
56
53
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
return text.replace(/\x1b\[[0-9;]*m/g, "").length;
|
|
54
|
+
function themeOf(ctx: ExtensionContext) {
|
|
55
|
+
return asCrewTheme(ctx.hasUI ? ctx.ui.theme : undefined);
|
|
60
56
|
}
|
|
61
57
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
const padding = Math.max(2, cols - visibleLen(left) - visibleLen(right));
|
|
68
|
-
return left + "\u00A0".repeat(padding) + right;
|
|
69
|
-
}
|
|
58
|
+
const footerSource: CrewVibesFooterSource = {
|
|
59
|
+
getConfig: () => config,
|
|
60
|
+
getQuotaUsage: () => lastProviderUsage,
|
|
61
|
+
getThinkingLevel: () => currentThinkingLevel,
|
|
62
|
+
};
|
|
70
63
|
|
|
71
|
-
|
|
72
|
-
|
|
64
|
+
/** Whether the custom footer has crew-vibes meters to add on top of pi's stats. */
|
|
65
|
+
function metersActive(): boolean {
|
|
66
|
+
return config.enabled && (config.capacity.enabled || config.capacity.providerUsage);
|
|
73
67
|
}
|
|
74
68
|
|
|
75
|
-
|
|
69
|
+
/** Install our custom footer, or restore pi's built-in footer when idle.
|
|
70
|
+
* Unlike setStatus (joined + right-truncated), the footer's render(width)
|
|
71
|
+
* gets the real width and owns line layout, so the quota is never chopped. */
|
|
72
|
+
function installFooter(ctx: ExtensionContext): void {
|
|
76
73
|
if (!ctx?.hasUI) return;
|
|
77
|
-
if (!
|
|
78
|
-
|
|
74
|
+
if (!metersActive()) {
|
|
75
|
+
setFooter(ctx, undefined);
|
|
79
76
|
return;
|
|
80
77
|
}
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
78
|
+
setFooter(ctx, (tui, theme, footerData) => createCrewVibesFooter({ tui, theme, footerData, ctx, source: footerSource }));
|
|
79
|
+
requestRender(ctx);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Trigger a footer repaint; the footer recomputes capacity/quota on render. */
|
|
83
|
+
function refreshFooter(ctx: ExtensionContext): void {
|
|
84
|
+
if (ctx?.hasUI) requestRender(ctx);
|
|
85
85
|
}
|
|
86
86
|
|
|
87
87
|
function publishSpeedFooter(ctx: ExtensionContext, speed = footerAnimator.value()): void {
|
|
@@ -146,11 +146,6 @@ export function registerCrewVibes(pi: ExtensionAPI): void {
|
|
|
146
146
|
const speed = speedTracker.liveTokS();
|
|
147
147
|
applyIndicator(ctx, speed);
|
|
148
148
|
renderWorking(ctx, speed);
|
|
149
|
-
// Update cat animation frame in web terminals
|
|
150
|
-
if (isWebTerminal()) {
|
|
151
|
-
catFrameIndex = (catFrameIndex + 1) % CAT_FRAMES.length;
|
|
152
|
-
ctx.ui.setWidget("crew-vibes-cat", [...CAT_FRAMES[catFrameIndex]], { placement: "aboveEditor" });
|
|
153
|
-
}
|
|
154
149
|
}, config.speed.renderIntervalMs);
|
|
155
150
|
liveTimer.unref?.();
|
|
156
151
|
}
|
|
@@ -171,34 +166,36 @@ export function registerCrewVibes(pi: ExtensionAPI): void {
|
|
|
171
166
|
function startCapacityTimer(ctx: ExtensionContext): void {
|
|
172
167
|
if (capacityTimer) return;
|
|
173
168
|
const interval = Math.max(250, config.capacity.refreshIntervalMs);
|
|
174
|
-
capacityTimer = setInterval(() =>
|
|
169
|
+
capacityTimer = setInterval(() => refreshFooter(ctx), interval);
|
|
175
170
|
capacityTimer.unref?.();
|
|
176
171
|
}
|
|
177
172
|
|
|
173
|
+
/** Fetch provider usage for currentProvider and refresh the footer.
|
|
174
|
+
* Called on start, on each timer tick, and immediately when the
|
|
175
|
+
* provider changes (model_select) so the quota reflects the new
|
|
176
|
+
* provider without waiting for the next 5-minute tick. */
|
|
177
|
+
async function fetchProviderAndRefresh(ctx: ExtensionContext): Promise<void> {
|
|
178
|
+
if (!config.enabled || !config.capacity.providerUsage) {
|
|
179
|
+
lastProviderUsage = null;
|
|
180
|
+
refreshFooter(ctx);
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
try {
|
|
184
|
+
lastProviderUsage = await fetchProviderUsage(config.capacity.providerRefreshMs, currentProvider);
|
|
185
|
+
} catch {
|
|
186
|
+
// Never crash on provider fetch failure
|
|
187
|
+
lastProviderUsage = null;
|
|
188
|
+
}
|
|
189
|
+
refreshFooter(ctx);
|
|
190
|
+
}
|
|
191
|
+
|
|
178
192
|
function startProviderTimer(ctx: ExtensionContext): void {
|
|
179
193
|
if (providerTimer) return;
|
|
180
194
|
if (!config.capacity.providerUsage) return;
|
|
181
195
|
const interval = Math.max(10000, config.capacity.providerRefreshMs);
|
|
182
196
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
stopProviderTimer();
|
|
186
|
-
return;
|
|
187
|
-
}
|
|
188
|
-
try {
|
|
189
|
-
const usage = await fetchProviderUsage(config.capacity.providerRefreshMs);
|
|
190
|
-
lastProviderText = renderProviderUsage(themeOf(ctx), usage);
|
|
191
|
-
// Re-render combined capacity + provider line
|
|
192
|
-
publishCapacity(ctx);
|
|
193
|
-
} catch {
|
|
194
|
-
// Never crash on provider fetch failure
|
|
195
|
-
lastProviderText = undefined;
|
|
196
|
-
publishCapacity(ctx);
|
|
197
|
-
}
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
tick(); // Fetch immediately on start
|
|
201
|
-
providerTimer = setInterval(tick, interval);
|
|
197
|
+
fetchProviderAndRefresh(ctx); // Fetch immediately on start
|
|
198
|
+
providerTimer = setInterval(() => fetchProviderAndRefresh(ctx), interval);
|
|
202
199
|
providerTimer.unref?.();
|
|
203
200
|
}
|
|
204
201
|
|
|
@@ -216,11 +213,13 @@ export function registerCrewVibes(pi: ExtensionAPI): void {
|
|
|
216
213
|
stopFooterTimer();
|
|
217
214
|
stopCapacityTimer();
|
|
218
215
|
stopProviderTimer();
|
|
216
|
+
setFooter(ctx, undefined);
|
|
219
217
|
clearVibesStatus(ctx);
|
|
220
218
|
return;
|
|
221
219
|
}
|
|
222
|
-
|
|
220
|
+
installFooter(ctx);
|
|
223
221
|
publishSpeedFooter(ctx);
|
|
222
|
+
startCapacityTimer(ctx);
|
|
224
223
|
if (config.capacity.providerUsage) startProviderTimer(ctx);
|
|
225
224
|
}
|
|
226
225
|
|
|
@@ -235,11 +234,15 @@ export function registerCrewVibes(pi: ExtensionAPI): void {
|
|
|
235
234
|
speedTracker.resetSession();
|
|
236
235
|
footerAnimator.reset(null);
|
|
237
236
|
clearProviderUsageCache();
|
|
237
|
+
// Initialize provider from current model — model_select only fires on manual switch
|
|
238
|
+
currentProvider = (ctx.model as { provider?: string } | undefined)?.provider;
|
|
239
|
+
currentThinkingLevel = undefined;
|
|
238
240
|
if (!config.enabled) {
|
|
241
|
+
setFooter(ctx, undefined);
|
|
239
242
|
clearVibesStatus(ctx);
|
|
240
243
|
return;
|
|
241
244
|
}
|
|
242
|
-
|
|
245
|
+
installFooter(ctx);
|
|
243
246
|
publishSpeedFooter(ctx);
|
|
244
247
|
startCapacityTimer(ctx);
|
|
245
248
|
startProviderTimer(ctx);
|
|
@@ -266,10 +269,6 @@ export function registerCrewVibes(pi: ExtensionAPI): void {
|
|
|
266
269
|
footerAnimator.reset(speedTracker.lastTokS);
|
|
267
270
|
startLiveTimer(ctx);
|
|
268
271
|
lastRenderedAt = 0;
|
|
269
|
-
// Show cat widget in web terminals
|
|
270
|
-
if (isWebTerminal() && ctx.hasUI) {
|
|
271
|
-
ctx.ui.setWidget("crew-vibes-cat", [...CAT_FRAMES[0]], { placement: "aboveEditor" });
|
|
272
|
-
}
|
|
273
272
|
});
|
|
274
273
|
|
|
275
274
|
pi.on("message_update", (event, ctx) => {
|
|
@@ -294,7 +293,7 @@ export function registerCrewVibes(pi: ExtensionAPI): void {
|
|
|
294
293
|
|
|
295
294
|
pi.on("message_end", (event, ctx) => {
|
|
296
295
|
if (!isAssistantMessage(event.message)) return;
|
|
297
|
-
|
|
296
|
+
refreshFooter(ctx);
|
|
298
297
|
if (!config.enabled || !config.speed.enabled || !speedTracker.isStreaming) return;
|
|
299
298
|
|
|
300
299
|
const completed = speedTracker.finishMessage(assistantUsageOutput(event.message) ?? 0, assistantStopReason(event.message));
|
|
@@ -304,10 +303,6 @@ export function registerCrewVibes(pi: ExtensionAPI): void {
|
|
|
304
303
|
publishSpeedFooter(ctx);
|
|
305
304
|
startFooterTimer(ctx);
|
|
306
305
|
applyIndicator(ctx, speedTracker.lastTokS);
|
|
307
|
-
// Remove cat widget on message end
|
|
308
|
-
if (isWebTerminal() && ctx.hasUI) {
|
|
309
|
-
ctx.ui.setWidget("crew-vibes-cat", undefined);
|
|
310
|
-
}
|
|
311
306
|
});
|
|
312
307
|
|
|
313
308
|
pi.on("turn_end", () => {
|
|
@@ -321,21 +316,30 @@ export function registerCrewVibes(pi: ExtensionAPI): void {
|
|
|
321
316
|
if (ctx && config.enabled && ctx.hasUI) {
|
|
322
317
|
applyIndicator(ctx, speedTracker.lastTokS);
|
|
323
318
|
ctx.ui.setWorkingMessage();
|
|
324
|
-
if (isWebTerminal()) ctx.ui.setWidget("crew-vibes-cat", undefined);
|
|
325
319
|
}
|
|
326
320
|
});
|
|
327
321
|
|
|
328
|
-
pi.on("model_select", (
|
|
329
|
-
|
|
330
|
-
|
|
322
|
+
pi.on("model_select", (event, ctx) => {
|
|
323
|
+
currentProvider = (event as { model?: { provider?: string } }).model?.provider;
|
|
324
|
+
clearProviderUsageCache();
|
|
325
|
+
// Fetch immediately so the quota reflects the new provider without
|
|
326
|
+
// waiting for the next 5-minute timer tick.
|
|
327
|
+
fetchProviderAndRefresh(ctx);
|
|
328
|
+
});
|
|
329
|
+
pi.on("thinking_level_select", (event, ctx) => {
|
|
330
|
+
currentThinkingLevel = (event as { level?: unknown }).level as string | undefined;
|
|
331
|
+
refreshFooter(ctx);
|
|
332
|
+
});
|
|
333
|
+
pi.on("session_compact", (_event, ctx) => refreshFooter(ctx));
|
|
334
|
+
pi.on("session_tree", (_event, ctx) => refreshFooter(ctx));
|
|
331
335
|
|
|
332
336
|
pi.on("session_shutdown", (_event, ctx) => {
|
|
333
337
|
stopLiveTimer();
|
|
334
338
|
stopFooterTimer();
|
|
335
339
|
stopCapacityTimer();
|
|
336
340
|
stopProviderTimer();
|
|
341
|
+
setFooter(ctx, undefined);
|
|
337
342
|
clearVibesStatus(ctx);
|
|
338
|
-
if (ctx.hasUI) ctx.ui.setWidget("crew-vibes-cat", undefined);
|
|
339
343
|
});
|
|
340
344
|
|
|
341
345
|
async function handleCommand(args: string, ctx: ExtensionCommandContext): Promise<void> {
|