@sreetej510/pi-usage 0.1.1 → 0.1.4

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.
@@ -1,131 +0,0 @@
1
- import { randomUUID } from "node:crypto";
2
- import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
3
- import { resolvePiCodexWhamAuth } from "./codex-auth.js";
4
- import { CODEX_CONSUME_RESET_CREDITS_URL, CODEX_RESET_CREDITS_URL } from "./constants.js";
5
- import { throwUsageEndpointError } from "./errors.js";
6
- import { fetchWithTimeout } from "./http.js";
7
- import type {
8
- CodexResetCredit,
9
- CodexResetCreditList,
10
- CodexResetCreditPayload,
11
- CodexResetCreditRowPayload,
12
- } from "./types.js";
13
- import { asNumber, asString, assertObject, parseJsonObject, redactErrorBody } from "./utils.js";
14
-
15
- export async function fetchCodexResetCredits(ctx: ExtensionContext, timeoutMs: number): Promise<CodexResetCreditList> {
16
- const auth = await resolvePiCodexWhamAuth(ctx);
17
- if (!auth) {
18
- throw new Error(
19
- "No Pi OpenAI Codex auth was available. Use a Pi OpenAI Codex model or run /login for OpenAI ChatGPT Plus/Pro (Codex).",
20
- );
21
- }
22
- const response = await fetchWithTimeout(
23
- CODEX_RESET_CREDITS_URL,
24
- { headers: auth.headers },
25
- timeoutMs,
26
- "Codex banked resets",
27
- );
28
- const text = await response.text();
29
- if (!response.ok) {
30
- throwUsageEndpointError("Codex banked resets", response, text);
31
- }
32
- const payload = parseJsonObject(text, "Codex banked reset credits response");
33
- return normalizeCodexResetCreditList(payload as CodexResetCreditPayload);
34
- }
35
-
36
- export async function consumeCodexResetCredit(
37
- ctx: ExtensionContext,
38
- timeoutMs: number,
39
- creditId: string,
40
- ): Promise<void> {
41
- const auth = await resolvePiCodexWhamAuth(ctx);
42
- if (!auth) {
43
- throw new Error(
44
- "No Pi OpenAI Codex auth was available. Use a Pi OpenAI Codex model or run /login for OpenAI ChatGPT Plus/Pro (Codex).",
45
- );
46
- }
47
- const redeemRequestId = randomUUID();
48
- const bodies = [
49
- { id: creditId, redeem_request_id: redeemRequestId },
50
- { credit_id: creditId, redeem_request_id: redeemRequestId },
51
- { rate_limit_reset_credit_id: creditId, redeem_request_id: redeemRequestId },
52
- { id: creditId, redeemRequestId },
53
- { credit_id: creditId, redeemRequestId },
54
- { rate_limit_reset_credit_id: creditId, redeemRequestId },
55
- ];
56
- let lastError: Error | undefined;
57
- for (const body of bodies) {
58
- const response = await fetchWithTimeout(
59
- CODEX_CONSUME_RESET_CREDITS_URL,
60
- { method: "POST", headers: auth.headers, body: JSON.stringify(body) },
61
- timeoutMs,
62
- "Codex consume banked reset",
63
- );
64
- const text = await response.text();
65
- if (response.ok) return;
66
- lastError = new Error(
67
- `Codex consume banked reset returned ${response.status} ${response.statusText}: ${redactErrorBody(text)}`,
68
- );
69
- if (response.status !== 400 && response.status !== 422) break;
70
- }
71
- throw lastError ?? new Error("Failed to consume Codex banked reset.");
72
- }
73
-
74
- function normalizeCodexResetCreditList(payload: CodexResetCreditPayload): CodexResetCreditList {
75
- const rawCredits = Array.isArray(payload.credits) ? payload.credits : [];
76
- const credits = rawCredits
77
- .map((value) => normalizeCodexResetCreditRow(value))
78
- .filter((value): value is CodexResetCredit => value !== undefined);
79
- const availableCount =
80
- asNumber(payload.available_count) ??
81
- asNumber(payload.availableCount) ??
82
- credits.filter((credit) => (credit.status ?? "available") === "available").length;
83
- return { availableCount: Math.max(0, Math.trunc(availableCount)), credits };
84
- }
85
-
86
- function normalizeCodexResetCreditRow(value: unknown): CodexResetCredit | undefined {
87
- if (!value) return undefined;
88
- const credit = assertObject(value, "Codex reset credit") as CodexResetCreditRowPayload;
89
- const id = asString(credit.id);
90
- if (!id) return undefined;
91
- return {
92
- id,
93
- resetType: asString(credit.reset_type) ?? asString(credit.resetType),
94
- status: asString(credit.status),
95
- grantedAt: asString(credit.granted_at) ?? asString(credit.grantedAt),
96
- expiresAt: asString(credit.expires_at) ?? asString(credit.expiresAt),
97
- };
98
- }
99
-
100
- export function formatCodexResetCreditList(list: CodexResetCreditList): string {
101
- const lines = [" >_ Codex Banked Resets", ""];
102
- lines.push(` Banked resets available: ${list.availableCount}`);
103
- if (list.credits.length === 0) {
104
- lines.push(" No banked reset credits found.");
105
- return lines.join("\n");
106
- }
107
- for (const credit of list.credits) {
108
- const status = credit.status ? ` [${credit.status}]` : "";
109
- const granted = credit.grantedAt ? `granted ${formatIsoReset(credit.grantedAt)}` : undefined;
110
- const expires = credit.expiresAt ? `expires ${formatIsoReset(credit.expiresAt)}` : undefined;
111
- const timing = [granted, expires].filter(Boolean).join(" • ");
112
- lines.push(` ${credit.id}${status}${timing ? ` — ${timing}` : ""}`);
113
- }
114
- return lines.join("\n");
115
- }
116
-
117
- export function formatCodexResetCreditChoice(credit: CodexResetCredit): string {
118
- const expires = credit.expiresAt ? `expires ${formatIsoReset(credit.expiresAt)}` : "no expiry shown";
119
- return `${credit.id} — ${expires}`;
120
- }
121
-
122
- function formatIsoReset(value: string, showDay = true): string {
123
- const reset = new Date(value);
124
- if (Number.isNaN(reset.getTime())) return "at an unknown time";
125
- const now = new Date();
126
- const time = `${reset.getHours().toString().padStart(2, "0")}:${reset.getMinutes().toString().padStart(2, "0")}`;
127
- if (!showDay && reset.toDateString() === now.toDateString()) return time;
128
- const day = reset.getDate().toString();
129
- const month = reset.toLocaleDateString(undefined, { month: "short" });
130
- return `${time} on ${day} ${month}`;
131
- }
package/src/command.ts DELETED
@@ -1,184 +0,0 @@
1
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
- import { fetchRawUsagePayloads } from "./anthropic-query.js";
3
- import { completeCodexStatusArguments, parseArgs } from "./args.js";
4
- import {
5
- consumeCodexResetCredit,
6
- fetchCodexResetCredits,
7
- formatCodexResetCreditChoice,
8
- formatCodexResetCreditList,
9
- } from "./codex-reset-credits.js";
10
- import { CACHE_TTL_MS, COMMAND_NAME } from "./constants.js";
11
- import { isRateLimitErrorMessage, rateLimitBackoffMs } from "./errors.js";
12
- import { formatQueryErrors, showReports } from "./format.js";
13
- import { queryAllUsage } from "./query.js";
14
- import {
15
- clearSharedBackoff,
16
- clearSharedUsageReport,
17
- readSharedUsageCache,
18
- saveSharedBackoff,
19
- saveSharedUsageReport,
20
- } from "./shared-cache.js";
21
- import {
22
- applyCurrentProviderStatusline,
23
- clearCodexMemoryCache,
24
- clearStatuslineValue,
25
- clearUsageStatusline,
26
- getCombinedCache,
27
- handleStaleContextError,
28
- isSessionActive,
29
- setCombinedCache,
30
- setStatuslineChecking,
31
- } from "./statusline.js";
32
- import type { SharedCacheEntry } from "./types.js";
33
- import { errorMessage } from "./utils.js";
34
-
35
- export function registerUsageCommand(pi: ExtensionAPI): void {
36
- pi.registerCommand(COMMAND_NAME, {
37
- description: "Show usage for all configured providers (Codex and Anthropic)",
38
- getArgumentCompletions: completeCodexStatusArguments,
39
- handler: async (args, ctx) => {
40
- try {
41
- const options = parseArgs(args);
42
- if (!options.ok) {
43
- ctx.ui.notify(options.error, "warning");
44
- return;
45
- }
46
-
47
- if (options.value.clearStatusline) {
48
- clearUsageStatusline(ctx);
49
- ctx.ui.notify("Usage statusline cleared.", "info");
50
- return;
51
- }
52
-
53
- if (options.value.raw) {
54
- const rawTimeoutMs = options.value.timeoutMs;
55
- void fetchRawUsagePayloads(ctx, rawTimeoutMs)
56
- .then((text) => {
57
- if (isSessionActive()) ctx.ui.notify(text, "info");
58
- })
59
- .catch((error: unknown) => {
60
- if (!handleStaleContextError(ctx, error)) {
61
- ctx.ui.notify(errorMessage(error), "error");
62
- }
63
- });
64
- return;
65
- }
66
-
67
- if (options.value.listBankedResets) {
68
- const resets = await fetchCodexResetCredits(ctx, options.value.timeoutMs);
69
- ctx.ui.notify(formatCodexResetCreditList(resets), "info");
70
- return;
71
- }
72
-
73
- if (options.value.consumeBankedReset) {
74
- const resets = await fetchCodexResetCredits(ctx, options.value.timeoutMs);
75
- if (resets.availableCount <= 0 || resets.credits.length === 0) {
76
- ctx.ui.notify("No Codex banked resets are available.", "info");
77
- return;
78
- }
79
- let resetId = options.value.consumeBankedResetId;
80
- if (!resetId) {
81
- if (!ctx.hasUI) {
82
- ctx.ui.notify(
83
- "Usage: /usage --consume-banked-reset <id> (or run with a TUI to pick interactively)",
84
- "warning",
85
- );
86
- return;
87
- }
88
- const selected = await ctx.ui.select(
89
- "Choose a Codex banked reset to consume",
90
- resets.credits.map((credit) => formatCodexResetCreditChoice(credit)),
91
- );
92
- if (!selected) return;
93
- resetId = resets.credits.find((credit) => formatCodexResetCreditChoice(credit) === selected)?.id;
94
- }
95
- if (!resetId) {
96
- ctx.ui.notify("Could not determine which banked reset to consume.", "warning");
97
- return;
98
- }
99
- await consumeCodexResetCredit(ctx, options.value.timeoutMs, resetId);
100
- clearCodexMemoryCache();
101
- setCombinedCache(undefined);
102
- clearSharedUsageReport("codex");
103
- ctx.ui.notify(`Consumed Codex banked reset ${resetId}.`, "info");
104
- void queryAllUsage(ctx, { timeoutMs: options.value.timeoutMs })
105
- .then((result) => {
106
- if (result.reports.length > 0) {
107
- setCombinedCache({ createdAt: Date.now(), reports: result.reports });
108
- for (const report of result.reports) saveSharedUsageReport(report);
109
- showReports(ctx, result.reports, false);
110
- }
111
- })
112
- .catch(() => {});
113
- return;
114
- }
115
-
116
- const combined = getCombinedCache();
117
- let cached = combined && Date.now() - combined.createdAt < CACHE_TTL_MS ? combined : undefined;
118
- if (!cached) {
119
- // Fall back to reports fetched by other pi sessions.
120
- const shared = readSharedUsageCache();
121
- if (shared) {
122
- const entries = Object.values(shared.entries).filter(
123
- (entry): entry is SharedCacheEntry => !!entry && Date.now() - entry.createdAt < CACHE_TTL_MS,
124
- );
125
- if (entries.length > 0) {
126
- cached = {
127
- createdAt: Math.min(...entries.map((entry) => entry.createdAt)),
128
- reports: entries.map((entry) => entry.report),
129
- };
130
- }
131
- }
132
- }
133
- if (cached && !options.value.refresh) {
134
- if (options.value.statusline) applyCurrentProviderStatusline(ctx, cached.reports);
135
- showReports(ctx, cached.reports, true);
136
- return;
137
- }
138
-
139
- // Fire and forget — return immediately so the command doesn't block.
140
- // Results arrive as a notification once both providers finish.
141
- const cmdOptions = options.value;
142
- // Explicit refresh is a manual override — drop any stored backoff.
143
- if (cmdOptions.refresh) clearSharedBackoff();
144
- if (cmdOptions.statusline) setStatuslineChecking(ctx);
145
- void queryAllUsage(ctx, cmdOptions)
146
- .then((result) => {
147
- if (!isSessionActive()) return;
148
- if (result.reports.length === 0) {
149
- if (cmdOptions.statusline) clearStatuslineValue(ctx);
150
- ctx.ui.notify(formatQueryErrors(result.errors), "error");
151
- return;
152
- }
153
- setCombinedCache({ createdAt: Date.now(), reports: result.reports });
154
- for (const report of result.reports) saveSharedUsageReport(report);
155
- for (const error of result.errors) {
156
- if (isRateLimitErrorMessage(error.message)) {
157
- saveSharedBackoff(
158
- error.source === "anthropic-oauth" ? "anthropic" : "codex",
159
- Date.now() + rateLimitBackoffMs([error]),
160
- );
161
- }
162
- }
163
- const kept = cmdOptions.statusline ? applyCurrentProviderStatusline(ctx, result.reports) : false;
164
- if (cmdOptions.statusline && !kept) clearStatuslineValue(ctx);
165
- showReports(ctx, result.reports, false);
166
- // Surface partial failures (e.g. one provider worked, the other didn't).
167
- if (result.errors.length > 0) {
168
- ctx.ui.notify(formatQueryErrors(result.errors), "warning");
169
- }
170
- })
171
- .catch((error: unknown) => {
172
- if (cmdOptions.statusline) clearStatuslineValue(ctx);
173
- if (!handleStaleContextError(ctx, error)) {
174
- ctx.ui.notify(errorMessage(error), "error");
175
- }
176
- });
177
- // Return right away — the fetch continues in the background.
178
- } catch (error) {
179
- if (handleStaleContextError(ctx, error)) return;
180
- throw error;
181
- }
182
- },
183
- });
184
- }
package/src/constants.ts DELETED
@@ -1,56 +0,0 @@
1
- import { homedir } from "node:os";
2
- import { join } from "node:path";
3
- import type { CommandArgumentCompletion } from "./types.js";
4
-
5
- export const COMMAND_NAME = "usage";
6
- export const CODEX_PROVIDER_ID = "openai-codex";
7
- export const ANTHROPIC_PROVIDER_ID = "anthropic";
8
- export const CODEX_USAGE_URL = "https://chatgpt.com/backend-api/wham/usage";
9
- export const CODEX_RESET_CREDITS_URL = "https://chatgpt.com/backend-api/wham/rate-limit-reset-credits";
10
- export const CODEX_CONSUME_RESET_CREDITS_URL = "https://chatgpt.com/backend-api/wham/rate-limit-reset-credits/consume";
11
- export const CODEX_OPENAI_BETA = "codex-1";
12
- export const ANTHROPIC_OAUTH_USAGE_URL = "https://api.anthropic.com/api/oauth/usage";
13
- export const DEFAULT_TIMEOUT_MS = 15_000;
14
- export const CACHE_TTL_MS = 3 * 60 * 1000;
15
- export const STATUSLINE_RETRY_ATTEMPTS = 3;
16
- export const STATUSLINE_RETRY_DELAY_MS = 1_000;
17
- /** Default backoff after a 429 when the server doesn't say when to retry. */
18
- export const RATE_LIMIT_BACKOFF_MS = 15 * 60 * 1000;
19
- /** Bounds for server-provided Retry-After values. */
20
- export const RATE_LIMIT_BACKOFF_MIN_MS = 30 * 1000;
21
- export const RATE_LIMIT_BACKOFF_MAX_MS = 60 * 60 * 1000;
22
- /** Shared on-disk usage cache so concurrent pi sessions don't each hit the APIs. */
23
- export const SHARED_CACHE_FILE = join(homedir(), ".pi", "agent", "usage-cache.json");
24
- export const SHARED_CACHE_VERSION = 1;
25
- export const STATUS_KEY = "provider-usage";
26
- export const BAR_SEGMENTS = 20;
27
- export const LIMIT_VALUE_COLUMN = 29;
28
- export const MAX_ERROR_BODY_CHARS = 600;
29
- export const RESET_FOREGROUND = "\x1b[39m";
30
- /** Payload keys that are not enterprise budget windows. Everything else that
31
- * looks like a `{ used_dollars, limit_dollars }` object is treated as one —
32
- * Anthropic uses rotating codenames (cinder_cove, amber_ladder, …) so we
33
- * detect windows by shape instead of a hardcoded key list. */
34
- export const ANTHROPIC_NON_WINDOW_KEYS = new Set(["five_hour", "seven_day", "extra_usage"]);
35
-
36
- export const COMMAND_COMPLETIONS: readonly CommandArgumentCompletion[] = [
37
- { value: "--refresh", label: "--refresh", description: "Refresh usage instead of cached data" },
38
- { value: "--no-statusline", label: "--no-statusline", description: "Do not update the statusline" },
39
- {
40
- value: "--clear-statusline",
41
- label: "--clear-statusline",
42
- description: "Clear the usage statusline",
43
- },
44
- { value: "--timeout ", label: "--timeout", description: "Set query timeout in seconds" },
45
- { value: "--raw", label: "--raw", description: "Show raw usage API responses (for debugging)" },
46
- {
47
- value: "--list-banked-resets",
48
- label: "--list-banked-resets",
49
- description: "List available Codex banked resets and expiry dates",
50
- },
51
- {
52
- value: "--consume-banked-reset ",
53
- label: "--consume-banked-reset",
54
- description: "Consume a Codex banked reset by id",
55
- },
56
- ];
package/src/errors.ts DELETED
@@ -1,49 +0,0 @@
1
- import { RATE_LIMIT_BACKOFF_MAX_MS, RATE_LIMIT_BACKOFF_MIN_MS, RATE_LIMIT_BACKOFF_MS } from "./constants.js";
2
- import type { UsageQueryError } from "./types.js";
3
- import { redactErrorBody } from "./utils.js";
4
-
5
- export function isStaleExtensionContextError(error: unknown): boolean {
6
- return (
7
- error instanceof Error && error.message.includes("This extension ctx is stale after session replacement or reload")
8
- );
9
- }
10
-
11
- /** Error thrown for HTTP failures from usage endpoints; carries Retry-After for 429s. */
12
- export class UsageEndpointError extends Error {
13
- retryAfterMs?: number;
14
- constructor(message: string, retryAfterMs?: number) {
15
- super(message);
16
- this.name = "UsageEndpointError";
17
- this.retryAfterMs = retryAfterMs;
18
- }
19
- }
20
-
21
- export function isRateLimitErrorMessage(message: string): boolean {
22
- return /\b429\b|rate_limit_error|rate.?limited/i.test(message);
23
- }
24
-
25
- export function throwUsageEndpointError(provider: string, response: Response, body: string): never {
26
- const message = `${provider} usage endpoint returned ${response.status} ${response.statusText}: ${redactErrorBody(body)}`;
27
- throw new UsageEndpointError(message, response.status === 429 ? parseRetryAfterMs(response) : undefined);
28
- }
29
-
30
- /** Parse a Retry-After header — either delta-seconds or an HTTP date. */
31
- export function parseRetryAfterMs(response: Response): number | undefined {
32
- const header = response.headers.get("retry-after");
33
- if (!header) return undefined;
34
- const seconds = Number(header);
35
- if (Number.isFinite(seconds) && seconds >= 0) return seconds * 1000;
36
- const date = Date.parse(header);
37
- if (!Number.isNaN(date)) return Math.max(0, date - Date.now());
38
- return undefined;
39
- }
40
-
41
- /** Server-provided Retry-After from a failed query, clamped to sane bounds. */
42
- export function rateLimitBackoffMs(errors: UsageQueryError[]): number {
43
- for (const error of errors) {
44
- if (error.cause instanceof UsageEndpointError && error.cause.retryAfterMs !== undefined) {
45
- return Math.min(Math.max(error.cause.retryAfterMs, RATE_LIMIT_BACKOFF_MIN_MS), RATE_LIMIT_BACKOFF_MAX_MS);
46
- }
47
- }
48
- return RATE_LIMIT_BACKOFF_MS;
49
- }
package/src/footer.ts DELETED
@@ -1,188 +0,0 @@
1
- import { dirname, isAbsolute, relative, resolve, sep } from "node:path";
2
- import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
3
- import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
4
- import { STATUS_KEY } from "./constants.js";
5
- import type { FooterDataView, FooterTheme, FooterTui } from "./types.js";
6
- import { asNumber, sanitizeStatusText } from "./utils.js";
7
-
8
- let footerRegistered = false;
9
-
10
- export function isFooterRegistered(): boolean {
11
- return footerRegistered;
12
- }
13
-
14
- export function setFooterRegistered(value: boolean): void {
15
- footerRegistered = value;
16
- }
17
-
18
- export function installUsageFooter(pi: ExtensionAPI, ctx: ExtensionContext): void {
19
- // biome-ignore lint/suspicious/noExplicitAny: footer factory params are provided by the host
20
- ctx.ui.setFooter((tui: any, theme: any, footerData: any) => {
21
- const footerTui = tui as FooterTui;
22
- const footerTheme = theme as FooterTheme;
23
- const footerView = footerData as FooterDataView;
24
- const unsubscribe = footerView.onBranchChange(() => footerTui.requestRender());
25
- return {
26
- dispose: unsubscribe,
27
- invalidate() {},
28
- render(width: number): string[] {
29
- return renderUsageFooter(pi, ctx, footerTheme, footerView, width);
30
- },
31
- };
32
- });
33
- }
34
-
35
- function renderUsageFooter(
36
- pi: ExtensionAPI,
37
- ctx: ExtensionContext,
38
- theme: FooterTheme,
39
- footerData: FooterDataView,
40
- width: number,
41
- ): string[] {
42
- let totalInput = 0;
43
- let totalOutput = 0;
44
- let totalCacheRead = 0;
45
- let totalCacheWrite = 0;
46
- let totalCost = 0;
47
- let latestCacheHitRate: number | undefined;
48
-
49
- for (const entry of ctx.sessionManager.getEntries()) {
50
- const message = (entry as { type?: string; message?: unknown }).message as
51
- | { role?: string; usage?: Record<string, unknown> }
52
- | undefined;
53
- if ((entry as { type?: string }).type !== "message" || message?.role !== "assistant") continue;
54
- const usage = message.usage ?? {};
55
- const input = asNumber(usage.input) ?? 0;
56
- const output = asNumber(usage.output) ?? 0;
57
- const cacheRead = asNumber(usage.cacheRead) ?? 0;
58
- const cacheWrite = asNumber(usage.cacheWrite) ?? 0;
59
- const cost = usage.cost as { total?: unknown } | undefined;
60
- totalInput += input;
61
- totalOutput += output;
62
- totalCacheRead += cacheRead;
63
- totalCacheWrite += cacheWrite;
64
- totalCost += asNumber(cost?.total) ?? 0;
65
- const prompt = input + cacheRead + cacheWrite;
66
- latestCacheHitRate = prompt > 0 ? (cacheRead / prompt) * 100 : undefined;
67
- }
68
-
69
- const contextUsage = ctx.getContextUsage();
70
- const contextWindow = contextUsage?.contextWindow ?? ctx.model?.contextWindow ?? 0;
71
- const contextPercentValue = contextUsage?.percent ?? 0;
72
- const contextPercent = contextUsage && contextUsage.percent !== null ? contextPercentValue.toFixed(1) : "?";
73
-
74
- let pwd = formatCwdForFooter(ctx.sessionManager.getCwd(), process.env.HOME || process.env.USERPROFILE);
75
- const branch = footerData.getGitBranch();
76
- if (branch) pwd = `${pwd} (${branch})`;
77
- const sessionName = ctx.sessionManager.getSessionName();
78
- if (sessionName) pwd = `${pwd} \u2022 ${sessionName}`;
79
-
80
- const statsParts: string[] = [];
81
- if (totalInput) statsParts.push(`\u2191${formatFooterTokens(totalInput)}`);
82
- if (totalOutput) statsParts.push(`\u2193${formatFooterTokens(totalOutput)}`);
83
- if (totalCacheRead) statsParts.push(`R${formatFooterTokens(totalCacheRead)}`);
84
- if (totalCacheWrite) statsParts.push(`W${formatFooterTokens(totalCacheWrite)}`);
85
- if ((totalCacheRead > 0 || totalCacheWrite > 0) && latestCacheHitRate !== undefined) {
86
- statsParts.push(`CH${latestCacheHitRate.toFixed(1)}%`);
87
- }
88
- const usingSubscription = ctx.model ? ctx.modelRegistry.isUsingOAuth(ctx.model) : false;
89
- if (totalCost || usingSubscription) {
90
- statsParts.push(`$${totalCost.toFixed(3)}${usingSubscription ? " (sub)" : ""}`);
91
- }
92
- const contextDisplay =
93
- contextPercent === "?"
94
- ? `?/${formatFooterTokens(contextWindow)}`
95
- : `${contextPercent}%/${formatFooterTokens(contextWindow)}`;
96
- let contextStr = contextDisplay;
97
- if (contextPercentValue > 90) contextStr = theme.fg("error", contextDisplay);
98
- else if (contextPercentValue > 70) contextStr = theme.fg("warning", contextDisplay);
99
- statsParts.push(contextStr);
100
-
101
- let statsLeft = statsParts.join(" ");
102
- let statsLeftWidth = visibleWidth(statsLeft);
103
- if (statsLeftWidth > width) {
104
- statsLeft = truncateToWidth(statsLeft, width, "...");
105
- statsLeftWidth = visibleWidth(statsLeft);
106
- }
107
-
108
- const modelName = ctx.model?.id || "no-model";
109
- let rightSide = modelName;
110
- if (ctx.model?.reasoning) {
111
- const level = pi.getThinkingLevel?.() ?? "off";
112
- rightSide = level === "off" ? `${modelName} \u2022 thinking off` : `${modelName} \u2022 ${level}`;
113
- }
114
- if (footerData.getAvailableProviderCount() > 1 && ctx.model) {
115
- const withProvider = `(${ctx.model.provider}) ${rightSide}`;
116
- if (statsLeftWidth + 2 + visibleWidth(withProvider) <= width) rightSide = withProvider;
117
- }
118
-
119
- const rightSideWidth = visibleWidth(rightSide);
120
- let statsLine: string;
121
- if (statsLeftWidth + 2 + rightSideWidth <= width) {
122
- statsLine = statsLeft + " ".repeat(width - statsLeftWidth - rightSideWidth) + rightSide;
123
- } else {
124
- const availableForRight = width - statsLeftWidth - 2;
125
- if (availableForRight > 0) {
126
- const truncatedRight = truncateToWidth(rightSide, availableForRight, "");
127
- const pad = Math.max(0, width - statsLeftWidth - visibleWidth(truncatedRight));
128
- statsLine = statsLeft + " ".repeat(pad) + truncatedRight;
129
- } else {
130
- statsLine = statsLeft;
131
- }
132
- }
133
-
134
- const dimStatsLeft = theme.fg("dim", statsLeft);
135
- const dimRemainder = theme.fg("dim", statsLine.slice(statsLeft.length));
136
- const pwdLine = truncateToWidth(theme.fg("dim", pwd), width, theme.fg("dim", "..."));
137
- const lines = [pwdLine, dimStatsLeft + dimRemainder];
138
-
139
- const statusLine = renderUsageStatusLine(theme, footerData, width);
140
- if (statusLine !== undefined) lines.push(statusLine);
141
- return lines;
142
- }
143
-
144
- function renderUsageStatusLine(theme: FooterTheme, footerData: FooterDataView, width: number): string | undefined {
145
- const statuses = footerData.getExtensionStatuses();
146
- if (statuses.size === 0) return undefined;
147
-
148
- const ours = statuses.get(STATUS_KEY);
149
- const others = Array.from(statuses.entries())
150
- .filter(([key]) => key !== STATUS_KEY)
151
- .sort(([a], [b]) => a.localeCompare(b))
152
- .map(([, text]) => sanitizeStatusText(text));
153
- const left = others.join(" ");
154
-
155
- if (!ours) {
156
- return left ? truncateToWidth(left, width, theme.fg("dim", "...")) : undefined;
157
- }
158
-
159
- const right = sanitizeStatusText(ours);
160
- const leftWidth = visibleWidth(left);
161
- const rightWidth = visibleWidth(right);
162
- if (leftWidth + 1 + rightWidth > width) {
163
- // Not enough room to right-align cleanly; keep usage visible on its own line.
164
- return truncateToWidth(left ? `${left} ${right}` : right, width, theme.fg("dim", "..."));
165
- }
166
- const padding = " ".repeat(Math.max(1, width - leftWidth - rightWidth));
167
- return left + padding + right;
168
- }
169
-
170
- function formatFooterTokens(count: number): string {
171
- if (count < 1000) return count.toString();
172
- if (count < 10000) return `${(count / 1000).toFixed(1)}k`;
173
- if (count < 1000000) return `${Math.round(count / 1000)}k`;
174
- if (count < 10000000) return `${(count / 1000000).toFixed(1)}M`;
175
- return `${Math.round(count / 1000000)}M`;
176
- }
177
-
178
- function formatCwdForFooter(cwd: string, home: string | undefined): string {
179
- if (!home) return cwd;
180
- const resolvedCwd = resolve(cwd);
181
- const resolvedHome = resolve(home);
182
- const relativeToHome = relative(resolvedHome, resolvedCwd);
183
- const isInsideHome =
184
- relativeToHome === "" ||
185
- (relativeToHome !== ".." && !relativeToHome.startsWith(`..${sep}`) && !isAbsolute(relativeToHome));
186
- if (!isInsideHome) return cwd;
187
- return relativeToHome === "" ? "~" : `~${sep}${relativeToHome}`;
188
- }