pi-crew 0.9.28 → 0.9.30

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.
@@ -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,19 +1,17 @@
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 { fetchProviderUsage, clearProviderUsageCache, providerSupportsQuota } from "./provider-usage.ts";
4
4
  import { intervalForSpeed } from "./figures.ts";
5
+ import { type CrewVibesFooterSource, createCrewVibesFooter } from "./footer.ts";
6
+ import { clearProviderUsageCache, fetchProviderUsage, type ProviderUsage } from "./provider-usage.ts";
5
7
  import {
6
8
  asCrewTheme,
7
9
  clearVibesStatus,
8
10
  crewIndicatorFrames,
9
11
  formatSpeed,
10
12
  getCapacityUsage,
11
- renderCapacity,
12
- renderProviderUsage,
13
13
  renderSpeedFooter,
14
14
  renderWorkingMessage,
15
- setCapacityStatus,
16
- setProviderStatus,
17
15
  setSpeedStatus,
18
16
  } from "./render.ts";
19
17
  import { SpeedAnimator, SpeedTracker } from "./speed.ts";
@@ -49,37 +47,41 @@ export function registerCrewVibes(pi: ExtensionAPI): void {
49
47
  let footerTimer: ReturnType<typeof setInterval> | undefined;
50
48
  let capacityTimer: ReturnType<typeof setInterval> | undefined;
51
49
  let providerTimer: ReturnType<typeof setInterval> | undefined;
52
- let lastProviderText: string | undefined;
50
+ let lastProviderUsage: ProviderUsage | null = null;
53
51
  let currentProvider: string | undefined;
52
+ let currentThinkingLevel: string | undefined;
54
53
 
55
- /** Strip ANSI codes to measure visible width. */
56
- function visibleLen(text: string): number {
57
- return text.replace(/\x1b\[[0-9;]*m/g, "").length;
54
+ function themeOf(ctx: ExtensionContext) {
55
+ return asCrewTheme(ctx.hasUI ? ctx.ui.theme : undefined);
58
56
  }
59
57
 
60
- /** Left-align `left`, right-align `right` on the same line.
61
- * Uses non-breaking space (U+00A0) for padding because pi's
62
- * sanitizeStatusText collapses regular spaces: / +/g → " ". */
63
- function spreadLine(left: string, right: string): string {
64
- const cols = process.stdout.columns || 120;
65
- const padding = Math.max(2, cols - visibleLen(left) - visibleLen(right));
66
- return left + "\u00A0".repeat(padding) + right;
67
- }
58
+ const footerSource: CrewVibesFooterSource = {
59
+ getConfig: () => config,
60
+ getQuotaUsage: () => lastProviderUsage,
61
+ getThinkingLevel: () => currentThinkingLevel,
62
+ };
68
63
 
69
- function themeOf(ctx: ExtensionContext) {
70
- return asCrewTheme(ctx.hasUI ? ctx.ui.theme : undefined);
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);
71
67
  }
72
68
 
73
- function publishCapacity(ctx: ExtensionContext): void {
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 {
74
73
  if (!ctx?.hasUI) return;
75
- if (!config.enabled || !config.capacity.enabled) {
76
- setCapacityStatus(ctx, config, undefined);
74
+ if (!metersActive()) {
75
+ setFooter(ctx, undefined);
77
76
  return;
78
77
  }
79
- const capText = renderCapacity(themeOf(ctx), config.capacity, getCapacityUsage(ctx));
80
- // Combine capacity + provider on one line with spacing
81
- const combined = lastProviderText ? spreadLine(capText, lastProviderText) : capText;
82
- setCapacityStatus(ctx, config, combined);
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);
83
85
  }
84
86
 
85
87
  function publishSpeedFooter(ctx: ExtensionContext, speed = footerAnimator.value()): void {
@@ -164,34 +166,36 @@ export function registerCrewVibes(pi: ExtensionAPI): void {
164
166
  function startCapacityTimer(ctx: ExtensionContext): void {
165
167
  if (capacityTimer) return;
166
168
  const interval = Math.max(250, config.capacity.refreshIntervalMs);
167
- capacityTimer = setInterval(() => publishCapacity(ctx), interval);
169
+ capacityTimer = setInterval(() => refreshFooter(ctx), interval);
168
170
  capacityTimer.unref?.();
169
171
  }
170
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
+
171
192
  function startProviderTimer(ctx: ExtensionContext): void {
172
193
  if (providerTimer) return;
173
194
  if (!config.capacity.providerUsage) return;
174
195
  const interval = Math.max(10000, config.capacity.providerRefreshMs);
175
196
 
176
- async function tick(): Promise<void> {
177
- if (!config.enabled || !config.capacity.providerUsage) {
178
- stopProviderTimer();
179
- return;
180
- }
181
- try {
182
- const usage = await fetchProviderUsage(config.capacity.providerRefreshMs, currentProvider);
183
- lastProviderText = renderProviderUsage(themeOf(ctx), usage);
184
- // Re-render combined capacity + provider line
185
- publishCapacity(ctx);
186
- } catch {
187
- // Never crash on provider fetch failure
188
- lastProviderText = undefined;
189
- publishCapacity(ctx);
190
- }
191
- }
192
-
193
- tick(); // Fetch immediately on start
194
- providerTimer = setInterval(tick, interval);
197
+ fetchProviderAndRefresh(ctx); // Fetch immediately on start
198
+ providerTimer = setInterval(() => fetchProviderAndRefresh(ctx), interval);
195
199
  providerTimer.unref?.();
196
200
  }
197
201
 
@@ -209,11 +213,13 @@ export function registerCrewVibes(pi: ExtensionAPI): void {
209
213
  stopFooterTimer();
210
214
  stopCapacityTimer();
211
215
  stopProviderTimer();
216
+ setFooter(ctx, undefined);
212
217
  clearVibesStatus(ctx);
213
218
  return;
214
219
  }
215
- publishCapacity(ctx);
220
+ installFooter(ctx);
216
221
  publishSpeedFooter(ctx);
222
+ startCapacityTimer(ctx);
217
223
  if (config.capacity.providerUsage) startProviderTimer(ctx);
218
224
  }
219
225
 
@@ -230,11 +236,13 @@ export function registerCrewVibes(pi: ExtensionAPI): void {
230
236
  clearProviderUsageCache();
231
237
  // Initialize provider from current model — model_select only fires on manual switch
232
238
  currentProvider = (ctx.model as { provider?: string } | undefined)?.provider;
239
+ currentThinkingLevel = undefined;
233
240
  if (!config.enabled) {
241
+ setFooter(ctx, undefined);
234
242
  clearVibesStatus(ctx);
235
243
  return;
236
244
  }
237
- publishCapacity(ctx);
245
+ installFooter(ctx);
238
246
  publishSpeedFooter(ctx);
239
247
  startCapacityTimer(ctx);
240
248
  startProviderTimer(ctx);
@@ -285,7 +293,7 @@ export function registerCrewVibes(pi: ExtensionAPI): void {
285
293
 
286
294
  pi.on("message_end", (event, ctx) => {
287
295
  if (!isAssistantMessage(event.message)) return;
288
- publishCapacity(ctx);
296
+ refreshFooter(ctx);
289
297
  if (!config.enabled || !config.speed.enabled || !speedTracker.isStreaming) return;
290
298
 
291
299
  const completed = speedTracker.finishMessage(assistantUsageOutput(event.message) ?? 0, assistantStopReason(event.message));
@@ -314,16 +322,23 @@ export function registerCrewVibes(pi: ExtensionAPI): void {
314
322
  pi.on("model_select", (event, ctx) => {
315
323
  currentProvider = (event as { model?: { provider?: string } }).model?.provider;
316
324
  clearProviderUsageCache();
317
- publishCapacity(ctx);
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);
318
332
  });
319
- pi.on("session_compact", (_event, ctx) => publishCapacity(ctx));
320
- pi.on("session_tree", (_event, ctx) => publishCapacity(ctx));
333
+ pi.on("session_compact", (_event, ctx) => refreshFooter(ctx));
334
+ pi.on("session_tree", (_event, ctx) => refreshFooter(ctx));
321
335
 
322
336
  pi.on("session_shutdown", (_event, ctx) => {
323
337
  stopLiveTimer();
324
338
  stopFooterTimer();
325
339
  stopCapacityTimer();
326
340
  stopProviderTimer();
341
+ setFooter(ctx, undefined);
327
342
  clearVibesStatus(ctx);
328
343
  });
329
344