@eiei114/pi-sub-bar 1.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +201 -0
- package/README.md +200 -0
- package/index.ts +1103 -0
- package/package.json +39 -0
- package/src/core-settings.ts +25 -0
- package/src/dividers.ts +48 -0
- package/src/errors.ts +71 -0
- package/src/formatting.ts +937 -0
- package/src/paths.ts +21 -0
- package/src/providers/extras.ts +21 -0
- package/src/providers/metadata.ts +199 -0
- package/src/providers/settings.ts +359 -0
- package/src/providers/windows.ts +23 -0
- package/src/settings/display.ts +786 -0
- package/src/settings/menu.ts +183 -0
- package/src/settings/themes.ts +378 -0
- package/src/settings/ui.ts +1388 -0
- package/src/settings-types.ts +651 -0
- package/src/settings-ui.ts +5 -0
- package/src/settings.ts +176 -0
- package/src/share.ts +75 -0
- package/src/status.ts +103 -0
- package/src/storage.ts +61 -0
- package/src/types.ts +25 -0
- package/src/ui/keybindings.ts +92 -0
- package/src/ui/settings-list.ts +304 -0
- package/src/usage/types.ts +5 -0
- package/src/utils.ts +42 -0
- package/test/all.test.ts +6 -0
- package/test/dividers.test.ts +34 -0
- package/test/formatting.test.ts +437 -0
- package/test/keybindings.test.ts +59 -0
- package/test/providers.test.ts +42 -0
- package/test/settings.test.ts +336 -0
- package/test/status.test.ts +27 -0
- package/tsconfig.json +5 -0
|
@@ -0,0 +1,937 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* UI formatting utilities for the sub-bar extension
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { Theme } from "@mariozechner/pi-coding-agent";
|
|
6
|
+
import { truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
|
|
7
|
+
import type { RateWindow, UsageSnapshot, ProviderStatus, ModelInfo } from "./types.js";
|
|
8
|
+
import type {
|
|
9
|
+
BaseTextColor,
|
|
10
|
+
BarStyle,
|
|
11
|
+
BarType,
|
|
12
|
+
BarCharacter,
|
|
13
|
+
BarWidth,
|
|
14
|
+
ColorScheme,
|
|
15
|
+
DividerBlanks,
|
|
16
|
+
ResetTimerContainment,
|
|
17
|
+
Settings,
|
|
18
|
+
} from "./settings-types.js";
|
|
19
|
+
import { isBackgroundColor, resolveBaseTextColor, resolveDividerColor } from "./settings-types.js";
|
|
20
|
+
import { formatErrorForDisplay, isExpectedMissingData } from "./errors.js";
|
|
21
|
+
import { getStatusIcon, getStatusLabel } from "./status.js";
|
|
22
|
+
import { shouldShowWindow } from "./providers/windows.js";
|
|
23
|
+
import { getUsageExtras } from "./providers/extras.js";
|
|
24
|
+
import { normalizeTokens } from "./utils.js";
|
|
25
|
+
|
|
26
|
+
export interface UsageWindowParts {
|
|
27
|
+
label: string;
|
|
28
|
+
bar: string;
|
|
29
|
+
pct: string;
|
|
30
|
+
reset: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Context window usage info from the pi framework
|
|
35
|
+
*/
|
|
36
|
+
export interface ContextInfo {
|
|
37
|
+
tokens: number;
|
|
38
|
+
contextWindow: number;
|
|
39
|
+
percent: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
type ModelInput = ModelInfo | string | undefined;
|
|
43
|
+
|
|
44
|
+
function resolveModelInfo(model?: ModelInput): ModelInfo | undefined {
|
|
45
|
+
if (!model) return undefined;
|
|
46
|
+
return typeof model === "string" ? { id: model } : model;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function isCodexSparkModel(model?: ModelInput): boolean {
|
|
50
|
+
const tokens = normalizeTokens(typeof model === "string" ? model : model?.id ?? "");
|
|
51
|
+
return tokens.includes("codex") && tokens.includes("spark");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function isCodexSparkWindow(window: RateWindow): boolean {
|
|
55
|
+
const tokens = normalizeTokens(window.label ?? "");
|
|
56
|
+
return tokens.includes("codex") && tokens.includes("spark");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function getDisplayWindowLabel(window: RateWindow, model?: ModelInput): string {
|
|
60
|
+
if (!isCodexSparkWindow(window)) return window.label;
|
|
61
|
+
if (!isCodexSparkModel(model)) return window.label;
|
|
62
|
+
const parts = window.label.trim().split(/\s+/);
|
|
63
|
+
const suffix = parts.at(-1) ?? "";
|
|
64
|
+
if (/^\d+h$/i.test(suffix) || /^day$/i.test(suffix) || /^week$/i.test(suffix)) {
|
|
65
|
+
return suffix;
|
|
66
|
+
}
|
|
67
|
+
return window.label;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Get the characters to use for progress bars
|
|
72
|
+
*/
|
|
73
|
+
function getBarCharacters(barCharacter: BarCharacter): { filled: string; empty: string } {
|
|
74
|
+
let filled = "━";
|
|
75
|
+
let empty = "━";
|
|
76
|
+
switch (barCharacter) {
|
|
77
|
+
case "light":
|
|
78
|
+
filled = "─";
|
|
79
|
+
empty = "─";
|
|
80
|
+
break;
|
|
81
|
+
case "heavy":
|
|
82
|
+
filled = "━";
|
|
83
|
+
empty = "━";
|
|
84
|
+
break;
|
|
85
|
+
case "double":
|
|
86
|
+
filled = "═";
|
|
87
|
+
empty = "═";
|
|
88
|
+
break;
|
|
89
|
+
case "block":
|
|
90
|
+
filled = "█";
|
|
91
|
+
empty = "█";
|
|
92
|
+
break;
|
|
93
|
+
default: {
|
|
94
|
+
const raw = String(barCharacter);
|
|
95
|
+
const trimmed = raw.trim();
|
|
96
|
+
if (!trimmed) return { filled, empty };
|
|
97
|
+
const segmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
|
|
98
|
+
const segments = Array.from(segmenter.segment(raw), (entry) => entry.segment);
|
|
99
|
+
const first = segments[0] ?? trimmed[0] ?? "━";
|
|
100
|
+
const second = segments[1];
|
|
101
|
+
filled = first;
|
|
102
|
+
empty = second ?? first;
|
|
103
|
+
break;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return { filled, empty };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Get color based on percentage and color scheme
|
|
111
|
+
*/
|
|
112
|
+
function getUsageColor(
|
|
113
|
+
percent: number,
|
|
114
|
+
isRemaining: boolean,
|
|
115
|
+
colorScheme: ColorScheme,
|
|
116
|
+
errorThreshold: number = 25,
|
|
117
|
+
warningThreshold: number = 50,
|
|
118
|
+
successThreshold: number = 75
|
|
119
|
+
): "error" | "warning" | "base" | "success" {
|
|
120
|
+
if (colorScheme === "monochrome") {
|
|
121
|
+
return "base";
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// For remaining percentage (Codex style), invert the logic
|
|
125
|
+
const effectivePercent = isRemaining ? percent : 100 - percent;
|
|
126
|
+
|
|
127
|
+
if (colorScheme === "success-base-warning-error") {
|
|
128
|
+
// >75%: success, >50%: base, >25%: warning, <=25%: error
|
|
129
|
+
if (effectivePercent < errorThreshold) return "error";
|
|
130
|
+
if (effectivePercent < warningThreshold) return "warning";
|
|
131
|
+
if (effectivePercent < successThreshold) return "base";
|
|
132
|
+
return "success";
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// base-warning-error (default)
|
|
136
|
+
// >50%: base, >25%: warning, <=25%: error
|
|
137
|
+
if (effectivePercent < errorThreshold) return "error";
|
|
138
|
+
if (effectivePercent < warningThreshold) return "warning";
|
|
139
|
+
return "base";
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function clampPercent(value: number): number {
|
|
143
|
+
return Math.max(0, Math.min(100, value));
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function getStatusColor(
|
|
147
|
+
indicator: NonNullable<UsageSnapshot["status"]>["indicator"],
|
|
148
|
+
colorScheme: ColorScheme
|
|
149
|
+
): "error" | "warning" | "success" | "base" {
|
|
150
|
+
if (colorScheme === "monochrome") {
|
|
151
|
+
return "base";
|
|
152
|
+
}
|
|
153
|
+
if (indicator === "minor" || indicator === "maintenance") {
|
|
154
|
+
return "warning";
|
|
155
|
+
}
|
|
156
|
+
if (indicator === "major" || indicator === "critical") {
|
|
157
|
+
return "error";
|
|
158
|
+
}
|
|
159
|
+
if (indicator === "none") {
|
|
160
|
+
return colorScheme === "success-base-warning-error" ? "success" : "base";
|
|
161
|
+
}
|
|
162
|
+
return "base";
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function resolveStatusTintColor(
|
|
166
|
+
color: "error" | "warning" | "success" | "base",
|
|
167
|
+
baseTextColor: BaseTextColor
|
|
168
|
+
): BaseTextColor {
|
|
169
|
+
return color === "base" ? baseTextColor : color;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function fgFromBgAnsi(ansi: string): string {
|
|
173
|
+
return ansi.replace(/\x1b\[48;/g, "\x1b[38;").replace(/\x1b\[49m/g, "\x1b[39m");
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function applyBaseTextColor(theme: Theme, color: BaseTextColor, text: string): string {
|
|
177
|
+
if (isBackgroundColor(color)) {
|
|
178
|
+
const fgAnsi = fgFromBgAnsi(theme.getBgAnsi(color as Parameters<Theme["getBgAnsi"]>[0]));
|
|
179
|
+
return `${fgAnsi}${text}\x1b[39m`;
|
|
180
|
+
}
|
|
181
|
+
return theme.fg(resolveDividerColor(color), text);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function resolveUsageColorTargets(settings?: Settings): {
|
|
185
|
+
title: boolean;
|
|
186
|
+
timer: boolean;
|
|
187
|
+
bar: boolean;
|
|
188
|
+
usageLabel: boolean;
|
|
189
|
+
status: boolean;
|
|
190
|
+
} {
|
|
191
|
+
const targets = settings?.display.usageColorTargets;
|
|
192
|
+
return {
|
|
193
|
+
title: targets?.title ?? true,
|
|
194
|
+
timer: targets?.timer ?? true,
|
|
195
|
+
bar: targets?.bar ?? true,
|
|
196
|
+
usageLabel: targets?.usageLabel ?? true,
|
|
197
|
+
status: targets?.status ?? true,
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function formatElapsedSince(timestamp: number): string {
|
|
202
|
+
const diffMs = Date.now() - timestamp;
|
|
203
|
+
if (diffMs < 60000) {
|
|
204
|
+
const seconds = Math.max(1, Math.floor(diffMs / 1000));
|
|
205
|
+
return `${seconds}s`;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const diffMins = Math.floor(diffMs / 60000);
|
|
209
|
+
if (diffMins < 60) return `${diffMins}m`;
|
|
210
|
+
|
|
211
|
+
const hours = Math.floor(diffMins / 60);
|
|
212
|
+
const mins = diffMins % 60;
|
|
213
|
+
if (hours < 24) return mins > 0 ? `${hours}h${mins}m` : `${hours}h`;
|
|
214
|
+
|
|
215
|
+
const days = Math.floor(hours / 24);
|
|
216
|
+
const remHours = hours % 24;
|
|
217
|
+
return remHours > 0 ? `${days}d${remHours}h` : `${days}d`;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const RESET_CONTAINMENT_SEGMENTER = new Intl.Segmenter(undefined, { granularity: "grapheme" });
|
|
221
|
+
|
|
222
|
+
function wrapResetContainment(text: string, containment: ResetTimerContainment): { wrapped: string; attachWithSpace: boolean } {
|
|
223
|
+
switch (containment) {
|
|
224
|
+
case "none":
|
|
225
|
+
return { wrapped: text, attachWithSpace: true };
|
|
226
|
+
case "blank":
|
|
227
|
+
return { wrapped: text, attachWithSpace: true };
|
|
228
|
+
case "[]":
|
|
229
|
+
return { wrapped: `[${text}]`, attachWithSpace: true };
|
|
230
|
+
case "<>":
|
|
231
|
+
return { wrapped: `<${text}>`, attachWithSpace: true };
|
|
232
|
+
case "()":
|
|
233
|
+
return { wrapped: `(${text})`, attachWithSpace: true };
|
|
234
|
+
default: {
|
|
235
|
+
const trimmed = String(containment).trim();
|
|
236
|
+
if (!trimmed) return { wrapped: `(${text})`, attachWithSpace: true };
|
|
237
|
+
const segments = Array.from(RESET_CONTAINMENT_SEGMENTER.segment(trimmed), (entry) => entry.segment)
|
|
238
|
+
.map((segment) => segment.trim())
|
|
239
|
+
.filter(Boolean);
|
|
240
|
+
if (segments.length === 0) return { wrapped: `(${text})`, attachWithSpace: true };
|
|
241
|
+
const left = segments[0];
|
|
242
|
+
const right = segments[1] ?? left;
|
|
243
|
+
return { wrapped: `${left}${text}${right}`, attachWithSpace: true };
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function formatResetDateTime(resetAt: string): string {
|
|
249
|
+
const date = new Date(resetAt);
|
|
250
|
+
if (Number.isNaN(date.getTime())) return resetAt;
|
|
251
|
+
return new Intl.DateTimeFormat(undefined, {
|
|
252
|
+
month: "short",
|
|
253
|
+
day: "numeric",
|
|
254
|
+
hour: "2-digit",
|
|
255
|
+
minute: "2-digit",
|
|
256
|
+
}).format(date);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function getBarTypeLevels(barType: BarType): string[] | null {
|
|
260
|
+
switch (barType) {
|
|
261
|
+
case "horizontal-single":
|
|
262
|
+
return ["▏", "▎", "▍", "▌", "▋", "▊", "▉", "█"];
|
|
263
|
+
case "vertical":
|
|
264
|
+
return ["▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"];
|
|
265
|
+
case "braille":
|
|
266
|
+
return ["⡀", "⡄", "⣄", "⣆", "⣇", "⣧", "⣷", "⣿"];
|
|
267
|
+
case "shade":
|
|
268
|
+
return ["░", "▒", "▓", "█"];
|
|
269
|
+
default:
|
|
270
|
+
return null;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function renderBarSegments(
|
|
275
|
+
percent: number,
|
|
276
|
+
width: number,
|
|
277
|
+
levels: string[],
|
|
278
|
+
options?: { allowMinimum?: boolean; emptyChar?: string }
|
|
279
|
+
): { segments: Array<{ char: string; filled: boolean }>; minimal: boolean } {
|
|
280
|
+
const totalUnits = Math.max(1, width) * levels.length;
|
|
281
|
+
let filledUnits = Math.round((percent / 100) * totalUnits);
|
|
282
|
+
let minimal = false;
|
|
283
|
+
if (options?.allowMinimum && percent > 0 && filledUnits === 0) {
|
|
284
|
+
filledUnits = 1;
|
|
285
|
+
minimal = true;
|
|
286
|
+
}
|
|
287
|
+
const emptyChar = options?.emptyChar ?? " ";
|
|
288
|
+
const segments: Array<{ char: string; filled: boolean }> = [];
|
|
289
|
+
for (let i = 0; i < Math.max(1, width); i++) {
|
|
290
|
+
if (filledUnits >= levels.length) {
|
|
291
|
+
segments.push({ char: levels[levels.length - 1], filled: true });
|
|
292
|
+
filledUnits -= levels.length;
|
|
293
|
+
continue;
|
|
294
|
+
}
|
|
295
|
+
if (filledUnits > 0) {
|
|
296
|
+
segments.push({ char: levels[Math.min(levels.length - 1, filledUnits - 1)], filled: true });
|
|
297
|
+
filledUnits = 0;
|
|
298
|
+
continue;
|
|
299
|
+
}
|
|
300
|
+
segments.push({ char: emptyChar, filled: false });
|
|
301
|
+
}
|
|
302
|
+
return { segments, minimal };
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function formatProviderLabel(theme: Theme, usage: UsageSnapshot, settings?: Settings, model?: ModelInput): string {
|
|
306
|
+
const showProviderName = settings?.display.showProviderName ?? true;
|
|
307
|
+
const showStatus = settings?.providers[usage.provider]?.showStatus ?? true;
|
|
308
|
+
const error = usage.error;
|
|
309
|
+
const fetchError = Boolean(error && !isExpectedMissingData(error));
|
|
310
|
+
const baseStatus = showStatus ? usage.status : undefined;
|
|
311
|
+
const lastSuccessAt = usage.lastSuccessAt;
|
|
312
|
+
const elapsed = lastSuccessAt ? formatElapsedSince(lastSuccessAt) : undefined;
|
|
313
|
+
const fetchDescription = elapsed
|
|
314
|
+
? (elapsed === "just now" ? "Last upd.: just now" : `Last upd.: ${elapsed} ago`)
|
|
315
|
+
: "Fetch failed";
|
|
316
|
+
const fetchStatus: ProviderStatus | undefined = fetchError
|
|
317
|
+
? { indicator: "minor", description: fetchDescription }
|
|
318
|
+
: undefined;
|
|
319
|
+
const status = showStatus ? (fetchStatus ?? baseStatus) : undefined;
|
|
320
|
+
const statusDismissOk = settings?.display.statusDismissOk ?? true;
|
|
321
|
+
const statusModeRaw = settings?.display.statusIndicatorMode ?? "icon";
|
|
322
|
+
const statusMode = statusModeRaw === "icon" || statusModeRaw === "text" || statusModeRaw === "icon+text"
|
|
323
|
+
? statusModeRaw
|
|
324
|
+
: "icon";
|
|
325
|
+
const statusIconPack = settings?.display.statusIconPack ?? "emoji";
|
|
326
|
+
const statusIconCustom = settings?.display.statusIconCustom;
|
|
327
|
+
const providerLabelSetting = settings?.display.providerLabel ?? "none";
|
|
328
|
+
const showColon = settings?.display.providerLabelColon ?? true;
|
|
329
|
+
const boldProviderLabel = settings?.display.providerLabelBold ?? false;
|
|
330
|
+
const baseTextColor = resolveBaseTextColor(settings?.display.baseTextColor);
|
|
331
|
+
const usageTargets = resolveUsageColorTargets(settings);
|
|
332
|
+
|
|
333
|
+
const statusActive = Boolean(status && (!statusDismissOk || status.indicator !== "none"));
|
|
334
|
+
const showIcon = statusActive && (statusMode === "icon" || statusMode === "icon+text");
|
|
335
|
+
const showText = statusActive && (statusMode === "text" || statusMode === "icon+text");
|
|
336
|
+
|
|
337
|
+
const labelSuffix = providerLabelSetting === "plan"
|
|
338
|
+
? "Plan"
|
|
339
|
+
: providerLabelSetting === "subscription"
|
|
340
|
+
? "Subscription"
|
|
341
|
+
: providerLabelSetting === "sub"
|
|
342
|
+
? "Sub."
|
|
343
|
+
: providerLabelSetting === "none"
|
|
344
|
+
? ""
|
|
345
|
+
: String(providerLabelSetting);
|
|
346
|
+
|
|
347
|
+
const rawName = usage.displayName?.trim() ?? "";
|
|
348
|
+
const baseName = rawName.replace(/\s+(plan|subscription|sub\.?)[\s]*$/i, "").trim();
|
|
349
|
+
const resolvedProviderName = baseName || rawName;
|
|
350
|
+
const isSpark = usage.provider === "codex" && isCodexSparkModel(model);
|
|
351
|
+
const providerName = isSpark ? `${resolvedProviderName} (Spark)` : resolvedProviderName;
|
|
352
|
+
const providerLabel = showProviderName
|
|
353
|
+
? [providerName, labelSuffix].filter(Boolean).join(" ")
|
|
354
|
+
: "";
|
|
355
|
+
const providerLabelWithColon = providerLabel && showColon ? `${providerLabel}:` : providerLabel;
|
|
356
|
+
|
|
357
|
+
const icon = showIcon && status ? getStatusIcon(status, statusIconPack, statusIconCustom) : "";
|
|
358
|
+
const statusText = showText && status ? getStatusLabel(status) : "";
|
|
359
|
+
const rawStatusColor = status
|
|
360
|
+
? getStatusColor(status.indicator, settings?.display.colorScheme ?? "base-warning-error")
|
|
361
|
+
: "base";
|
|
362
|
+
const statusTint = usageTargets.status
|
|
363
|
+
? resolveStatusTintColor(rawStatusColor, baseTextColor)
|
|
364
|
+
: baseTextColor;
|
|
365
|
+
const statusColor = statusTint;
|
|
366
|
+
const dividerEnabled = settings?.display.statusProviderDivider ?? false;
|
|
367
|
+
const dividerChar = settings?.display.dividerCharacter ?? "│";
|
|
368
|
+
const dividerColor = resolveDividerColor(settings?.display.dividerColor);
|
|
369
|
+
const dividerGlyph = dividerChar === "none"
|
|
370
|
+
? ""
|
|
371
|
+
: dividerChar === "blank"
|
|
372
|
+
? " "
|
|
373
|
+
: dividerChar;
|
|
374
|
+
|
|
375
|
+
const statusParts: string[] = [];
|
|
376
|
+
if (icon) statusParts.push(applyBaseTextColor(theme, statusColor, icon));
|
|
377
|
+
if (statusText) statusParts.push(applyBaseTextColor(theme, statusColor, statusText));
|
|
378
|
+
|
|
379
|
+
const parts: string[] = [];
|
|
380
|
+
if (statusParts.length > 0) {
|
|
381
|
+
parts.push(statusParts.join(" "));
|
|
382
|
+
}
|
|
383
|
+
if (providerLabelWithColon) {
|
|
384
|
+
if (statusParts.length > 0 && dividerEnabled && dividerGlyph) {
|
|
385
|
+
parts.push(theme.fg(dividerColor, dividerGlyph));
|
|
386
|
+
}
|
|
387
|
+
const colored = applyBaseTextColor(theme, baseTextColor, providerLabelWithColon);
|
|
388
|
+
parts.push(boldProviderLabel ? theme.bold(colored) : colored);
|
|
389
|
+
}
|
|
390
|
+
if (parts.length === 0) return "";
|
|
391
|
+
return parts.join(" ");
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* Format a single usage window as a styled string
|
|
396
|
+
*/
|
|
397
|
+
export function formatUsageWindow(
|
|
398
|
+
theme: Theme,
|
|
399
|
+
window: RateWindow,
|
|
400
|
+
isCodex: boolean,
|
|
401
|
+
settings?: Settings,
|
|
402
|
+
usage?: UsageSnapshot,
|
|
403
|
+
options?: { useNormalColors?: boolean; barWidthOverride?: number },
|
|
404
|
+
model?: ModelInput
|
|
405
|
+
): string {
|
|
406
|
+
const parts = formatUsageWindowParts(theme, window, isCodex, settings, usage, options, model);
|
|
407
|
+
const baseTextColor = resolveBaseTextColor(settings?.display.baseTextColor);
|
|
408
|
+
const usageTargets = resolveUsageColorTargets(settings);
|
|
409
|
+
|
|
410
|
+
// Special handling for Extra usage label
|
|
411
|
+
if (window.label.startsWith("Extra [")) {
|
|
412
|
+
const match = window.label.match(/^(Extra \[)(on|active)(\] .*)$/);
|
|
413
|
+
if (match) {
|
|
414
|
+
const [, prefix, status, suffix] = match;
|
|
415
|
+
const styledLabel =
|
|
416
|
+
status === "active"
|
|
417
|
+
? applyBaseTextColor(theme, baseTextColor, prefix)
|
|
418
|
+
+ theme.fg("text", status)
|
|
419
|
+
+ applyBaseTextColor(theme, baseTextColor, suffix)
|
|
420
|
+
: applyBaseTextColor(theme, baseTextColor, window.label);
|
|
421
|
+
const extraParts = [styledLabel, parts.bar, parts.pct].filter(Boolean);
|
|
422
|
+
return extraParts.join(" ");
|
|
423
|
+
}
|
|
424
|
+
if (!usageTargets.title) {
|
|
425
|
+
const extraParts = [applyBaseTextColor(theme, baseTextColor, window.label), parts.bar, parts.pct].filter(Boolean);
|
|
426
|
+
return extraParts.join(" ");
|
|
427
|
+
}
|
|
428
|
+
const extraColor = getUsageColor(window.usedPercent, false, settings?.display.colorScheme ?? "base-warning-error");
|
|
429
|
+
const extraTextColor = (options?.useNormalColors && extraColor === "base")
|
|
430
|
+
? "text"
|
|
431
|
+
: extraColor === "base"
|
|
432
|
+
? baseTextColor
|
|
433
|
+
: extraColor;
|
|
434
|
+
const extraParts = [applyBaseTextColor(theme, extraTextColor, window.label), parts.bar, parts.pct].filter(Boolean);
|
|
435
|
+
return extraParts.join(" ");
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
const joinedParts = [parts.label, parts.bar, parts.pct, parts.reset].filter(Boolean);
|
|
439
|
+
return joinedParts.join(" ");
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
export function formatUsageWindowParts(
|
|
443
|
+
theme: Theme,
|
|
444
|
+
window: RateWindow,
|
|
445
|
+
isCodex: boolean,
|
|
446
|
+
settings?: Settings,
|
|
447
|
+
usage?: UsageSnapshot,
|
|
448
|
+
options?: { useNormalColors?: boolean; barWidthOverride?: number },
|
|
449
|
+
model?: ModelInput
|
|
450
|
+
): UsageWindowParts {
|
|
451
|
+
const barStyle: BarStyle = settings?.display.barStyle ?? "both";
|
|
452
|
+
const barWidthSetting = settings?.display.barWidth;
|
|
453
|
+
const containBar = settings?.display.containBar ?? false;
|
|
454
|
+
const barWidth = options?.barWidthOverride ?? (typeof barWidthSetting === "number" ? barWidthSetting : 6);
|
|
455
|
+
const barType: BarType = settings?.display.barType ?? "horizontal-bar";
|
|
456
|
+
const brailleFillEmpty = settings?.display.brailleFillEmpty ?? false;
|
|
457
|
+
const brailleFullBlocks = settings?.display.brailleFullBlocks ?? false;
|
|
458
|
+
const barCharacter: BarCharacter = settings?.display.barCharacter ?? "heavy";
|
|
459
|
+
const colorScheme: ColorScheme = settings?.display.colorScheme ?? "base-warning-error";
|
|
460
|
+
const resetTimePosition = settings?.display.resetTimePosition ?? "front";
|
|
461
|
+
const resetTimeFormat = settings?.display.resetTimeFormat ?? "relative";
|
|
462
|
+
const showUsageLabels = settings?.display.showUsageLabels ?? true;
|
|
463
|
+
const showWindowTitle = settings?.display.showWindowTitle ?? true;
|
|
464
|
+
const boldWindowTitle = settings?.display.boldWindowTitle ?? false;
|
|
465
|
+
const baseTextColor = resolveBaseTextColor(settings?.display.baseTextColor);
|
|
466
|
+
const errorThreshold = settings?.display.errorThreshold ?? 25;
|
|
467
|
+
const warningThreshold = settings?.display.warningThreshold ?? 50;
|
|
468
|
+
const successThreshold = settings?.display.successThreshold ?? 75;
|
|
469
|
+
|
|
470
|
+
const rawUsedPct = Math.round(window.usedPercent);
|
|
471
|
+
const usedPct = clampPercent(rawUsedPct);
|
|
472
|
+
const displayPct = isCodex ? clampPercent(100 - usedPct) : usedPct;
|
|
473
|
+
const isRemaining = isCodex;
|
|
474
|
+
|
|
475
|
+
const barPercent = clampPercent(displayPct);
|
|
476
|
+
const filled = Math.round((barPercent / 100) * barWidth);
|
|
477
|
+
const empty = Math.max(0, barWidth - filled);
|
|
478
|
+
|
|
479
|
+
const baseColor = getUsageColor(displayPct, isRemaining, colorScheme, errorThreshold, warningThreshold, successThreshold);
|
|
480
|
+
const usageTargets = resolveUsageColorTargets(settings);
|
|
481
|
+
const usageTextColor = (options?.useNormalColors && baseColor === "base")
|
|
482
|
+
? "text"
|
|
483
|
+
: baseColor === "base"
|
|
484
|
+
? baseTextColor
|
|
485
|
+
: baseColor;
|
|
486
|
+
const neutralTextColor = options?.useNormalColors ? "text" : baseTextColor;
|
|
487
|
+
const titleColor = usageTargets.title ? usageTextColor : neutralTextColor;
|
|
488
|
+
const timerColor = usageTargets.timer ? usageTextColor : neutralTextColor;
|
|
489
|
+
const usageLabelColor = usageTargets.usageLabel ? usageTextColor : neutralTextColor;
|
|
490
|
+
const barUsageColor = (options?.useNormalColors && baseColor === "base") ? "text" : baseColor === "base" ? "muted" : baseColor;
|
|
491
|
+
const neutralBarColor = baseTextColor === "dim" ? "dim" : "muted";
|
|
492
|
+
const barColor = usageTargets.bar ? barUsageColor : neutralBarColor;
|
|
493
|
+
const { filled: filledChar, empty: emptyChar } = getBarCharacters(barCharacter);
|
|
494
|
+
|
|
495
|
+
const emptyColor = "dim";
|
|
496
|
+
|
|
497
|
+
let barStr = "";
|
|
498
|
+
if ((barStyle === "bar" || barStyle === "both") && barWidth > 0) {
|
|
499
|
+
let levels = getBarTypeLevels(barType);
|
|
500
|
+
if (barType === "braille" && brailleFullBlocks) {
|
|
501
|
+
levels = ["⣿"];
|
|
502
|
+
}
|
|
503
|
+
if (!levels || barType === "horizontal-bar") {
|
|
504
|
+
const filledCharWidth = Math.max(1, visibleWidth(filledChar));
|
|
505
|
+
const emptyCharWidth = Math.max(1, visibleWidth(emptyChar));
|
|
506
|
+
const segmentCount = barWidth > 0 ? Math.floor(barWidth / filledCharWidth) : 0;
|
|
507
|
+
const filledSegments = segmentCount > 0 ? Math.round((barPercent / 100) * segmentCount) : 0;
|
|
508
|
+
const filledStr = filledChar.repeat(filledSegments);
|
|
509
|
+
const filledWidth = filledSegments * filledCharWidth;
|
|
510
|
+
const remainingWidth = Math.max(0, barWidth - filledWidth);
|
|
511
|
+
const emptySegments = emptyCharWidth > 0 ? Math.floor(remainingWidth / emptyCharWidth) : 0;
|
|
512
|
+
const emptyStr = emptyChar.repeat(emptySegments);
|
|
513
|
+
const emptyRendered = emptyChar === " " ? emptyStr : theme.fg(emptyColor, emptyStr);
|
|
514
|
+
barStr = theme.fg(barColor as Parameters<typeof theme.fg>[0], filledStr) + emptyRendered;
|
|
515
|
+
const barVisualWidth = visibleWidth(barStr);
|
|
516
|
+
if (barVisualWidth < barWidth) {
|
|
517
|
+
barStr += " ".repeat(barWidth - barVisualWidth);
|
|
518
|
+
}
|
|
519
|
+
} else {
|
|
520
|
+
const emptyChar = barType === "braille" && brailleFillEmpty && barWidth > 1 ? "⣿" : " ";
|
|
521
|
+
const { segments, minimal } = renderBarSegments(barPercent, barWidth, levels, {
|
|
522
|
+
allowMinimum: true,
|
|
523
|
+
emptyChar,
|
|
524
|
+
});
|
|
525
|
+
const filledColor = minimal ? "dim" : barColor;
|
|
526
|
+
barStr = segments
|
|
527
|
+
.map((segment) => {
|
|
528
|
+
if (segment.filled) {
|
|
529
|
+
return theme.fg(filledColor as Parameters<typeof theme.fg>[0], segment.char);
|
|
530
|
+
}
|
|
531
|
+
if (segment.char === " ") {
|
|
532
|
+
return segment.char;
|
|
533
|
+
}
|
|
534
|
+
return theme.fg("dim", segment.char);
|
|
535
|
+
})
|
|
536
|
+
.join("");
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
if (settings?.display.containBar && barStr) {
|
|
540
|
+
const leftCap = theme.fg(barColor as Parameters<typeof theme.fg>[0], "▕");
|
|
541
|
+
const rightCap = theme.fg(barColor as Parameters<typeof theme.fg>[0], "▏");
|
|
542
|
+
barStr = leftCap + barStr + rightCap;
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
let pctStr = "";
|
|
547
|
+
if (barStyle === "percentage" || barStyle === "both") {
|
|
548
|
+
// Special handling for Copilot Month window - can show percentage or requests
|
|
549
|
+
if (window.label === "Month" && usage?.provider === "copilot") {
|
|
550
|
+
const quotaDisplay = settings?.providers.copilot.quotaDisplay ?? "percentage";
|
|
551
|
+
if (quotaDisplay === "requests" && usage.requestsRemaining !== undefined && usage.requestsEntitlement !== undefined) {
|
|
552
|
+
const used = usage.requestsEntitlement - usage.requestsRemaining;
|
|
553
|
+
const suffix = showUsageLabels ? " used" : "";
|
|
554
|
+
pctStr = applyBaseTextColor(theme, usageLabelColor, `${used}/${usage.requestsEntitlement}${suffix}`);
|
|
555
|
+
} else {
|
|
556
|
+
const suffix = showUsageLabels ? " used" : "";
|
|
557
|
+
pctStr = applyBaseTextColor(theme, usageLabelColor, `${usedPct}%${suffix}`);
|
|
558
|
+
}
|
|
559
|
+
} else if (isCodex) {
|
|
560
|
+
const suffix = showUsageLabels ? " rem." : "";
|
|
561
|
+
pctStr = applyBaseTextColor(theme, usageLabelColor, `${displayPct}%${suffix}`);
|
|
562
|
+
} else {
|
|
563
|
+
const suffix = showUsageLabels ? " used" : "";
|
|
564
|
+
pctStr = applyBaseTextColor(theme, usageLabelColor, `${usedPct}%${suffix}`);
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
const isActiveReset = window.resetDescription === "__ACTIVE__";
|
|
569
|
+
const resetText = isActiveReset
|
|
570
|
+
? undefined
|
|
571
|
+
: resetTimeFormat === "datetime"
|
|
572
|
+
? (window.resetAt ? formatResetDateTime(window.resetAt) : window.resetDescription)
|
|
573
|
+
: window.resetDescription;
|
|
574
|
+
const resetContainment = settings?.display.resetTimeContainment ?? "()";
|
|
575
|
+
const leftSuffix = resetText && resetTimeFormat === "relative" && showUsageLabels ? " left" : "";
|
|
576
|
+
|
|
577
|
+
const displayLabel = getDisplayWindowLabel(window, model);
|
|
578
|
+
const coloredTitle = applyBaseTextColor(theme, titleColor, displayLabel);
|
|
579
|
+
const titlePart = showWindowTitle ? (boldWindowTitle ? theme.bold(coloredTitle) : coloredTitle) : "";
|
|
580
|
+
|
|
581
|
+
let labelPart = titlePart;
|
|
582
|
+
if (resetText) {
|
|
583
|
+
const resetBody = `${resetText}${leftSuffix}`;
|
|
584
|
+
const { wrapped, attachWithSpace } = wrapResetContainment(resetBody, resetContainment);
|
|
585
|
+
const coloredReset = applyBaseTextColor(theme, timerColor, wrapped);
|
|
586
|
+
if (resetTimePosition === "front") {
|
|
587
|
+
if (!titlePart) {
|
|
588
|
+
labelPart = coloredReset;
|
|
589
|
+
} else {
|
|
590
|
+
labelPart = attachWithSpace ? `${titlePart} ${coloredReset}` : `${titlePart}${coloredReset}`;
|
|
591
|
+
}
|
|
592
|
+
} else if (resetTimePosition === "integrated") {
|
|
593
|
+
labelPart = titlePart ? `${applyBaseTextColor(theme, timerColor, `${wrapped}/`)}${titlePart}` : coloredReset;
|
|
594
|
+
} else if (resetTimePosition === "back") {
|
|
595
|
+
labelPart = titlePart;
|
|
596
|
+
}
|
|
597
|
+
} else if (!titlePart) {
|
|
598
|
+
labelPart = "";
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
const resetPart =
|
|
602
|
+
resetTimePosition === "back" && resetText
|
|
603
|
+
? applyBaseTextColor(theme, timerColor, wrapResetContainment(`${resetText}${leftSuffix}`, resetContainment).wrapped)
|
|
604
|
+
: "";
|
|
605
|
+
|
|
606
|
+
return {
|
|
607
|
+
label: labelPart,
|
|
608
|
+
bar: barStr,
|
|
609
|
+
pct: pctStr,
|
|
610
|
+
reset: resetPart,
|
|
611
|
+
};
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
/**
|
|
615
|
+
* Format context window usage as a progress bar
|
|
616
|
+
*/
|
|
617
|
+
export function formatContextBar(
|
|
618
|
+
theme: Theme,
|
|
619
|
+
context: ContextInfo,
|
|
620
|
+
settings?: Settings,
|
|
621
|
+
options?: { barWidthOverride?: number }
|
|
622
|
+
): string {
|
|
623
|
+
// Create a pseudo-RateWindow for context display
|
|
624
|
+
const contextWindow: RateWindow = {
|
|
625
|
+
label: "Ctx",
|
|
626
|
+
usedPercent: context.percent,
|
|
627
|
+
// No reset description for context
|
|
628
|
+
};
|
|
629
|
+
// Format using the same window formatting logic, but with "used" semantics (not inverted)
|
|
630
|
+
return formatUsageWindow(theme, contextWindow, false, settings, undefined, options);
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
/**
|
|
634
|
+
* Format a complete usage snapshot as a usage line
|
|
635
|
+
*/
|
|
636
|
+
export function formatUsageStatus(
|
|
637
|
+
theme: Theme,
|
|
638
|
+
usage: UsageSnapshot,
|
|
639
|
+
model?: ModelInput,
|
|
640
|
+
settings?: Settings,
|
|
641
|
+
context?: ContextInfo
|
|
642
|
+
): string | undefined {
|
|
643
|
+
const baseTextColor = resolveBaseTextColor(settings?.display.baseTextColor);
|
|
644
|
+
const modelInfo = resolveModelInfo(model);
|
|
645
|
+
const label = formatProviderLabel(theme, usage, settings, modelInfo);
|
|
646
|
+
|
|
647
|
+
// If no windows, just show the provider name with error
|
|
648
|
+
if (usage.windows.length === 0) {
|
|
649
|
+
const errorMsg = usage.error
|
|
650
|
+
? applyBaseTextColor(theme, baseTextColor, `(${formatErrorForDisplay(usage.error)})`)
|
|
651
|
+
: "";
|
|
652
|
+
if (!label) {
|
|
653
|
+
return errorMsg;
|
|
654
|
+
}
|
|
655
|
+
return errorMsg ? `${label} ${errorMsg}` : label;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
// Build usage bars
|
|
659
|
+
const parts: string[] = [];
|
|
660
|
+
const isCodex = usage.provider === "codex";
|
|
661
|
+
const invertUsage = isCodex && (settings?.providers.codex.invertUsage ?? false);
|
|
662
|
+
const modelId = modelInfo?.id;
|
|
663
|
+
|
|
664
|
+
// Add context bar as leftmost element if enabled
|
|
665
|
+
const showContextBar = settings?.display.showContextBar ?? false;
|
|
666
|
+
if (showContextBar && context && context.contextWindow > 0) {
|
|
667
|
+
parts.push(formatContextBar(theme, context, settings));
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
for (const w of usage.windows) {
|
|
671
|
+
// Skip windows that are disabled in settings
|
|
672
|
+
if (!shouldShowWindow(usage, w, settings, modelInfo)) {
|
|
673
|
+
continue;
|
|
674
|
+
}
|
|
675
|
+
parts.push(formatUsageWindow(theme, w, invertUsage, settings, usage, undefined, modelInfo));
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
// Add extra usage lines (extra usage off, copilot multiplier, etc.)
|
|
679
|
+
const extras = getUsageExtras(usage, settings, modelId);
|
|
680
|
+
for (const extra of extras) {
|
|
681
|
+
parts.push(applyBaseTextColor(theme, baseTextColor, extra.label));
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
// Build divider from settings
|
|
685
|
+
const dividerChar = settings?.display.dividerCharacter ?? "•";
|
|
686
|
+
const dividerColor = resolveDividerColor(settings?.display.dividerColor);
|
|
687
|
+
const blanksSetting = settings?.display.dividerBlanks ?? 1;
|
|
688
|
+
const showProviderDivider = settings?.display.showProviderDivider ?? false;
|
|
689
|
+
const blanksPerSide = typeof blanksSetting === "number" ? blanksSetting : 1;
|
|
690
|
+
const spacing = " ".repeat(blanksPerSide);
|
|
691
|
+
const charToDisplay = dividerChar === "blank" ? " " : dividerChar === "none" ? "" : dividerChar;
|
|
692
|
+
const divider = charToDisplay ? spacing + theme.fg(dividerColor, charToDisplay) + spacing : spacing + spacing;
|
|
693
|
+
const labelGap = label && parts.length > 0
|
|
694
|
+
? showProviderDivider && charToDisplay !== ""
|
|
695
|
+
? divider
|
|
696
|
+
: spacing
|
|
697
|
+
: "";
|
|
698
|
+
|
|
699
|
+
return label + labelGap + parts.join(divider);
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
export function formatUsageStatusWithWidth(
|
|
703
|
+
theme: Theme,
|
|
704
|
+
usage: UsageSnapshot,
|
|
705
|
+
width: number,
|
|
706
|
+
model?: ModelInput,
|
|
707
|
+
settings?: Settings,
|
|
708
|
+
options?: { labelGapFill?: boolean },
|
|
709
|
+
context?: ContextInfo
|
|
710
|
+
): string | undefined {
|
|
711
|
+
const labelGapFill = options?.labelGapFill ?? false;
|
|
712
|
+
const baseTextColor = resolveBaseTextColor(settings?.display.baseTextColor);
|
|
713
|
+
const modelInfo = resolveModelInfo(model);
|
|
714
|
+
const label = formatProviderLabel(theme, usage, settings, modelInfo);
|
|
715
|
+
const showContextBar = settings?.display.showContextBar ?? false;
|
|
716
|
+
const hasContext = showContextBar && context && context.contextWindow > 0;
|
|
717
|
+
|
|
718
|
+
// If no windows, just show the provider name with error
|
|
719
|
+
if (usage.windows.length === 0) {
|
|
720
|
+
const errorMsg = usage.error
|
|
721
|
+
? applyBaseTextColor(theme, baseTextColor, `(${formatErrorForDisplay(usage.error)})`)
|
|
722
|
+
: "";
|
|
723
|
+
if (!label) {
|
|
724
|
+
return errorMsg;
|
|
725
|
+
}
|
|
726
|
+
return errorMsg ? `${label} ${errorMsg}` : label;
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
const barStyle: BarStyle = settings?.display.barStyle ?? "both";
|
|
730
|
+
const hasBar = barStyle === "bar" || barStyle === "both";
|
|
731
|
+
const barWidthSetting = settings?.display.barWidth ?? 6;
|
|
732
|
+
const dividerBlanksSetting = settings?.display.dividerBlanks ?? 1;
|
|
733
|
+
const dividerColor = resolveDividerColor(settings?.display.dividerColor);
|
|
734
|
+
const showProviderDivider = settings?.display.showProviderDivider ?? false;
|
|
735
|
+
const containBar = settings?.display.containBar ?? false;
|
|
736
|
+
|
|
737
|
+
const barFill = barWidthSetting === "fill";
|
|
738
|
+
const barBaseWidth = typeof barWidthSetting === "number" ? barWidthSetting : (hasBar ? 1 : 0);
|
|
739
|
+
const barContainerExtra = containBar && hasBar ? 2 : 0;
|
|
740
|
+
const barBaseContentWidth = barFill ? 0 : barBaseWidth;
|
|
741
|
+
const barBaseWidthCalc = barFill ? 0 : barBaseContentWidth + barContainerExtra;
|
|
742
|
+
const barTotalBaseWidth = barBaseWidthCalc;
|
|
743
|
+
const baseDividerBlanks = typeof dividerBlanksSetting === "number" ? dividerBlanksSetting : 1;
|
|
744
|
+
|
|
745
|
+
const dividerFill = dividerBlanksSetting === "fill";
|
|
746
|
+
|
|
747
|
+
// Build usage windows
|
|
748
|
+
const windows: RateWindow[] = [];
|
|
749
|
+
const isCodex = usage.provider === "codex";
|
|
750
|
+
const invertUsage = isCodex && (settings?.providers.codex.invertUsage ?? false);
|
|
751
|
+
const modelId = modelInfo?.id;
|
|
752
|
+
|
|
753
|
+
// Add context window as first entry if enabled
|
|
754
|
+
let contextWindowIndex = -1;
|
|
755
|
+
if (hasContext) {
|
|
756
|
+
contextWindowIndex = windows.length;
|
|
757
|
+
windows.push({
|
|
758
|
+
label: "Ctx",
|
|
759
|
+
usedPercent: context!.percent,
|
|
760
|
+
});
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
for (const w of usage.windows) {
|
|
764
|
+
if (!shouldShowWindow(usage, w, settings, modelInfo)) {
|
|
765
|
+
continue;
|
|
766
|
+
}
|
|
767
|
+
windows.push(w);
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
const barEligibleCount = hasBar ? windows.length : 0;
|
|
771
|
+
const extras = getUsageExtras(usage, settings, modelId);
|
|
772
|
+
const extraParts = extras.map((extra) => applyBaseTextColor(theme, baseTextColor, extra.label));
|
|
773
|
+
|
|
774
|
+
const barSpacerWidth = hasBar ? 1 : 0;
|
|
775
|
+
const baseWindowWidths = windows.map((w, i) => {
|
|
776
|
+
// Context window uses false for invertUsage (always show used percentage)
|
|
777
|
+
const isContext = i === contextWindowIndex;
|
|
778
|
+
return (
|
|
779
|
+
visibleWidth(
|
|
780
|
+
formatUsageWindow(
|
|
781
|
+
theme,
|
|
782
|
+
w,
|
|
783
|
+
isContext ? false : invertUsage,
|
|
784
|
+
settings,
|
|
785
|
+
isContext ? undefined : usage,
|
|
786
|
+
{ barWidthOverride: 0 },
|
|
787
|
+
modelInfo
|
|
788
|
+
)
|
|
789
|
+
) + barSpacerWidth
|
|
790
|
+
);
|
|
791
|
+
});
|
|
792
|
+
const extraWidths = extraParts.map((part) => visibleWidth(part));
|
|
793
|
+
|
|
794
|
+
const partCount = windows.length + extraParts.length;
|
|
795
|
+
const dividerCount = Math.max(0, partCount - 1);
|
|
796
|
+
const dividerChar = settings?.display.dividerCharacter ?? "•";
|
|
797
|
+
const charToDisplay = dividerChar === "blank" ? " " : dividerChar === "none" ? "" : dividerChar;
|
|
798
|
+
const dividerBaseWidth = (charToDisplay ? 1 : 0) + baseDividerBlanks * 2;
|
|
799
|
+
const labelGapEnabled = partCount > 0 && (label !== "" || labelGapFill);
|
|
800
|
+
const providerDividerActive = showProviderDivider && charToDisplay !== "" && label !== "";
|
|
801
|
+
const labelGapBaseWidth = labelGapEnabled
|
|
802
|
+
? providerDividerActive
|
|
803
|
+
? dividerBaseWidth
|
|
804
|
+
: baseDividerBlanks
|
|
805
|
+
: 0;
|
|
806
|
+
|
|
807
|
+
const labelWidth = visibleWidth(label);
|
|
808
|
+
const baseTotalWidth =
|
|
809
|
+
labelWidth +
|
|
810
|
+
labelGapBaseWidth +
|
|
811
|
+
baseWindowWidths.reduce((sum, w) => sum + w, 0) +
|
|
812
|
+
extraWidths.reduce((sum, w) => sum + w, 0) +
|
|
813
|
+
(barEligibleCount * barTotalBaseWidth) +
|
|
814
|
+
(dividerCount * dividerBaseWidth);
|
|
815
|
+
|
|
816
|
+
let remainingWidth = width - baseTotalWidth;
|
|
817
|
+
if (remainingWidth < 0) {
|
|
818
|
+
remainingWidth = 0;
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
const useBars = barFill && barEligibleCount > 0;
|
|
822
|
+
const labelGapUnits = labelGapEnabled ? (providerDividerActive ? 2 : 1) : 0;
|
|
823
|
+
const dividerSlots = dividerCount + (labelGapEnabled ? 1 : 0);
|
|
824
|
+
const dividerUnits = dividerCount * 2 + labelGapUnits;
|
|
825
|
+
const useDividers = dividerFill && dividerUnits > 0;
|
|
826
|
+
|
|
827
|
+
let barExtraTotal = 0;
|
|
828
|
+
let dividerExtraTotal = 0;
|
|
829
|
+
if (remainingWidth > 0 && (useBars || useDividers)) {
|
|
830
|
+
const barWeight = useBars ? barEligibleCount : 0;
|
|
831
|
+
const dividerWeight = useDividers ? dividerUnits : 0;
|
|
832
|
+
const totalWeight = barWeight + dividerWeight;
|
|
833
|
+
if (totalWeight > 0) {
|
|
834
|
+
barExtraTotal = Math.floor((remainingWidth * barWeight) / totalWeight);
|
|
835
|
+
dividerExtraTotal = remainingWidth - barExtraTotal;
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
const barWidths: number[] = windows.map(() => barBaseWidthCalc);
|
|
840
|
+
if (useBars && barEligibleCount > 0) {
|
|
841
|
+
const perBar = Math.floor(barExtraTotal / barEligibleCount);
|
|
842
|
+
let remainder = barExtraTotal % barEligibleCount;
|
|
843
|
+
for (let i = 0; i < barWidths.length; i++) {
|
|
844
|
+
barWidths[i] = barBaseWidthCalc + perBar + (remainder > 0 ? 1 : 0);
|
|
845
|
+
if (remainder > 0) remainder -= 1;
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
let labelBlanks = labelGapEnabled ? baseDividerBlanks : 0;
|
|
850
|
+
const dividerBlanks: number[] = [];
|
|
851
|
+
if (dividerUnits > 0) {
|
|
852
|
+
const baseUnit = useDividers ? Math.floor(dividerExtraTotal / dividerUnits) : 0;
|
|
853
|
+
let remainderUnits = useDividers ? dividerExtraTotal % dividerUnits : 0;
|
|
854
|
+
if (labelGapEnabled) {
|
|
855
|
+
if (useDividers && providerDividerActive) {
|
|
856
|
+
let extraUnits = baseUnit * 2;
|
|
857
|
+
if (remainderUnits >= 2) {
|
|
858
|
+
extraUnits += 2;
|
|
859
|
+
remainderUnits -= 2;
|
|
860
|
+
}
|
|
861
|
+
labelBlanks = baseDividerBlanks + Math.floor(extraUnits / 2);
|
|
862
|
+
} else if (useDividers) {
|
|
863
|
+
labelBlanks = baseDividerBlanks + baseUnit + (remainderUnits > 0 ? 1 : 0);
|
|
864
|
+
if (remainderUnits > 0) remainderUnits -= 1;
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
for (let i = 0; i < dividerCount; i++) {
|
|
868
|
+
let extraUnits = baseUnit * 2;
|
|
869
|
+
if (remainderUnits >= 2) {
|
|
870
|
+
extraUnits += 2;
|
|
871
|
+
remainderUnits -= 2;
|
|
872
|
+
}
|
|
873
|
+
const blanks = baseDividerBlanks + Math.floor(extraUnits / 2);
|
|
874
|
+
dividerBlanks.push(blanks);
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
const parts: string[] = [];
|
|
879
|
+
for (let i = 0; i < windows.length; i++) {
|
|
880
|
+
const totalWidth = barWidths[i] ?? barBaseWidthCalc;
|
|
881
|
+
const contentWidth = containBar ? Math.max(0, totalWidth - barContainerExtra) : totalWidth;
|
|
882
|
+
const isContext = i === contextWindowIndex;
|
|
883
|
+
parts.push(
|
|
884
|
+
formatUsageWindow(
|
|
885
|
+
theme,
|
|
886
|
+
windows[i],
|
|
887
|
+
isContext ? false : invertUsage,
|
|
888
|
+
settings,
|
|
889
|
+
isContext ? undefined : usage,
|
|
890
|
+
{ barWidthOverride: contentWidth },
|
|
891
|
+
modelInfo
|
|
892
|
+
)
|
|
893
|
+
);
|
|
894
|
+
}
|
|
895
|
+
for (const extra of extraParts) {
|
|
896
|
+
parts.push(extra);
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
let rest = "";
|
|
900
|
+
for (let i = 0; i < parts.length; i++) {
|
|
901
|
+
rest += parts[i];
|
|
902
|
+
if (i < dividerCount) {
|
|
903
|
+
const blanks = dividerBlanks[i] ?? baseDividerBlanks;
|
|
904
|
+
const spacing = " ".repeat(Math.max(0, blanks));
|
|
905
|
+
rest += charToDisplay
|
|
906
|
+
? spacing + theme.fg(dividerColor, charToDisplay) + spacing
|
|
907
|
+
: spacing + spacing;
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
let labelGapExtra = 0;
|
|
912
|
+
if (labelGapFill && labelGapEnabled) {
|
|
913
|
+
const restWidth = visibleWidth(rest);
|
|
914
|
+
const labelGapWidth = providerDividerActive
|
|
915
|
+
? (Math.max(0, labelBlanks) * 2) + (charToDisplay ? 1 : 0)
|
|
916
|
+
: Math.max(0, labelBlanks);
|
|
917
|
+
const totalWidth = visibleWidth(label) + restWidth + labelGapWidth;
|
|
918
|
+
labelGapExtra = Math.max(0, width - totalWidth);
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
let output = label;
|
|
922
|
+
if (labelGapEnabled) {
|
|
923
|
+
if (providerDividerActive) {
|
|
924
|
+
const spacing = " ".repeat(Math.max(0, labelBlanks));
|
|
925
|
+
output += spacing + theme.fg(dividerColor, charToDisplay) + spacing + " ".repeat(labelGapExtra);
|
|
926
|
+
} else {
|
|
927
|
+
output += " ".repeat(Math.max(0, labelBlanks + labelGapExtra));
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
output += rest;
|
|
931
|
+
|
|
932
|
+
if (width > 0 && visibleWidth(output) > width) {
|
|
933
|
+
return truncateToWidth(output, width, "");
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
return output;
|
|
937
|
+
}
|