@eiei114/pi-sub-core 1.5.1
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/CHANGELOG.md +190 -0
- package/README.md +178 -0
- package/index.ts +540 -0
- package/package.json +35 -0
- package/src/cache.ts +546 -0
- package/src/config.ts +35 -0
- package/src/dependencies.ts +37 -0
- package/src/errors.ts +71 -0
- package/src/paths.ts +55 -0
- package/src/provider.ts +66 -0
- package/src/providers/detection.ts +51 -0
- package/src/providers/impl/anthropic.ts +174 -0
- package/src/providers/impl/antigravity.ts +226 -0
- package/src/providers/impl/codex.ts +186 -0
- package/src/providers/impl/copilot.ts +176 -0
- package/src/providers/impl/gemini.ts +130 -0
- package/src/providers/impl/kiro.ts +92 -0
- package/src/providers/impl/zai.ts +120 -0
- package/src/providers/index.ts +5 -0
- package/src/providers/metadata.ts +16 -0
- package/src/providers/registry.ts +54 -0
- package/src/providers/settings.ts +109 -0
- package/src/providers/status.ts +25 -0
- package/src/settings/behavior.ts +58 -0
- package/src/settings/menu.ts +83 -0
- package/src/settings/tools.ts +38 -0
- package/src/settings/ui.ts +450 -0
- package/src/settings-types.ts +95 -0
- package/src/settings-ui.ts +1 -0
- package/src/settings.ts +137 -0
- package/src/status.ts +245 -0
- package/src/storage/lock.ts +150 -0
- package/src/storage.ts +61 -0
- package/src/types.ts +33 -0
- package/src/ui/keybindings.ts +92 -0
- package/src/ui/settings-list.ts +290 -0
- package/src/usage/controller.ts +250 -0
- package/src/usage/fetch.ts +215 -0
- package/src/usage/types.ts +5 -0
- package/src/utils.ts +158 -0
- package/test/all.test.ts +9 -0
- package/test/cache.test.ts +157 -0
- package/test/controller.test.ts +101 -0
- package/test/detection.test.ts +24 -0
- package/test/extension.test.ts +233 -0
- package/test/helpers.ts +48 -0
- package/test/keybindings.test.ts +59 -0
- package/test/lock.test.ts +49 -0
- package/test/prioritize.test.ts +81 -0
- package/test/providers.test.ts +385 -0
- package/test/status.test.ts +70 -0
- package/tsconfig.json +5 -0
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
import type { Component, SettingItem, SettingsListTheme } from "@mariozechner/pi-tui";
|
|
2
|
+
import {
|
|
3
|
+
Input,
|
|
4
|
+
fuzzyFilter,
|
|
5
|
+
truncateToWidth,
|
|
6
|
+
visibleWidth,
|
|
7
|
+
wrapTextWithAnsi,
|
|
8
|
+
} from "@mariozechner/pi-tui";
|
|
9
|
+
import { getSettingsKeybindings } from "./keybindings.js";
|
|
10
|
+
|
|
11
|
+
export interface SettingsListOptions {
|
|
12
|
+
enableSearch?: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export const CUSTOM_OPTION = "__custom__";
|
|
16
|
+
export const CUSTOM_LABEL = "custom";
|
|
17
|
+
|
|
18
|
+
export type { SettingItem, SettingsListTheme };
|
|
19
|
+
|
|
20
|
+
export class SettingsList implements Component {
|
|
21
|
+
private items: SettingItem[];
|
|
22
|
+
private filteredItems: SettingItem[];
|
|
23
|
+
private theme: SettingsListTheme;
|
|
24
|
+
private selectedIndex = 0;
|
|
25
|
+
private maxVisible: number;
|
|
26
|
+
private onChange: (id: string, newValue: string) => void;
|
|
27
|
+
private onCancel: () => void;
|
|
28
|
+
private searchInput?: Input;
|
|
29
|
+
private searchEnabled: boolean;
|
|
30
|
+
private submenuComponent: Component | null = null;
|
|
31
|
+
private submenuItemIndex: number | null = null;
|
|
32
|
+
|
|
33
|
+
constructor(
|
|
34
|
+
items: SettingItem[],
|
|
35
|
+
maxVisible: number,
|
|
36
|
+
theme: SettingsListTheme,
|
|
37
|
+
onChange: (id: string, newValue: string) => void,
|
|
38
|
+
onCancel: () => void,
|
|
39
|
+
options: SettingsListOptions = {},
|
|
40
|
+
) {
|
|
41
|
+
this.items = items;
|
|
42
|
+
this.filteredItems = items;
|
|
43
|
+
this.maxVisible = maxVisible;
|
|
44
|
+
this.theme = theme;
|
|
45
|
+
this.onChange = onChange;
|
|
46
|
+
this.onCancel = onCancel;
|
|
47
|
+
this.searchEnabled = options.enableSearch ?? false;
|
|
48
|
+
|
|
49
|
+
if (this.searchEnabled) {
|
|
50
|
+
this.searchInput = new Input();
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Update an item's currentValue */
|
|
55
|
+
updateValue(id: string, newValue: string): void {
|
|
56
|
+
const item = this.items.find((i) => i.id === id);
|
|
57
|
+
if (item) {
|
|
58
|
+
item.currentValue = newValue;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
invalidate(): void {
|
|
63
|
+
this.submenuComponent?.invalidate?.();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
render(width: number): string[] {
|
|
67
|
+
// If submenu is active, render it instead
|
|
68
|
+
if (this.submenuComponent) {
|
|
69
|
+
return this.submenuComponent.render(width);
|
|
70
|
+
}
|
|
71
|
+
return this.renderMainList(width);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
private renderMainList(width: number): string[] {
|
|
75
|
+
const lines: string[] = [];
|
|
76
|
+
if (this.searchEnabled && this.searchInput) {
|
|
77
|
+
lines.push(...this.searchInput.render(width));
|
|
78
|
+
lines.push("");
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (this.items.length === 0) {
|
|
82
|
+
lines.push(this.theme.hint(" No settings available"));
|
|
83
|
+
if (this.searchEnabled) {
|
|
84
|
+
this.addHintLine(lines);
|
|
85
|
+
}
|
|
86
|
+
return lines;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const displayItems = this.searchEnabled ? this.filteredItems : this.items;
|
|
90
|
+
if (displayItems.length === 0) {
|
|
91
|
+
lines.push(this.theme.hint(" No matching settings"));
|
|
92
|
+
this.addHintLine(lines);
|
|
93
|
+
return lines;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Calculate visible range with scrolling
|
|
97
|
+
const startIndex = Math.max(
|
|
98
|
+
0,
|
|
99
|
+
Math.min(
|
|
100
|
+
this.selectedIndex - Math.floor(this.maxVisible / 2),
|
|
101
|
+
displayItems.length - this.maxVisible,
|
|
102
|
+
),
|
|
103
|
+
);
|
|
104
|
+
const endIndex = Math.min(startIndex + this.maxVisible, displayItems.length);
|
|
105
|
+
|
|
106
|
+
// Calculate max label width for alignment
|
|
107
|
+
const maxLabelWidth = Math.min(30, Math.max(...this.items.map((item) => visibleWidth(item.label))));
|
|
108
|
+
|
|
109
|
+
// Render visible items
|
|
110
|
+
for (let i = startIndex; i < endIndex; i++) {
|
|
111
|
+
const item = displayItems[i];
|
|
112
|
+
if (!item) continue;
|
|
113
|
+
const isSelected = i === this.selectedIndex;
|
|
114
|
+
const prefix = isSelected ? this.theme.cursor : " ";
|
|
115
|
+
const prefixWidth = visibleWidth(prefix);
|
|
116
|
+
|
|
117
|
+
// Pad label to align values
|
|
118
|
+
const labelPadded = item.label + " ".repeat(Math.max(0, maxLabelWidth - visibleWidth(item.label)));
|
|
119
|
+
const labelText = this.theme.label(labelPadded, isSelected);
|
|
120
|
+
|
|
121
|
+
// Calculate space for value
|
|
122
|
+
const separator = " ";
|
|
123
|
+
const usedWidth = prefixWidth + maxLabelWidth + visibleWidth(separator);
|
|
124
|
+
const valueMaxWidth = Math.max(1, width - usedWidth - 2);
|
|
125
|
+
const optionLines = isSelected && item.values && item.values.length > 0
|
|
126
|
+
? wrapTextWithAnsi(this.formatOptionsInline(item, item.values), valueMaxWidth)
|
|
127
|
+
: null;
|
|
128
|
+
const valueText = optionLines
|
|
129
|
+
? optionLines[0] ?? ""
|
|
130
|
+
: this.theme.value(truncateToWidth(item.currentValue, valueMaxWidth, ""), isSelected);
|
|
131
|
+
const line = prefix + labelText + separator + valueText;
|
|
132
|
+
lines.push(truncateToWidth(line, width, ""));
|
|
133
|
+
if (optionLines && optionLines.length > 1) {
|
|
134
|
+
const indent = " ".repeat(prefixWidth + maxLabelWidth + visibleWidth(separator));
|
|
135
|
+
for (const continuation of optionLines.slice(1)) {
|
|
136
|
+
lines.push(truncateToWidth(indent + continuation, width, ""));
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Add scroll indicator if needed
|
|
142
|
+
if (startIndex > 0 || endIndex < displayItems.length) {
|
|
143
|
+
const scrollText = ` (${this.selectedIndex + 1}/${displayItems.length})`;
|
|
144
|
+
lines.push(this.theme.hint(truncateToWidth(scrollText, width - 2, "")));
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Add description for selected item
|
|
148
|
+
const selectedItem = displayItems[this.selectedIndex];
|
|
149
|
+
if (selectedItem?.description) {
|
|
150
|
+
lines.push("");
|
|
151
|
+
const wrapWidth = Math.max(1, width - 4);
|
|
152
|
+
const wrappedDesc = wrapTextWithAnsi(selectedItem.description, wrapWidth);
|
|
153
|
+
for (const line of wrappedDesc) {
|
|
154
|
+
const prefixed = ` ${line}`;
|
|
155
|
+
lines.push(this.theme.description(truncateToWidth(prefixed, width, "")));
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
// Add hint
|
|
161
|
+
this.addHintLine(lines);
|
|
162
|
+
return lines;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
handleInput(data: string): void {
|
|
166
|
+
// If submenu is active, delegate all input to it
|
|
167
|
+
// The submenu's onCancel (triggered by escape) will call done() which closes it
|
|
168
|
+
if (this.submenuComponent) {
|
|
169
|
+
this.submenuComponent.handleInput?.(data);
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const kb = getSettingsKeybindings();
|
|
174
|
+
const displayItems = this.searchEnabled ? this.filteredItems : this.items;
|
|
175
|
+
|
|
176
|
+
if (kb.matches(data, "selectUp")) {
|
|
177
|
+
if (displayItems.length === 0) return;
|
|
178
|
+
this.selectedIndex = this.selectedIndex === 0 ? displayItems.length - 1 : this.selectedIndex - 1;
|
|
179
|
+
} else if (kb.matches(data, "selectDown")) {
|
|
180
|
+
if (displayItems.length === 0) return;
|
|
181
|
+
this.selectedIndex = this.selectedIndex === displayItems.length - 1 ? 0 : this.selectedIndex + 1;
|
|
182
|
+
} else if (kb.matches(data, "cursorLeft")) {
|
|
183
|
+
this.stepValue(-1);
|
|
184
|
+
} else if (kb.matches(data, "cursorRight")) {
|
|
185
|
+
this.stepValue(1);
|
|
186
|
+
} else if (kb.matches(data, "selectConfirm") || data === " ") {
|
|
187
|
+
this.activateItem();
|
|
188
|
+
} else if (kb.matches(data, "selectCancel")) {
|
|
189
|
+
this.onCancel();
|
|
190
|
+
} else if (this.searchEnabled && this.searchInput) {
|
|
191
|
+
const sanitized = data.replace(/ /g, "");
|
|
192
|
+
if (!sanitized) {
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
this.searchInput.handleInput(sanitized);
|
|
196
|
+
this.applyFilter(this.searchInput.getValue());
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
private stepValue(direction: -1 | 1): void {
|
|
201
|
+
const displayItems = this.searchEnabled ? this.filteredItems : this.items;
|
|
202
|
+
const item = displayItems[this.selectedIndex];
|
|
203
|
+
if (!item || !item.values || item.values.length === 0) return;
|
|
204
|
+
const values = item.values;
|
|
205
|
+
let currentIndex = values.indexOf(item.currentValue);
|
|
206
|
+
if (currentIndex === -1) {
|
|
207
|
+
currentIndex = direction > 0 ? 0 : values.length - 1;
|
|
208
|
+
}
|
|
209
|
+
const nextIndex = (currentIndex + direction + values.length) % values.length;
|
|
210
|
+
const newValue = values[nextIndex];
|
|
211
|
+
if (newValue === CUSTOM_OPTION) {
|
|
212
|
+
item.currentValue = newValue;
|
|
213
|
+
this.onChange(item.id, newValue);
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
item.currentValue = newValue;
|
|
217
|
+
this.onChange(item.id, newValue);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
private activateItem(): void {
|
|
221
|
+
const item = this.searchEnabled ? this.filteredItems[this.selectedIndex] : this.items[this.selectedIndex];
|
|
222
|
+
if (!item) return;
|
|
223
|
+
|
|
224
|
+
const hasCustom = Boolean(item.values && item.values.includes(CUSTOM_OPTION));
|
|
225
|
+
const currentIsCustom = hasCustom && item.values && !item.values.includes(item.currentValue);
|
|
226
|
+
|
|
227
|
+
if (item.submenu && hasCustom) {
|
|
228
|
+
if (currentIsCustom || item.currentValue === CUSTOM_OPTION) {
|
|
229
|
+
this.openSubmenu(item);
|
|
230
|
+
}
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if (item.submenu) {
|
|
235
|
+
this.openSubmenu(item);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
private closeSubmenu(): void {
|
|
240
|
+
this.submenuComponent = null;
|
|
241
|
+
// Restore selection to the item that opened the submenu
|
|
242
|
+
if (this.submenuItemIndex !== null) {
|
|
243
|
+
this.selectedIndex = this.submenuItemIndex;
|
|
244
|
+
this.submenuItemIndex = null;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
private applyFilter(query: string): void {
|
|
249
|
+
this.filteredItems = fuzzyFilter(this.items, query, (item) => item.label);
|
|
250
|
+
this.selectedIndex = 0;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
private formatOptionsInline(item: SettingItem, values: string[]): string {
|
|
254
|
+
const separator = this.theme.description(" • ");
|
|
255
|
+
const hasCustom = values.includes(CUSTOM_OPTION);
|
|
256
|
+
const currentIsCustom = hasCustom && !values.includes(item.currentValue);
|
|
257
|
+
return values
|
|
258
|
+
.map((value) => {
|
|
259
|
+
const label = value === CUSTOM_OPTION
|
|
260
|
+
? (currentIsCustom ? `${CUSTOM_LABEL} (${item.currentValue})` : CUSTOM_LABEL)
|
|
261
|
+
: value;
|
|
262
|
+
const selected = value === item.currentValue || (currentIsCustom && value === CUSTOM_OPTION);
|
|
263
|
+
return this.theme.value(label, selected);
|
|
264
|
+
})
|
|
265
|
+
.join(separator);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
private openSubmenu(item: SettingItem): void {
|
|
269
|
+
if (!item.submenu) return;
|
|
270
|
+
this.submenuItemIndex = this.selectedIndex;
|
|
271
|
+
this.submenuComponent = item.submenu(item.currentValue, (selectedValue) => {
|
|
272
|
+
if (selectedValue !== undefined) {
|
|
273
|
+
item.currentValue = selectedValue;
|
|
274
|
+
this.onChange(item.id, selectedValue);
|
|
275
|
+
}
|
|
276
|
+
this.closeSubmenu();
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
private addHintLine(lines: string[]): void {
|
|
281
|
+
lines.push("");
|
|
282
|
+
lines.push(
|
|
283
|
+
this.theme.hint(
|
|
284
|
+
this.searchEnabled
|
|
285
|
+
? " Type to search · ←/→ change · Enter/Space edit custom · Esc to cancel"
|
|
286
|
+
: " ←/→ change · Enter/Space edit custom · Esc to cancel",
|
|
287
|
+
),
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Usage refresh and provider selection controller.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
|
|
6
|
+
import type { ProviderName, UsageSnapshot } from "../types.js";
|
|
7
|
+
import type { Settings } from "../settings-types.js";
|
|
8
|
+
import { detectProviderFromModel } from "../providers/detection.js";
|
|
9
|
+
import { isExpectedMissingData } from "../errors.js";
|
|
10
|
+
import { formatElapsedSince } from "../utils.js";
|
|
11
|
+
import { fetchUsageForProvider, refreshStatusForProvider } from "./fetch.js";
|
|
12
|
+
import type { Dependencies } from "../types.js";
|
|
13
|
+
import { getCachedData, readCache } from "../cache.js";
|
|
14
|
+
import { hasProviderCredentials } from "../providers/registry.js";
|
|
15
|
+
|
|
16
|
+
export interface UsageControllerState {
|
|
17
|
+
currentProvider?: ProviderName;
|
|
18
|
+
cachedUsage?: UsageSnapshot;
|
|
19
|
+
lastSuccessAt?: number;
|
|
20
|
+
providerCycleIndex: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface UsageUpdate {
|
|
24
|
+
provider?: ProviderName;
|
|
25
|
+
usage?: UsageSnapshot;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type UsageUpdateHandler = (update: UsageUpdate) => void;
|
|
29
|
+
|
|
30
|
+
export function createUsageController(deps: Dependencies) {
|
|
31
|
+
function isProviderAvailable(
|
|
32
|
+
settings: Settings,
|
|
33
|
+
provider: ProviderName,
|
|
34
|
+
options?: { skipCredentials?: boolean }
|
|
35
|
+
): boolean {
|
|
36
|
+
const setting = settings.providers[provider];
|
|
37
|
+
if (setting.enabled === "off" || setting.enabled === false) return false;
|
|
38
|
+
if (setting.enabled === "on" || setting.enabled === true) return true;
|
|
39
|
+
if (options?.skipCredentials) return true;
|
|
40
|
+
return hasProviderCredentials(provider, deps);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function getEnabledProviders(settings: Settings): ProviderName[] {
|
|
44
|
+
return settings.providerOrder.filter((p) => isProviderAvailable(settings, p));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function resolveProvider(
|
|
48
|
+
ctx: ExtensionContext,
|
|
49
|
+
settings: Settings,
|
|
50
|
+
state: UsageControllerState,
|
|
51
|
+
options?: { skipCredentials?: boolean }
|
|
52
|
+
): ProviderName | undefined {
|
|
53
|
+
const detected = detectProviderFromModel(ctx.model);
|
|
54
|
+
if (detected && isProviderAvailable(settings, detected, options)) {
|
|
55
|
+
return detected;
|
|
56
|
+
}
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function emitUpdate(state: UsageControllerState, onUpdate: UsageUpdateHandler): void {
|
|
61
|
+
onUpdate({
|
|
62
|
+
provider: state.currentProvider,
|
|
63
|
+
usage: state.cachedUsage,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function refresh(
|
|
68
|
+
ctx: ExtensionContext,
|
|
69
|
+
settings: Settings,
|
|
70
|
+
state: UsageControllerState,
|
|
71
|
+
onUpdate: UsageUpdateHandler,
|
|
72
|
+
options?: { force?: boolean; allowStaleCache?: boolean; forceStatus?: boolean; skipFetch?: boolean }
|
|
73
|
+
): Promise<void> {
|
|
74
|
+
const provider = resolveProvider(ctx, settings, state, { skipCredentials: options?.skipFetch });
|
|
75
|
+
if (!provider) {
|
|
76
|
+
state.currentProvider = undefined;
|
|
77
|
+
state.cachedUsage = undefined;
|
|
78
|
+
emitUpdate(state, onUpdate);
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const providerChanged = provider !== state.currentProvider;
|
|
83
|
+
state.currentProvider = provider;
|
|
84
|
+
if (providerChanged) {
|
|
85
|
+
state.cachedUsage = undefined;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const cache = readCache();
|
|
89
|
+
let cachedEntry = await getCachedData(provider, settings.behavior.refreshInterval * 1000, cache);
|
|
90
|
+
if (!cachedEntry && options?.allowStaleCache) {
|
|
91
|
+
cachedEntry = cache[provider] ?? null;
|
|
92
|
+
}
|
|
93
|
+
if (cachedEntry?.usage) {
|
|
94
|
+
state.cachedUsage = {
|
|
95
|
+
...cachedEntry.usage,
|
|
96
|
+
status: cachedEntry.status,
|
|
97
|
+
lastSuccessAt: cachedEntry.fetchedAt,
|
|
98
|
+
};
|
|
99
|
+
if (!cachedEntry.usage.error) {
|
|
100
|
+
state.lastSuccessAt = cachedEntry.fetchedAt;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
emitUpdate(state, onUpdate);
|
|
104
|
+
|
|
105
|
+
if (options?.skipFetch) {
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const result = await fetchUsageForProvider(deps, settings, provider, options);
|
|
110
|
+
const error = result.usage?.error;
|
|
111
|
+
const fetchError = Boolean(error && !isExpectedMissingData(error));
|
|
112
|
+
if (fetchError) {
|
|
113
|
+
let fallback = state.cachedUsage;
|
|
114
|
+
let fallbackFetchedAt = state.lastSuccessAt;
|
|
115
|
+
if (!fallback || fallback.windows.length === 0) {
|
|
116
|
+
const cachedEntry = cache[provider];
|
|
117
|
+
const cachedUsage = cachedEntry?.usage ? { ...cachedEntry.usage, status: cachedEntry.status } : undefined;
|
|
118
|
+
fallback = cachedUsage && cachedUsage.windows.length > 0 ? cachedUsage : undefined;
|
|
119
|
+
if (cachedEntry?.fetchedAt) fallbackFetchedAt = cachedEntry.fetchedAt;
|
|
120
|
+
}
|
|
121
|
+
if (fallback && fallback.windows.length > 0) {
|
|
122
|
+
const lastSuccessAt = fallbackFetchedAt ?? state.lastSuccessAt;
|
|
123
|
+
const elapsed = lastSuccessAt ? formatElapsedSince(lastSuccessAt) : undefined;
|
|
124
|
+
const description = elapsed ? (elapsed === "just now" ? "just now" : `${elapsed} ago`) : "Fetch failed";
|
|
125
|
+
state.cachedUsage = {
|
|
126
|
+
...fallback,
|
|
127
|
+
lastSuccessAt,
|
|
128
|
+
error,
|
|
129
|
+
status: { indicator: "minor", description },
|
|
130
|
+
};
|
|
131
|
+
} else {
|
|
132
|
+
state.cachedUsage = result.usage ? { ...result.usage, status: result.status } : undefined;
|
|
133
|
+
}
|
|
134
|
+
} else {
|
|
135
|
+
const successAt = Date.now();
|
|
136
|
+
state.cachedUsage = result.usage
|
|
137
|
+
? { ...result.usage, status: result.status, lastSuccessAt: successAt }
|
|
138
|
+
: undefined;
|
|
139
|
+
if (result.usage && !result.usage.error) {
|
|
140
|
+
state.lastSuccessAt = successAt;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
emitUpdate(state, onUpdate);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async function refreshStatus(
|
|
147
|
+
ctx: ExtensionContext,
|
|
148
|
+
settings: Settings,
|
|
149
|
+
state: UsageControllerState,
|
|
150
|
+
onUpdate: UsageUpdateHandler,
|
|
151
|
+
options?: { force?: boolean; allowStaleCache?: boolean; skipFetch?: boolean }
|
|
152
|
+
): Promise<void> {
|
|
153
|
+
const provider = resolveProvider(ctx, settings, state, { skipCredentials: options?.skipFetch });
|
|
154
|
+
if (!provider) {
|
|
155
|
+
state.currentProvider = undefined;
|
|
156
|
+
state.cachedUsage = undefined;
|
|
157
|
+
emitUpdate(state, onUpdate);
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const providerChanged = provider !== state.currentProvider;
|
|
162
|
+
state.currentProvider = provider;
|
|
163
|
+
if (providerChanged) {
|
|
164
|
+
state.cachedUsage = undefined;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const cache = readCache();
|
|
168
|
+
let cachedEntry = await getCachedData(provider, settings.behavior.refreshInterval * 1000, cache);
|
|
169
|
+
if (!cachedEntry && options?.allowStaleCache) {
|
|
170
|
+
cachedEntry = cache[provider] ?? null;
|
|
171
|
+
}
|
|
172
|
+
if (cachedEntry?.usage) {
|
|
173
|
+
state.cachedUsage = {
|
|
174
|
+
...cachedEntry.usage,
|
|
175
|
+
status: cachedEntry.status,
|
|
176
|
+
lastSuccessAt: cachedEntry.fetchedAt,
|
|
177
|
+
};
|
|
178
|
+
if (!cachedEntry.usage.error) {
|
|
179
|
+
state.lastSuccessAt = cachedEntry.fetchedAt;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (options?.skipFetch) {
|
|
184
|
+
emitUpdate(state, onUpdate);
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const status = await refreshStatusForProvider(deps, settings, provider, { force: options?.force });
|
|
189
|
+
if (status && state.cachedUsage) {
|
|
190
|
+
state.cachedUsage = { ...state.cachedUsage, status };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
emitUpdate(state, onUpdate);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async function cycleProvider(
|
|
197
|
+
ctx: ExtensionContext,
|
|
198
|
+
settings: Settings,
|
|
199
|
+
state: UsageControllerState,
|
|
200
|
+
onUpdate: UsageUpdateHandler
|
|
201
|
+
): Promise<void> {
|
|
202
|
+
const enabledProviders = getEnabledProviders(settings);
|
|
203
|
+
if (enabledProviders.length === 0) {
|
|
204
|
+
state.currentProvider = undefined;
|
|
205
|
+
state.cachedUsage = undefined;
|
|
206
|
+
emitUpdate(state, onUpdate);
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const currentIndex = state.currentProvider
|
|
211
|
+
? enabledProviders.indexOf(state.currentProvider)
|
|
212
|
+
: -1;
|
|
213
|
+
if (currentIndex >= 0) {
|
|
214
|
+
state.providerCycleIndex = currentIndex;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const total = enabledProviders.length;
|
|
218
|
+
for (let i = 0; i < total; i += 1) {
|
|
219
|
+
state.providerCycleIndex = (state.providerCycleIndex + 1) % total;
|
|
220
|
+
const nextProvider = enabledProviders[state.providerCycleIndex];
|
|
221
|
+
const result = await fetchUsageForProvider(deps, settings, nextProvider);
|
|
222
|
+
if (!isUsageAvailable(result.usage)) {
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
state.currentProvider = nextProvider;
|
|
226
|
+
state.cachedUsage = result.usage ? { ...result.usage, status: result.status } : undefined;
|
|
227
|
+
emitUpdate(state, onUpdate);
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
state.currentProvider = undefined;
|
|
232
|
+
state.cachedUsage = undefined;
|
|
233
|
+
emitUpdate(state, onUpdate);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function isUsageAvailable(usage: UsageSnapshot | undefined): usage is UsageSnapshot {
|
|
237
|
+
if (!usage) return false;
|
|
238
|
+
if (usage.windows.length > 0) return true;
|
|
239
|
+
if (!usage.error) return false;
|
|
240
|
+
return !isExpectedMissingData(usage.error);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
return {
|
|
244
|
+
getEnabledProviders,
|
|
245
|
+
resolveProvider,
|
|
246
|
+
refresh,
|
|
247
|
+
refreshStatus,
|
|
248
|
+
cycleProvider,
|
|
249
|
+
};
|
|
250
|
+
}
|