@aemonge-dev/pi-stardeck 0.5.0 → 0.7.0

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.
Files changed (30) hide show
  1. package/README.md +3 -2
  2. package/docs/architecture.md +8 -5
  3. package/licenses/README.md +9 -7
  4. package/package.json +2 -2
  5. package/packages/footer/README.md +15 -0
  6. package/packages/footer/index.ts +37 -0
  7. package/packages/footer/package.json +32 -0
  8. package/packages/footer/src/protocol.ts +10 -0
  9. package/packages/footer/src/quota.ts +26 -0
  10. package/packages/footer/src/telemetry.ts +59 -0
  11. package/packages/pi-stardeck/README.md +7 -6
  12. package/packages/pi-stardeck/src/deck.ts +79 -44
  13. package/packages/status/package.json +1 -1
  14. package/src/health.ts +0 -1
  15. package/src/index.ts +3 -4
  16. package/licenses/pi-fancy-footer-LICENSE +0 -21
  17. package/packages/pi-fancy-footer-full-palette/LICENSE +0 -21
  18. package/packages/pi-fancy-footer-full-palette/LOCAL-CHANGES.md +0 -41
  19. package/packages/pi-fancy-footer-full-palette/README.md +0 -468
  20. package/packages/pi-fancy-footer-full-palette/package.json +0 -52
  21. package/packages/pi-fancy-footer-full-palette/src/api.ts +0 -204
  22. package/packages/pi-fancy-footer-full-palette/src/config-editor.ts +0 -634
  23. package/packages/pi-fancy-footer-full-palette/src/config.ts +0 -852
  24. package/packages/pi-fancy-footer-full-palette/src/data-widgets.ts +0 -259
  25. package/packages/pi-fancy-footer-full-palette/src/git.ts +0 -130
  26. package/packages/pi-fancy-footer-full-palette/src/index.ts +0 -479
  27. package/packages/pi-fancy-footer-full-palette/src/layout.ts +0 -309
  28. package/packages/pi-fancy-footer-full-palette/src/provider-status.ts +0 -1466
  29. package/packages/pi-fancy-footer-full-palette/src/render.ts +0 -1061
  30. package/packages/pi-fancy-footer-full-palette/src/shared.ts +0 -903
@@ -1,1061 +0,0 @@
1
- import type { AssistantMessage } from "@earendil-works/pi-ai";
2
- import type {
3
- ExtensionContext,
4
- SessionEntry,
5
- Theme,
6
- } from "@earendil-works/pi-coding-agent";
7
- import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
8
- import {
9
- FOOTER_WIDGET_META,
10
- MAX_WIDGET_MIN_WIDTH,
11
- MAX_WIDGET_POSITION,
12
- MAX_WIDGET_ROW,
13
- type BuiltInFooterWidgetId,
14
- type FooterConfigSnapshot,
15
- type FooterWidgetConfigOverride,
16
- type FooterIconFamily,
17
- type FooterMetrics,
18
- type FooterWidget,
19
- type FooterWidgetSize,
20
- type GitCounts,
21
- type GitInfo,
22
- type PreparedWidget,
23
- type PreparedWidgetGroup,
24
- type ProviderStatusConfigSnapshot,
25
- type ProviderStatusSnapshot,
26
- type ProviderStatusWindow,
27
- type SessionUsageMetrics,
28
- type ThinkingLevel,
29
- type WidgetRenderContext,
30
- type GaugeColorsSnapshot,
31
- type GaugeStyleDef,
32
- buildGauge,
33
- gaugeColorFor,
34
- gaugeSeverity,
35
- clampInt,
36
- closeOpenTerminalHyperlinks,
37
- formatThinkingLevel,
38
- formatTerminalHyperlink,
39
- formatTokens,
40
- formatGaugePercent,
41
- getThinkingLevelFromEntries,
42
- getGaugeStyle,
43
- getDefaultWidgetIcon,
44
- getStatuslineSymbols,
45
- normalizeModel,
46
- normalizePath,
47
- toNumber,
48
- } from "./shared.ts";
49
- import {
50
- resolveDataWidgetIcon,
51
- type NormalizedFancyFooterDataWidget,
52
- } from "./data-widgets.ts";
53
- import {
54
- buildProviderStatusGauge,
55
- formatProviderStatusText,
56
- isProviderStatusRelevantToModel,
57
- projectProviderStatusForModel,
58
- providerStatusColor,
59
- resetCountdownText,
60
- } from "./provider-status.ts";
61
-
62
- function providerQuotaPercent(snapshot: ProviderStatusSnapshot): number | undefined {
63
- const known = [snapshot.primary, snapshot.secondary].filter(
64
- (window): window is ProviderStatusWindow =>
65
- window !== undefined && !window.usageUnknown,
66
- );
67
- if (known.length === 0) return undefined;
68
- return Math.max(...known.map((window) => window.usedPercent));
69
- }
70
-
71
- export function providerQuotaBatteryGlyph(usedPercent: number): string {
72
- const used = Number.isFinite(usedPercent)
73
- ? Math.max(0, Math.min(100, usedPercent))
74
- : 0;
75
- if (used <= 20) return "";
76
- if (used <= 40) return "";
77
- if (used <= 60) return "";
78
- if (used <= 80) return "";
79
- return "";
80
- }
81
-
82
- function buildProviderStatusPart(
83
- snapshot: ProviderStatusSnapshot,
84
- config: Pick<
85
- ProviderStatusConfigSnapshot,
86
- "display" | "showCredits" | "showReset" | "resetMinUsedPercent"
87
- >,
88
- barStyle: GaugeStyleDef,
89
- gaugeWidth: number,
90
- gaugeColors: GaugeColorsSnapshot,
91
- theme: Theme,
92
- defaultTextColor: WidgetRenderContext["defaultTextColor"],
93
- nowMs: number,
94
- ): string {
95
- if (config.display === "percent") {
96
- const percent = providerQuotaPercent(snapshot);
97
- if (percent === undefined) return "";
98
- const color = gaugeSeverity(100 - percent);
99
- return theme.fg(
100
- color,
101
- `› ${providerQuotaBatteryGlyph(percent)} ${formatGaugePercent(percent)}`,
102
- );
103
- }
104
-
105
- const text = formatProviderStatusText(snapshot, config, nowMs);
106
- if (!text) return "";
107
-
108
- const gauge =
109
- config.display === "gauge"
110
- ? buildProviderStatusGauge(snapshot, barStyle, gaugeWidth)
111
- : [];
112
- let body: string;
113
- if (gauge.length > 0) {
114
- const pieces = gauge.map((segment) => {
115
- let piece =
116
- theme.fg(defaultTextColor, `${segment.label} `) +
117
- theme.fg(gaugeColorFor(segment.color, gaugeColors), segment.filledGlyphs) +
118
- theme.fg(gaugeColors.ok, segment.emptyGlyphs) +
119
- theme.fg(defaultTextColor, ` ${segment.percentText}`);
120
- const reset = resetCountdownText(segment, segment.role, config, nowMs);
121
- if (reset) piece += theme.fg(gaugeColors.ok, ` ${reset}`);
122
- return piece;
123
- });
124
- body = pieces.join(" ");
125
- if (config.showCredits && snapshot.credits) {
126
- body += theme.fg(defaultTextColor, ` cr:${snapshot.credits}`);
127
- }
128
- } else {
129
- const color = providerStatusColor(snapshot);
130
- body = theme.fg(
131
- color === "dim" ? defaultTextColor : gaugeColorFor(color, gaugeColors),
132
- text,
133
- );
134
- }
135
-
136
- return body;
137
- }
138
-
139
- export function formatCompactCost(cost: number): string {
140
- const normalized = Number.isFinite(cost) ? Math.max(0, cost) : 0;
141
- if (normalized === 0) return "$0";
142
-
143
- const decimals =
144
- normalized >= 0.01
145
- ? 2
146
- : Math.min(6, Math.max(3, Math.ceil(-Math.log10(normalized)) + 1));
147
- return `$${normalized.toFixed(decimals).replace(/\.?0+$/, "")}`;
148
- }
149
-
150
- function getUsageData(entries: SessionEntry[]): SessionUsageMetrics {
151
- let latest: SessionUsageMetrics["latest"];
152
- let totalCost = 0;
153
- let totalCacheRead = 0;
154
- let totalCacheWrite = 0;
155
-
156
- for (const entry of entries) {
157
- if (entry.type !== "message") continue;
158
-
159
- const message = entry.message as Partial<AssistantMessage>;
160
- if (message.role !== "assistant" || !message.usage) continue;
161
-
162
- const usage = message.usage;
163
- latest = {
164
- input: Math.max(0, toNumber(usage.input)),
165
- cacheRead: Math.max(0, toNumber(usage.cacheRead)),
166
- cacheWrite: Math.max(0, toNumber(usage.cacheWrite)),
167
- cost: Math.max(0, toNumber(usage.cost?.total)),
168
- };
169
- totalCost += latest.cost;
170
- totalCacheRead += latest.cacheRead;
171
- totalCacheWrite += latest.cacheWrite;
172
- }
173
-
174
- return { latest, totalCost, totalCacheRead, totalCacheWrite };
175
- }
176
-
177
- export function collectSessionUsageMetrics(ctx: ExtensionContext): SessionUsageMetrics {
178
- return getUsageData(ctx.sessionManager.getBranch());
179
- }
180
-
181
- function contextUsedPercent(totalTokens: number, usedTokens: number): number {
182
- const total = Math.max(1, Math.floor(totalTokens));
183
- const used = Math.max(0, Math.min(total, Math.floor(usedTokens)));
184
- return (used / total) * 100;
185
- }
186
-
187
- function renderGauge(
188
- usedPercent: number,
189
- style: GaugeStyleDef,
190
- cells: number,
191
- colors: GaugeColorsSnapshot,
192
- theme: Theme,
193
- textColor: WidgetRenderContext["defaultTextColor"],
194
- ): string {
195
- const gauge = buildGauge(usedPercent, style, cells);
196
- return (
197
- theme.fg(gaugeColorFor(gauge.color, colors), gauge.filledGlyphs) +
198
- theme.fg("dim", gauge.emptyGlyphs) +
199
- theme.fg(textColor, ` ${gauge.percentText}`)
200
- );
201
- }
202
-
203
- // Renders the context bar when it grows across the row: a used-tokens label
204
- // followed by a bar that fills the allocated width.
205
- function renderGrowGauge(
206
- usedPercent: number,
207
- usedTokens: number,
208
- style: GaugeStyleDef,
209
- availableWidth: number,
210
- colors: GaugeColorsSnapshot,
211
- theme: Theme,
212
- textColor: WidgetRenderContext["defaultTextColor"],
213
- ): string {
214
- const label = `${formatTokens(usedTokens)} `;
215
- const cells = Math.max(1, Math.floor(availableWidth) - visibleWidth(label));
216
- const gauge = buildGauge(usedPercent, style, cells);
217
- return (
218
- theme.fg(textColor, label) +
219
- theme.fg(gaugeColorFor(gauge.color, colors), gauge.filledGlyphs) +
220
- theme.fg("dim", gauge.emptyGlyphs)
221
- );
222
- }
223
-
224
- function buildGitStatus(
225
- counts: GitCounts,
226
- iconFamily: FooterIconFamily,
227
- ): Pick<FooterMetrics, "gitStatusSymbol" | "gitStatusText"> {
228
- const symbols = getStatuslineSymbols(iconFamily);
229
-
230
- if (counts.ahead > 0 && counts.behind > 0) {
231
- return {
232
- gitStatusSymbol: symbols.gitDiverged,
233
- gitStatusText: `${counts.ahead}/${counts.behind}`,
234
- };
235
- }
236
- if (counts.ahead > 0) {
237
- return {
238
- gitStatusSymbol: symbols.gitAhead,
239
- gitStatusText: `${counts.ahead}`,
240
- };
241
- }
242
- if (counts.behind > 0) {
243
- return {
244
- gitStatusSymbol: symbols.gitBehind,
245
- gitStatusText: `${counts.behind}`,
246
- };
247
- }
248
- return { gitStatusSymbol: "", gitStatusText: "" };
249
- }
250
-
251
- function resolveGitStatusSymbolColor(
252
- symbol: string,
253
- configuredColor: FooterConfigSnapshot["defaultIconColor"],
254
- iconFamily: FooterIconFamily,
255
- ): FooterConfigSnapshot["defaultIconColor"] {
256
- if (configuredColor !== "text") return configuredColor;
257
-
258
- const symbols = getStatuslineSymbols(iconFamily);
259
- if (symbol === symbols.gitBehind) return "warning";
260
- if (symbol === symbols.gitAhead || symbol === symbols.gitDiverged) return "accent";
261
- return configuredColor;
262
- }
263
-
264
- function truncateFooterText(text: string, maxWidth: number, ellipsis = "..."): string {
265
- return closeOpenTerminalHyperlinks(
266
- truncateToWidth(text, maxWidth, ellipsis),
267
- `\x1b[0m${ellipsis}`,
268
- );
269
- }
270
-
271
- function widgetKey(widget: FooterWidget): string {
272
- return `${widget.location.row}:${widget.id}`;
273
- }
274
-
275
- function resolveWidgetSize(
276
- size: FooterWidgetSize | undefined,
277
- renderCtx: WidgetRenderContext,
278
- ): number {
279
- if (typeof size === "number") {
280
- return clampInt(size, 0, MAX_WIDGET_MIN_WIDTH);
281
- }
282
- if (typeof size === "function") {
283
- return clampInt(size(renderCtx), 0, MAX_WIDGET_MIN_WIDTH);
284
- }
285
- return 0;
286
- }
287
-
288
- function isWidgetVisible(
289
- widget: FooterWidget,
290
- renderCtx: WidgetRenderContext,
291
- ): boolean {
292
- if (!widget.visible) return true;
293
- return widget.visible(renderCtx);
294
- }
295
-
296
- function renderWidget(
297
- widget: FooterWidget,
298
- renderCtx: WidgetRenderContext,
299
- allocatedWidth?: number,
300
- ): string {
301
- if (!isWidgetVisible(widget, renderCtx)) return "";
302
-
303
- const hasIcon = Boolean(widget.icon?.text);
304
- const iconText = widget.icon?.text ?? "";
305
- const iconWidth = hasIcon ? visibleWidth(iconText) : 0;
306
-
307
- const maxTotalWidth =
308
- allocatedWidth === undefined ? undefined : Math.max(0, Math.floor(allocatedWidth));
309
- const contentWidth =
310
- maxTotalWidth === undefined
311
- ? undefined
312
- : Math.max(0, maxTotalWidth - iconWidth - (hasIcon ? 1 : 0));
313
-
314
- const rawText = widget.renderText(renderCtx, contentWidth);
315
- if (!rawText && !hasIcon) return "";
316
- const styledText = widget.styled
317
- ? rawText
318
- : renderCtx.theme.fg(widget.textColor ?? "dim", rawText);
319
-
320
- const styledIcon =
321
- hasIcon && widget.icon ? renderCtx.theme.fg(widget.icon.color, iconText) : "";
322
-
323
- const iconGap = hasIcon && rawText ? " " : "";
324
- const combined = `${styledIcon}${iconGap}${styledText}`;
325
- const linked = widget.href
326
- ? formatTerminalHyperlink(widget.href, combined)
327
- : combined;
328
- if (maxTotalWidth === undefined) return linked;
329
- return truncateFooterText(linked, maxTotalWidth, "");
330
- }
331
-
332
- function prepareWidgetGroup(
333
- widgets: FooterWidget[],
334
- renderCtx: WidgetRenderContext,
335
- ): PreparedWidgetGroup {
336
- const sorted = [...widgets].sort((a, b) => a.location.position - b.location.position);
337
- const prepared: PreparedWidget[] = [];
338
-
339
- for (const widget of sorted) {
340
- const fill = widget.fill ?? "none";
341
- if (fill === "grow") {
342
- prepared.push({
343
- widget,
344
- fill,
345
- minWidth: resolveWidgetSize(widget.minWidth, renderCtx),
346
- fixedText: "",
347
- fixedWidth: 0,
348
- });
349
- continue;
350
- }
351
-
352
- const renderedText = renderWidget(widget, renderCtx);
353
- const renderedWidth = visibleWidth(renderedText);
354
- if (renderedWidth <= 0) continue;
355
-
356
- const fixedWidth = Math.max(
357
- renderedWidth,
358
- resolveWidgetSize(widget.minWidth, renderCtx),
359
- );
360
- const fixedText = `${renderedText}${" ".repeat(fixedWidth - renderedWidth)}`;
361
- prepared.push({
362
- widget,
363
- fill,
364
- minWidth: 0,
365
- fixedText,
366
- fixedWidth,
367
- });
368
- }
369
-
370
- const gaps = Math.max(0, prepared.length - 1);
371
- const contentMinWidth = prepared.reduce((acc, item) => {
372
- if (item.fill === "grow") return acc + item.minWidth;
373
- return acc + item.fixedWidth;
374
- }, 0);
375
-
376
- return {
377
- widgets: prepared,
378
- minWidth: contentMinWidth + gaps,
379
- };
380
- }
381
-
382
- function renderGroup(
383
- group: PreparedWidgetGroup,
384
- renderCtx: WidgetRenderContext,
385
- fillExtras: Map<string, number>,
386
- ): string {
387
- const parts: string[] = [];
388
-
389
- for (const item of group.widgets) {
390
- if (item.fill === "grow") {
391
- const extra = fillExtras.get(widgetKey(item.widget)) ?? 0;
392
- const allocatedWidth = item.minWidth + extra;
393
- const rendered = renderWidget(item.widget, renderCtx, allocatedWidth);
394
- if (rendered) parts.push(rendered);
395
- continue;
396
- }
397
-
398
- if (item.fixedText) parts.push(item.fixedText);
399
- }
400
-
401
- return parts.join(" ");
402
- }
403
-
404
- const ALIGNED_GROUP_GAP = 4;
405
- const RAMONA_AVATAR = "󰠭";
406
- const RAMONA_AVATAR_SLOT_WIDTH = 4;
407
- const RAMONA_AVATAR_MIN_TERMINAL_WIDTH = 100;
408
-
409
- function composeAlignedRow(
410
- width: number,
411
- left: string,
412
- middle: string,
413
- right: string,
414
- theme: Theme,
415
- insetWideRight = true,
416
- ): string {
417
- const leftWidth = visibleWidth(left);
418
- const middleWidth = visibleWidth(middle);
419
- const rightWidth = visibleWidth(right);
420
-
421
- if (
422
- insetWideRight &&
423
- width >= RAMONA_AVATAR_MIN_TERMINAL_WIDTH &&
424
- left &&
425
- !middle &&
426
- right
427
- ) {
428
- const effectiveWidth = width - RAMONA_AVATAR_SLOT_WIDTH;
429
- if (leftWidth + rightWidth + ALIGNED_GROUP_GAP <= effectiveWidth) {
430
- const avatarSlot = ` ${theme.fg("thinkingHigh", "⟨")} ${theme.fg("customMessageLabel", RAMONA_AVATAR)}`;
431
- return `${composeAlignedRow(
432
- effectiveWidth,
433
- left,
434
- "",
435
- right,
436
- theme,
437
- false,
438
- )}${avatarSlot}`;
439
- }
440
- }
441
-
442
- const fallback = () => {
443
- const joined = [left, middle, right].filter((part) => part).join(" ");
444
- return truncateFooterText(joined, width, theme.fg("dim", "..."));
445
- };
446
-
447
- if (!middle) {
448
- if (left && right) {
449
- if (leftWidth + rightWidth + ALIGNED_GROUP_GAP > width) return fallback();
450
- const gap = Math.max(ALIGNED_GROUP_GAP, width - leftWidth - rightWidth);
451
- return truncateFooterText(
452
- `${left}${" ".repeat(gap)}${right}`,
453
- width,
454
- theme.fg("dim", "..."),
455
- );
456
- }
457
-
458
- if (left) return truncateFooterText(left, width, theme.fg("dim", "..."));
459
- if (right) {
460
- if (rightWidth > width) return fallback();
461
- return `${" ".repeat(width - rightWidth)}${right}`;
462
- }
463
- return "";
464
- }
465
-
466
- if (!left && !right) {
467
- if (middleWidth > width) return fallback();
468
- const start = Math.max(0, Math.floor((width - middleWidth) / 2));
469
- return truncateFooterText(
470
- `${" ".repeat(start)}${middle}`,
471
- width,
472
- theme.fg("dim", "..."),
473
- );
474
- }
475
-
476
- const gapLeftMiddle = left ? ALIGNED_GROUP_GAP : 0;
477
- const gapMiddleRight = right ? ALIGNED_GROUP_GAP : 0;
478
-
479
- const leftBoundary = leftWidth + gapLeftMiddle;
480
- const rightBoundary = width - rightWidth - gapMiddleRight;
481
- if (rightBoundary < leftBoundary) return fallback();
482
-
483
- const slotWidth = rightBoundary - leftBoundary;
484
- let middleText = middle;
485
- if (middleWidth > slotWidth) {
486
- middleText = truncateFooterText(middleText, slotWidth, "");
487
- }
488
-
489
- const middleTextWidth = visibleWidth(middleText);
490
- const middleStart =
491
- leftBoundary + Math.max(0, Math.floor((slotWidth - middleTextWidth) / 2));
492
-
493
- const preMiddleSpaces = Math.max(0, middleStart - leftBoundary);
494
- const postMiddleSpaces = Math.max(0, rightBoundary - (middleStart + middleTextWidth));
495
-
496
- let out = "";
497
- if (left) {
498
- out += left;
499
- out += " ";
500
- }
501
- out += " ".repeat(preMiddleSpaces);
502
- out += middleText;
503
- out += " ".repeat(postMiddleSpaces);
504
- if (right) {
505
- out += " ";
506
- out += right;
507
- }
508
-
509
- return truncateFooterText(out, width, theme.fg("dim", "..."));
510
- }
511
-
512
- function computeFooterMetrics(
513
- ctx: ExtensionContext,
514
- git: GitInfo,
515
- fallbackThinkingLevel: ThinkingLevel,
516
- usageMetrics: SessionUsageMetrics,
517
- iconFamily: FooterIconFamily,
518
- ): FooterMetrics {
519
- const { latest, totalCost, totalCacheRead, totalCacheWrite } = usageMetrics;
520
-
521
- const contextUsage = ctx.getContextUsage();
522
- const totalTokens = Math.max(
523
- 1,
524
- Math.floor(
525
- toNumber(contextUsage?.contextWindow ?? ctx.model?.contextWindow ?? 200_000),
526
- ),
527
- );
528
-
529
- const contextTokensRaw = contextUsage?.tokens;
530
- const contextTokensKnown =
531
- typeof contextTokensRaw === "number" && Number.isFinite(contextTokensRaw);
532
- const contextTokens = contextTokensKnown
533
- ? Math.max(0, Math.floor(contextTokensRaw))
534
- : 0;
535
- const contextUsageUnknown =
536
- contextUsage?.tokens === null || contextUsage?.percent === null;
537
-
538
- const usedRaw = contextUsage?.percent;
539
- const hasUsedPercent =
540
- typeof usedRaw === "number" && Number.isFinite(usedRaw) && usedRaw >= 0;
541
- const usedPct = Math.max(
542
- 0,
543
- Math.min(
544
- 100,
545
- Math.round(
546
- hasUsedPercent ? usedRaw : (contextTokens * 100) / Math.max(1, totalTokens),
547
- ),
548
- ),
549
- );
550
-
551
- let inputTokens = latest ? latest.input : contextTokens;
552
- let cacheTokens = latest ? latest.cacheRead + latest.cacheWrite : 0;
553
-
554
- const minUsedTokens = Math.max(0, contextTokens);
555
- if (inputTokens + cacheTokens < minUsedTokens) {
556
- inputTokens += minUsedTokens - (inputTokens + cacheTokens);
557
- }
558
-
559
- const usageFromLatest = Math.max(0, Math.floor(inputTokens + cacheTokens));
560
- const usageFromPercent = hasUsedPercent
561
- ? Math.floor((usedPct * totalTokens) / 100)
562
- : 0;
563
- // Right after compaction, Pi deliberately reports null until the next model
564
- // response supplies post-compaction usage. Empty the gauge instead of falling
565
- // back to the latest assistant usage, which still describes the old context.
566
- let usedTokensForBar: number;
567
- if (contextUsageUnknown) {
568
- usedTokensForBar = 0;
569
- } else if (contextTokensKnown) {
570
- usedTokensForBar = contextTokens;
571
- } else {
572
- usedTokensForBar = Math.max(usageFromPercent, usageFromLatest);
573
- }
574
-
575
- const model = normalizeModel(ctx.model?.name || ctx.model?.id || "Claude");
576
- // Providers carry a display name ("Anthropic", "OpenAI Codex"); fall back to
577
- // the raw id for providers defined only in models.json.
578
- const providerId = ctx.model?.provider ?? "";
579
- const provider = providerId
580
- ? (ctx.modelRegistry?.getProviderDisplayName(providerId) ?? providerId)
581
- : "";
582
- const thinking = formatThinkingLevel(
583
- getThinkingLevelFromEntries(ctx.sessionManager.getBranch(), fallbackThinkingLevel),
584
- );
585
-
586
- const latestPromptTokens = latest
587
- ? latest.input + latest.cacheRead + latest.cacheWrite
588
- : 0;
589
- const cacheHitRatePercent =
590
- latest && latestPromptTokens > 0
591
- ? (latest.cacheRead / latestPromptTokens) * 100
592
- : undefined;
593
-
594
- return {
595
- model,
596
- provider,
597
- thinking,
598
- totalTokens,
599
- usedTokensForBar,
600
- totalCost,
601
- totalCacheRead,
602
- totalCacheWrite,
603
- cacheHitRatePercent,
604
- locationText: git.repository || normalizePath(ctx.cwd),
605
- branch: git.branch,
606
- commit: git.commit,
607
- added: git.added,
608
- removed: git.removed,
609
- ...buildGitStatus(git.counts, iconFamily),
610
- };
611
- }
612
-
613
- function baseWidgetDefaults(
614
- widgetId: BuiltInFooterWidgetId,
615
- iconFamily: FooterIconFamily,
616
- ): Pick<
617
- FooterWidget,
618
- "id" | "location" | "align" | "fill" | "defaultEnabled" | "icon" | "textColor"
619
- > {
620
- const defaults = FOOTER_WIDGET_META[widgetId].defaults;
621
-
622
- return {
623
- id: widgetId,
624
- location: {
625
- row: defaults.row,
626
- position: defaults.position,
627
- },
628
- align: defaults.align,
629
- fill: defaults.fill,
630
- defaultEnabled: defaults.enabled,
631
- icon: getDefaultWidgetIcon(widgetId, iconFamily),
632
- textColor: "dim",
633
- };
634
- }
635
-
636
- function buildFooterWidgets(
637
- iconFamily: FooterIconFamily,
638
- barStyle: GaugeStyleDef,
639
- ): FooterWidget[] {
640
- return [
641
- {
642
- ...baseWidgetDefaults("provider", iconFamily),
643
- visible: ({ metrics }) => metrics.provider !== "",
644
- renderText: ({ metrics }) => metrics.provider,
645
- },
646
- {
647
- ...baseWidgetDefaults("model", iconFamily),
648
- renderText: ({ metrics }) => metrics.model,
649
- },
650
- {
651
- ...baseWidgetDefaults("thinking", iconFamily),
652
- visible: ({ metrics }) => metrics.thinking !== "",
653
- renderText: ({ metrics }) => metrics.thinking,
654
- },
655
- {
656
- ...baseWidgetDefaults("context-capacity", iconFamily),
657
- renderText: ({ metrics }) => formatTokens(metrics.totalTokens),
658
- },
659
- {
660
- ...baseWidgetDefaults("context-bar", iconFamily),
661
- minWidth: ({ width }) => (width >= 100 ? 12 : width >= 70 ? 8 : 4),
662
- styled: true,
663
- renderText: (
664
- { metrics, theme, gaugeWidth, gaugeColors, defaultTextColor },
665
- availableWidth,
666
- ) => {
667
- const usedPercent = contextUsedPercent(
668
- metrics.totalTokens,
669
- metrics.usedTokensForBar,
670
- );
671
- if (availableWidth === undefined) {
672
- return renderGauge(
673
- usedPercent,
674
- barStyle,
675
- gaugeWidth,
676
- gaugeColors,
677
- theme,
678
- defaultTextColor,
679
- );
680
- }
681
- return renderGrowGauge(
682
- usedPercent,
683
- metrics.usedTokensForBar,
684
- barStyle,
685
- availableWidth,
686
- gaugeColors,
687
- theme,
688
- defaultTextColor,
689
- );
690
- },
691
- },
692
- {
693
- ...baseWidgetDefaults("total-cost", iconFamily),
694
- styled: true,
695
- visible: ({ width, metrics }) => width >= 60 && metrics.totalCost > 0,
696
- renderText: ({ metrics, theme }) =>
697
- theme.fg("accent", `⟩ ${formatCompactCost(metrics.totalCost)}`),
698
- },
699
- {
700
- ...baseWidgetDefaults("cache-read", iconFamily),
701
- visible: ({ width, metrics }) => width >= 60 && metrics.totalCacheRead > 0,
702
- renderText: ({ metrics }) => formatTokens(metrics.totalCacheRead),
703
- },
704
- {
705
- ...baseWidgetDefaults("cache-write", iconFamily),
706
- visible: ({ width, metrics }) => width >= 60 && metrics.totalCacheWrite > 0,
707
- renderText: ({ metrics }) => formatTokens(metrics.totalCacheWrite),
708
- },
709
- {
710
- ...baseWidgetDefaults("cache-hit-rate", iconFamily),
711
- styled: true,
712
- visible: ({ width, metrics }) =>
713
- width >= 60 &&
714
- (metrics.totalCacheRead > 0 || metrics.totalCacheWrite > 0) &&
715
- metrics.cacheHitRatePercent !== undefined,
716
- renderText: ({ metrics, theme }) =>
717
- theme.fg(
718
- "accent",
719
- `› 󰍛 ${formatGaugePercent(metrics.cacheHitRatePercent ?? 0)}`,
720
- ),
721
- },
722
- {
723
- ...baseWidgetDefaults("location", iconFamily),
724
- renderText: ({ metrics }) => metrics.locationText,
725
- },
726
- {
727
- ...baseWidgetDefaults("branch", iconFamily),
728
- visible: ({ metrics }) => metrics.branch !== "",
729
- renderText: ({ metrics }) => metrics.branch,
730
- },
731
- {
732
- ...baseWidgetDefaults("commit", iconFamily),
733
- visible: ({ metrics }) => metrics.commit !== "",
734
- renderText: ({ metrics }) => metrics.commit,
735
- },
736
- {
737
- ...baseWidgetDefaults("provider-status", iconFamily),
738
- styled: true,
739
- visible: ({ providerStatuses, providerStatusConfig, nowMs }) =>
740
- providerStatuses.some((snapshot) =>
741
- providerStatusConfig.display === "percent"
742
- ? providerQuotaPercent(snapshot) !== undefined
743
- : formatProviderStatusText(snapshot, providerStatusConfig, nowMs) !== "",
744
- ),
745
- renderText: ({
746
- providerStatuses,
747
- providerStatusConfig,
748
- theme,
749
- gaugeWidth,
750
- gaugeColors,
751
- defaultTextColor,
752
- nowMs,
753
- }) => {
754
- const parts: string[] = [];
755
- for (const snapshot of providerStatuses) {
756
- const part = buildProviderStatusPart(
757
- snapshot,
758
- providerStatusConfig,
759
- barStyle,
760
- gaugeWidth,
761
- gaugeColors,
762
- theme,
763
- defaultTextColor,
764
- nowMs,
765
- );
766
- if (part) parts.push(part);
767
- }
768
- const text = parts.join(" ");
769
- if (!text) return "";
770
- return providerStatusConfig.display === "percent"
771
- ? text
772
- : `${theme.fg(defaultTextColor, "qta")} ${text}`;
773
- },
774
- },
775
- {
776
- ...baseWidgetDefaults("diff-added", iconFamily),
777
- visible: ({ metrics }) => metrics.added > 0,
778
- renderText: ({ metrics }) => `${metrics.added}`,
779
- },
780
- {
781
- ...baseWidgetDefaults("diff-removed", iconFamily),
782
- visible: ({ metrics }) => metrics.removed > 0,
783
- renderText: ({ metrics }) => `${metrics.removed}`,
784
- },
785
- {
786
- ...baseWidgetDefaults("git-status", iconFamily),
787
- styled: true,
788
- visible: ({ metrics }) => metrics.gitStatusSymbol !== "",
789
- renderText: ({ metrics, theme, defaultIconColor, defaultTextColor }) => {
790
- const symbolColor = resolveGitStatusSymbolColor(
791
- metrics.gitStatusSymbol,
792
- defaultIconColor,
793
- iconFamily,
794
- );
795
- return `${theme.fg(symbolColor, metrics.gitStatusSymbol)}${theme.fg(defaultTextColor, metrics.gitStatusText)}`;
796
- },
797
- },
798
- ];
799
- }
800
-
801
- function buildExtensionWidgets(
802
- widgets: readonly NormalizedFancyFooterDataWidget[],
803
- iconFamily: FooterIconFamily,
804
- ): FooterWidget[] {
805
- return widgets.map((widget) => {
806
- const icon = resolveDataWidgetIcon(widget.icon, iconFamily);
807
- const showIconWhenEmpty = widget.defaults.enabled !== false;
808
- return {
809
- id: widget.id,
810
- location: {
811
- row: clampInt(widget.defaults.row, 0, MAX_WIDGET_ROW),
812
- position: clampInt(widget.defaults.position, 0, MAX_WIDGET_POSITION),
813
- },
814
- align: widget.defaults.align,
815
- fill: widget.defaults.fill,
816
- defaultEnabled: widget.defaults.enabled,
817
- minWidth:
818
- widget.defaults.minWidth !== undefined
819
- ? clampInt(widget.defaults.minWidth, 0, MAX_WIDGET_MIN_WIDTH)
820
- : undefined,
821
- icon,
822
- href: widget.content.href,
823
- preferredIconColor: widget.icon ? widget.icon.color : undefined,
824
- preferredTextColor: widget.preferredTextColor,
825
- styled: widget.bold === true,
826
- forceVisibleWhenEnabled: false,
827
- visible: () =>
828
- widget.content.text !== "" || (showIconWhenEmpty && icon !== undefined),
829
- renderText: ({ theme, defaultTextColor }, availableWidth) => {
830
- const rendered = widget.bold
831
- ? theme.fg(defaultTextColor, theme.bold(widget.content.text))
832
- : widget.content.text;
833
- return availableWidth === undefined
834
- ? rendered
835
- : truncateFooterText(rendered, availableWidth, "...");
836
- },
837
- };
838
- });
839
- }
840
-
841
- function applyWidgetConfigOverrides(
842
- widgets: FooterWidget[],
843
- overrides: Record<string, FooterWidgetConfigOverride | undefined>,
844
- defaultTextColor: FooterConfigSnapshot["defaultTextColor"],
845
- defaultIconColor: FooterConfigSnapshot["defaultIconColor"],
846
- iconFamily: FooterIconFamily,
847
- ): FooterWidget[] {
848
- return widgets.map((widget) => {
849
- const override = overrides[widget.id] ?? {};
850
-
851
- const location = {
852
- row: clampInt(override.row ?? widget.location.row, 0, MAX_WIDGET_ROW),
853
- position: clampInt(
854
- override.position ?? widget.location.position,
855
- 0,
856
- MAX_WIDGET_POSITION,
857
- ),
858
- };
859
-
860
- const textColor =
861
- override.textColor ?? widget.preferredTextColor ?? defaultTextColor;
862
- const resolvedIconColor =
863
- override.iconColor ?? widget.preferredIconColor ?? defaultIconColor;
864
-
865
- let icon = widget.icon;
866
- if (override.icon === "hide") {
867
- icon = undefined;
868
- } else if (!icon) {
869
- icon = getDefaultWidgetIcon(widget.id, iconFamily);
870
- }
871
-
872
- if (icon) {
873
- icon = {
874
- ...icon,
875
- color: resolvedIconColor,
876
- };
877
- }
878
-
879
- let visible = widget.visible;
880
- let renderText = widget.renderText;
881
-
882
- const baseRenderText = renderText;
883
- renderText = (renderCtx, availableWidth) =>
884
- baseRenderText(
885
- {
886
- ...renderCtx,
887
- defaultIconColor: resolvedIconColor,
888
- defaultTextColor: textColor,
889
- },
890
- availableWidth,
891
- );
892
-
893
- if ((override.enabled ?? widget.defaultEnabled ?? true) === false) {
894
- visible = () => false;
895
- } else if (override.enabled === true && widget.forceVisibleWhenEnabled !== false) {
896
- visible = () => true;
897
- const originalRenderText = renderText;
898
- renderText = (renderCtx, availableWidth) => {
899
- const out = originalRenderText(renderCtx, availableWidth);
900
- if (visibleWidth(out) > 0) return out;
901
- return widget.styled ? renderCtx.theme.fg("dim", "·") : "·";
902
- };
903
- }
904
-
905
- return {
906
- ...widget,
907
- location,
908
- align: override.align ?? widget.align,
909
- fill: override.fill ?? widget.fill,
910
- minWidth:
911
- override.minWidth !== undefined
912
- ? clampInt(override.minWidth, 0, MAX_WIDGET_MIN_WIDTH)
913
- : widget.minWidth,
914
- icon,
915
- textColor,
916
- visible,
917
- renderText,
918
- };
919
- });
920
- }
921
-
922
- function renderWidgetRow(
923
- width: number,
924
- rowWidgets: FooterWidget[],
925
- renderCtx: WidgetRenderContext,
926
- insetWideRight = false,
927
- ): string {
928
- if (width <= 0 || rowWidgets.length === 0) return "";
929
-
930
- const visibleWidgets = rowWidgets.filter((widget) =>
931
- isWidgetVisible(widget, renderCtx),
932
- );
933
- if (visibleWidgets.length === 0) return "";
934
-
935
- const leftGroup = prepareWidgetGroup(
936
- visibleWidgets.filter((widget) => widget.align === "left"),
937
- renderCtx,
938
- );
939
- const middleGroup = prepareWidgetGroup(
940
- visibleWidgets.filter((widget) => widget.align === "middle"),
941
- renderCtx,
942
- );
943
- const rightGroup = prepareWidgetGroup(
944
- visibleWidgets.filter((widget) => widget.align === "right"),
945
- renderCtx,
946
- );
947
-
948
- const groups = [leftGroup, middleGroup, rightGroup];
949
- const occupiedGroups = groups.filter((group) => group.widgets.length > 0);
950
- const interGroupGaps = Math.max(0, occupiedGroups.length - 1) * ALIGNED_GROUP_GAP;
951
-
952
- const minRequiredWidth =
953
- groups.reduce((acc, group) => acc + group.minWidth, 0) + interGroupGaps;
954
-
955
- const fillExtras = new Map<string, number>();
956
-
957
- if (minRequiredWidth <= width) {
958
- const fillWidgets = groups.flatMap((group) =>
959
- group.widgets.filter((item) => item.fill === "grow"),
960
- );
961
- if (fillWidgets.length > 0) {
962
- const extraSpace = width - minRequiredWidth;
963
- const baseExtra = Math.floor(extraSpace / fillWidgets.length);
964
- let remainder = extraSpace % fillWidgets.length;
965
-
966
- for (const item of fillWidgets) {
967
- const add = baseExtra + (remainder > 0 ? 1 : 0);
968
- if (remainder > 0) remainder -= 1;
969
- fillExtras.set(widgetKey(item.widget), add);
970
- }
971
- }
972
- }
973
-
974
- const leftText = renderGroup(leftGroup, renderCtx, fillExtras);
975
- const middleText = renderGroup(middleGroup, renderCtx, fillExtras);
976
- const rightText = renderGroup(rightGroup, renderCtx, fillExtras);
977
-
978
- if (minRequiredWidth > width) {
979
- const fallback = [leftText, middleText, rightText].filter((part) => part).join(" ");
980
- return truncateFooterText(fallback, width, renderCtx.theme.fg("dim", "..."));
981
- }
982
-
983
- return composeAlignedRow(
984
- width,
985
- leftText,
986
- middleText,
987
- rightText,
988
- renderCtx.theme,
989
- insetWideRight,
990
- );
991
- }
992
-
993
- export function renderFooterLines(
994
- width: number,
995
- ctx: ExtensionContext,
996
- git: GitInfo,
997
- fallbackThinkingLevel: ThinkingLevel,
998
- theme: Theme,
999
- usageMetrics: SessionUsageMetrics,
1000
- footerConfig: FooterConfigSnapshot,
1001
- extensionWidgets: readonly NormalizedFancyFooterDataWidget[] = [],
1002
- providerStatuses: readonly ProviderStatusSnapshot[] = [],
1003
- nowMs = Date.now(),
1004
- ): string[] {
1005
- if (width <= 0) return ["", ""];
1006
-
1007
- const metrics = computeFooterMetrics(
1008
- ctx,
1009
- git,
1010
- fallbackThinkingLevel,
1011
- usageMetrics,
1012
- footerConfig.iconFamily,
1013
- );
1014
- // Model-scoped quota windows resolve here rather than at fetch time: the
1015
- // cached snapshot stays model-agnostic, so switching models updates the
1016
- // footer without another usage request.
1017
- const activeProviderStatuses = providerStatuses
1018
- .filter((snapshot) => isProviderStatusRelevantToModel(snapshot.provider, ctx.model))
1019
- .map((snapshot) => projectProviderStatusForModel(snapshot, ctx.model));
1020
- const renderCtx: WidgetRenderContext = {
1021
- width,
1022
- nowMs,
1023
- theme,
1024
- ctx,
1025
- gaugeWidth: footerConfig.gaugeWidth,
1026
- gaugeColors: footerConfig.gaugeColors,
1027
- metrics,
1028
- providerStatuses: activeProviderStatuses,
1029
- providerStatusConfig: footerConfig.providerStatus,
1030
- defaultIconColor: footerConfig.defaultIconColor,
1031
- defaultTextColor: footerConfig.defaultTextColor,
1032
- };
1033
-
1034
- const barStyle = getGaugeStyle(footerConfig.gaugeStyle);
1035
- const widgets = applyWidgetConfigOverrides(
1036
- [
1037
- ...buildFooterWidgets(footerConfig.iconFamily, barStyle),
1038
- ...buildExtensionWidgets(extensionWidgets, footerConfig.iconFamily),
1039
- ],
1040
- {
1041
- ...(footerConfig.widgets as Record<string, FooterWidgetConfigOverride>),
1042
- ...footerConfig.extensionWidgets,
1043
- },
1044
- footerConfig.defaultTextColor,
1045
- footerConfig.defaultIconColor,
1046
- footerConfig.iconFamily,
1047
- );
1048
- const visibleRows = widgets
1049
- .filter((widget) => isWidgetVisible(widget, renderCtx))
1050
- .map((widget) => widget.location.row);
1051
- const highestRow = clampInt(Math.max(0, ...visibleRows), 0, MAX_WIDGET_ROW);
1052
-
1053
- const rows: string[] = [];
1054
- for (let row = 0; row <= highestRow; row++) {
1055
- const rowWidgets = widgets.filter((widget) => widget.location.row === row);
1056
- const line = renderWidgetRow(width, rowWidgets, renderCtx, highestRow === 0);
1057
- rows.push(truncateFooterText(line, width, theme.fg("dim", "...")));
1058
- }
1059
-
1060
- return rows;
1061
- }