@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.
- package/README.md +3 -2
- package/docs/architecture.md +8 -5
- package/licenses/README.md +9 -7
- package/package.json +2 -2
- package/packages/footer/README.md +15 -0
- package/packages/footer/index.ts +37 -0
- package/packages/footer/package.json +32 -0
- package/packages/footer/src/protocol.ts +10 -0
- package/packages/footer/src/quota.ts +26 -0
- package/packages/footer/src/telemetry.ts +59 -0
- package/packages/pi-stardeck/README.md +7 -6
- package/packages/pi-stardeck/src/deck.ts +79 -44
- package/packages/status/package.json +1 -1
- package/src/health.ts +0 -1
- package/src/index.ts +3 -4
- package/licenses/pi-fancy-footer-LICENSE +0 -21
- package/packages/pi-fancy-footer-full-palette/LICENSE +0 -21
- package/packages/pi-fancy-footer-full-palette/LOCAL-CHANGES.md +0 -41
- package/packages/pi-fancy-footer-full-palette/README.md +0 -468
- package/packages/pi-fancy-footer-full-palette/package.json +0 -52
- package/packages/pi-fancy-footer-full-palette/src/api.ts +0 -204
- package/packages/pi-fancy-footer-full-palette/src/config-editor.ts +0 -634
- package/packages/pi-fancy-footer-full-palette/src/config.ts +0 -852
- package/packages/pi-fancy-footer-full-palette/src/data-widgets.ts +0 -259
- package/packages/pi-fancy-footer-full-palette/src/git.ts +0 -130
- package/packages/pi-fancy-footer-full-palette/src/index.ts +0 -479
- package/packages/pi-fancy-footer-full-palette/src/layout.ts +0 -309
- package/packages/pi-fancy-footer-full-palette/src/provider-status.ts +0 -1466
- package/packages/pi-fancy-footer-full-palette/src/render.ts +0 -1061
- 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
|
-
}
|