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,462 @@
1
+ import type {
2
+ ExtensionAPI,
3
+ ExtensionCommandContext,
4
+ ExtensionContext,
5
+ } from "@earendil-works/pi-coding-agent";
6
+ import { formatCodexUsageStatusline, formatQueryErrors, showReport } from "./format.ts";
7
+ import { isOpenAICodexModel, isStaleExtensionContextError, queryUsage } from "./query.ts";
8
+ import {
9
+ formatTokenSwitchBalance,
10
+ isTokenSwitchModel,
11
+ queryTokenSwitchBalance,
12
+ } from "../token-switch.js";
13
+ import type {
14
+ CachedReport,
15
+ CodexUsageModel,
16
+ CodexUsageReport,
17
+ QueryUsageOptions,
18
+ } from "./types.ts";
19
+
20
+ const COMMAND_NAME = "codex-status";
21
+ const REFRESH_COMMAND_NAME = "usage-refresh";
22
+ const DEFAULT_TIMEOUT_MS = 15_000;
23
+ const CACHE_TTL_MS = 5 * 60 * 1000;
24
+
25
+ interface CommandArgumentCompletion {
26
+ value: string;
27
+ label: string;
28
+ description?: string;
29
+ }
30
+
31
+ /** Provider-specific hooks driving the shared usage-statusline refresh pipeline. */
32
+ interface UsageProbe<T> {
33
+ readCache: () => { createdAt: number; value: T } | undefined;
34
+ writeCache: (value: T) => void;
35
+ query: () => Promise<T>;
36
+ render: (value: T) => string;
37
+ pendingLabel: string;
38
+ errorLabel: string;
39
+ }
40
+
41
+ const COMMAND_COMPLETIONS: readonly CommandArgumentCompletion[] = [
42
+ { value: "--refresh", label: "--refresh", description: "Refresh usage instead of cached data" },
43
+ {
44
+ value: "--no-statusline",
45
+ label: "--no-statusline",
46
+ description: "Do not update the statusline",
47
+ },
48
+ {
49
+ value: "--clear-statusline",
50
+ label: "--clear-statusline",
51
+ description: "Clear the usage statusline",
52
+ },
53
+ { value: "--timeout ", label: "--timeout", description: "Set query timeout in seconds" },
54
+ ];
55
+
56
+ export default function registerCodexUsage(
57
+ pi: ExtensionAPI,
58
+ onHudStatusChange: (ctx: ExtensionContext, value: string | undefined) => void,
59
+ ) {
60
+ let cache: CachedReport | undefined;
61
+ let tokenSwitchCache: { createdAt: number; balance: number } | undefined;
62
+ let statuslineClearTimer: ReturnType<typeof setTimeout> | undefined;
63
+ let statuslineRefreshTimer: ReturnType<typeof setTimeout> | undefined;
64
+ let statuslineRequestId = 0;
65
+ let sessionGeneration = 0;
66
+ let sessionActive = false;
67
+ let activeStatuslineContext: ExtensionContext | undefined;
68
+ const activeQueries = new Set<AbortController>();
69
+
70
+ const cancelActiveQueries = () => {
71
+ for (const controller of activeQueries) controller.abort();
72
+ activeQueries.clear();
73
+ };
74
+
75
+ const runQuery = async (ctx: ExtensionContext, options: Pick<QueryUsageOptions, "timeoutMs">) => {
76
+ const controller = new AbortController();
77
+ activeQueries.add(controller);
78
+ try {
79
+ return await queryUsage(ctx, options, controller.signal);
80
+ } finally {
81
+ activeQueries.delete(controller);
82
+ }
83
+ };
84
+
85
+ const runTokenSwitchQuery = async (timeoutMs: number) => {
86
+ const controller = new AbortController();
87
+ activeQueries.add(controller);
88
+ try {
89
+ return await queryTokenSwitchBalance({ timeoutMs, signal: controller.signal });
90
+ } finally {
91
+ activeQueries.delete(controller);
92
+ }
93
+ };
94
+
95
+ const clearStatuslineTimers = () => {
96
+ if (statuslineClearTimer) clearTimeout(statuslineClearTimer);
97
+ if (statuslineRefreshTimer) clearTimeout(statuslineRefreshTimer);
98
+ statuslineClearTimer = undefined;
99
+ statuslineRefreshTimer = undefined;
100
+ };
101
+
102
+ const handleStaleContextError = (ctx: ExtensionContext, error: unknown): boolean => {
103
+ if (!isStaleExtensionContextError(error)) return false;
104
+ if (ctx === activeStatuslineContext) {
105
+ statuslineRequestId += 1;
106
+ clearStatuslineTimers();
107
+ activeStatuslineContext = undefined;
108
+ }
109
+ return true;
110
+ };
111
+
112
+ const rethrowUnlessStaleContextError = (ctx: ExtensionContext) => (error: unknown) => {
113
+ if (!handleStaleContextError(ctx, error)) throw error;
114
+ };
115
+
116
+ const setStatuslineValue = (ctx: ExtensionContext, value: string | undefined): boolean => {
117
+ try {
118
+ onHudStatusChange(ctx, value);
119
+ return true;
120
+ } catch (error) {
121
+ if (handleStaleContextError(ctx, error)) return false;
122
+ throw error;
123
+ }
124
+ };
125
+
126
+ const clearUsageStatusline = (ctx: ExtensionContext) => {
127
+ statuslineRequestId += 1;
128
+ clearStatuslineTimers();
129
+ activeStatuslineContext = undefined;
130
+ setStatuslineValue(ctx, undefined);
131
+ };
132
+
133
+ const scheduleTemporaryStatuslineClear = (ctx: ExtensionContext) => {
134
+ if (statuslineClearTimer) clearTimeout(statuslineClearTimer);
135
+ const requestId = statuslineRequestId;
136
+ statuslineClearTimer = setTimeout(() => {
137
+ statuslineClearTimer = undefined;
138
+ if (!sessionActive || requestId !== statuslineRequestId) return;
139
+ setStatuslineValue(ctx, undefined);
140
+ }, CACHE_TTL_MS);
141
+ statuslineClearTimer.unref?.();
142
+ };
143
+
144
+ const scheduleStatuslineRefresh = (ctx: ExtensionContext) => {
145
+ if (statuslineRefreshTimer) clearTimeout(statuslineRefreshTimer);
146
+ const requestId = statuslineRequestId;
147
+ statuslineRefreshTimer = setTimeout(() => {
148
+ statuslineRefreshTimer = undefined;
149
+ if (!sessionActive || requestId !== statuslineRequestId) return;
150
+ // Resolve the model when the timer fires: it may differ from the one that
151
+ // scheduled this refresh.
152
+ void refreshCurrentUsageStatusline(ctx, true).catch(rethrowUnlessStaleContextError(ctx));
153
+ }, CACHE_TTL_MS);
154
+ statuslineRefreshTimer.unref?.();
155
+ };
156
+
157
+ const setUsageStatusline = (
158
+ ctx: ExtensionContext,
159
+ report: CodexUsageReport,
160
+ options: { autoRefresh: boolean; model: CodexUsageModel | undefined },
161
+ ) => {
162
+ if (!setStatuslineValue(ctx, formatCodexUsageStatusline(report, options.model))) return;
163
+ activeStatuslineContext = ctx;
164
+ if (statuslineClearTimer) clearTimeout(statuslineClearTimer);
165
+ statuslineClearTimer = undefined;
166
+ if (options.autoRefresh) scheduleStatuslineRefresh(ctx);
167
+ else scheduleTemporaryStatuslineClear(ctx);
168
+ };
169
+
170
+ /**
171
+ * Shared refresh pipeline for every provider-specific usage probe: it owns the
172
+ * cache TTL, the request/generation guards, and the rule that a stale-but-valid
173
+ * reading always beats a placeholder or an error label.
174
+ */
175
+ const refreshUsageStatusline = async <T>(
176
+ ctx: ExtensionContext,
177
+ force: boolean,
178
+ probe: UsageProbe<T>,
179
+ ) => {
180
+ if (!sessionActive) return;
181
+ activeStatuslineContext = ctx;
182
+ const requestId = statuslineRequestId + 1;
183
+ statuslineRequestId = requestId;
184
+ const generation = sessionGeneration;
185
+ const isCurrent = () =>
186
+ sessionActive && generation === sessionGeneration && requestId === statuslineRequestId;
187
+
188
+ const entry = probe.readCache();
189
+ const cached = entry && Date.now() - entry.createdAt < CACHE_TTL_MS ? entry.value : undefined;
190
+ const showValue = (value: T) => {
191
+ if (!setStatuslineValue(ctx, probe.render(value))) return;
192
+ activeStatuslineContext = ctx;
193
+ if (statuslineClearTimer) clearTimeout(statuslineClearTimer);
194
+ statuslineClearTimer = undefined;
195
+ scheduleStatuslineRefresh(ctx);
196
+ };
197
+
198
+ if (cached !== undefined && !force) {
199
+ showValue(cached);
200
+ return;
201
+ }
202
+
203
+ const placeholder = cached !== undefined ? probe.render(cached) : probe.pendingLabel;
204
+ if (!setStatuslineValue(ctx, placeholder)) return;
205
+
206
+ let value: T;
207
+ try {
208
+ value = await probe.query();
209
+ } catch (error) {
210
+ // A replaced session must surface as a stale-context error, not as a label.
211
+ if (isStaleExtensionContextError(error)) throw error;
212
+ if (!isCurrent()) return;
213
+ const fallback = cached !== undefined ? probe.render(cached) : probe.errorLabel;
214
+ if (setStatuslineValue(ctx, fallback)) scheduleStatuslineRefresh(ctx);
215
+ return;
216
+ }
217
+
218
+ if (!isCurrent()) return;
219
+ probe.writeCache(value);
220
+ showValue(value);
221
+ };
222
+
223
+ const queryCodexReport = async (ctx: ExtensionContext): Promise<CodexUsageReport> => {
224
+ const result = await runQuery(ctx, { timeoutMs: DEFAULT_TIMEOUT_MS });
225
+ if (!result.ok) throw new Error("Codex usage query failed.");
226
+ return result.report;
227
+ };
228
+
229
+ const refreshCurrentUsageStatusline = async (
230
+ ctx: ExtensionContext,
231
+ force: boolean,
232
+ model?: CodexUsageModel,
233
+ ) => {
234
+ if (!sessionActive) return;
235
+ activeStatuslineContext = ctx;
236
+ const selectedModel = model ?? ctx.model;
237
+
238
+ if (isTokenSwitchModel(selectedModel)) {
239
+ await refreshUsageStatusline<number>(ctx, force, {
240
+ readCache: () =>
241
+ tokenSwitchCache
242
+ ? { createdAt: tokenSwitchCache.createdAt, value: tokenSwitchCache.balance }
243
+ : undefined,
244
+ writeCache: (balance) => {
245
+ tokenSwitchCache = { createdAt: Date.now(), balance };
246
+ },
247
+ query: () => runTokenSwitchQuery(DEFAULT_TIMEOUT_MS),
248
+ render: formatTokenSwitchBalance,
249
+ pendingLabel: "checking balance",
250
+ errorLabel: "balance error",
251
+ });
252
+ return;
253
+ }
254
+
255
+ if (!isOpenAICodexModel(selectedModel)) {
256
+ clearUsageStatusline(ctx);
257
+ return;
258
+ }
259
+
260
+ await refreshUsageStatusline<CodexUsageReport>(ctx, force, {
261
+ readCache: () => (cache ? { createdAt: cache.createdAt, value: cache.report } : undefined),
262
+ writeCache: (report) => {
263
+ cache = { createdAt: Date.now(), report };
264
+ },
265
+ query: () => queryCodexReport(ctx),
266
+ render: (report) => formatCodexUsageStatusline(report, selectedModel),
267
+ pendingLabel: "checking",
268
+ errorLabel: "usage error",
269
+ });
270
+ };
271
+
272
+ const handleCodexStatus = async (args: string, ctx: ExtensionCommandContext) => {
273
+ const commandGeneration = sessionGeneration;
274
+ try {
275
+ const options = parseArgs(args);
276
+ if (!options.ok) {
277
+ ctx.ui.notify(options.error, "warning");
278
+ return;
279
+ }
280
+
281
+ if (options.value.clearStatusline) {
282
+ clearUsageStatusline(ctx);
283
+ ctx.ui.notify("Codex usage cleared from the Sakura Zentui Footer.", "info");
284
+ return;
285
+ }
286
+
287
+ // On a Token Switch model the statusline belongs to the balance probe, so the
288
+ // Codex report is shown without overwriting it.
289
+ const useStatusline = options.value.statusline && !isTokenSwitchModel(ctx.model);
290
+
291
+ const cached = cache && Date.now() - cache.createdAt < CACHE_TTL_MS ? cache : undefined;
292
+ if (cached && !options.value.refresh) {
293
+ if (useStatusline) {
294
+ setUsageStatusline(ctx, cached.report, {
295
+ autoRefresh: isOpenAICodexModel(ctx.model),
296
+ model: ctx.model,
297
+ });
298
+ }
299
+ showReport(ctx, cached.report, true);
300
+ return;
301
+ }
302
+
303
+ let keepStatusline = false;
304
+ const statuslineStarted = useStatusline && setStatuslineValue(ctx, "checking");
305
+ try {
306
+ const result = await runQuery(ctx, options.value);
307
+ if (commandGeneration !== sessionGeneration || !sessionActive) return;
308
+ if (!result.ok) {
309
+ ctx.ui.notify(formatQueryErrors(result.errors), "error");
310
+ return;
311
+ }
312
+
313
+ cache = { createdAt: Date.now(), report: result.report };
314
+ if (useStatusline) {
315
+ setUsageStatusline(ctx, result.report, {
316
+ autoRefresh: isOpenAICodexModel(ctx.model),
317
+ model: ctx.model,
318
+ });
319
+ keepStatusline = true;
320
+ }
321
+ showReport(ctx, result.report, false);
322
+ } finally {
323
+ if (statuslineStarted && !keepStatusline) setStatuslineValue(ctx, undefined);
324
+ }
325
+ } catch (error) {
326
+ if (handleStaleContextError(ctx, error)) return;
327
+ throw error;
328
+ }
329
+ };
330
+
331
+ pi.registerCommand(COMMAND_NAME, {
332
+ description: "Show Codex ChatGPT subscription usage and rate-limit windows",
333
+ getArgumentCompletions: completeCodexStatusArguments,
334
+ handler: handleCodexStatus,
335
+ });
336
+
337
+ pi.registerCommand(REFRESH_COMMAND_NAME, {
338
+ description: "Refresh the model usage or balance shown in the footer now",
339
+ handler: async (_args, ctx) => {
340
+ // Refresh whichever probe owns the statusline for the active provider.
341
+ if (isTokenSwitchModel(ctx.model)) {
342
+ await refreshCurrentUsageStatusline(ctx, true).catch(rethrowUnlessStaleContextError(ctx));
343
+ return;
344
+ }
345
+ await handleCodexStatus("--refresh", ctx);
346
+ },
347
+ });
348
+
349
+ pi.on("session_start", (_event, ctx) => {
350
+ sessionGeneration += 1;
351
+ statuslineRequestId += 1;
352
+ cancelActiveQueries();
353
+ clearStatuslineTimers();
354
+ cache = undefined;
355
+ tokenSwitchCache = undefined;
356
+ activeStatuslineContext = undefined;
357
+ sessionActive = true;
358
+ void refreshCurrentUsageStatusline(ctx, false, ctx.model).catch(
359
+ rethrowUnlessStaleContextError(ctx),
360
+ );
361
+ });
362
+
363
+ pi.on("session_tree", (_event, ctx) => {
364
+ void refreshCurrentUsageStatusline(ctx, false, ctx.model).catch(
365
+ rethrowUnlessStaleContextError(ctx),
366
+ );
367
+ });
368
+
369
+ pi.on("model_select", (event, ctx) => {
370
+ void refreshCurrentUsageStatusline(ctx, true, event.model).catch(
371
+ rethrowUnlessStaleContextError(ctx),
372
+ );
373
+ });
374
+
375
+ pi.on("session_shutdown", (_event, ctx) => {
376
+ sessionActive = false;
377
+ sessionGeneration += 1;
378
+ cancelActiveQueries();
379
+ clearUsageStatusline(ctx);
380
+ });
381
+ }
382
+
383
+ export function completeCodexStatusArguments(
384
+ argumentPrefix: string,
385
+ ): CommandArgumentCompletion[] | null {
386
+ const prefix = argumentPrefix.trimStart();
387
+ if (prefix === "") return [...COMMAND_COMPLETIONS];
388
+
389
+ const trailingSpace = /\s$/.test(prefix);
390
+ const tokens = prefix.trimEnd().split(/\s+/).filter(Boolean);
391
+ const previous = tokens.at(-1);
392
+ if (previous === "--timeout" && trailingSpace) return null;
393
+ if (!trailingSpace && tokens.at(-2) === "--timeout") return null;
394
+
395
+ const current = trailingSpace ? "" : (previous ?? "");
396
+ if (current && !current.startsWith("-")) return null;
397
+
398
+ const currentRaw = trailingSpace ? "" : (prefix.match(/\S+$/)?.[0] ?? "");
399
+ const completionPrefix = trailingSpace
400
+ ? prefix
401
+ : prefix.slice(0, prefix.length - currentRaw.length);
402
+ const matches = COMMAND_COMPLETIONS.filter((item) => item.value.startsWith(current));
403
+ return matches.length > 0
404
+ ? matches.map((item) => ({ ...item, value: `${completionPrefix}${item.value}` }))
405
+ : null;
406
+ }
407
+
408
+ export function parseArgs(
409
+ args: string,
410
+ ): { ok: true; value: QueryUsageOptions } | { ok: false; error: string } {
411
+ const tokens = args.trim().split(/\s+/).filter(Boolean);
412
+ let clearStatusline = false;
413
+ let refresh = false;
414
+ let statusline = true;
415
+ let timeoutMs = DEFAULT_TIMEOUT_MS;
416
+
417
+ for (let index = 0; index < tokens.length; index++) {
418
+ const token = tokens[index];
419
+ if (token === "--clear-statusline") {
420
+ clearStatusline = true;
421
+ continue;
422
+ }
423
+ if (token === "--no-statusline") {
424
+ statusline = false;
425
+ continue;
426
+ }
427
+ if (token === "--refresh") {
428
+ refresh = true;
429
+ continue;
430
+ }
431
+ if (token === "--timeout") {
432
+ const rawValue = tokens[index + 1];
433
+ if (!rawValue)
434
+ return { ok: false, error: "Usage: /codex-status [--refresh] [--timeout seconds]" };
435
+ const parsed = Number(rawValue);
436
+ if (!Number.isFinite(parsed) || parsed <= 0 || parsed > 120) {
437
+ return { ok: false, error: "--timeout must be a number of seconds between 1 and 120." };
438
+ }
439
+ timeoutMs = Math.round(parsed * 1000);
440
+ index += 1;
441
+ continue;
442
+ }
443
+ return {
444
+ ok: false,
445
+ error: `Unknown option: ${token}. Usage: /codex-status [--refresh] [--no-statusline] [--clear-statusline] [--timeout seconds]`,
446
+ };
447
+ }
448
+
449
+ return { ok: true, value: { clearStatusline, refresh, statusline, timeoutMs } };
450
+ }
451
+
452
+ export { formatCodexUsageReport, formatCodexUsageStatusline } from "./format.ts";
453
+ export { normalizeAppServerResponse, normalizeBackendPayload } from "./normalize.ts";
454
+ export { isStaleExtensionContextError } from "./query.ts";
455
+ export type {
456
+ CodexUsageModel,
457
+ CodexUsageReport,
458
+ NormalizedRateLimitResetCredit,
459
+ NormalizedRateLimitResetCredits,
460
+ NormalizedRateLimitSnapshot,
461
+ NormalizedRateLimitWindow,
462
+ } from "./types.ts";