@victor-software-house/pi-multicodex 1.0.4 → 1.0.7
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/README.md +11 -0
- package/commands.ts +12 -0
- package/extension.ts +23 -1
- package/index.ts +5 -0
- package/package.json +16 -2
- package/status.ts +462 -0
package/README.md
CHANGED
|
@@ -51,6 +51,8 @@ pi -e ./index.ts
|
|
|
51
51
|
- Select an account manually for the current session.
|
|
52
52
|
- `/multicodex-status`
|
|
53
53
|
- Show account state and cached usage information.
|
|
54
|
+
- `/multicodex-footer`
|
|
55
|
+
- Open an interactive panel to configure footer fields and ordering.
|
|
54
56
|
|
|
55
57
|
## Project direction
|
|
56
58
|
|
|
@@ -67,6 +69,11 @@ Current direction:
|
|
|
67
69
|
Current next step:
|
|
68
70
|
|
|
69
71
|
- add active-account usage visibility in pi for this extension's managed Codex accounts
|
|
72
|
+
- mirror the existing codex usage footer style, including support for displaying both reset countdowns
|
|
73
|
+
- show footer usage only when the selected model uses the `multicodex` provider override
|
|
74
|
+
- show the active account identifier beside the 5h and 7d usage metrics
|
|
75
|
+
- configure footer fields and ordering through an interactive panel
|
|
76
|
+
- refresh the footer from the active managed account without polling aggressively
|
|
70
77
|
|
|
71
78
|
## Release validation
|
|
72
79
|
|
|
@@ -90,6 +97,8 @@ Prepare locally:
|
|
|
90
97
|
npm run release:prepare -- <version>
|
|
91
98
|
```
|
|
92
99
|
|
|
100
|
+
The helper updates `package.json` with `bun pm pkg set` and then runs the release checks.
|
|
101
|
+
|
|
93
102
|
Example:
|
|
94
103
|
|
|
95
104
|
```bash
|
|
@@ -104,3 +113,5 @@ Do not use local `npm publish` for normal releases in this repo.
|
|
|
104
113
|
## Acknowledgment
|
|
105
114
|
|
|
106
115
|
This project descends from earlier MultiCodex work. Thanks to the original creator for the starting point that made this package possible.
|
|
116
|
+
|
|
117
|
+
The active-account usage footer work also draws on ideas from `calesennett/pi-codex-usage`. Thanks to its author for the reference implementation and footer design.
|
package/commands.ts
CHANGED
|
@@ -5,6 +5,7 @@ import type {
|
|
|
5
5
|
} from "@mariozechner/pi-coding-agent";
|
|
6
6
|
import type { AccountManager } from "./account-manager";
|
|
7
7
|
import { openLoginInBrowser } from "./browser";
|
|
8
|
+
import type { createUsageStatusController } from "./status";
|
|
8
9
|
import { formatResetAt, isUsageUntouched } from "./usage";
|
|
9
10
|
|
|
10
11
|
function getErrorMessage(error: unknown): string {
|
|
@@ -15,6 +16,7 @@ function getErrorMessage(error: unknown): string {
|
|
|
15
16
|
export function registerCommands(
|
|
16
17
|
pi: ExtensionAPI,
|
|
17
18
|
accountManager: AccountManager,
|
|
19
|
+
statusController: ReturnType<typeof createUsageStatusController>,
|
|
18
20
|
): void {
|
|
19
21
|
pi.registerCommand("multicodex-login", {
|
|
20
22
|
description: "Login to an OpenAI Codex account for the rotation pool",
|
|
@@ -135,4 +137,14 @@ export function registerCommands(
|
|
|
135
137
|
await ctx.ui.select("MultiCodex Accounts", options);
|
|
136
138
|
},
|
|
137
139
|
});
|
|
140
|
+
|
|
141
|
+
pi.registerCommand("multicodex-footer", {
|
|
142
|
+
description: "Configure the MultiCodex usage footer",
|
|
143
|
+
handler: async (
|
|
144
|
+
_args: string,
|
|
145
|
+
ctx: ExtensionCommandContext,
|
|
146
|
+
): Promise<void> => {
|
|
147
|
+
await statusController.openPreferencesPanel(ctx);
|
|
148
|
+
},
|
|
149
|
+
});
|
|
138
150
|
}
|
package/extension.ts
CHANGED
|
@@ -6,9 +6,11 @@ import { AccountManager } from "./account-manager";
|
|
|
6
6
|
import { registerCommands } from "./commands";
|
|
7
7
|
import { handleNewSessionSwitch, handleSessionStart } from "./hooks";
|
|
8
8
|
import { buildMulticodexProviderConfig, PROVIDER_ID } from "./provider";
|
|
9
|
+
import { createUsageStatusController } from "./status";
|
|
9
10
|
|
|
10
11
|
export default function multicodexExtension(pi: ExtensionAPI) {
|
|
11
12
|
const accountManager = new AccountManager();
|
|
13
|
+
const statusController = createUsageStatusController(accountManager);
|
|
12
14
|
let lastContext: ExtensionContext | undefined;
|
|
13
15
|
|
|
14
16
|
accountManager.setWarningHandler((message) => {
|
|
@@ -22,11 +24,16 @@ export default function multicodexExtension(pi: ExtensionAPI) {
|
|
|
22
24
|
buildMulticodexProviderConfig(accountManager),
|
|
23
25
|
);
|
|
24
26
|
|
|
25
|
-
registerCommands(pi, accountManager);
|
|
27
|
+
registerCommands(pi, accountManager, statusController);
|
|
26
28
|
|
|
27
29
|
pi.on("session_start", (_event: unknown, ctx: ExtensionContext) => {
|
|
28
30
|
lastContext = ctx;
|
|
29
31
|
handleSessionStart(accountManager);
|
|
32
|
+
statusController.startAutoRefresh();
|
|
33
|
+
void (async () => {
|
|
34
|
+
await statusController.loadPreferences(ctx);
|
|
35
|
+
await statusController.refreshFor(ctx);
|
|
36
|
+
})();
|
|
30
37
|
});
|
|
31
38
|
|
|
32
39
|
pi.on(
|
|
@@ -36,6 +43,21 @@ export default function multicodexExtension(pi: ExtensionAPI) {
|
|
|
36
43
|
if (event.reason === "new") {
|
|
37
44
|
handleNewSessionSwitch(accountManager);
|
|
38
45
|
}
|
|
46
|
+
void statusController.refreshFor(ctx);
|
|
39
47
|
},
|
|
40
48
|
);
|
|
49
|
+
|
|
50
|
+
pi.on("turn_end", (_event: unknown, ctx: ExtensionContext) => {
|
|
51
|
+
lastContext = ctx;
|
|
52
|
+
void statusController.refreshFor(ctx);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
pi.on("model_select", (_event: unknown, ctx: ExtensionContext) => {
|
|
56
|
+
lastContext = ctx;
|
|
57
|
+
void statusController.refreshFor(ctx);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
pi.on("session_shutdown", (_event: unknown, ctx: ExtensionContext) => {
|
|
61
|
+
statusController.stopAutoRefresh(ctx);
|
|
62
|
+
});
|
|
41
63
|
}
|
package/index.ts
CHANGED
|
@@ -11,6 +11,11 @@ export {
|
|
|
11
11
|
isAccountAvailable,
|
|
12
12
|
pickBestAccount,
|
|
13
13
|
} from "./selection";
|
|
14
|
+
export {
|
|
15
|
+
createUsageStatusController,
|
|
16
|
+
formatActiveAccountStatus,
|
|
17
|
+
isManagedModel,
|
|
18
|
+
} from "./status";
|
|
14
19
|
export type { Account } from "./storage";
|
|
15
20
|
export { createStreamWrapper } from "./stream-wrapper";
|
|
16
21
|
export type { CodexUsageSnapshot } from "./usage";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@victor-software-house/pi-multicodex",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.7",
|
|
4
4
|
"description": "Codex account rotation extension for pi",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -40,6 +40,7 @@
|
|
|
40
40
|
"provider.ts",
|
|
41
41
|
"quota.ts",
|
|
42
42
|
"selection.ts",
|
|
43
|
+
"status.ts",
|
|
43
44
|
"storage.ts",
|
|
44
45
|
"stream-wrapper.ts",
|
|
45
46
|
"usage-client.ts",
|
|
@@ -59,12 +60,25 @@
|
|
|
59
60
|
},
|
|
60
61
|
"peerDependencies": {
|
|
61
62
|
"@mariozechner/pi-ai": "*",
|
|
62
|
-
"@mariozechner/pi-coding-agent": "*"
|
|
63
|
+
"@mariozechner/pi-coding-agent": "*",
|
|
64
|
+
"@mariozechner/pi-tui": "*"
|
|
65
|
+
},
|
|
66
|
+
"peerDependenciesMeta": {
|
|
67
|
+
"@mariozechner/pi-ai": {
|
|
68
|
+
"optional": true
|
|
69
|
+
},
|
|
70
|
+
"@mariozechner/pi-coding-agent": {
|
|
71
|
+
"optional": true
|
|
72
|
+
},
|
|
73
|
+
"@mariozechner/pi-tui": {
|
|
74
|
+
"optional": true
|
|
75
|
+
}
|
|
63
76
|
},
|
|
64
77
|
"devDependencies": {
|
|
65
78
|
"@biomejs/biome": "^2.4.7",
|
|
66
79
|
"@mariozechner/pi-ai": "^0.58.1",
|
|
67
80
|
"@mariozechner/pi-coding-agent": "^0.58.1",
|
|
81
|
+
"@mariozechner/pi-tui": "^0.58.1",
|
|
68
82
|
"@types/node": "^25.5.0",
|
|
69
83
|
"@typescript/native-preview": "7.0.0-dev.20260314.1",
|
|
70
84
|
"typescript": "^5.9.3",
|
package/status.ts
ADDED
|
@@ -0,0 +1,462 @@
|
|
|
1
|
+
import { promises as fs } from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import type { Api, Model } from "@mariozechner/pi-ai";
|
|
5
|
+
import type {
|
|
6
|
+
ExtensionCommandContext,
|
|
7
|
+
ExtensionContext,
|
|
8
|
+
} from "@mariozechner/pi-coding-agent";
|
|
9
|
+
import { getSettingsListTheme } from "@mariozechner/pi-coding-agent";
|
|
10
|
+
import {
|
|
11
|
+
Container,
|
|
12
|
+
type SettingItem,
|
|
13
|
+
SettingsList,
|
|
14
|
+
Text,
|
|
15
|
+
} from "@mariozechner/pi-tui";
|
|
16
|
+
import type { AccountManager } from "./account-manager";
|
|
17
|
+
import { PROVIDER_ID } from "./provider";
|
|
18
|
+
import type { CodexUsageSnapshot } from "./usage";
|
|
19
|
+
|
|
20
|
+
const STATUS_KEY = "multicodex-usage";
|
|
21
|
+
const SETTINGS_KEY = "pi-multicodex";
|
|
22
|
+
const SETTINGS_FILE = path.join(os.homedir(), ".pi", "agent", "settings.json");
|
|
23
|
+
const REFRESH_INTERVAL_MS = 60_000;
|
|
24
|
+
const UNKNOWN_PERCENT = "--";
|
|
25
|
+
const FIVE_HOUR_LABEL = "5h:";
|
|
26
|
+
const SEVEN_DAY_LABEL = "7d:";
|
|
27
|
+
|
|
28
|
+
type MaybeModel = Model<Api> | undefined;
|
|
29
|
+
export type PercentDisplayMode = "left" | "used";
|
|
30
|
+
export type ResetWindowMode = "5h" | "7d" | "both";
|
|
31
|
+
export type StatusOrder = "account-first" | "usage-first";
|
|
32
|
+
|
|
33
|
+
export interface FooterPreferences {
|
|
34
|
+
usageMode: PercentDisplayMode;
|
|
35
|
+
resetWindow: ResetWindowMode;
|
|
36
|
+
showAccount: boolean;
|
|
37
|
+
showReset: boolean;
|
|
38
|
+
order: StatusOrder;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const DEFAULT_PREFERENCES: FooterPreferences = {
|
|
42
|
+
usageMode: "left",
|
|
43
|
+
resetWindow: "7d",
|
|
44
|
+
showAccount: true,
|
|
45
|
+
showReset: true,
|
|
46
|
+
order: "account-first",
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
function asObject(value: unknown): Record<string, unknown> | null {
|
|
50
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
51
|
+
return value as Record<string, unknown>;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function isPercentDisplayMode(value: unknown): value is PercentDisplayMode {
|
|
55
|
+
return value === "left" || value === "used";
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function isResetWindowMode(value: unknown): value is ResetWindowMode {
|
|
59
|
+
return value === "5h" || value === "7d" || value === "both";
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function isStatusOrder(value: unknown): value is StatusOrder {
|
|
63
|
+
return value === "account-first" || value === "usage-first";
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function normalizePreferences(value: unknown): FooterPreferences {
|
|
67
|
+
const record = asObject(value);
|
|
68
|
+
return {
|
|
69
|
+
usageMode: isPercentDisplayMode(record?.usageMode)
|
|
70
|
+
? record.usageMode
|
|
71
|
+
: DEFAULT_PREFERENCES.usageMode,
|
|
72
|
+
resetWindow: isResetWindowMode(record?.resetWindow)
|
|
73
|
+
? record.resetWindow
|
|
74
|
+
: DEFAULT_PREFERENCES.resetWindow,
|
|
75
|
+
showAccount:
|
|
76
|
+
typeof record?.showAccount === "boolean"
|
|
77
|
+
? record.showAccount
|
|
78
|
+
: DEFAULT_PREFERENCES.showAccount,
|
|
79
|
+
showReset:
|
|
80
|
+
typeof record?.showReset === "boolean"
|
|
81
|
+
? record.showReset
|
|
82
|
+
: DEFAULT_PREFERENCES.showReset,
|
|
83
|
+
order: isStatusOrder(record?.order)
|
|
84
|
+
? record.order
|
|
85
|
+
: DEFAULT_PREFERENCES.order,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function readSettingsFile(): Promise<Record<string, unknown>> {
|
|
90
|
+
try {
|
|
91
|
+
const raw = await fs.readFile(SETTINGS_FILE, "utf8");
|
|
92
|
+
const parsed = JSON.parse(raw) as unknown;
|
|
93
|
+
return asObject(parsed) ?? {};
|
|
94
|
+
} catch (error) {
|
|
95
|
+
const withCode = error as Error & { code?: string };
|
|
96
|
+
if (withCode.code === "ENOENT") return {};
|
|
97
|
+
throw error;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function writeSettingsFile(
|
|
102
|
+
settings: Record<string, unknown>,
|
|
103
|
+
): Promise<void> {
|
|
104
|
+
await fs.mkdir(path.dirname(SETTINGS_FILE), { recursive: true });
|
|
105
|
+
await fs.writeFile(
|
|
106
|
+
SETTINGS_FILE,
|
|
107
|
+
`${JSON.stringify(settings, null, 2)}\n`,
|
|
108
|
+
"utf8",
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export async function loadFooterPreferences(): Promise<FooterPreferences> {
|
|
113
|
+
const settings = await readSettingsFile();
|
|
114
|
+
return normalizePreferences(settings[SETTINGS_KEY]);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export async function persistFooterPreferences(
|
|
118
|
+
preferences: FooterPreferences,
|
|
119
|
+
): Promise<void> {
|
|
120
|
+
const settings = await readSettingsFile();
|
|
121
|
+
settings[SETTINGS_KEY] = {
|
|
122
|
+
...asObject(settings[SETTINGS_KEY]),
|
|
123
|
+
...preferences,
|
|
124
|
+
};
|
|
125
|
+
await writeSettingsFile(settings);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function clampPercent(value: number): number {
|
|
129
|
+
return Math.min(100, Math.max(0, value));
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function usedToDisplayPercent(
|
|
133
|
+
value: number | undefined,
|
|
134
|
+
mode: PercentDisplayMode,
|
|
135
|
+
): number | undefined {
|
|
136
|
+
if (typeof value !== "number" || Number.isNaN(value)) return undefined;
|
|
137
|
+
const left = clampPercent(100 - value);
|
|
138
|
+
return mode === "left" ? left : clampPercent(100 - left);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function formatPercent(
|
|
142
|
+
ctx: ExtensionContext,
|
|
143
|
+
displayPercent: number | undefined,
|
|
144
|
+
mode: PercentDisplayMode,
|
|
145
|
+
): string {
|
|
146
|
+
if (typeof displayPercent !== "number" || Number.isNaN(displayPercent)) {
|
|
147
|
+
return ctx.ui.theme.fg("muted", UNKNOWN_PERCENT);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const text = `${Math.round(clampPercent(displayPercent))}% ${mode}`;
|
|
151
|
+
if (mode === "left") {
|
|
152
|
+
if (displayPercent <= 10) return ctx.ui.theme.fg("error", text);
|
|
153
|
+
if (displayPercent <= 25) return ctx.ui.theme.fg("warning", text);
|
|
154
|
+
return ctx.ui.theme.fg("success", text);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (displayPercent >= 90) return ctx.ui.theme.fg("error", text);
|
|
158
|
+
if (displayPercent >= 75) return ctx.ui.theme.fg("warning", text);
|
|
159
|
+
return ctx.ui.theme.fg("success", text);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function formatResetCountdown(resetAt: number | undefined): string | undefined {
|
|
163
|
+
if (typeof resetAt !== "number" || Number.isNaN(resetAt)) return undefined;
|
|
164
|
+
const totalSeconds = Math.max(0, Math.round((resetAt - Date.now()) / 1000));
|
|
165
|
+
const days = Math.floor(totalSeconds / 86_400);
|
|
166
|
+
const hours = Math.floor((totalSeconds % 86_400) / 3_600);
|
|
167
|
+
const minutes = Math.floor((totalSeconds % 3_600) / 60);
|
|
168
|
+
const seconds = totalSeconds % 60;
|
|
169
|
+
if (days > 0) return `${days}d${hours}h`;
|
|
170
|
+
if (hours > 0) return `${hours}h${minutes}m`;
|
|
171
|
+
if (minutes > 0) return `${minutes}m`;
|
|
172
|
+
return `${seconds}s`;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function isManagedModel(model: MaybeModel): boolean {
|
|
176
|
+
return model?.provider === PROVIDER_ID;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export function formatActiveAccountStatus(
|
|
180
|
+
ctx: ExtensionContext,
|
|
181
|
+
accountEmail: string,
|
|
182
|
+
usage: CodexUsageSnapshot | undefined,
|
|
183
|
+
preferences: FooterPreferences,
|
|
184
|
+
): string {
|
|
185
|
+
const accountText = preferences.showAccount
|
|
186
|
+
? ctx.ui.theme.fg("muted", accountEmail)
|
|
187
|
+
: undefined;
|
|
188
|
+
if (!usage) {
|
|
189
|
+
return [
|
|
190
|
+
ctx.ui.theme.fg("dim", "Codex"),
|
|
191
|
+
accountText,
|
|
192
|
+
ctx.ui.theme.fg("dim", "loading..."),
|
|
193
|
+
]
|
|
194
|
+
.filter(Boolean)
|
|
195
|
+
.join(" ");
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const fiveHour = `${ctx.ui.theme.fg("dim", FIVE_HOUR_LABEL)}${formatPercent(
|
|
199
|
+
ctx,
|
|
200
|
+
usedToDisplayPercent(usage.primary?.usedPercent, preferences.usageMode),
|
|
201
|
+
preferences.usageMode,
|
|
202
|
+
)}`;
|
|
203
|
+
const sevenDay = `${ctx.ui.theme.fg("dim", SEVEN_DAY_LABEL)}${formatPercent(
|
|
204
|
+
ctx,
|
|
205
|
+
usedToDisplayPercent(usage.secondary?.usedPercent, preferences.usageMode),
|
|
206
|
+
preferences.usageMode,
|
|
207
|
+
)}`;
|
|
208
|
+
const fiveHourReset = preferences.showReset
|
|
209
|
+
? formatResetCountdown(usage.primary?.resetAt)
|
|
210
|
+
: undefined;
|
|
211
|
+
const sevenDayReset = preferences.showReset
|
|
212
|
+
? formatResetCountdown(usage.secondary?.resetAt)
|
|
213
|
+
: undefined;
|
|
214
|
+
const resetText =
|
|
215
|
+
preferences.resetWindow === "5h"
|
|
216
|
+
? fiveHourReset
|
|
217
|
+
? ctx.ui.theme.fg("dim", `(${FIVE_HOUR_LABEL}↺${fiveHourReset})`)
|
|
218
|
+
: undefined
|
|
219
|
+
: preferences.resetWindow === "7d"
|
|
220
|
+
? sevenDayReset
|
|
221
|
+
? ctx.ui.theme.fg("dim", `(${SEVEN_DAY_LABEL}↺${sevenDayReset})`)
|
|
222
|
+
: undefined
|
|
223
|
+
: [
|
|
224
|
+
fiveHourReset
|
|
225
|
+
? ctx.ui.theme.fg("dim", `(${FIVE_HOUR_LABEL}↺${fiveHourReset})`)
|
|
226
|
+
: undefined,
|
|
227
|
+
sevenDayReset
|
|
228
|
+
? ctx.ui.theme.fg("dim", `(${SEVEN_DAY_LABEL}↺${sevenDayReset})`)
|
|
229
|
+
: undefined,
|
|
230
|
+
]
|
|
231
|
+
.filter(Boolean)
|
|
232
|
+
.join(" ") || undefined;
|
|
233
|
+
|
|
234
|
+
const leading =
|
|
235
|
+
preferences.order === "account-first"
|
|
236
|
+
? [ctx.ui.theme.fg("dim", "Codex"), accountText]
|
|
237
|
+
: [ctx.ui.theme.fg("dim", "Codex")];
|
|
238
|
+
const trailing =
|
|
239
|
+
preferences.order === "account-first" ? [] : [accountText].filter(Boolean);
|
|
240
|
+
|
|
241
|
+
return [...leading, fiveHour, sevenDay, resetText, ...trailing]
|
|
242
|
+
.filter(Boolean)
|
|
243
|
+
.join(" ");
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function getBooleanLabel(value: boolean): string {
|
|
247
|
+
return value ? "on" : "off";
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function createSettingsItems(preferences: FooterPreferences): SettingItem[] {
|
|
251
|
+
return [
|
|
252
|
+
{
|
|
253
|
+
id: "usageMode",
|
|
254
|
+
label: "Usage display",
|
|
255
|
+
description: "Show remaining or consumed quota percentages",
|
|
256
|
+
currentValue: preferences.usageMode,
|
|
257
|
+
values: ["left", "used"],
|
|
258
|
+
},
|
|
259
|
+
{
|
|
260
|
+
id: "resetWindow",
|
|
261
|
+
label: "Reset countdown window",
|
|
262
|
+
description:
|
|
263
|
+
"Choose whether the footer shows the 5h countdown, the 7d countdown, or both",
|
|
264
|
+
currentValue: preferences.resetWindow,
|
|
265
|
+
values: ["5h", "7d", "both"],
|
|
266
|
+
},
|
|
267
|
+
{
|
|
268
|
+
id: "showAccount",
|
|
269
|
+
label: "Show account",
|
|
270
|
+
description: "Display the active account identifier in the footer",
|
|
271
|
+
currentValue: getBooleanLabel(preferences.showAccount),
|
|
272
|
+
values: ["on", "off"],
|
|
273
|
+
},
|
|
274
|
+
{
|
|
275
|
+
id: "showReset",
|
|
276
|
+
label: "Show reset countdown",
|
|
277
|
+
description:
|
|
278
|
+
"Display a reset countdown like the codex usage footer extension",
|
|
279
|
+
currentValue: getBooleanLabel(preferences.showReset),
|
|
280
|
+
values: ["on", "off"],
|
|
281
|
+
},
|
|
282
|
+
{
|
|
283
|
+
id: "order",
|
|
284
|
+
label: "Footer order",
|
|
285
|
+
description:
|
|
286
|
+
"Choose whether the account appears before or after usage fields",
|
|
287
|
+
currentValue: preferences.order,
|
|
288
|
+
values: ["account-first", "usage-first"],
|
|
289
|
+
},
|
|
290
|
+
];
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function applyPreferenceChange(
|
|
294
|
+
preferences: FooterPreferences,
|
|
295
|
+
id: string,
|
|
296
|
+
newValue: string,
|
|
297
|
+
): FooterPreferences {
|
|
298
|
+
if (id === "usageMode" && isPercentDisplayMode(newValue)) {
|
|
299
|
+
return { ...preferences, usageMode: newValue };
|
|
300
|
+
}
|
|
301
|
+
if (id === "resetWindow" && isResetWindowMode(newValue)) {
|
|
302
|
+
return { ...preferences, resetWindow: newValue };
|
|
303
|
+
}
|
|
304
|
+
if (id === "showAccount") {
|
|
305
|
+
return { ...preferences, showAccount: newValue === "on" };
|
|
306
|
+
}
|
|
307
|
+
if (id === "showReset") {
|
|
308
|
+
return { ...preferences, showReset: newValue === "on" };
|
|
309
|
+
}
|
|
310
|
+
if (id === "order" && isStatusOrder(newValue)) {
|
|
311
|
+
return { ...preferences, order: newValue };
|
|
312
|
+
}
|
|
313
|
+
return preferences;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
export function createUsageStatusController(accountManager: AccountManager) {
|
|
317
|
+
let refreshTimer: ReturnType<typeof setInterval> | undefined;
|
|
318
|
+
let activeContext: ExtensionContext | undefined;
|
|
319
|
+
let refreshInFlight = false;
|
|
320
|
+
let queuedRefresh = false;
|
|
321
|
+
let preferences: FooterPreferences = DEFAULT_PREFERENCES;
|
|
322
|
+
|
|
323
|
+
function clearStatus(ctx?: ExtensionContext): void {
|
|
324
|
+
ctx?.ui.setStatus(STATUS_KEY, undefined);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
async function ensurePreferencesLoaded(): Promise<void> {
|
|
328
|
+
preferences = await loadFooterPreferences();
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
async function updateStatus(ctx: ExtensionContext): Promise<void> {
|
|
332
|
+
if (!ctx.hasUI) return;
|
|
333
|
+
if (!isManagedModel(ctx.model)) {
|
|
334
|
+
clearStatus(ctx);
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const activeAccount = accountManager.getActiveAccount();
|
|
339
|
+
if (!activeAccount) {
|
|
340
|
+
ctx.ui.setStatus(
|
|
341
|
+
STATUS_KEY,
|
|
342
|
+
ctx.ui.theme.fg("warning", "Multicodex no active account"),
|
|
343
|
+
);
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
const cachedUsage = accountManager.getCachedUsage(activeAccount.email);
|
|
348
|
+
const usage =
|
|
349
|
+
(await accountManager.refreshUsageForAccount(activeAccount)) ??
|
|
350
|
+
cachedUsage;
|
|
351
|
+
ctx.ui.setStatus(
|
|
352
|
+
STATUS_KEY,
|
|
353
|
+
formatActiveAccountStatus(ctx, activeAccount.email, usage, preferences),
|
|
354
|
+
);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
async function refreshFor(ctx: ExtensionContext): Promise<void> {
|
|
358
|
+
activeContext = ctx;
|
|
359
|
+
if (refreshInFlight) {
|
|
360
|
+
queuedRefresh = true;
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
refreshInFlight = true;
|
|
365
|
+
try {
|
|
366
|
+
await updateStatus(ctx);
|
|
367
|
+
} finally {
|
|
368
|
+
refreshInFlight = false;
|
|
369
|
+
if (queuedRefresh && activeContext) {
|
|
370
|
+
queuedRefresh = false;
|
|
371
|
+
await refreshFor(activeContext);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function startAutoRefresh(): void {
|
|
377
|
+
if (refreshTimer) clearInterval(refreshTimer);
|
|
378
|
+
refreshTimer = setInterval(() => {
|
|
379
|
+
if (!activeContext) return;
|
|
380
|
+
void refreshFor(activeContext);
|
|
381
|
+
}, REFRESH_INTERVAL_MS);
|
|
382
|
+
refreshTimer.unref?.();
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function stopAutoRefresh(ctx?: ExtensionContext): void {
|
|
386
|
+
if (refreshTimer) {
|
|
387
|
+
clearInterval(refreshTimer);
|
|
388
|
+
refreshTimer = undefined;
|
|
389
|
+
}
|
|
390
|
+
clearStatus(ctx ?? activeContext);
|
|
391
|
+
activeContext = undefined;
|
|
392
|
+
queuedRefresh = false;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
async function loadPreferences(ctx?: ExtensionContext): Promise<void> {
|
|
396
|
+
try {
|
|
397
|
+
await ensurePreferencesLoaded();
|
|
398
|
+
} catch (error) {
|
|
399
|
+
preferences = DEFAULT_PREFERENCES;
|
|
400
|
+
ctx?.ui.notify(
|
|
401
|
+
`Multicodex: failed to load ${SETTINGS_FILE}: ${String(error)}`,
|
|
402
|
+
"warning",
|
|
403
|
+
);
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
async function openPreferencesPanel(
|
|
408
|
+
ctx: ExtensionCommandContext,
|
|
409
|
+
): Promise<void> {
|
|
410
|
+
await loadPreferences(ctx);
|
|
411
|
+
let draft = preferences;
|
|
412
|
+
|
|
413
|
+
await ctx.ui.custom((_tui, theme, _kb, done) => {
|
|
414
|
+
const container = new Container();
|
|
415
|
+
container.addChild(
|
|
416
|
+
new Text(theme.fg("accent", theme.bold("MultiCodex Footer")), 1, 0),
|
|
417
|
+
);
|
|
418
|
+
container.addChild(
|
|
419
|
+
new Text(
|
|
420
|
+
theme.fg(
|
|
421
|
+
"dim",
|
|
422
|
+
"Configure the usage footer to match the codex usage extension style.",
|
|
423
|
+
),
|
|
424
|
+
1,
|
|
425
|
+
0,
|
|
426
|
+
),
|
|
427
|
+
);
|
|
428
|
+
|
|
429
|
+
const settingsList = new SettingsList(
|
|
430
|
+
createSettingsItems(draft),
|
|
431
|
+
7,
|
|
432
|
+
getSettingsListTheme(),
|
|
433
|
+
(id: string, newValue: string) => {
|
|
434
|
+
draft = applyPreferenceChange(draft, id, newValue);
|
|
435
|
+
settingsList.updateValue(id, newValue);
|
|
436
|
+
},
|
|
437
|
+
() => done(undefined),
|
|
438
|
+
{ enableSearch: true },
|
|
439
|
+
);
|
|
440
|
+
container.addChild(settingsList);
|
|
441
|
+
|
|
442
|
+
return {
|
|
443
|
+
render: (width: number) => container.render(width),
|
|
444
|
+
invalidate: () => container.invalidate(),
|
|
445
|
+
handleInput: (data: string) => settingsList.handleInput(data),
|
|
446
|
+
};
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
preferences = draft;
|
|
450
|
+
await persistFooterPreferences(preferences);
|
|
451
|
+
await refreshFor(ctx);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
return {
|
|
455
|
+
loadPreferences,
|
|
456
|
+
openPreferencesPanel,
|
|
457
|
+
refreshFor,
|
|
458
|
+
startAutoRefresh,
|
|
459
|
+
stopAutoRefresh,
|
|
460
|
+
getPreferences: () => preferences,
|
|
461
|
+
};
|
|
462
|
+
}
|