@narumitw/pi-usage 0.61.2 → 0.63.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.
@@ -29,124 +29,126 @@ export async function showUsageSettings(
29
29
  }
30
30
  const { HorizontalRule, renderBoundedFrame } = await import("@narumitw/pi-tui-kit");
31
31
  if (parentSignal.aborted || !isCurrent()) return false;
32
- return (
33
- (await ctx.ui.custom<boolean>((tui, theme, _keybindings, done) => {
34
- const localController = new AbortController();
35
- const signal = AbortSignal.any([parentSignal, localController.signal]);
36
- let changed = false;
37
- let closing = false;
38
- let saveQueue = Promise.resolve();
39
- const state = settingsRuntime.get();
40
- const items: SettingItem[] = [
41
- {
42
- id: "codexFastMode",
43
- label: "Codex Fast mode",
44
- description: "Use faster Codex routing at increased plan allowance consumption.",
45
- currentValue: state.settings.codexFastMode ? ON : OFF,
46
- values: [OFF, ON],
47
- },
48
- {
49
- id: "codexStatusResetCountdown",
50
- label: "Codex reset countdown",
51
- description: "Show time remaining until each Codex usage limit resets.",
52
- currentValue: state.settings.codexStatusResetCountdown ? ON : OFF,
53
- values: [OFF, ON],
54
- },
55
- {
56
- id: "codexStatusPercentage",
57
- label: "Codex percentage",
58
- description: "Show remaining or used Codex quota in the statusline.",
59
- currentValue: settingValueLabel("codexStatusPercentage", state.settings.codexStatusPercentage),
60
- values: [REMAINING, USED],
61
- },
62
- ];
63
- const rule = new HorizontalRule({ ruleStyle: (text) => theme.fg("border", text) });
32
+ let changed = false;
33
+ return ctx.ui.custom<boolean>((tui, theme, _keybindings, done) => {
34
+ const localController = new AbortController();
35
+ const signal = AbortSignal.any([parentSignal, localController.signal]);
36
+ let closing = false;
37
+ let saveQueue = Promise.resolve();
38
+ const state = settingsRuntime.get();
39
+ const items: SettingItem[] = [
40
+ {
41
+ id: "codexFastMode",
42
+ label: "Codex Fast mode",
43
+ description: "Use faster Codex routing at increased plan allowance consumption.",
44
+ currentValue: state.settings.codexFastMode ? ON : OFF,
45
+ values: [OFF, ON],
46
+ },
47
+ {
48
+ id: "codexStatusResetCountdown",
49
+ label: "Codex reset countdown",
50
+ description: "Show time remaining until each Codex usage limit resets.",
51
+ currentValue: state.settings.codexStatusResetCountdown ? ON : OFF,
52
+ values: [OFF, ON],
53
+ },
54
+ {
55
+ id: "codexStatusPercentage",
56
+ label: "Codex percentage",
57
+ description: "Show remaining or used Codex quota in the statusline.",
58
+ currentValue: settingValueLabel("codexStatusPercentage", state.settings.codexStatusPercentage),
59
+ values: [REMAINING, USED],
60
+ },
61
+ ];
62
+ const rule = new HorizontalRule({ ruleStyle: (text) => theme.fg("border", text) });
64
63
 
65
- let settingsList: SettingsList;
66
- const cancel = () => {
67
- if (closing) return;
68
- closing = true;
69
- localController.abort();
70
- done(changed);
71
- };
72
- const queueUpdate = <Id extends UsageSettingId>(id: Id, requested: UsageSettings[Id], display: string) => {
73
- saveQueue = saveQueue.then(async () => {
74
- const previous = settingsRuntime.get().settings[id];
75
- if (settingsRuntime.get().kind === "invalid") {
76
- settingsList.updateValue(id, settingValueLabel(id, previous));
77
- if (!signal.aborted && isCurrent()) {
78
- ctx.ui.notify("Repair pi-usage.json and reload before changing settings.", "error");
79
- tui.requestRender();
80
- }
81
- return;
82
- }
83
- try {
84
- const patch: Partial<UsageSettings> = {};
85
- patch[id] = requested;
86
- await settingsRuntime.update(patch, signal);
87
- } catch (error) {
88
- if (signal.aborted || !isCurrent()) return;
89
- settingsList.updateValue(id, settingValueLabel(id, previous));
90
- ctx.ui.notify(`Could not save pi-usage.json: ${errorMessage(error)}`, "error");
64
+ let settingsList: SettingsList;
65
+ const close = (result: boolean) => {
66
+ if (closing) return;
67
+ closing = true;
68
+ localController.abort();
69
+ done(result);
70
+ };
71
+ const cancel = () => close(changed);
72
+ const queueUpdate = <Id extends UsageSettingId>(id: Id, requested: UsageSettings[Id], display: string) => {
73
+ saveQueue = saveQueue.then(async () => {
74
+ if (signal.aborted || !isCurrent()) return;
75
+ const previous = settingsRuntime.get().settings[id];
76
+ if (settingsRuntime.get().kind === "invalid") {
77
+ settingsList.updateValue(id, settingValueLabel(id, previous));
78
+ if (!signal.aborted && isCurrent()) {
79
+ ctx.ui.notify("Repair pi-usage.json and reload before changing settings.", "error");
91
80
  tui.requestRender();
92
- return;
93
- }
94
- if (previous !== requested) {
95
- changed = true;
96
- onApplied(id);
97
81
  }
82
+ return;
83
+ }
84
+ try {
85
+ const patch: Partial<UsageSettings> = {};
86
+ patch[id] = requested;
87
+ await settingsRuntime.update(patch, signal);
88
+ } catch (error) {
98
89
  if (signal.aborted || !isCurrent()) return;
99
- settingsList.updateValue(id, display);
90
+ settingsList.updateValue(id, settingValueLabel(id, previous));
91
+ ctx.ui.notify(`Could not save pi-usage.json: ${errorMessage(error)}`, "error");
100
92
  tui.requestRender();
101
- });
102
- };
103
- settingsList = new SettingsList(
104
- items,
105
- items.length + 2,
106
- getSettingsListTheme(),
107
- (id, value) => {
108
- if (closing || signal.aborted || !isCurrent()) return;
109
- if (id === "codexStatusPercentage") {
110
- queueUpdate(id, value === USED ? "used" : "remaining", value);
111
- } else if (id === "codexFastMode" || id === "codexStatusResetCountdown") {
112
- queueUpdate(id, value === ON, value);
113
- }
114
- },
115
- cancel,
116
- );
93
+ return;
94
+ }
95
+ // A rename can win a cancellation race. Apply owned lifecycle cleanup after a
96
+ // durable save, but let the owner guard its session before touching UI.
97
+ if (previous !== requested) {
98
+ changed = true;
99
+ onApplied(id);
100
+ }
101
+ if (signal.aborted || !isCurrent()) return;
102
+ settingsList.updateValue(id, display);
103
+ tui.requestRender();
104
+ });
105
+ };
106
+ settingsList = new SettingsList(
107
+ items,
108
+ items.length + 2,
109
+ getSettingsListTheme(),
110
+ (id, value) => {
111
+ if (closing || signal.aborted || !isCurrent()) return;
112
+ if (id === "codexStatusPercentage") {
113
+ queueUpdate(id, value === USED ? "used" : "remaining", value);
114
+ } else if (id === "codexFastMode" || id === "codexStatusResetCountdown") {
115
+ queueUpdate(id, value === ON, value);
116
+ }
117
+ },
118
+ cancel,
119
+ );
117
120
 
118
- parentSignal.addEventListener("abort", cancel, { once: true });
119
- return {
120
- render(width: number) {
121
- // Compatibility: Kit puts the title directly below the top rule and keeps compact
122
- // rules when at least five rows fit; both replace the legacy wrapper layout.
123
- const title = new Text(theme.fg("accent", theme.bold("pi-usage Settings")), 1, 0).render(width);
124
- const content = settingsList.render(width);
125
- const focusedRow = content.findIndex((line) => /^[→›]\s/u.test(stripVTControlCharacters(line)));
126
- const terminalRows = Number.isFinite(tui.terminal?.rows) ? Math.floor(tui.terminal.rows) : 24;
127
- const [ruleLine = ""] = rule.render(width);
128
- return renderBoundedFrame({
129
- width,
130
- maxRows: Math.max(1, terminalRows - 3),
131
- rule: ruleLine,
132
- title,
133
- content,
134
- priorityRows: focusedRow < 0 ? [] : [focusedRow],
135
- focusedRow,
136
- });
137
- },
138
- invalidate: () => settingsList.invalidate(),
139
- handleInput(data: string) {
140
- if (closing) return;
141
- if (matchesKey(data, Key.ctrl("c"))) cancel();
142
- else settingsList.handleInput(data);
143
- tui.requestRender();
144
- },
145
- dispose() {
146
- localController.abort();
147
- parentSignal.removeEventListener("abort", cancel);
148
- },
149
- };
150
- })) ?? false
151
- );
121
+ parentSignal.addEventListener("abort", cancel, { once: true });
122
+ return {
123
+ render(width: number) {
124
+ // Compatibility: Kit puts the title directly below the top rule and keeps compact
125
+ // rules when at least five rows fit; both replace the legacy wrapper layout.
126
+ const title = new Text(theme.fg("accent", theme.bold("pi-usage Settings")), 1, 0).render(width);
127
+ const content = settingsList.render(width);
128
+ const focusedRow = content.findIndex((line) => /^[→›]\s/u.test(stripVTControlCharacters(line)));
129
+ const terminalRows = Number.isFinite(tui.terminal?.rows) ? Math.floor(tui.terminal.rows) : 24;
130
+ const [ruleLine = ""] = rule.render(width);
131
+ return renderBoundedFrame({
132
+ width,
133
+ maxRows: Math.max(1, terminalRows - 3),
134
+ rule: ruleLine,
135
+ title,
136
+ content,
137
+ priorityRows: focusedRow < 0 ? [] : [focusedRow],
138
+ focusedRow,
139
+ });
140
+ },
141
+ invalidate: () => settingsList.invalidate(),
142
+ handleInput(data: string) {
143
+ if (closing) return;
144
+ if (matchesKey(data, Key.ctrl("c"))) cancel();
145
+ else settingsList.handleInput(data);
146
+ tui.requestRender();
147
+ },
148
+ dispose() {
149
+ localController.abort();
150
+ parentSignal.removeEventListener("abort", cancel);
151
+ },
152
+ };
153
+ });
152
154
  }
package/src/usage.ts CHANGED
@@ -22,7 +22,12 @@ import { abortError, awaitWithDeadline, errorMessage, runWithConcurrency, UsageC
22
22
  import { formatProviderStates, formatUsageStatusline } from "./format.js";
23
23
  import { createOAuthCredentialCandidateReader } from "./oauth-credential-source.js";
24
24
  import { UnsupportedOpenAIUsageAuthError } from "./providers/openai-chatgpt.js";
25
- import { adapterForProvider, isStaleExtensionContextError, queryProviderUsage, resolveUsageAuth } from "./query.js";
25
+ import {
26
+ adapterForProvider,
27
+ isStaleExtensionContextError,
28
+ queryProviderUsage,
29
+ resolveUsageAuth as resolveRuntimeUsageAuth,
30
+ } from "./query.js";
26
31
  import { createUsageSettingsRuntime, type UsageSettingsRuntime, type UsageSettingsState } from "./settings.js";
27
32
  import type {
28
33
  PiModel,
@@ -84,10 +89,12 @@ export default function usageExtension(pi: ExtensionAPI, dependencies: UsageExte
84
89
  const credentialCandidates = createOAuthCredentialCandidateReader(pi, credentialReader);
85
90
  const createRedemptionId = dependencies.createRedemptionId ?? randomUUID;
86
91
  const settingsRuntime = dependencies.settingsRuntime ?? createUsageSettingsRuntime();
92
+ const resolveUsageAuth = resolveRuntimeUsageAuth;
87
93
  const cache = new UsageCache(CACHE_TTL_MS);
88
94
  const failureBackoff = new Map<string, { until: number; message: string }>();
89
95
  const latestQueries = new Map<string, number>();
90
96
  const activeControllers = new Set<AbortController>();
97
+ const nativeQueryControllers = new Set<AbortController>();
91
98
  let querySequence = 0;
92
99
  let activeCurrentIdentity: string | undefined;
93
100
  let sessionActive = false;
@@ -183,7 +190,7 @@ export default function usageExtension(pi: ExtensionAPI, dependencies: UsageExte
183
190
  if (shouldSchedule && sessionActive) scheduleStatusRefresh(ctx, model);
184
191
  if (
185
192
  sessionActive &&
186
- showCodexResetCountdown &&
193
+ (showCodexResetCountdown || outcome.state.report.source === "openai-chatgpt-companion") &&
187
194
  outcome.state.report.buckets.some(
188
195
  (bucket) => bucket.resetsAt !== undefined && Number.isFinite(bucket.resetsAt) && bucket.resetsAt * 1_000 > now,
189
196
  )
@@ -220,7 +227,7 @@ export default function usageExtension(pi: ExtensionAPI, dependencies: UsageExte
220
227
  activeCurrentIdentity = nextIdentity;
221
228
  };
222
229
 
223
- const queryAdapterState = async (
230
+ const queryAdapterStateInternal = async (
224
231
  ctx: ExtensionContext,
225
232
  adapter: UsageProviderAdapter,
226
233
  displayState: UsageDisplayState,
@@ -244,6 +251,7 @@ export default function usageExtension(pi: ExtensionAPI, dependencies: UsageExte
244
251
  );
245
252
  } catch (error) {
246
253
  if (isStaleExtensionContextError(error) || isAbortError(error)) throw error;
254
+ if (adapter.id === "openai") invalidateProviderState(adapter.id);
247
255
  const authState =
248
256
  error instanceof UnsupportedOpenAIUsageAuthError
249
257
  ? "unsupported"
@@ -288,6 +296,7 @@ export default function usageExtension(pi: ExtensionAPI, dependencies: UsageExte
288
296
  settingsRuntime.get().settings.selectedTargets[adapter.id] !== expectedTargetId);
289
297
  if (requiresRequestBoundaryGuard && requestContextChanged()) throw abortError();
290
298
  if (!auth) {
299
+ if (adapter.id === "openai") invalidateProviderState(adapter.id);
291
300
  if (displayState === "current") {
292
301
  transitionCurrentIdentity(`${adapter.id}:unavailable`, adapter.id);
293
302
  }
@@ -302,6 +311,7 @@ export default function usageExtension(pi: ExtensionAPI, dependencies: UsageExte
302
311
  authState: "unavailable",
303
312
  };
304
313
  }
314
+ if (adapter.id === "openai" && !auth.openaiCompanion) invalidateProviderState(adapter.id);
305
315
  let retryableAuthChanged = false;
306
316
  const guard = async () => {
307
317
  if (signal.aborted || requestContextChanged()) throw abortError();
@@ -466,6 +476,25 @@ export default function usageExtension(pi: ExtensionAPI, dependencies: UsageExte
466
476
  }
467
477
  };
468
478
 
479
+ const queryAdapterState = async (...args: Parameters<typeof queryAdapterStateInternal>): Promise<QueryOutcome> => {
480
+ const [ctx, adapter, displayState, force, signal, ...rest] = args;
481
+ if (adapter.id !== "openai") return queryAdapterStateInternal(...args);
482
+ const controller = new AbortController();
483
+ nativeQueryControllers.add(controller);
484
+ try {
485
+ return await queryAdapterStateInternal(
486
+ ctx,
487
+ adapter,
488
+ displayState,
489
+ force,
490
+ AbortSignal.any([signal, controller.signal]),
491
+ ...rest,
492
+ );
493
+ } finally {
494
+ nativeQueryControllers.delete(controller);
495
+ }
496
+ };
497
+
469
498
  const loadTargetChoices = async (ctx: ExtensionContext, adapter: UsageProviderAdapter, signal: AbortSignal) => {
470
499
  if (!adapter.targets) throw new Error("Provider does not support usage targets.");
471
500
  const expectedSessionGeneration = sessionGeneration;
@@ -686,7 +715,7 @@ export default function usageExtension(pi: ExtensionAPI, dependencies: UsageExte
686
715
  const generation = statusGeneration;
687
716
  const outcome = await queryAdapterState(ctx, adapter, displayState, force, signal);
688
717
  if (signal.aborted) throw abortError();
689
- if (!outcome.authState || (await outcomeStillCurrent(ctx, model, generation, outcome, signal))) return outcome;
718
+ if (await outcomeStillCurrent(ctx, model, generation, outcome, signal)) return outcome;
690
719
  force = false;
691
720
  }
692
721
  throw new Error("OpenAI runtime authentication kept changing; reopen /usage to retry.");
@@ -1253,6 +1282,17 @@ export default function usageExtension(pi: ExtensionAPI, dependencies: UsageExte
1253
1282
  }
1254
1283
  const revalidated = await queryStableCurrent(ctx, false, controller, "Revalidating current usage…");
1255
1284
  if (!revalidated) return { kind: "back" };
1285
+ // Current-provider revalidation can await unrelated work after the configured
1286
+ // native report was checked. Refresh its identity at the publication boundary.
1287
+ if (adapter.id === "openai") {
1288
+ outcome = await runMenuOperation(
1289
+ ctx,
1290
+ "Revalidating ChatGPT companion usage…",
1291
+ controller.signal,
1292
+ (signal) => queryStableAdapterState(ctx, adapter, "configured", false, signal),
1293
+ );
1294
+ if (!outcome) return { kind: "back" };
1295
+ }
1256
1296
  stableCurrent = revalidated;
1257
1297
  current = revalidated.outcome;
1258
1298
  visibleStates = [
@@ -1300,6 +1340,22 @@ export default function usageExtension(pi: ExtensionAPI, dependencies: UsageExte
1300
1340
  });
1301
1341
  const revalidated = await queryStableCurrent(ctx, false, controller, "Revalidating current usage…");
1302
1342
  if (!revalidated) return { kind: "stay" };
1343
+ const nativeIndex = adapters.findIndex(
1344
+ (adapter) => adapter.id === "openai" && adapter.id !== currentProviderId,
1345
+ );
1346
+ if (nativeIndex >= 0) {
1347
+ const nativeAdapter = adapters[nativeIndex];
1348
+ if (nativeAdapter) {
1349
+ const native = await runMenuOperation(
1350
+ ctx,
1351
+ "Revalidating ChatGPT companion usage…",
1352
+ controller.signal,
1353
+ (signal) => queryStableAdapterState(ctx, nativeAdapter, "configured", false, signal),
1354
+ );
1355
+ if (!native) return { kind: "stay" };
1356
+ queriedStates[nativeIndex] = native.state;
1357
+ }
1358
+ }
1303
1359
  stableCurrent = revalidated;
1304
1360
  current = revalidated.outcome;
1305
1361
  visibleStates = [
@@ -1356,6 +1412,8 @@ export default function usageExtension(pi: ExtensionAPI, dependencies: UsageExte
1356
1412
  startStatusRefresh(ctx, ctx.model, false);
1357
1413
  });
1358
1414
  pi.on("model_select", (event, ctx) => {
1415
+ // Cancel read-only native queries, not confirmed legacy reset mutations.
1416
+ for (const controller of nativeQueryControllers) controller.abort();
1359
1417
  startStatusRefresh(ctx, event.model, false);
1360
1418
  });
1361
1419
  pi.on("turn_start", (_event, ctx) => {