osdy-pi 1.2.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.md +160 -30
  2. package/bin/osdy-pi.mjs +29 -6
  3. package/bin/osdy.mjs +43 -0
  4. package/extensions/osdy-pi/account-profiles.test.ts +111 -97
  5. package/extensions/osdy-pi/account-profiles.ts +91 -162
  6. package/extensions/osdy-pi/animation.test.ts +84 -0
  7. package/extensions/osdy-pi/animation.ts +17 -8
  8. package/extensions/osdy-pi/codex-usage-ui.test.ts +1145 -0
  9. package/extensions/osdy-pi/codex-usage-ui.ts +728 -0
  10. package/extensions/osdy-pi/codex-usage.test.ts +276 -0
  11. package/extensions/osdy-pi/codex-usage.ts +276 -0
  12. package/extensions/osdy-pi/constants.ts +134 -34
  13. package/extensions/osdy-pi/context-usage-ui.test.ts +168 -0
  14. package/extensions/osdy-pi/context-usage-ui.ts +60 -0
  15. package/extensions/osdy-pi/editor-settings.test.ts +121 -23
  16. package/extensions/osdy-pi/editor-settings.ts +39 -3
  17. package/extensions/osdy-pi/metrics.test.ts +95 -0
  18. package/extensions/osdy-pi/metrics.ts +39 -1
  19. package/extensions/osdy-pi/profile-label.ts +2 -2
  20. package/extensions/osdy-pi/runtime-helpers.test.ts +220 -1
  21. package/extensions/osdy-pi/runtime-helpers.ts +42 -19
  22. package/extensions/osdy-pi/runtime.test.ts +355 -7
  23. package/extensions/osdy-pi/runtime.ts +344 -66
  24. package/extensions/osdy-pi/types.ts +26 -1
  25. package/extensions/osdy-pi/ui.test.ts +128 -0
  26. package/extensions/osdy-pi/ui.ts +57 -40
  27. package/package.json +5 -2
  28. package/scripts/osdy-pi-account-profiles.mjs +521 -76
  29. package/scripts/osdy-pi-gentle-coexistence.mjs +200 -0
  30. package/scripts/osdy-pi-profile-setup.mjs +175 -0
  31. package/themes/osdy-pi-catppuccin-frappe.json +6 -2
  32. package/themes/osdy-pi-catppuccin-latte.json +6 -2
  33. package/themes/osdy-pi-catppuccin-macchiato.json +6 -2
  34. package/themes/osdy-pi-catppuccin-mocha.json +6 -2
  35. package/themes/osdy-pi-dark.json +6 -2
  36. package/themes/osdy-pi-dracula.json +6 -2
  37. package/themes/osdy-pi-kanagawa-dragon.json +6 -2
  38. package/themes/osdy-pi-kanagawa-lotus.json +6 -2
  39. package/themes/osdy-pi-kanagawa-wave.json +6 -2
  40. package/themes/osdy-pi-lucent-orange.json +6 -2
  41. package/themes/osdy-pi-matrix.json +6 -2
  42. package/themes/osdy-pi-new.json +6 -2
  43. package/themes/osdy-pi-sexy.json +6 -2
  44. package/themes/osdy-pi-tokyo-night.json +6 -2
@@ -0,0 +1,728 @@
1
+ import {
2
+ matchesKey,
3
+ truncateToWidth,
4
+ visibleWidth,
5
+ type Component,
6
+ type TUI,
7
+ } from "@earendil-works/pi-tui";
8
+ import type { CodexUsageSnapshot, CodexUsageWindow } from "./codex-usage.js";
9
+ import type { CodexUsageState, SimpleTheme } from "./types.js";
10
+
11
+ export type CodexUsageViewState = CodexUsageState;
12
+
13
+ export type CodexUsageRefresh = () => Promise<void>;
14
+
15
+ export type CodexUsagePresentation = {
16
+ profile?: string | undefined;
17
+ provider?: string | undefined;
18
+ model?: string | undefined;
19
+ };
20
+
21
+ export const CODEX_USAGE_OVERLAY_OPTIONS = {
22
+ anchor: "center",
23
+ width: 96,
24
+ minWidth: 48,
25
+ maxHeight: "92%",
26
+ margin: 1,
27
+ } as const;
28
+
29
+ const ESCAPE = String.fromCharCode(27);
30
+ const BELL = String.fromCharCode(7);
31
+ const ANSI_ESCAPE_SEQUENCE = new RegExp(
32
+ `${ESCAPE}(?:\\][\\s\\S]*?(?:${BELL}|${ESCAPE}\\\\)|[PX^_][\\s\\S]*?${ESCAPE}\\\\|\\[[0-?]*[ -/]*[@-~]|[@-_])`,
33
+ "g",
34
+ );
35
+
36
+ function sanitizeExternalField(
37
+ value: string | undefined,
38
+ fallback: string,
39
+ ): string {
40
+ const sanitized = Array.from(
41
+ (value?.replace(ANSI_ESCAPE_SEQUENCE, "") ?? "").replace(/\s+/g, " "),
42
+ )
43
+ .filter((character) => {
44
+ const code = character.charCodeAt(0);
45
+ return code >= 32 && (code < 127 || code > 159);
46
+ })
47
+ .join("")
48
+ .trim();
49
+ return sanitized || fallback;
50
+ }
51
+
52
+ function bucketDisplay(bucket: CodexUsageSnapshot["buckets"][number]): {
53
+ id: string;
54
+ label: string;
55
+ } {
56
+ const id = sanitizeExternalField(bucket.id, "unknown");
57
+ return {
58
+ id,
59
+ label: sanitizeExternalField(bucket.label, id === "codex" ? "Codex" : id),
60
+ };
61
+ }
62
+
63
+ function divider(theme: SimpleTheme, width: number): string {
64
+ return theme.fg("border", "─".repeat(Math.max(1, Math.floor(width))));
65
+ }
66
+
67
+ export function formatRemainingPercent(usedPercent: number): string {
68
+ return `${Math.max(0, Math.round(100 - usedPercent))}% left`;
69
+ }
70
+
71
+ function remainingPercentTheme(
72
+ usedPercent: number,
73
+ ): "muted" | "warning" | "error" {
74
+ const remainingPercent = Math.max(0, Math.round(100 - usedPercent));
75
+ if (remainingPercent <= 15) return "error";
76
+ if (remainingPercent <= 40) return "warning";
77
+ return "muted";
78
+ }
79
+
80
+ function themedRemainingPercent(
81
+ theme: SimpleTheme,
82
+ usedPercent: number,
83
+ ): string {
84
+ return theme.fg("muted", formatRemainingPercent(usedPercent));
85
+ }
86
+
87
+ function formatResetRelative(resetAt: number, now: number): string {
88
+ const seconds = Math.round((resetAt * 1_000 - now) / 1_000);
89
+ if (seconds <= 0) return "reset time passed";
90
+ if (seconds < 60) return `resets in ${seconds}s`;
91
+ if (seconds < 3_600) return `resets in ${Math.round(seconds / 60)}m`;
92
+ if (seconds < 86_400) return `resets in ${Math.round(seconds / 3_600)}h`;
93
+ return `resets in ${Math.round(seconds / 86_400)}d`;
94
+ }
95
+
96
+ function formatResetUtc(resetAt: number): string {
97
+ const date = new Date(resetAt * 1_000);
98
+ const pad = (value: number): string => String(value).padStart(2, "0");
99
+ return `${date.getUTCFullYear()}-${pad(date.getUTCMonth() + 1)}-${pad(date.getUTCDate())} ${pad(date.getUTCHours())}:${pad(date.getUTCMinutes())} UTC`;
100
+ }
101
+
102
+ function formatResetTiming(
103
+ resetAt: number | undefined,
104
+ now: number,
105
+ ): string | undefined {
106
+ return resetAt === undefined
107
+ ? undefined
108
+ : `${formatResetRelative(resetAt, now)} (${formatResetUtc(resetAt)})`;
109
+ }
110
+
111
+ export function formatCodexUsageWindow(
112
+ window: CodexUsageWindow | undefined,
113
+ now = Date.now(),
114
+ ): string | undefined {
115
+ if (!window) return undefined;
116
+ const duration =
117
+ window.windowMinutes === undefined
118
+ ? "quota"
119
+ : window.windowMinutes % 1_440 === 0
120
+ ? `${window.windowMinutes / 1_440}d`
121
+ : `${Math.round(window.windowMinutes / 60)}h`;
122
+ const reset = formatResetTiming(window.resetsAt, now);
123
+ return [`${duration} ${formatRemainingPercent(window.usedPercent)}`, reset]
124
+ .filter((part): part is string => part !== undefined)
125
+ .join(" · ");
126
+ }
127
+
128
+ export function formatCodexQuotaMetadata(snapshot: CodexUsageSnapshot): string {
129
+ const main = snapshot.buckets[0];
130
+ return [
131
+ formatCodexUsageWindow(main?.primary),
132
+ formatCodexUsageWindow(main?.secondary),
133
+ ]
134
+ .filter((part): part is string => part !== undefined)
135
+ .join(" · ");
136
+ }
137
+
138
+ export function formatCodexUsageMetadata(
139
+ model: string,
140
+ thinking: string,
141
+ snapshot: CodexUsageSnapshot,
142
+ ): string {
143
+ const quota = formatCodexQuotaMetadata(snapshot);
144
+ return [model, `think ${thinking}`, quota]
145
+ .filter((part) => part.length > 0)
146
+ .join(" · ");
147
+ }
148
+
149
+ export function resolveCodexUsageLayout(
150
+ _left: string,
151
+ modelAndThinking: string,
152
+ ): { topRight: string } {
153
+ return { topRight: modelAndThinking };
154
+ }
155
+
156
+ function borderBox(
157
+ theme: SimpleTheme,
158
+ width: number,
159
+ title: string,
160
+ lines: string[],
161
+ titleTheme: "accent" | "mdLink" = "accent",
162
+ ): string[] {
163
+ const innerWidth = Math.max(1, width - 2);
164
+ const heading = truncateToWidth(` ${title} `, innerWidth, "...", true);
165
+ const leftWidth = Math.floor(
166
+ Math.max(0, innerWidth - visibleWidth(heading)) / 2,
167
+ );
168
+ const rightWidth = Math.max(0, innerWidth - visibleWidth(heading) - leftWidth);
169
+ const pad = (line: string): string => {
170
+ const text = truncateToWidth(line, innerWidth, "...", true);
171
+ return `${text}${" ".repeat(Math.max(0, innerWidth - visibleWidth(text)))}`;
172
+ };
173
+ return [
174
+ `${theme.fg("border", `╭${"─".repeat(leftWidth)}`)}${theme.fg(titleTheme, heading)}${theme.fg("border", `${"─".repeat(rightWidth)}╮`)}`,
175
+ `${theme.fg("border", "│")}${divider(theme, innerWidth)}${theme.fg("border", "│")}`,
176
+ ...lines.map(
177
+ (line) => `${theme.fg("border", "│")}${pad(line)}${theme.fg("border", "│")}`,
178
+ ),
179
+ theme.fg("border", `╰${"─".repeat(innerWidth)}╯`),
180
+ ];
181
+ }
182
+
183
+ function doubleBorderBox(
184
+ theme: SimpleTheme,
185
+ width: number,
186
+ title: string,
187
+ lines: string[],
188
+ ): string[] {
189
+ const innerWidth = Math.max(1, width - 2);
190
+ const heading = truncateToWidth(` ${title} `, innerWidth, "...", true);
191
+ const leftWidth = Math.floor(
192
+ Math.max(0, innerWidth - visibleWidth(heading)) / 2,
193
+ );
194
+ const rightWidth = Math.max(0, innerWidth - visibleWidth(heading) - leftWidth);
195
+ const pad = (line: string): string => {
196
+ const text = truncateToWidth(line, innerWidth, "...", true);
197
+ return `${text}${" ".repeat(Math.max(0, innerWidth - visibleWidth(text)))}`;
198
+ };
199
+ return [
200
+ `${theme.fg("border", `╔${"═".repeat(leftWidth)}`)}${theme.fg("accent", heading)}${theme.fg("border", `${"═".repeat(rightWidth)}╗`)}`,
201
+ ...lines.map(
202
+ (line) => `${theme.fg("border", "║")}${pad(line)}${theme.fg("border", "║")}`,
203
+ ),
204
+ theme.fg("border", `╚${"═".repeat(innerWidth)}╝`),
205
+ ];
206
+ }
207
+
208
+ function quotaTheme(name: "Session" | "Weekly"): "accent" | "mdLink" {
209
+ return name === "Session" ? "accent" : "mdLink";
210
+ }
211
+
212
+ function quotaLabel(
213
+ theme: SimpleTheme,
214
+ name: "Session" | "Weekly",
215
+ suffix = "",
216
+ ): string {
217
+ return theme.fg("accent", `\u001B[1m${name}\u001B[22m${suffix}`);
218
+ }
219
+
220
+ function progress(
221
+ theme: SimpleTheme,
222
+ window: CodexUsageWindow,
223
+ width = 10,
224
+ normalFillTheme: "accent" | "mdLink" = "accent",
225
+ ): string {
226
+ const barWidth = Math.max(0, Math.round(width));
227
+ const remainingPercent = Number.isFinite(window.usedPercent)
228
+ ? Math.max(0, Math.min(100, 100 - window.usedPercent))
229
+ : 0;
230
+ const filled = Math.max(
231
+ 0,
232
+ Math.min(barWidth, Math.round((remainingPercent / 100) * barWidth)),
233
+ );
234
+ const thresholdTheme = remainingPercentTheme(window.usedPercent);
235
+ const fillTheme =
236
+ thresholdTheme === "muted" ? normalFillTheme : thresholdTheme;
237
+ return `${theme.fg(fillTheme, "█".repeat(filled))}${theme.fg("muted", "░".repeat(barWidth - filled))}`;
238
+ }
239
+
240
+ /** Renders the main Codex bucket as compact, remaining-capacity quota bars. */
241
+ export function renderCompactCodexQuotaBars(
242
+ theme: SimpleTheme,
243
+ snapshot: CodexUsageSnapshot,
244
+ width: number,
245
+ ): string[] {
246
+ const maximumWidth = Math.max(1, Math.floor(width));
247
+ const main = snapshot.buckets.find((bucket) => bucket.id === "codex");
248
+ const bars = (
249
+ [
250
+ ["Session", main?.primary],
251
+ ["Weekly", main?.secondary],
252
+ ] as const
253
+ ).flatMap(([name, window]) => {
254
+ if (!window) return [];
255
+ const duration = formatWindowDuration(window);
256
+ const remaining = formatRemainingPercent(window.usedPercent);
257
+ const label =
258
+ [
259
+ `${name} ${duration} ${remaining}`,
260
+ `${duration} ${remaining}`,
261
+ duration,
262
+ "",
263
+ ].find(
264
+ (candidate) => maximumWidth > visibleWidth(candidate) + (candidate ? 1 : 0),
265
+ ) ?? "";
266
+ const gap = label ? " " : "";
267
+ const barWidth = Math.min(
268
+ 12,
269
+ maximumWidth - visibleWidth(label) - visibleWidth(gap),
270
+ );
271
+ const hasName = label.startsWith(`${name} `);
272
+ const labelText = hasName ? label.slice(name.length + 1) : label;
273
+ const themedLabelText = labelText.endsWith(remaining)
274
+ ? `${theme.fg("muted", labelText.slice(0, -remaining.length))}${themedRemainingPercent(theme, window.usedPercent)}${theme.fg("muted", gap)}`
275
+ : labelText
276
+ ? theme.fg("muted", `${labelText}${gap}`)
277
+ : "";
278
+ const themedLabel = hasName
279
+ ? `${quotaLabel(theme, name, " ")}${themedLabelText}`
280
+ : themedLabelText;
281
+ const line = `${themedLabel}${progress(theme, window, barWidth, quotaTheme(name))}`;
282
+ return [truncateToWidth(line, maximumWidth, "")];
283
+ });
284
+ if (bars.length !== 2) return bars;
285
+ const combined = `${bars[0]} ${bars[1]}`;
286
+ return visibleWidth(combined) <= maximumWidth ? [combined] : bars;
287
+ }
288
+
289
+ export function renderCodexUsageReadyLines(
290
+ theme: SimpleTheme,
291
+ snapshot: CodexUsageSnapshot,
292
+ now = Date.now(),
293
+ ): string[] {
294
+ const lines = [
295
+ theme.fg("muted", "r refresh · esc/q close"),
296
+ theme.fg(
297
+ "mdLink",
298
+ `Plan: ${sanitizeExternalField(snapshot.planType, "unknown")}`,
299
+ ),
300
+ ];
301
+ for (const bucket of snapshot.buckets) {
302
+ const { label } = bucketDisplay(bucket);
303
+ lines.push(theme.fg("accent", label));
304
+ for (const [name, window] of [
305
+ ["Session", bucket.primary],
306
+ ["Weekly", bucket.secondary],
307
+ ] as const) {
308
+ if (!window) continue;
309
+ const reset = formatResetTiming(window.resetsAt, now);
310
+ lines.push(
311
+ `${quotaLabel(theme, name, ": ")}${progress(theme, window, 10, quotaTheme(name))}${theme.fg("muted", " ")}${themedRemainingPercent(theme, window.usedPercent)}${reset ? theme.fg("muted", ` · ${reset}`) : ""}`,
312
+ );
313
+ }
314
+ }
315
+ if (snapshot.credits) {
316
+ const creditText = snapshot.credits.unlimited
317
+ ? "unlimited"
318
+ : sanitizeExternalField(
319
+ snapshot.credits.balance,
320
+ snapshot.credits.hasCredits ? "available" : "none",
321
+ );
322
+ lines.push(theme.fg("mdLink", `Credits: ${creditText}`));
323
+ if (snapshot.credits.resetCreditCount !== undefined)
324
+ lines.push(
325
+ theme.fg("muted", `Credit resets: ${snapshot.credits.resetCreditCount}`),
326
+ );
327
+ }
328
+ if (snapshot.ordinaryUsageAllowed !== undefined)
329
+ lines.push(
330
+ theme.fg(
331
+ "muted",
332
+ `Ordinary usage: ${snapshot.ordinaryUsageAllowed ? "allowed" : "not allowed"}`,
333
+ ),
334
+ );
335
+ lines.push(
336
+ theme.fg(
337
+ "muted",
338
+ `Updated: ${new Date(snapshot.fetchedAt).toLocaleString()}`,
339
+ ),
340
+ );
341
+ return lines;
342
+ }
343
+
344
+ function formatWindowDuration(window: CodexUsageWindow): string {
345
+ if (window.windowMinutes === undefined) return "quota window";
346
+ if (window.windowMinutes % 1_440 === 0)
347
+ return `${window.windowMinutes / 1_440}d`;
348
+ if (window.windowMinutes % 60 === 0) return `${window.windowMinutes / 60}h`;
349
+ return `${window.windowMinutes}m`;
350
+ }
351
+
352
+ function formatResetDetails(
353
+ resetAt: number | undefined,
354
+ now: number,
355
+ ): string[] {
356
+ if (resetAt === undefined) return ["Reset: unavailable"];
357
+ const date = new Date(resetAt * 1_000);
358
+ return [
359
+ `Reset: ${formatResetRelative(resetAt, now)}`,
360
+ `Local: ${date.toLocaleString()}`,
361
+ `UTC: ${formatResetUtc(resetAt)}`,
362
+ ];
363
+ }
364
+
365
+ function renderQuotaWindowCard(
366
+ theme: SimpleTheme,
367
+ name: "Session" | "Weekly",
368
+ window: CodexUsageWindow,
369
+ now: number,
370
+ width: number,
371
+ ): string[] {
372
+ const resetDetails = formatResetDetails(window.resetsAt, now);
373
+ const contentWidth = Math.max(1, width - 2);
374
+ const lines = [
375
+ `Window: ${formatWindowDuration(window)}`,
376
+ ...resetDetails,
377
+ ].flatMap((line) =>
378
+ wrapToVisibleWidth(line, contentWidth).map((part) => theme.fg("muted", part)),
379
+ );
380
+ return borderBox(
381
+ theme,
382
+ Math.max(4, width),
383
+ `\u001B[1m${name}\u001B[22m · ${formatWindowDuration(window)}`,
384
+ lines,
385
+ "accent",
386
+ );
387
+ }
388
+
389
+ function renderQuotaBucketSection(
390
+ theme: SimpleTheme,
391
+ bucket: CodexUsageSnapshot["buckets"][number],
392
+ now: number,
393
+ width: number,
394
+ ): string[] {
395
+ const { id, label } = bucketDisplay(bucket);
396
+ const windows = [
397
+ ["Session", bucket.primary],
398
+ ["Weekly", bucket.secondary],
399
+ ] as const;
400
+ const availableWindows = windows.filter(
401
+ (entry): entry is ["Session" | "Weekly", CodexUsageWindow] =>
402
+ entry[1] !== undefined,
403
+ );
404
+ // 72 content columns retain two useful 34-column cards plus their three-column gap.
405
+ const wide = availableWindows.length === 2 && width >= 72;
406
+ const cards = availableWindows.map(([name, window]) =>
407
+ renderQuotaWindowCard(
408
+ theme,
409
+ name,
410
+ window,
411
+ now,
412
+ wide ? Math.floor((width - 3) / 2) : width,
413
+ ),
414
+ );
415
+ const lines = [
416
+ ...wrapToVisibleWidth(`${label} [${id}]`, width).map((line) =>
417
+ theme.fg("accent", line),
418
+ ),
419
+ divider(theme, width),
420
+ ];
421
+ if (cards.length === 0) {
422
+ lines.push(theme.fg("muted", "No quota windows available"));
423
+ return lines;
424
+ }
425
+ const [primaryCard, secondaryCard] = cards;
426
+ if (wide && primaryCard && secondaryCard)
427
+ return [...lines, ...combineCards(primaryCard, secondaryCard)];
428
+ return [...lines, ...cards.flat()];
429
+ }
430
+
431
+ function combineCards(left: string[], right: string[]): string[] {
432
+ const gap = " ";
433
+ const leftWidth = left.reduce(
434
+ (maximum, line) => Math.max(maximum, visibleWidth(line)),
435
+ 0,
436
+ );
437
+ const rows = Math.max(left.length, right.length);
438
+ return Array.from({ length: rows }, (_value, index) => {
439
+ const leftLine = left[index] ?? "";
440
+ const rightLine = right[index] ?? "";
441
+ return `${leftLine}${" ".repeat(Math.max(0, leftWidth - visibleWidth(leftLine)))}${gap}${rightLine}`;
442
+ });
443
+ }
444
+
445
+ function wrapToVisibleWidth(text: string, width: number): string[] {
446
+ const maximumWidth = Math.max(1, Math.floor(width));
447
+ const lines: string[] = [];
448
+ let line = "";
449
+ for (const character of Array.from(text)) {
450
+ if (line && visibleWidth(`${line}${character}`) > maximumWidth) {
451
+ lines.push(line.trimEnd());
452
+ line = character === " " ? "" : character;
453
+ } else {
454
+ line += character;
455
+ }
456
+ }
457
+ if (line || lines.length === 0) lines.push(line);
458
+ return lines;
459
+ }
460
+
461
+ function renderQuotaFooterLines(
462
+ theme: SimpleTheme,
463
+ snapshot: CodexUsageSnapshot,
464
+ presentation: CodexUsagePresentation,
465
+ width: number,
466
+ ): string[] {
467
+ const lines = [theme.fg("accent", "Quotas + account"), divider(theme, width)];
468
+ const quotaLines: string[] = [];
469
+ for (const bucket of snapshot.buckets) {
470
+ const { label } = bucketDisplay(bucket);
471
+ for (const [name, window] of [
472
+ ["Session", bucket.primary],
473
+ ["Weekly", bucket.secondary],
474
+ ] as const) {
475
+ if (!window) continue;
476
+ const prefix = `${label} ${name} `;
477
+ const themedLabel = `${theme.fg("muted", `${label} `)}${quotaLabel(theme, name, " ")}`;
478
+ const remaining = formatRemainingPercent(window.usedPercent);
479
+ const suffix = ` ${remaining} · ${Math.round(window.usedPercent)}% used`;
480
+ const themedSuffix = `${theme.fg("muted", " ")}${themedRemainingPercent(theme, window.usedPercent)}${theme.fg("muted", ` · ${Math.round(window.usedPercent)}% used`)}`;
481
+ const compactThemedSuffix = `${themedRemainingPercent(theme, window.usedPercent)}${theme.fg("muted", ` · ${Math.round(window.usedPercent)}% used`)}`;
482
+ const availableBarWidth =
483
+ width - visibleWidth(prefix) - visibleWidth(suffix);
484
+ if (availableBarWidth < 6) {
485
+ quotaLines.push(
486
+ ...wrapToVisibleWidth(`${themedLabel}${compactThemedSuffix}`, width),
487
+ progress(theme, window, Math.max(1, width), quotaTheme(name)),
488
+ );
489
+ } else {
490
+ const barWidth = Math.min(28, availableBarWidth);
491
+ quotaLines.push(
492
+ `${themedLabel}${progress(theme, window, barWidth, quotaTheme(name))}${themedSuffix}`,
493
+ );
494
+ }
495
+ }
496
+ }
497
+ lines.push(
498
+ ...quotaLines.flatMap((line, index) => (index === 0 ? [line] : ["", line])),
499
+ );
500
+ if (quotaLines.length > 0) lines.push("");
501
+ const profile = sanitizeExternalField(presentation.profile, "none");
502
+ const provider = sanitizeExternalField(presentation.provider, "unknown");
503
+ const model = sanitizeExternalField(presentation.model, "unknown");
504
+ const narrow = width < 72;
505
+ const presentationText = `Profile: ${profile} · Provider: ${provider} · Model: ${model}`;
506
+ if (narrow) {
507
+ for (const field of [
508
+ `Profile: ${profile}`,
509
+ `Provider: ${provider}`,
510
+ `Model: ${model}`,
511
+ ])
512
+ lines.push(
513
+ ...wrapToVisibleWidth(field, width).map((line) => theme.fg("muted", line)),
514
+ );
515
+ } else {
516
+ lines.push(
517
+ ...(visibleWidth(presentationText) > width
518
+ ? wrapToVisibleWidth(presentationText, width)
519
+ : [presentationText]
520
+ ).map((line) => theme.fg("muted", line)),
521
+ );
522
+ }
523
+ const accountFields = [
524
+ snapshot.planType === undefined
525
+ ? undefined
526
+ : `Plan: ${sanitizeExternalField(snapshot.planType, "unknown")}`,
527
+ snapshot.ordinaryUsageAllowed === undefined
528
+ ? undefined
529
+ : `Availability: ${snapshot.ordinaryUsageAllowed ? "allowed" : "not allowed"}`,
530
+ ].filter((field): field is string => field !== undefined);
531
+ if (accountFields.length > 0) {
532
+ const text = accountFields.join(" · ");
533
+ lines.push(
534
+ ...(narrow || visibleWidth(text) > width
535
+ ? wrapToVisibleWidth(text, width)
536
+ : [text]
537
+ ).map((line) => theme.fg("mdLink", line)),
538
+ );
539
+ }
540
+ if (snapshot.credits) {
541
+ const creditText = snapshot.credits.unlimited
542
+ ? "unlimited"
543
+ : sanitizeExternalField(
544
+ snapshot.credits.balance,
545
+ snapshot.credits.hasCredits ? "available" : "none",
546
+ );
547
+ const creditFields = [
548
+ `Credits: ${creditText}`,
549
+ snapshot.credits.resetCreditCount === undefined
550
+ ? undefined
551
+ : `Credit resets: ${snapshot.credits.resetCreditCount}`,
552
+ ].filter((field): field is string => field !== undefined);
553
+ const text = creditFields.join(" · ");
554
+ lines.push(
555
+ ...(narrow || visibleWidth(text) > width
556
+ ? wrapToVisibleWidth(text, width)
557
+ : [text]
558
+ ).map((line) => theme.fg("mdLink", line)),
559
+ );
560
+ }
561
+ return lines;
562
+ }
563
+
564
+ function controlHints(theme: SimpleTheme, width: number): string {
565
+ const text = "r refresh · esc/q close";
566
+ return theme.fg(
567
+ "muted",
568
+ `${" ".repeat(Math.max(0, width - visibleWidth(text)))}${text}`,
569
+ );
570
+ }
571
+
572
+ export function renderCodexUsageDashboardLines(
573
+ theme: SimpleTheme,
574
+ snapshot: CodexUsageSnapshot,
575
+ presentation: CodexUsagePresentation = {},
576
+ now = Date.now(),
577
+ width = 94,
578
+ ): string[] {
579
+ const lines: string[] = [];
580
+ for (const bucket of snapshot.buckets)
581
+ lines.push(...renderQuotaBucketSection(theme, bucket, now, width));
582
+ lines.push(theme.fg("border", "─".repeat(Math.max(1, width))));
583
+ lines.push(...renderQuotaFooterLines(theme, snapshot, presentation, width));
584
+ lines.push(controlHints(theme, width));
585
+ return lines;
586
+ }
587
+
588
+ export function renderCodexUsagePanelLines(
589
+ theme: SimpleTheme,
590
+ state: CodexUsageViewState,
591
+ presentation: CodexUsagePresentation = {},
592
+ now = Date.now(),
593
+ width = 94,
594
+ ): string[] {
595
+ const contentWidth = Math.max(1, width - 2);
596
+ const controls = controlHints(theme, contentWidth);
597
+ const content =
598
+ state.kind === "loading"
599
+ ? [theme.fg("muted", "Loading subscription usage…"), controls]
600
+ : state.kind === "error"
601
+ ? [
602
+ theme.fg("warning", state.message),
603
+ ...(state.snapshot
604
+ ? renderCodexUsageDashboardLines(
605
+ theme,
606
+ state.snapshot,
607
+ presentation,
608
+ now,
609
+ contentWidth,
610
+ )
611
+ : [controls]),
612
+ ]
613
+ : state.kind === "ready"
614
+ ? renderCodexUsageDashboardLines(
615
+ theme,
616
+ state.snapshot,
617
+ presentation,
618
+ now,
619
+ contentWidth,
620
+ )
621
+ : [controls];
622
+ return doubleBorderBox(theme, width, "Codex subscription usage", content);
623
+ }
624
+
625
+ class CodexUsagePanel implements Component {
626
+ private readonly theme: SimpleTheme;
627
+ private readonly getState: () => CodexUsageViewState;
628
+ private readonly refresh: CodexUsageRefresh;
629
+ private readonly presentation: CodexUsagePresentation;
630
+ private readonly close: () => void;
631
+ private refreshInFlight = false;
632
+
633
+ constructor(
634
+ theme: SimpleTheme,
635
+ getState: () => CodexUsageViewState,
636
+ refresh: CodexUsageRefresh,
637
+ presentation: CodexUsagePresentation,
638
+ close: () => void,
639
+ ) {
640
+ this.theme = theme;
641
+ this.getState = getState;
642
+ this.refresh = refresh;
643
+ this.presentation = presentation;
644
+ this.close = close;
645
+ }
646
+
647
+ private async refreshUsage(): Promise<void> {
648
+ if (this.refreshInFlight) return;
649
+ this.refreshInFlight = true;
650
+ try {
651
+ await this.refresh();
652
+ } finally {
653
+ this.refreshInFlight = false;
654
+ }
655
+ }
656
+
657
+ handleInput(data: string): void {
658
+ if (matchesKey(data, "escape") || matchesKey(data, "q")) return this.close();
659
+ if (matchesKey(data, "r")) void this.refreshUsage();
660
+ }
661
+
662
+ render(width: number): string[] {
663
+ return renderCodexUsagePanelLines(
664
+ this.theme,
665
+ this.getState(),
666
+ this.presentation,
667
+ Date.now(),
668
+ width,
669
+ );
670
+ }
671
+
672
+ invalidate(): void {}
673
+ }
674
+
675
+ export async function showCodexUsagePanel(
676
+ ctx: {
677
+ ui: {
678
+ custom<T>(
679
+ factory: (
680
+ tui: TUI,
681
+ theme: SimpleTheme,
682
+ keybindings: unknown,
683
+ done: (result: T) => void,
684
+ ) => Component,
685
+ options: {
686
+ overlay: boolean;
687
+ overlayOptions: {
688
+ anchor: "center";
689
+ width: number;
690
+ minWidth: number;
691
+ maxHeight: "92%";
692
+ margin: number;
693
+ };
694
+ },
695
+ ): Promise<T>;
696
+ };
697
+ },
698
+ getState: () => CodexUsageViewState,
699
+ refresh: CodexUsageRefresh,
700
+ presentation: CodexUsagePresentation = {},
701
+ ): Promise<void> {
702
+ await ctx.ui.custom<void>(
703
+ (tui, theme, _keybindings, done) => {
704
+ const initialRefresh = refresh();
705
+ tui.requestRender();
706
+ void initialRefresh.finally(() => tui.requestRender());
707
+ return new CodexUsagePanel(
708
+ theme,
709
+ getState,
710
+ async () => {
711
+ const pendingRefresh = refresh();
712
+ tui.requestRender();
713
+ try {
714
+ await pendingRefresh;
715
+ } finally {
716
+ tui.requestRender();
717
+ }
718
+ },
719
+ presentation,
720
+ () => done(),
721
+ );
722
+ },
723
+ {
724
+ overlay: true,
725
+ overlayOptions: CODEX_USAGE_OVERLAY_OPTIONS,
726
+ },
727
+ );
728
+ }