rinco-pi-hud 1.0.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 (45) hide show
  1. package/LICENSE +21 -0
  2. package/NOTICE +18 -0
  3. package/README.md +204 -0
  4. package/docs/CONTRIBUTING.md +110 -0
  5. package/docs/README.zh-CN.md +204 -0
  6. package/docs/footer.md +139 -0
  7. package/extensions/hud/commands/settings.ts +765 -0
  8. package/extensions/hud/config/config-counts.ts +87 -0
  9. package/extensions/hud/config/config.ts +65 -0
  10. package/extensions/hud/config/file-io.ts +128 -0
  11. package/extensions/hud/config/normalize.ts +374 -0
  12. package/extensions/hud/config/persist.ts +277 -0
  13. package/extensions/hud/config/schema.ts +303 -0
  14. package/extensions/hud/footer/format.ts +279 -0
  15. package/extensions/hud/footer/index.ts +543 -0
  16. package/extensions/hud/footer/layout.ts +183 -0
  17. package/extensions/hud/footer/render-labels.ts +305 -0
  18. package/extensions/hud/index.ts +466 -0
  19. package/extensions/hud/segments/extension-status.ts +88 -0
  20. package/extensions/hud/segments/git.ts +384 -0
  21. package/extensions/hud/segments/mcp-status.ts +25 -0
  22. package/extensions/hud/segments/package-version.ts +651 -0
  23. package/extensions/hud/segments/project-state.ts +67 -0
  24. package/extensions/hud/segments/runtime-definitions.ts +695 -0
  25. package/extensions/hud/segments/runtime.ts +167 -0
  26. package/extensions/hud/segments/skill-activity.ts +152 -0
  27. package/extensions/hud/session/context.ts +19 -0
  28. package/extensions/hud/session/lifecycle.ts +60 -0
  29. package/extensions/hud/session/live-context.ts +75 -0
  30. package/extensions/hud/state/index.ts +67 -0
  31. package/extensions/hud/state/project-refresh.ts +104 -0
  32. package/extensions/hud/state/telemetry.ts +261 -0
  33. package/extensions/hud/telemetry/codex-usage/LICENSE +21 -0
  34. package/extensions/hud/telemetry/codex-usage/app-server-client.ts +231 -0
  35. package/extensions/hud/telemetry/codex-usage/format.ts +278 -0
  36. package/extensions/hud/telemetry/codex-usage/index.ts +462 -0
  37. package/extensions/hud/telemetry/codex-usage/normalize.ts +264 -0
  38. package/extensions/hud/telemetry/codex-usage/query.ts +207 -0
  39. package/extensions/hud/telemetry/codex-usage/safety.ts +27 -0
  40. package/extensions/hud/telemetry/codex-usage/types.ts +137 -0
  41. package/extensions/hud/telemetry/format.ts +434 -0
  42. package/extensions/hud/telemetry/token-switch.ts +114 -0
  43. package/extensions/hud/ui/icons.ts +239 -0
  44. package/extensions/hud/ui/style.ts +276 -0
  45. package/package.json +56 -0
@@ -0,0 +1,278 @@
1
+ import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
+ import type {
3
+ CodexUsageModel,
4
+ CodexUsageReport,
5
+ NormalizedRateLimitSnapshot,
6
+ NormalizedRateLimitWindow,
7
+ UsageQueryError,
8
+ } from "./types.ts";
9
+
10
+ const USAGE_SETTINGS_URL = "https://chatgpt.com/codex/settings/usage";
11
+ const BAR_SEGMENTS = 20;
12
+ const LIMIT_VALUE_COLUMN = 29;
13
+ const RESET_FOREGROUND = "\x1b[39m";
14
+
15
+ function isOpenAICodexModel(model: Pick<CodexUsageModel, "provider"> | undefined): boolean {
16
+ return model?.provider === "openai-codex";
17
+ }
18
+
19
+ export function formatCodexUsageReport(report: CodexUsageReport, _cacheAgeMs?: number): string {
20
+ const lines = [
21
+ " >_ OpenAI Codex Usage",
22
+ "",
23
+ `Visit ${USAGE_SETTINGS_URL} for up-to-date`,
24
+ "information on rate limits and credits",
25
+ "",
26
+ ];
27
+
28
+ for (const snapshot of report.snapshots) {
29
+ const label = snapshot.limitName ?? snapshot.limitId;
30
+ if (!isPrimaryCodexSnapshot(snapshot)) {
31
+ lines.push(` ${label} limit:`);
32
+ }
33
+ if (snapshot.primary) lines.push(formatWindowLine(snapshot.primary, "5h"));
34
+ if (snapshot.secondary) lines.push(formatWindowLine(snapshot.secondary, "weekly"));
35
+ if (!snapshot.primary && !snapshot.secondary) {
36
+ lines.push(" Limits unavailable for this account");
37
+ }
38
+ }
39
+
40
+ if (report.resetCredits) {
41
+ if (report.snapshots.length > 0) lines.push("");
42
+ lines.push(
43
+ ` ${"Usage limit resets:".padEnd(LIMIT_VALUE_COLUMN)}${report.resetCredits.availableCount} available`,
44
+ );
45
+ }
46
+
47
+ return lines.join("\n");
48
+ }
49
+
50
+ export function formatCodexUsageStatusline(
51
+ report: CodexUsageReport,
52
+ model?: CodexUsageModel,
53
+ ): string {
54
+ const snapshot = selectSnapshotForModel(report, model);
55
+ if (!snapshot) return "usage unavailable";
56
+
57
+ const weekly = selectWeeklyWindow(snapshot);
58
+ if (weekly) return formatStatuslineWindow(snapshot, weekly);
59
+
60
+ // A model-specific bucket may omit the long window; fall back to the account-wide one.
61
+ const codexSnapshot = report.snapshots.find(isPrimaryCodexSnapshot);
62
+ if (codexSnapshot && codexSnapshot !== snapshot) {
63
+ const fallbackWeekly = selectWeeklyWindow(codexSnapshot);
64
+ if (fallbackWeekly) return formatStatuslineWindow(codexSnapshot, fallbackWeekly);
65
+ }
66
+
67
+ return `${formatStatuslinePrefix(snapshot)} weekly unavailable`;
68
+ }
69
+
70
+ function formatStatuslineWindow(
71
+ snapshot: NormalizedRateLimitSnapshot,
72
+ window: NormalizedRateLimitWindow,
73
+ ): string {
74
+ return `${formatStatuslinePrefix(snapshot)} ${formatRemainingPercent(window)} ${formatWindowLabel(window, "weekly", true)}`;
75
+ }
76
+
77
+ // Prefer the secondary (long) window, but some accounts only report a single
78
+ // long window as `primary` — treat any window of a day or more as the weekly one.
79
+ function selectWeeklyWindow(
80
+ snapshot: NormalizedRateLimitSnapshot,
81
+ ): NormalizedRateLimitWindow | undefined {
82
+ if (snapshot.secondary) return snapshot.secondary;
83
+ if (snapshot.primary && isLongWindow(snapshot.primary)) return snapshot.primary;
84
+ return undefined;
85
+ }
86
+
87
+ function isLongWindow(window: NormalizedRateLimitWindow): boolean {
88
+ const minutes = window.windowMinutes;
89
+ return minutes !== undefined && Number.isFinite(minutes) && minutes >= 1_440;
90
+ }
91
+
92
+ function selectSnapshotForModel(
93
+ report: CodexUsageReport,
94
+ model: CodexUsageModel | undefined,
95
+ ): NormalizedRateLimitSnapshot | undefined {
96
+ const codexSnapshot = report.snapshots.find(isPrimaryCodexSnapshot);
97
+ if (!model || !isOpenAICodexModel(model)) return codexSnapshot ?? report.snapshots[0];
98
+
99
+ const modelKeys = normalizedModelUsageKeys(model);
100
+ const exactMatch = report.snapshots.find((snapshot) =>
101
+ normalizedSnapshotUsageKeys(snapshot).some((key) => modelKeys.has(key)),
102
+ );
103
+ if (exactMatch) return exactMatch;
104
+
105
+ const variants = codexModelVariantKeys(modelKeys);
106
+ for (const variant of variants) {
107
+ const matches = report.snapshots.filter(
108
+ (snapshot) =>
109
+ !isPrimaryCodexSnapshot(snapshot) &&
110
+ normalizedSnapshotUsageKeys(snapshot).some((key) => normalizedKeyHasToken(key, variant)),
111
+ );
112
+ if (matches.length === 1) return matches[0];
113
+ }
114
+
115
+ return codexSnapshot ?? report.snapshots[0];
116
+ }
117
+
118
+ function normalizedModelUsageKeys(model: CodexUsageModel): Set<string> {
119
+ const keys = new Set<string>();
120
+ addNormalizedUsageKey(keys, model.id);
121
+ addNormalizedUsageKey(keys, model.name);
122
+
123
+ for (const key of [...keys]) {
124
+ const codexIndex = key.indexOf("codex");
125
+ if (codexIndex >= 0) keys.add(key.slice(codexIndex));
126
+ }
127
+
128
+ return keys;
129
+ }
130
+
131
+ function normalizedSnapshotUsageKeys(snapshot: NormalizedRateLimitSnapshot): string[] {
132
+ return [normalizedUsageKey(snapshot.limitId), normalizedUsageKey(snapshot.limitName)].filter(
133
+ (key): key is string => key !== undefined,
134
+ );
135
+ }
136
+
137
+ function addNormalizedUsageKey(keys: Set<string>, value: string | undefined): void {
138
+ const key = normalizedUsageKey(value);
139
+ if (key) keys.add(key);
140
+ }
141
+
142
+ function normalizedUsageKey(value: string | undefined): string | undefined {
143
+ const key = value
144
+ ?.toLowerCase()
145
+ .replace(/[^a-z0-9]+/g, "-")
146
+ .replace(/^-+|-+$/g, "");
147
+ return key || undefined;
148
+ }
149
+
150
+ function codexModelVariantKeys(modelKeys: Set<string>): string[] {
151
+ const variants = new Set<string>();
152
+ for (const key of modelKeys) {
153
+ const match = key.match(/(?:^|-)codex-(.+)$/);
154
+ if (match?.[1]) variants.add(match[1]);
155
+ }
156
+ return [...variants];
157
+ }
158
+
159
+ function normalizedKeyHasToken(key: string, token: string): boolean {
160
+ return (
161
+ key === token ||
162
+ key.startsWith(`${token}-`) ||
163
+ key.endsWith(`-${token}`) ||
164
+ key.includes(`-${token}-`)
165
+ );
166
+ }
167
+
168
+ function formatStatuslinePrefix(snapshot: NormalizedRateLimitSnapshot): string {
169
+ if (isPrimaryCodexSnapshot(snapshot)) return "codex";
170
+ const label = snapshot.limitName ?? snapshot.limitId;
171
+ return `codex ${compactLimitLabel(label)}`;
172
+ }
173
+
174
+ function compactLimitLabel(label: string): string {
175
+ const normalized = label.replace(/[_-]+/g, " ").trim();
176
+ const codexVariant = normalized.match(/\bcodex\s+(.+)$/i)?.[1]?.trim();
177
+ const compact = codexVariant || normalized;
178
+ return compact.toLowerCase().replace(/\s+/g, " ");
179
+ }
180
+
181
+ function formatRemainingPercent(window: NormalizedRateLimitWindow): string {
182
+ return `${(100 - clampPercent(window.usedPercent)).toFixed(0)}%`;
183
+ }
184
+
185
+ export function showReport(
186
+ ctx: ExtensionCommandContext,
187
+ report: CodexUsageReport,
188
+ fromCache: boolean,
189
+ ): void {
190
+ const text = formatCodexUsageReport(
191
+ report,
192
+ fromCache ? Date.now() - report.capturedAt : undefined,
193
+ );
194
+ ctx.ui.notify(ctx.hasUI ? brightenInfoNotification(text) : text, "info");
195
+ }
196
+
197
+ function brightenInfoNotification(text: string): string {
198
+ return `${RESET_FOREGROUND}${text}`;
199
+ }
200
+
201
+ function isPrimaryCodexSnapshot(snapshot: NormalizedRateLimitSnapshot): boolean {
202
+ return (
203
+ normalizedUsageKey(snapshot.limitId) === "codex" ||
204
+ normalizedUsageKey(snapshot.limitName) === "codex"
205
+ );
206
+ }
207
+
208
+ function formatWindowLine(
209
+ window: NormalizedRateLimitWindow,
210
+ fallback: "5h" | "weekly",
211
+ ): string {
212
+ const label = `${formatWindowLabel(window, fallback, false)} limit:`;
213
+ return ` ${label.padEnd(LIMIT_VALUE_COLUMN)}${formatWindow(window)}`;
214
+ }
215
+
216
+ function formatWindowLabel(
217
+ window: NormalizedRateLimitWindow,
218
+ fallback: "5h" | "weekly",
219
+ compact: boolean,
220
+ ): string {
221
+ const minutes = window.windowMinutes;
222
+ if (!minutes || !Number.isFinite(minutes) || minutes <= 0) {
223
+ return compact && fallback === "weekly" ? "wk" : capitalize(fallback);
224
+ }
225
+ if (minutes === 10_080) return compact ? "wk" : "Weekly";
226
+ if (minutes % 10_080 === 0) return `${minutes / 10_080}w`;
227
+ if (minutes % 1_440 === 0) return `${minutes / 1_440}d`;
228
+ if (minutes % 60 === 0) return `${minutes / 60}h`;
229
+ return `${minutes}m`;
230
+ }
231
+
232
+ function capitalize(value: string): string {
233
+ return `${value[0]?.toUpperCase() ?? ""}${value.slice(1)}`;
234
+ }
235
+
236
+ function formatWindow(window: NormalizedRateLimitWindow): string {
237
+ const remaining = 100 - clampPercent(window.usedPercent);
238
+ const reset = window.resetsAt ? ` (resets ${formatReset(window.resetsAt)})` : "";
239
+ return `${progressBar(remaining)} ${remaining.toFixed(0)}% left${reset}`;
240
+ }
241
+
242
+ function progressBar(percentRemaining: number): string {
243
+ const filled = Math.round((clampPercent(percentRemaining) / 100) * BAR_SEGMENTS);
244
+ return `[${"█".repeat(filled)}${"░".repeat(BAR_SEGMENTS - filled)}]`;
245
+ }
246
+
247
+ function formatReset(epochSeconds: number): string {
248
+ const reset = new Date(epochSeconds * 1000);
249
+ if (Number.isNaN(reset.getTime())) return "at an unknown time";
250
+
251
+ const now = new Date();
252
+ const time = `${reset.getHours().toString().padStart(2, "0")}:${reset
253
+ .getMinutes()
254
+ .toString()
255
+ .padStart(2, "0")}`;
256
+ if (reset.toDateString() === now.toDateString()) return time;
257
+ const day = reset.getDate().toString();
258
+ const month = reset.toLocaleDateString(undefined, { month: "short" });
259
+ return `${time} on ${day} ${month}`;
260
+ }
261
+
262
+ export function formatQueryErrors(errors: UsageQueryError[]): string {
263
+ const lines = ["Unable to read Codex usage."];
264
+ for (const error of errors) {
265
+ const source = error.source === "pi-auth" ? "Pi auth direct" : "Codex app-server fallback";
266
+ lines.push(`- ${source}: ${error.message}`);
267
+ }
268
+ lines.push("");
269
+ lines.push(
270
+ "Tip: use a Pi OpenAI Codex model or run /login for OpenAI ChatGPT Plus/Pro. If Pi auth is unavailable, install Codex CLI and run codex login for the fallback.",
271
+ );
272
+ return lines.join("\n");
273
+ }
274
+
275
+ function clampPercent(value: number): number {
276
+ if (!Number.isFinite(value)) return 0;
277
+ return Math.min(100, Math.max(0, value));
278
+ }