@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.
@@ -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
+ }