pi-openai-usage 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +196 -0
- package/index.ts +32 -0
- package/package.json +43 -0
- package/src/auth.ts +303 -0
- package/src/color.ts +391 -0
- package/src/config.ts +767 -0
- package/src/diagnostics-reporter.ts +202 -0
- package/src/display-width.ts +83 -0
- package/src/format.ts +356 -0
- package/src/interactive-settings-menu.ts +363 -0
- package/src/progress-bar.ts +163 -0
- package/src/status-controller.ts +280 -0
- package/src/usage-client.ts +144 -0
- package/src/usage-command-facade.ts +103 -0
- package/src/usage-refresh-coordinator.ts +193 -0
- package/src/usage-settings.ts +331 -0
- package/src/usage-snapshot.ts +136 -0
- package/src/usage-state.ts +66 -0
- package/src/visibility.ts +39 -0
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import type { CodexCredentialResolution } from "./auth";
|
|
2
|
+
import type { LoadedUsageConfig } from "./config";
|
|
3
|
+
import { USAGE_ENDPOINT, type UsageFetchError } from "./usage-client";
|
|
4
|
+
import type { UsageStateStore } from "./usage-state";
|
|
5
|
+
|
|
6
|
+
export type DiagnosticsRuntime = {
|
|
7
|
+
hasUI?: boolean;
|
|
8
|
+
model?: {
|
|
9
|
+
provider?: string;
|
|
10
|
+
id?: string;
|
|
11
|
+
};
|
|
12
|
+
signal?: {
|
|
13
|
+
aborted?: boolean;
|
|
14
|
+
};
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export type DiagnosticsReportInput = {
|
|
18
|
+
loaded: LoadedUsageConfig;
|
|
19
|
+
credentials: CodexCredentialResolution;
|
|
20
|
+
usageState: UsageStateStore;
|
|
21
|
+
runtime?: DiagnosticsRuntime;
|
|
22
|
+
endpoint?: string;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export type RedactedDiagnosticValue =
|
|
26
|
+
| string
|
|
27
|
+
| number
|
|
28
|
+
| boolean
|
|
29
|
+
| null
|
|
30
|
+
| RedactedDiagnosticValue[]
|
|
31
|
+
| { [key: string]: RedactedDiagnosticValue };
|
|
32
|
+
|
|
33
|
+
const REDACTED = "<redacted>";
|
|
34
|
+
|
|
35
|
+
export function formatDiagnosticsReport(input: DiagnosticsReportInput): string {
|
|
36
|
+
return ["openai-usage diagnostics", ...formatDiagnosticsLines(input)].join("\n");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function formatDiagnosticsLines(input: DiagnosticsReportInput): string[] {
|
|
40
|
+
const { loaded, credentials, usageState, runtime } = input;
|
|
41
|
+
const endpoint = input.endpoint ?? USAGE_ENDPOINT;
|
|
42
|
+
|
|
43
|
+
return [
|
|
44
|
+
"Diagnostics:",
|
|
45
|
+
` Config path: ${loaded.configPath}`,
|
|
46
|
+
` Project config path: ${loaded.projectConfigPath}`,
|
|
47
|
+
` Global config path: ${loaded.globalConfigPath}`,
|
|
48
|
+
` Project config exists: ${formatBooleanText(loaded.projectConfigExists)}`,
|
|
49
|
+
` Global config exists: ${formatBooleanText(loaded.globalConfigExists)}`,
|
|
50
|
+
` Endpoint: ${endpoint}`,
|
|
51
|
+
" Runtime:",
|
|
52
|
+
` UI available: ${formatBooleanText(runtime?.hasUI === true)}`,
|
|
53
|
+
` Model provider: ${formatOptionalText(runtime?.model?.provider)}`,
|
|
54
|
+
` Model ID: ${formatOptionalText(runtime?.model?.id)}`,
|
|
55
|
+
` Signal aborted: ${formatBooleanText(runtime?.signal?.aborted === true)}`,
|
|
56
|
+
` Last fetch: ${formatDateTime(usageState.getLastAttemptAt())}`,
|
|
57
|
+
` Last success: ${formatDateTime(usageState.getLastSuccessAt())}`,
|
|
58
|
+
` Last error: ${formatLastError(usageState.getLastError())}`,
|
|
59
|
+
` Auth source: ${credentials.diagnostics.source}`,
|
|
60
|
+
` Checked auth sources: ${credentials.diagnostics.checkedSources.join(", ") || "<none>"}`,
|
|
61
|
+
` Has access token: ${formatBooleanText(credentials.diagnostics.hasAccessToken)}`,
|
|
62
|
+
` Has account ID: ${formatBooleanText(credentials.diagnostics.hasAccountId)}`,
|
|
63
|
+
` Account ID: ${credentials.diagnostics.accountId ?? "<redacted or missing>"}`,
|
|
64
|
+
...formatCredentialDetails(credentials),
|
|
65
|
+
` Effective enabled: ${formatBooleanText(loaded.effective.enabled)}`,
|
|
66
|
+
` Effective refresh interval (ms): ${loaded.effective.refreshIntervalMs}`,
|
|
67
|
+
` Config enabled: ${formatBooleanText(loaded.effective.enabled)}`,
|
|
68
|
+
` Refresh interval (ms): ${loaded.effective.refreshIntervalMs}`,
|
|
69
|
+
...formatJsonBlock(" Raw project config:", loaded.raw.project),
|
|
70
|
+
...formatJsonBlock(" Raw global config:", loaded.raw.global),
|
|
71
|
+
];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function redactDiagnosticValue(
|
|
75
|
+
value: unknown,
|
|
76
|
+
keyPath: readonly string[] = [],
|
|
77
|
+
): RedactedDiagnosticValue {
|
|
78
|
+
const key = keyPath.at(-1);
|
|
79
|
+
if (key !== undefined && isSensitiveKey(key)) {
|
|
80
|
+
return REDACTED;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (value === null) return null;
|
|
84
|
+
|
|
85
|
+
if (typeof value === "string") {
|
|
86
|
+
return redactDiagnosticString(value);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (typeof value === "number") {
|
|
90
|
+
return Number.isFinite(value) ? value : null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (typeof value === "boolean") {
|
|
94
|
+
return value;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (Array.isArray(value)) {
|
|
98
|
+
return value.map((entry) => redactDiagnosticValue(entry, keyPath));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (isRecord(value)) {
|
|
102
|
+
const redacted: { [key: string]: RedactedDiagnosticValue } = {};
|
|
103
|
+
for (const [childKey, childValue] of Object.entries(value)) {
|
|
104
|
+
redacted[childKey] = redactDiagnosticValue(childValue, [...keyPath, childKey]);
|
|
105
|
+
}
|
|
106
|
+
return redacted;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return value === undefined ? null : String(value);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function formatCredentialDetails(credentials: CodexCredentialResolution): string[] {
|
|
113
|
+
if (!credentials.ok) {
|
|
114
|
+
return [` Credential error: ${redactDiagnosticString(credentials.error.message)}`];
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const redactedCredentials = credentials.toJSON().credentials;
|
|
118
|
+
return [
|
|
119
|
+
` Credential source: ${redactedCredentials.source}`,
|
|
120
|
+
` Credential account ID (redacted): ${redactedCredentials.accountId}`,
|
|
121
|
+
];
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function formatJsonBlock(label: string, value: Record<string, unknown>): string[] {
|
|
125
|
+
const redacted = redactDiagnosticValue(value);
|
|
126
|
+
const serialized = JSON.stringify(redacted, null, 2) ?? "null";
|
|
127
|
+
return [label, ...serialized.split("\n").map((line) => ` ${line}`)];
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function formatDateTime(value: Date | undefined): string {
|
|
131
|
+
return value === undefined ? "<none>" : value.toISOString();
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function formatLastError(error: UsageFetchError | undefined): string {
|
|
135
|
+
if (error === undefined) return "<none>";
|
|
136
|
+
const message = redactDiagnosticString(error.message);
|
|
137
|
+
if (error.status === undefined) {
|
|
138
|
+
return `${error.kind}: ${message}`;
|
|
139
|
+
}
|
|
140
|
+
return `${error.kind} (${error.status}): ${message}`;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function formatBooleanText(value: boolean): string {
|
|
144
|
+
return value ? "yes" : "no";
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function formatOptionalText(value: string | undefined): string {
|
|
148
|
+
return value === undefined || value.trim().length === 0 ? "<none>" : redactDiagnosticString(value);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function redactDiagnosticString(value: string): string {
|
|
152
|
+
let redacted = value.replace(
|
|
153
|
+
/\bBearer\s+([A-Za-z0-9._~+/=-]+)/giu,
|
|
154
|
+
"Bearer <redacted>",
|
|
155
|
+
);
|
|
156
|
+
|
|
157
|
+
redacted = redacted.replace(
|
|
158
|
+
/\b(access[_ -]?token|refresh[_ -]?token|bearer[_ -]?token|api[_ -]?key|authorization|token)\b(\s*[:=]\s*)(["']?)([^"'\s,;]+)/giu,
|
|
159
|
+
(_match, label: string, separator: string, quote: string, _secret: string) =>
|
|
160
|
+
`${label}${separator}${quote}${REDACTED}${quote}`,
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
redacted = redacted.replace(/\bsk-[A-Za-z0-9_-]{8,}\b/giu, REDACTED);
|
|
164
|
+
redacted = redacted.replace(/\bgh[pousr]_[A-Za-z0-9_]{8,}\b/giu, REDACTED);
|
|
165
|
+
redacted = redacted.replace(
|
|
166
|
+
/\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/gu,
|
|
167
|
+
REDACTED,
|
|
168
|
+
);
|
|
169
|
+
redacted = redacted.replace(
|
|
170
|
+
/\b[A-Za-z0-9_-]*(?:token|secret|api-key|apikey)[A-Za-z0-9_-]*\b/giu,
|
|
171
|
+
(match) => (match.length >= 8 ? REDACTED : match),
|
|
172
|
+
);
|
|
173
|
+
|
|
174
|
+
return isLikelyOpaqueToken(redacted) ? REDACTED : redacted;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function isSensitiveKey(key: string): boolean {
|
|
178
|
+
const normalized = key.toLowerCase().replace(/[^a-z0-9]/gu, "");
|
|
179
|
+
return (
|
|
180
|
+
normalized.includes("token") ||
|
|
181
|
+
normalized.includes("apikey") ||
|
|
182
|
+
normalized.includes("secret") ||
|
|
183
|
+
normalized.includes("password") ||
|
|
184
|
+
normalized.includes("credential") ||
|
|
185
|
+
normalized === "access" ||
|
|
186
|
+
normalized === "authorization" ||
|
|
187
|
+
normalized === "bearer" ||
|
|
188
|
+
normalized === "key" ||
|
|
189
|
+
normalized === "accountid"
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function isLikelyOpaqueToken(value: string): boolean {
|
|
194
|
+
const trimmed = value.trim();
|
|
195
|
+
if (trimmed.length < 20) return false;
|
|
196
|
+
if (!/^[A-Za-z0-9._~+/=-]+$/u.test(trimmed)) return false;
|
|
197
|
+
return /[A-Za-z]/u.test(trimmed) && /\d/u.test(trimmed);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
201
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
202
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
const COMBINING_MARK_RANGES: ReadonlyArray<readonly [number, number]> = [
|
|
2
|
+
[0x0300, 0x036f],
|
|
3
|
+
[0x1ab0, 0x1aff],
|
|
4
|
+
[0x1dc0, 0x1dff],
|
|
5
|
+
[0x20d0, 0x20ff],
|
|
6
|
+
[0xfe20, 0xfe2f],
|
|
7
|
+
];
|
|
8
|
+
|
|
9
|
+
const WIDE_CHARACTER_RANGES: ReadonlyArray<readonly [number, number]> = [
|
|
10
|
+
[0x1100, 0x115f],
|
|
11
|
+
[0x231a, 0x231b],
|
|
12
|
+
[0x2329, 0x232a],
|
|
13
|
+
[0x23e9, 0x23ec],
|
|
14
|
+
[0x23f0, 0x23f0],
|
|
15
|
+
[0x23f3, 0x23f3],
|
|
16
|
+
[0x25fd, 0x25fe],
|
|
17
|
+
[0x2614, 0x2615],
|
|
18
|
+
[0x2648, 0x2653],
|
|
19
|
+
[0x267f, 0x267f],
|
|
20
|
+
[0x2693, 0x2693],
|
|
21
|
+
[0x26a1, 0x26a1],
|
|
22
|
+
[0x26aa, 0x26ab],
|
|
23
|
+
[0x26bd, 0x26be],
|
|
24
|
+
[0x26c4, 0x26c5],
|
|
25
|
+
[0x26ce, 0x26ce],
|
|
26
|
+
[0x26d4, 0x26d4],
|
|
27
|
+
[0x26ea, 0x26ea],
|
|
28
|
+
[0x26f2, 0x26f3],
|
|
29
|
+
[0x26f5, 0x26f5],
|
|
30
|
+
[0x26fa, 0x26fa],
|
|
31
|
+
[0x26fd, 0x26fd],
|
|
32
|
+
[0x2705, 0x2705],
|
|
33
|
+
[0x270a, 0x270b],
|
|
34
|
+
[0x2728, 0x2728],
|
|
35
|
+
[0x274c, 0x274c],
|
|
36
|
+
[0x274e, 0x274e],
|
|
37
|
+
[0x2753, 0x2755],
|
|
38
|
+
[0x2757, 0x2757],
|
|
39
|
+
[0x2795, 0x2797],
|
|
40
|
+
[0x27b0, 0x27b0],
|
|
41
|
+
[0x27bf, 0x27bf],
|
|
42
|
+
[0x2b1b, 0x2b1c],
|
|
43
|
+
[0x2b50, 0x2b50],
|
|
44
|
+
[0x2b55, 0x2b55],
|
|
45
|
+
[0x2e80, 0xa4cf],
|
|
46
|
+
[0xac00, 0xd7a3],
|
|
47
|
+
[0xf900, 0xfaff],
|
|
48
|
+
[0xfe10, 0xfe19],
|
|
49
|
+
[0xfe30, 0xfe6f],
|
|
50
|
+
[0xff00, 0xff60],
|
|
51
|
+
[0xffe0, 0xffe6],
|
|
52
|
+
[0x1f300, 0x1f64f],
|
|
53
|
+
[0x1f680, 0x1f6ff],
|
|
54
|
+
[0x1f900, 0x1f9ff],
|
|
55
|
+
[0x20000, 0x3fffd],
|
|
56
|
+
];
|
|
57
|
+
|
|
58
|
+
export function visibleCellWidth(text: string): number {
|
|
59
|
+
let width = 0;
|
|
60
|
+
for (const char of text) {
|
|
61
|
+
width += codePointCellWidth(char.codePointAt(0) ?? 0);
|
|
62
|
+
}
|
|
63
|
+
return width;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function isSingleCellGlyph(text: string): boolean {
|
|
67
|
+
return Array.from(text).length === 1 && visibleCellWidth(text) === 1;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function codePointCellWidth(codePoint: number): number {
|
|
71
|
+
if (codePoint === 0) return 0;
|
|
72
|
+
if (codePoint < 0x20 || (codePoint >= 0x7f && codePoint < 0xa0)) return 0;
|
|
73
|
+
if (isInRanges(codePoint, COMBINING_MARK_RANGES)) return 0;
|
|
74
|
+
if (isInRanges(codePoint, WIDE_CHARACTER_RANGES)) return 2;
|
|
75
|
+
return 1;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function isInRanges(
|
|
79
|
+
codePoint: number,
|
|
80
|
+
ranges: ReadonlyArray<readonly [number, number]>,
|
|
81
|
+
): boolean {
|
|
82
|
+
return ranges.some(([start, end]) => codePoint >= start && codePoint <= end);
|
|
83
|
+
}
|
package/src/format.ts
ADDED
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
BarConfig,
|
|
3
|
+
ColorConfig,
|
|
4
|
+
UsageConfig,
|
|
5
|
+
ResetWidgetConfig,
|
|
6
|
+
WindowWidgetConfig,
|
|
7
|
+
} from "./config";
|
|
8
|
+
import {
|
|
9
|
+
colorizeProgressBarSegments,
|
|
10
|
+
colorizeUsageText,
|
|
11
|
+
resolveUsageColor,
|
|
12
|
+
type UsageColorTheme,
|
|
13
|
+
} from "./color";
|
|
14
|
+
import { renderProgressBarSegments, type ProgressBarSegment } from "./progress-bar";
|
|
15
|
+
import type { UsageSnapshot } from "./usage-snapshot";
|
|
16
|
+
|
|
17
|
+
const LOGIN_REQUIRED_STATUS_TEXT = "Usage login required";
|
|
18
|
+
const AUTH_FAILED_STATUS_TEXT = "Usage auth failed";
|
|
19
|
+
const REFRESH_FAILED_STATUS_TEXT = "Usage refresh failed";
|
|
20
|
+
const REFRESH_WARNING_TEXT = "refresh failed";
|
|
21
|
+
const VISIBLE_LINE_BREAK_PATTERN = /[\r\n\u2028\u2029]+/gu;
|
|
22
|
+
|
|
23
|
+
export type FormatUsageStatusLineOptions = {
|
|
24
|
+
snapshot: UsageSnapshot | undefined;
|
|
25
|
+
config: UsageConfig;
|
|
26
|
+
now?: Date;
|
|
27
|
+
locale?: string | string[];
|
|
28
|
+
timeZone?: string;
|
|
29
|
+
theme?: UsageColorTheme;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
type FormattedWidget = {
|
|
33
|
+
text: string;
|
|
34
|
+
hasAvailableValue: boolean;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Formats a Usage Snapshot into one status-line string.
|
|
39
|
+
*
|
|
40
|
+
* The formatter returns text only; Pi UI APIs are owned by the status controller.
|
|
41
|
+
* Undefined means the controller should clear the status entry.
|
|
42
|
+
*/
|
|
43
|
+
export function formatUsageStatusLine(
|
|
44
|
+
options: FormatUsageStatusLineOptions,
|
|
45
|
+
): string | undefined {
|
|
46
|
+
if (options.snapshot === undefined) return undefined;
|
|
47
|
+
|
|
48
|
+
const widgets = formatUsageWidgets(options);
|
|
49
|
+
if (widgets.length === 0) return undefined;
|
|
50
|
+
if (!widgets.some((widget) => widget.hasAvailableValue)) return undefined;
|
|
51
|
+
|
|
52
|
+
return toSingleVisibleLine(composeStatusLine(options.config, widgets, options.theme));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function formatUsageLoginRequiredStatusLine(theme?: UsageColorTheme): string {
|
|
56
|
+
return toSingleVisibleLine(formatNeutralText(LOGIN_REQUIRED_STATUS_TEXT, theme));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function formatUsageAuthFailedStatusLine(theme?: UsageColorTheme): string {
|
|
60
|
+
return toSingleVisibleLine(formatNeutralText(AUTH_FAILED_STATUS_TEXT, theme));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function formatUsageRefreshFailedStatusLine(theme?: UsageColorTheme): string {
|
|
64
|
+
return toSingleVisibleLine(formatNeutralText(REFRESH_FAILED_STATUS_TEXT, theme));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function appendUsageRefreshFailureMarker(
|
|
68
|
+
statusText: string,
|
|
69
|
+
theme?: UsageColorTheme,
|
|
70
|
+
): string {
|
|
71
|
+
const markerText = theme?.fg("warning", REFRESH_WARNING_TEXT) ?? REFRESH_WARNING_TEXT;
|
|
72
|
+
return `${statusText}${formatNeutralText(" (", theme)}${markerText}${formatNeutralText(")", theme)}`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function formatUsageWidgets(options: FormatUsageStatusLineOptions): FormattedWidget[] {
|
|
76
|
+
const { config, snapshot } = options;
|
|
77
|
+
if (snapshot === undefined) return [];
|
|
78
|
+
|
|
79
|
+
return [
|
|
80
|
+
formatWindowWidget(snapshot.fiveHourLeftPercent, config.widgets.fiveHour, options),
|
|
81
|
+
formatWindowWidget(snapshot.sevenDayLeftPercent, config.widgets.sevenDay, options),
|
|
82
|
+
formatResetWidget(snapshot.fiveHourResetInSeconds, config.widgets.fiveHourReset, options),
|
|
83
|
+
formatResetWidget(snapshot.sevenDayResetInSeconds, config.widgets.sevenDayReset, options),
|
|
84
|
+
].filter((widget): widget is FormattedWidget => widget !== undefined);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function composeStatusLine(
|
|
88
|
+
config: UsageConfig,
|
|
89
|
+
widgets: readonly FormattedWidget[],
|
|
90
|
+
theme?: UsageColorTheme,
|
|
91
|
+
): string {
|
|
92
|
+
const body = widgets.map((widget) => widget.text).join(formatNeutralText(config.display.separator, theme));
|
|
93
|
+
const label = formatStatusLabel(config);
|
|
94
|
+
return label === undefined ? body : `${formatNeutralText(`${label}: `, theme)}${body}`;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function formatStatusLabel(config: UsageConfig): string | undefined {
|
|
98
|
+
if (!config.display.showLabel) return undefined;
|
|
99
|
+
|
|
100
|
+
const label = config.display.label.trim();
|
|
101
|
+
return label.length > 0 ? label : undefined;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function formatWindowWidget(
|
|
105
|
+
percent: number | null,
|
|
106
|
+
config: WindowWidgetConfig,
|
|
107
|
+
options: FormatUsageStatusLineOptions,
|
|
108
|
+
): FormattedWidget | undefined {
|
|
109
|
+
if (!isWidgetVisible(config)) return undefined;
|
|
110
|
+
|
|
111
|
+
return {
|
|
112
|
+
text: formatWindowWidgetText(percent, config, options),
|
|
113
|
+
hasAvailableValue: isFiniteNumber(percent),
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function formatWindowWidgetText(
|
|
118
|
+
percent: number | null,
|
|
119
|
+
config: WindowWidgetConfig,
|
|
120
|
+
options: FormatUsageStatusLineOptions,
|
|
121
|
+
): string {
|
|
122
|
+
const barConfig = options.config.bar;
|
|
123
|
+
const colorContext = {
|
|
124
|
+
colors: options.config.colors,
|
|
125
|
+
theme: options.theme,
|
|
126
|
+
isLimited: options.snapshot?.isLimited,
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
switch (config.mode) {
|
|
130
|
+
case "percent":
|
|
131
|
+
return formatColorableWindowWidget({
|
|
132
|
+
labelPrefix: `${config.label}: `,
|
|
133
|
+
percentText: formatPercent(percent),
|
|
134
|
+
percent,
|
|
135
|
+
context: colorContext,
|
|
136
|
+
});
|
|
137
|
+
case "bar": {
|
|
138
|
+
const barSegments = renderProgressBarSegments(percent, barConfig);
|
|
139
|
+
return formatColorableWindowWidget({
|
|
140
|
+
labelPrefix: `${config.label} `,
|
|
141
|
+
barSegments,
|
|
142
|
+
percent,
|
|
143
|
+
context: colorContext,
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
case "bar-percent": {
|
|
147
|
+
const barSegments = renderProgressBarSegments(percent, barConfig);
|
|
148
|
+
return formatColorableWindowWidget({
|
|
149
|
+
labelPrefix: `${config.label} `,
|
|
150
|
+
barSegments,
|
|
151
|
+
percentText: formatPercent(percent),
|
|
152
|
+
percent,
|
|
153
|
+
context: colorContext,
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
case "hidden":
|
|
157
|
+
return config.label;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
type WindowColorContext = {
|
|
162
|
+
colors: ColorConfig;
|
|
163
|
+
theme?: UsageColorTheme;
|
|
164
|
+
isLimited?: boolean;
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
type ColorableWindowWidgetParts = {
|
|
168
|
+
labelPrefix: string;
|
|
169
|
+
barSegments?: readonly ProgressBarSegment[];
|
|
170
|
+
percentText?: string;
|
|
171
|
+
percent: number | null;
|
|
172
|
+
context: WindowColorContext;
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
function formatColorableWindowWidget(parts: ColorableWindowWidgetParts): string {
|
|
176
|
+
const barText = formatBarText(parts.barSegments);
|
|
177
|
+
const valueText = joinWindowValueParts(barText, parts.percentText);
|
|
178
|
+
const uncoloredText = `${parts.labelPrefix}${valueText}`;
|
|
179
|
+
const neutralLabelPrefix = formatNeutralText(parts.labelPrefix, parts.context.theme);
|
|
180
|
+
const neutralBarText = formatNeutralText(barText, parts.context.theme);
|
|
181
|
+
const usesGradient = usesLayeredBarGradient(parts.context.colors, parts.barSegments);
|
|
182
|
+
|
|
183
|
+
switch (parts.context.colors.target) {
|
|
184
|
+
case "widget":
|
|
185
|
+
if (!usesGradient) return colorizeWindowText(uncoloredText, parts);
|
|
186
|
+
return `${colorizeWindowText(parts.labelPrefix, parts)}${formatColorableBar(parts)}${formatColorablePercentWithPrefix(
|
|
187
|
+
parts,
|
|
188
|
+
true,
|
|
189
|
+
)}`;
|
|
190
|
+
case "value":
|
|
191
|
+
return `${neutralLabelPrefix}${formatColorableValue(parts)}`;
|
|
192
|
+
case "bar":
|
|
193
|
+
return `${neutralLabelPrefix}${formatColorableBar(parts)}${formatColorablePercentWithPrefix(parts, false)}`;
|
|
194
|
+
case "percent":
|
|
195
|
+
return `${neutralLabelPrefix}${neutralBarText}${formatColorablePercentWithPrefix(parts, true)}`;
|
|
196
|
+
case "none":
|
|
197
|
+
return formatNeutralText(uncoloredText, parts.context.theme);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function formatColorableValue(parts: ColorableWindowWidgetParts): string {
|
|
202
|
+
if (parts.barSegments === undefined) return colorizeWindowText(parts.percentText ?? "", parts);
|
|
203
|
+
return `${formatColorableBar(parts)}${formatColorablePercentWithPrefix(parts, false)}`;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function formatColorableBar(parts: ColorableWindowWidgetParts): string {
|
|
207
|
+
if (parts.barSegments === undefined) return "";
|
|
208
|
+
|
|
209
|
+
if (usesLayeredBarGradient(parts.context.colors, parts.barSegments)) {
|
|
210
|
+
return colorizeProgressBarSegments({
|
|
211
|
+
segments: parts.barSegments,
|
|
212
|
+
percent: parts.percent,
|
|
213
|
+
colors: parts.context.colors,
|
|
214
|
+
theme: parts.context.theme,
|
|
215
|
+
isLimited: parts.context.isLimited,
|
|
216
|
+
formatNeutralSegment: (text) => formatNeutralText(text, parts.context.theme),
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return colorizeWindowText(formatBarText(parts.barSegments), parts);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function formatColorablePercentWithPrefix(
|
|
224
|
+
parts: ColorableWindowWidgetParts,
|
|
225
|
+
shouldColor: boolean,
|
|
226
|
+
): string {
|
|
227
|
+
if (parts.percentText === undefined) return "";
|
|
228
|
+
const separator = parts.barSegments === undefined ? "" : " ";
|
|
229
|
+
const percentText = shouldColor
|
|
230
|
+
? colorizeWindowText(parts.percentText, parts)
|
|
231
|
+
: formatNeutralText(parts.percentText, parts.context.theme);
|
|
232
|
+
return `${formatNeutralText(separator, parts.context.theme)}${percentText}`;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function colorizeWindowText(text: string, parts: ColorableWindowWidgetParts): string {
|
|
236
|
+
const color = resolveUsageColor({
|
|
237
|
+
percent: parts.percent,
|
|
238
|
+
colors: parts.context.colors,
|
|
239
|
+
theme: parts.context.theme,
|
|
240
|
+
isLimited: parts.context.isLimited,
|
|
241
|
+
});
|
|
242
|
+
if (color === undefined) return formatNeutralText(text, parts.context.theme);
|
|
243
|
+
|
|
244
|
+
return colorizeUsageText({
|
|
245
|
+
text,
|
|
246
|
+
percent: parts.percent,
|
|
247
|
+
colors: parts.context.colors,
|
|
248
|
+
theme: parts.context.theme,
|
|
249
|
+
isLimited: parts.context.isLimited,
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function formatNeutralText(text: string, theme?: UsageColorTheme): string {
|
|
254
|
+
if (theme === undefined || text.length === 0) return text;
|
|
255
|
+
return theme.fg("dim", text);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function formatBarText(segments: readonly ProgressBarSegment[] | undefined): string {
|
|
259
|
+
if (segments === undefined) return "";
|
|
260
|
+
return segments.map((segment) => segment.text).join("");
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function joinWindowValueParts(barText: string, percentText: string | undefined): string {
|
|
264
|
+
if (barText.length === 0) return percentText ?? "";
|
|
265
|
+
if (percentText === undefined) return barText;
|
|
266
|
+
return `${barText} ${percentText}`;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function usesLayeredBarGradient(
|
|
270
|
+
colors: ColorConfig,
|
|
271
|
+
barSegments: readonly ProgressBarSegment[] | undefined,
|
|
272
|
+
): boolean {
|
|
273
|
+
return (
|
|
274
|
+
barSegments !== undefined &&
|
|
275
|
+
colors.barGradient.enabled &&
|
|
276
|
+
colors.scheme !== "none" &&
|
|
277
|
+
colors.target !== "none" &&
|
|
278
|
+
colors.target !== "percent"
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function formatResetWidget(
|
|
283
|
+
seconds: number | null,
|
|
284
|
+
config: ResetWidgetConfig,
|
|
285
|
+
options: FormatUsageStatusLineOptions,
|
|
286
|
+
): FormattedWidget | undefined {
|
|
287
|
+
if (!isWidgetVisible(config)) return undefined;
|
|
288
|
+
|
|
289
|
+
return {
|
|
290
|
+
text: formatNeutralText(`${config.label} ${formatResetValue(seconds, config.mode, options)}`, options.theme),
|
|
291
|
+
hasAvailableValue: isFiniteNumber(seconds),
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function isWidgetVisible(config: WindowWidgetConfig | ResetWidgetConfig): boolean {
|
|
296
|
+
return config.enabled && config.mode !== "hidden";
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function formatPercent(percent: number | null): string {
|
|
300
|
+
if (!isFiniteNumber(percent)) return "--";
|
|
301
|
+
return `${Math.round(clamp(percent, 0, 100))}%`;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function formatResetValue(
|
|
305
|
+
seconds: number | null,
|
|
306
|
+
mode: ResetWidgetConfig["mode"],
|
|
307
|
+
options: FormatUsageStatusLineOptions,
|
|
308
|
+
): string {
|
|
309
|
+
if (!isFiniteNumber(seconds)) return "--";
|
|
310
|
+
|
|
311
|
+
switch (mode) {
|
|
312
|
+
case "countdown":
|
|
313
|
+
return formatResetCountdown(seconds);
|
|
314
|
+
case "clock":
|
|
315
|
+
return formatResetClock(seconds, options);
|
|
316
|
+
case "both":
|
|
317
|
+
return `${formatResetCountdown(seconds)} - ${formatResetClock(seconds, options)}`;
|
|
318
|
+
case "hidden":
|
|
319
|
+
return "--";
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
export function formatResetCountdown(seconds: number): string {
|
|
324
|
+
const totalSeconds = Math.max(0, Math.round(seconds));
|
|
325
|
+
const days = Math.floor(totalSeconds / 86_400);
|
|
326
|
+
const hours = Math.floor((totalSeconds % 86_400) / 3_600);
|
|
327
|
+
const minutes = Math.floor((totalSeconds % 3_600) / 60);
|
|
328
|
+
const remainingSeconds = totalSeconds % 60;
|
|
329
|
+
|
|
330
|
+
if (days > 0) return `${days}d${hours}h`;
|
|
331
|
+
if (hours > 0) return `${hours}h${minutes}m`;
|
|
332
|
+
if (minutes > 0) return `${minutes}m`;
|
|
333
|
+
return `${remainingSeconds}s`;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function formatResetClock(seconds: number, options: FormatUsageStatusLineOptions): string {
|
|
337
|
+
const now = options.now ?? new Date();
|
|
338
|
+
const resetAt = new Date(now.getTime() + Math.max(0, seconds) * 1000);
|
|
339
|
+
return new Intl.DateTimeFormat(options.locale, {
|
|
340
|
+
hour: "numeric",
|
|
341
|
+
minute: "2-digit",
|
|
342
|
+
timeZone: options.timeZone,
|
|
343
|
+
}).format(resetAt);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function toSingleVisibleLine(text: string): string {
|
|
347
|
+
return text.replace(VISIBLE_LINE_BREAK_PATTERN, " ").trim();
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function isFiniteNumber(value: number | null): value is number {
|
|
351
|
+
return typeof value === "number" && Number.isFinite(value);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function clamp(value: number, min: number, max: number): number {
|
|
355
|
+
return Math.max(min, Math.min(max, value));
|
|
356
|
+
}
|