@howaboua/pi-codex-conversion 1.5.9 → 1.5.10

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 CHANGED
@@ -1,5 +1,10 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.5.10
4
+
5
+ - Added `/codex usage` and a Usage tab for OpenAI Codex subscription limits, with automatic refresh and aligned 5-hour/weekly usage columns.
6
+ - Moved settings links into a dedicated About tab.
7
+
3
8
  ## 1.5.9
4
9
 
5
10
  - Fixed native Responses compaction replay when provider payloads include in-flight tail items that are not yet persisted in the session branch.
package/README.md CHANGED
@@ -46,11 +46,12 @@ Use `/codex` to change adapter settings.
46
46
  - `/codex fast` — toggle priority service tier for the OpenAI Codex provider
47
47
  - `/codex search` — toggle native Codex web search
48
48
  - `/codex image` — toggle native Codex image generation
49
+ - `/codex usage` — show Codex subscription usage windows for the active OpenAI Codex model
49
50
  - `/codex low`, `/codex medium`, `/codex high` — set Responses API verbosity
50
51
 
51
52
  Settings are saved globally in `~/.pi/agent/pi-codex-conversion.json`.
52
53
 
53
- The settings UI also has an **Overrides** tab. These options intentionally do not have `/codex ...` command shortcuts:
54
+ The settings UI also has **Usage**, **Overrides**, and **About** tabs. **Usage** refreshes automatically when opened and can be refreshed manually with `r`. Override options intentionally do not have `/codex ...` command shortcuts:
54
55
 
55
56
  - add only the Pi `apply_patch` tool for GPT/Codex models while keeping Pi's default toolkit, prompt, provider behavior, and compaction flow
56
57
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@howaboua/pi-codex-conversion",
3
- "version": "1.5.9",
3
+ "version": "1.5.10",
4
4
  "description": "Codex-oriented tool and prompt adapter for pi coding agent",
5
5
  "type": "module",
6
6
  "repository": {
@@ -8,9 +8,10 @@ import {
8
8
  import { syncAdapter } from "../adapter/activation.ts";
9
9
  import type { AdapterState } from "../adapter/state.ts";
10
10
  import { openCodexSettingsScreen } from "./ui.ts";
11
+ import { fetchCodexUsage, formatCodexUsage } from "./usage.ts";
11
12
 
12
- const CODEX_COMMAND_COMPLETIONS = ["all", "status", "fast", "search", "image", "compact", "low", "medium", "high"] as const;
13
- const CODEX_USAGE = "Usage: /codex, /codex all, /codex status, /codex fast, /codex search, /codex image, /codex compact, /codex low|medium|high";
13
+ const CODEX_COMMAND_COMPLETIONS = ["all", "status", "fast", "search", "image", "compact", "usage", "low", "medium", "high"] as const;
14
+ const CODEX_USAGE = "Usage: /codex, /codex all, /codex status, /codex fast, /codex search, /codex image, /codex compact, /codex usage, /codex low|medium|high";
14
15
 
15
16
  export function registerCodexCommand(pi: ExtensionAPI, state: AdapterState, onConfigApplied?: (config: CodexConversionConfig) => void): void {
16
17
  function saveAndApply(ctx: ExtensionContext, nextConfig: CodexConversionConfig): boolean {
@@ -32,6 +33,36 @@ export function registerCodexCommand(pi: ExtensionAPI, state: AdapterState, onCo
32
33
  handler: async (args, ctx) => {
33
34
  state.config = readCodexConversionConfig();
34
35
  const arg = args.trim().toLowerCase();
36
+ if (arg === "usage") {
37
+ let usage;
38
+ try {
39
+ usage = await fetchCodexUsage(ctx);
40
+ } catch (error) {
41
+ const message = error instanceof Error ? error.message : String(error);
42
+ if (!ctx.hasUI) {
43
+ ctx.ui.notify(message, "error");
44
+ return;
45
+ }
46
+ await openCodexSettingsScreen(ctx, {
47
+ initialConfig: state.config,
48
+ initialTab: "usage",
49
+ initialUsage: { error: message },
50
+ onChange: (config) => saveAndApply(ctx, config),
51
+ });
52
+ return;
53
+ }
54
+ if (!ctx.hasUI) {
55
+ ctx.ui.notify(formatCodexUsage(usage), "info");
56
+ return;
57
+ }
58
+ await openCodexSettingsScreen(ctx, {
59
+ initialConfig: state.config,
60
+ initialTab: "usage",
61
+ initialUsage: usage,
62
+ onChange: (config) => saveAndApply(ctx, config),
63
+ });
64
+ return;
65
+ }
35
66
  if (arg === "compact") {
36
67
  if (!ctx.hasUI) {
37
68
  ctx.ui.notify(formatCodexSettings(state.config), "info");
@@ -10,25 +10,41 @@ import {
10
10
  type CodexConversionConfig,
11
11
  } from "../adapter/config.ts";
12
12
  import { CHANGELOG_URL, DISCORD_URL, GITHUB_URL, ISSUE_URL, openExternalUrl } from "./links.ts";
13
+ import { fetchCodexUsage, formatCodexUsage, type CodexUsageSnapshot } from "./usage.ts";
13
14
 
14
15
  export interface CodexSettingsScreenOptions {
15
16
  initialConfig: CodexConversionConfig;
16
17
  onChange: (nextConfig: CodexConversionConfig) => boolean;
17
18
  initialTab?: SettingsTab;
19
+ initialUsage?: CodexUsageSnapshot | { error: string };
20
+ onRefreshUsage?: () => Promise<CodexUsageSnapshot>;
18
21
  }
19
22
 
20
- type SettingsTab = "general" | "compaction" | "overrides";
23
+ type SettingsTab = "general" | "compaction" | "usage" | "overrides" | "about";
21
24
 
22
- const TAB_ORDER: readonly SettingsTab[] = ["general", "compaction", "overrides"];
25
+ const TAB_ORDER: readonly SettingsTab[] = ["general", "compaction", "usage", "overrides", "about"];
23
26
 
24
27
  export async function openCodexSettingsScreen(ctx: ExtensionContext, options: CodexSettingsScreenOptions): Promise<void> {
25
28
  let draft = { ...options.initialConfig };
26
29
  let activeTab: SettingsTab = options.initialTab ?? "general";
30
+ let usageState: CodexUsageSnapshot | { error: string } | undefined = options.initialUsage;
31
+ let usageLoading = false;
32
+
33
+ const loadUsage = (requestRender: () => void) => {
34
+ if (usageLoading) return;
35
+ usageLoading = true;
36
+ requestRender();
37
+ (options.onRefreshUsage ?? (() => fetchCodexUsage(ctx)))()
38
+ .then((usage) => { usageState = usage; })
39
+ .catch((error) => { usageState = { error: error instanceof Error ? error.message : String(error) }; })
40
+ .finally(() => { usageLoading = false; requestRender(); });
41
+ };
27
42
 
28
43
  await ctx.ui.custom<void>((tui, theme, _kb, done) => {
29
44
  let settingsList = createSettingsList(activeTab, draft, options, (nextDraft) => {
30
45
  draft = nextDraft;
31
46
  }, done, () => tui.requestRender());
47
+ if (activeTab === "usage" && !usageState) loadUsage(() => tui.requestRender());
32
48
 
33
49
  const switchTab = () => {
34
50
  const currentIndex = TAB_ORDER.indexOf(activeTab);
@@ -36,6 +52,7 @@ export async function openCodexSettingsScreen(ctx: ExtensionContext, options: Co
36
52
  settingsList = createSettingsList(activeTab, draft, options, (nextDraft) => {
37
53
  draft = nextDraft;
38
54
  }, done, () => tui.requestRender());
55
+ if (activeTab === "usage" && !usageState) loadUsage(() => tui.requestRender());
39
56
  tui.requestRender();
40
57
  };
41
58
 
@@ -47,12 +64,12 @@ export async function openCodexSettingsScreen(ctx: ExtensionContext, options: Co
47
64
  rule(width, theme, "borderMuted"),
48
65
  ...(activeTab === "compaction" ? formatCompactionNotes(theme) : []),
49
66
  ...(activeTab === "overrides" ? formatOverridesNotes(theme) : []),
67
+ ...(activeTab === "usage" ? formatUsageLines(theme, usageState, usageLoading) : []),
68
+ ...(activeTab === "about" ? formatLinks(theme) : []),
50
69
  "",
51
- ...settingsList.render(width),
52
- rule(width, theme, "borderMuted"),
53
- ...formatLinks(theme),
70
+ ...(activeTab === "usage" || activeTab === "about" ? [] : settingsList.render(width)),
54
71
  rule(width, theme, "accent"),
55
- theme.fg("dim", " Tab to switch sections · g/c/d/i open links"),
72
+ theme.fg("dim", formatFooter(activeTab)),
56
73
  ].map((line) => truncateToWidth(line, width, "")),
57
74
  invalidate: () => settingsList.invalidate(),
58
75
  handleInput: (data: string) => {
@@ -60,7 +77,11 @@ export async function openCodexSettingsScreen(ctx: ExtensionContext, options: Co
60
77
  switchTab();
61
78
  return;
62
79
  }
63
- if (handleLinkKey(data, ctx)) return;
80
+ if (activeTab === "about" && handleLinkKey(data, ctx)) return;
81
+ if (activeTab === "usage" && data.toLowerCase() === "r") {
82
+ loadUsage(() => tui.requestRender());
83
+ return;
84
+ }
64
85
  settingsList.handleInput?.(data);
65
86
  tui.requestRender();
66
87
  },
@@ -109,6 +130,8 @@ function createSettingsList(
109
130
  }
110
131
 
111
132
  function buildItems(tab: SettingsTab, draft: CodexConversionConfig): SettingItem[] {
133
+ if (tab === "usage" || tab === "about") return [];
134
+
112
135
  if (tab === "compaction") {
113
136
  return [
114
137
  { id: "responsesCompaction", label: "Responses compaction", currentValue: (draft.responsesCompaction ?? false) ? "on" : "off", values: ["off", "on"] },
@@ -150,7 +173,75 @@ function applySettingChange(id: string, value: string, draft: CodexConversionCon
150
173
 
151
174
  function formatTabs(activeTab: SettingsTab, theme: Theme): string {
152
175
  const renderTab = (tab: SettingsTab, label: string) => activeTab === tab ? theme.bold(label) : theme.fg("dim", label);
153
- return ` ${renderTab("general", "General")} ${theme.fg("dim", "/")} ${renderTab("compaction", "Compaction")} ${theme.fg("dim", "/")} ${renderTab("overrides", "Overrides")}`;
176
+ return ` ${renderTab("general", "General")} ${theme.fg("dim", "/")} ${renderTab("compaction", "Compaction")} ${theme.fg("dim", "/")} ${renderTab("usage", "Usage")} ${theme.fg("dim", "/")} ${renderTab("overrides", "Overrides")} ${theme.fg("dim", "/")} ${renderTab("about", "About")}`;
177
+ }
178
+
179
+ function formatFooter(activeTab: SettingsTab): string {
180
+ if (activeTab === "usage") return " Tab to switch sections · r refresh";
181
+ if (activeTab === "about") return " Tab to switch sections · g/c/d/i open links";
182
+ return " Tab to switch sections";
183
+ }
184
+
185
+ function formatUsageLines(theme: Theme, usageState: CodexUsageSnapshot | { error: string } | undefined, loading: boolean): string[] {
186
+ if (loading && !usageState) return [theme.fg("dim", " Loading Codex usage…")];
187
+ if (!usageState) return [theme.fg("dim", " Loading Codex usage…")];
188
+ if ("error" in usageState) return [theme.fg("error", ` ${usageState.error}`), theme.fg("dim", " Press r to retry.")];
189
+
190
+ const rows = usageState.limits.map((limit) => {
191
+ const primary = usageColumns(limit.primary);
192
+ const secondary = usageColumns(limit.secondary);
193
+ return [limit.limitName ?? limit.limitId, primary.bar, primary.percent, primary.reset, secondary.bar, secondary.percent, secondary.reset];
194
+ });
195
+ const headers = ["Limit", "5h", "", "Reset", "Weekly", "", "Reset"];
196
+ const widths = columnWidths([headers, ...rows]);
197
+ return [
198
+ ` ${theme.bold(`Codex usage${usageState.planType ? ` · ${usageState.planType}` : ""}`)}${loading ? theme.fg("dim", " refreshing…") : ""}`,
199
+ "",
200
+ formatUsageRow(headers.map((header) => theme.fg("dim", header)), widths),
201
+ theme.fg("borderMuted", ` ${"─".repeat(widths.reduce((sum, width) => sum + width, 0) + (2 * (widths.length - 1)))}`),
202
+ ...rows.map((row) => formatUsageRow(row, widths)),
203
+ ];
204
+ }
205
+
206
+ function columnWidths(rows: string[][]): number[] {
207
+ const columnCount = Math.max(...rows.map((row) => row.length));
208
+ return Array.from({ length: columnCount }, (_, index) => Math.max(...rows.map((row) => stripAnsi(row[index] ?? "").length)));
209
+ }
210
+
211
+ function stripAnsi(value: string): string {
212
+ return value.replace(/\x1b\[[0-9;]*m/g, "");
213
+ }
214
+
215
+ function padCell(value: string, width: number): string {
216
+ return value + " ".repeat(Math.max(0, width - stripAnsi(value).length));
217
+ }
218
+
219
+ function formatUsageRow(row: string[], widths: number[]): string {
220
+ return ` ${row.map((cell, index) => padCell(cell, widths[index] ?? 0)).join(" ")}`;
221
+ }
222
+
223
+ function usageColumns(window: { usedPercent?: number; windowMinutes?: number; resetsAt?: number } | undefined): { bar: string; percent: string; reset: string } {
224
+ if (!window) return { bar: "—", percent: "", reset: "" };
225
+ const percent = window.usedPercent === undefined ? undefined : Math.max(0, Math.min(100, window.usedPercent));
226
+ return {
227
+ bar: bar(percent),
228
+ percent: percent === undefined ? "?%" : `${Math.round(percent)}%`,
229
+ reset: formatResetShort(window.resetsAt),
230
+ };
231
+ }
232
+
233
+ function bar(percent: number | undefined): string {
234
+ if (percent === undefined) return "░░░░░░░░░░";
235
+ const filled = Math.max(0, Math.min(10, Math.round(percent / 10)));
236
+ return "█".repeat(filled) + "░".repeat(10 - filled);
237
+ }
238
+
239
+ function formatResetShort(timestampSeconds: number | undefined): string {
240
+ if (!timestampSeconds) return "reset ?";
241
+ const minutes = Math.max(0, Math.round((timestampSeconds * 1000 - Date.now()) / 60000));
242
+ if (minutes < 90) return `~${minutes}m`;
243
+ if (minutes < 60 * 48) return `~${Math.round(minutes / 60)}h`;
244
+ return `~${Math.round(minutes / 1440)}d`;
154
245
  }
155
246
 
156
247
  function formatLinks(theme: Theme): string[] {
@@ -0,0 +1,151 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import type { Api, Model } from "@earendil-works/pi-ai";
3
+
4
+ const DEFAULT_CODEX_BASE_URL = "https://chatgpt.com/backend-api";
5
+ const JWT_CLAIM_PATH = "https://api.openai.com/auth";
6
+
7
+ type RuntimeModel = Model<Api>;
8
+
9
+ export interface CodexUsageWindow {
10
+ usedPercent?: number;
11
+ windowMinutes?: number;
12
+ resetsAt?: number;
13
+ }
14
+
15
+ export interface CodexUsageLimit {
16
+ limitId: string;
17
+ limitName?: string;
18
+ primary?: CodexUsageWindow;
19
+ secondary?: CodexUsageWindow;
20
+ }
21
+
22
+ export interface CodexUsageSnapshot {
23
+ planType?: string;
24
+ limits: CodexUsageLimit[];
25
+ raw: unknown;
26
+ }
27
+
28
+ function isRecord(value: unknown): value is Record<string, unknown> {
29
+ return typeof value === "object" && value !== null && !Array.isArray(value);
30
+ }
31
+
32
+ function numberValue(value: unknown): number | undefined {
33
+ return typeof value === "number" && Number.isFinite(value) ? value : undefined;
34
+ }
35
+
36
+ function stringValue(value: unknown): string | undefined {
37
+ return typeof value === "string" && value.trim().length > 0 ? value : undefined;
38
+ }
39
+
40
+ export function buildCodexUsageUrl(): string {
41
+ return `${DEFAULT_CODEX_BASE_URL}/wham/usage`;
42
+ }
43
+
44
+ function extractBearerToken(headers: Headers): string | undefined {
45
+ const authorization = headers.get("authorization")?.trim();
46
+ const match = authorization?.match(/^Bearer\s+(.+)$/i);
47
+ return match?.[1]?.trim();
48
+ }
49
+
50
+ function extractAccountId(token: string): string | undefined {
51
+ try {
52
+ const parts = token.split(".");
53
+ if (parts.length !== 3) return undefined;
54
+ const payload = JSON.parse(Buffer.from(parts[1] ?? "", "base64").toString("utf8")) as unknown;
55
+ const authClaims = isRecord(payload) ? payload[JWT_CLAIM_PATH] : undefined;
56
+ const accountId = isRecord(authClaims) ? authClaims.chatgpt_account_id : undefined;
57
+ return stringValue(accountId);
58
+ } catch {
59
+ return undefined;
60
+ }
61
+ }
62
+
63
+ async function buildCodexUsageHeaders(ctx: ExtensionContext, model: RuntimeModel): Promise<Headers> {
64
+ const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
65
+ if (!auth.ok) throw new Error(auth.error);
66
+ const headers = new Headers(model.headers);
67
+ for (const [key, value] of Object.entries(auth.headers ?? {})) headers.set(key, value);
68
+ if (auth.apiKey) headers.set("authorization", `Bearer ${auth.apiKey}`);
69
+ const token = auth.apiKey ?? extractBearerToken(headers);
70
+ const accountId = token ? extractAccountId(token) : undefined;
71
+ if (accountId) headers.set("chatgpt-account-id", accountId);
72
+ headers.set("accept", "application/json");
73
+ headers.set("originator", "pi");
74
+ return headers;
75
+ }
76
+
77
+ function parseWindow(value: unknown): CodexUsageWindow | undefined {
78
+ if (!isRecord(value)) return undefined;
79
+ const usedPercent = numberValue(value.used_percent);
80
+ const limitWindowSeconds = numberValue(value.limit_window_seconds);
81
+ const windowMinutes = numberValue(value.window_minutes) ?? (limitWindowSeconds === undefined ? undefined : Math.ceil(limitWindowSeconds / 60));
82
+ const resetsAt = numberValue(value.resets_at) ?? numberValue(value.reset_at);
83
+ return usedPercent === undefined && windowMinutes === undefined && resetsAt === undefined ? undefined : { usedPercent, windowMinutes, resetsAt };
84
+ }
85
+
86
+ function parseRateLimit(value: unknown): { primary?: CodexUsageWindow; secondary?: CodexUsageWindow } {
87
+ if (!isRecord(value)) return {};
88
+ return {
89
+ primary: parseWindow(value.primary_window) ?? parseWindow(value.primary),
90
+ secondary: parseWindow(value.secondary_window) ?? parseWindow(value.secondary),
91
+ };
92
+ }
93
+
94
+ export function parseCodexUsagePayload(payload: unknown): CodexUsageSnapshot {
95
+ const root = isRecord(payload) ? payload : {};
96
+ const limits: CodexUsageLimit[] = [];
97
+ const addLimit = (limitId: string, limitName: string | undefined, source: unknown) => {
98
+ const rateLimit = isRecord(source) && "rate_limit" in source ? source.rate_limit : source;
99
+ const parsed = parseRateLimit(rateLimit);
100
+ limits.push({
101
+ limitId,
102
+ ...(limitName ? { limitName } : {}),
103
+ ...(parsed.primary ? { primary: parsed.primary } : {}),
104
+ ...(parsed.secondary ? { secondary: parsed.secondary } : {}),
105
+ });
106
+ };
107
+ addLimit("codex", undefined, root.rate_limit);
108
+ if (Array.isArray(root.additional_rate_limits)) {
109
+ for (const item of root.additional_rate_limits) {
110
+ if (!isRecord(item)) continue;
111
+ addLimit(stringValue(item.metered_feature) ?? "additional", stringValue(item.limit_name), item);
112
+ }
113
+ }
114
+ return { planType: stringValue(root.plan_type), limits, raw: payload };
115
+ }
116
+
117
+ export async function fetchCodexUsage(ctx: ExtensionContext): Promise<CodexUsageSnapshot> {
118
+ const model = ctx.model;
119
+ if (!model) throw new Error("No active model selected.");
120
+ if (model.provider !== "openai-codex") {
121
+ throw new Error("Codex usage is only available for OpenAI Codex subscription models.");
122
+ }
123
+ const response = await fetch(buildCodexUsageUrl(), { method: "GET", headers: await buildCodexUsageHeaders(ctx, model), signal: ctx.signal });
124
+ const text = await response.text();
125
+ if (!response.ok) throw new Error(`Usage request failed (${response.status}): ${text || response.statusText}`);
126
+ return parseCodexUsagePayload(JSON.parse(text));
127
+ }
128
+
129
+ function formatReset(timestampSeconds: number | undefined): string {
130
+ if (!timestampSeconds) return "reset unknown";
131
+ const ms = timestampSeconds * 1000;
132
+ const minutes = Math.max(0, Math.round((ms - Date.now()) / 60000));
133
+ return minutes < 90 ? `resets in ~${minutes}m` : `resets ${new Date(ms).toLocaleString()}`;
134
+ }
135
+
136
+ function formatWindow(label: string, window: CodexUsageWindow | undefined): string | undefined {
137
+ if (!window) return undefined;
138
+ const percent = window.usedPercent === undefined ? "?" : `${Math.round(window.usedPercent)}%`;
139
+ const span = window.windowMinutes ? `${Math.round(window.windowMinutes)}m` : "window";
140
+ return `${label}: ${percent} used (${span}, ${formatReset(window.resetsAt)})`;
141
+ }
142
+
143
+ export function formatCodexUsage(snapshot: CodexUsageSnapshot): string {
144
+ const lines = [`Codex usage${snapshot.planType ? ` (${snapshot.planType})` : ""}:`];
145
+ for (const limit of snapshot.limits) {
146
+ const title = limit.limitName ?? limit.limitId;
147
+ const parts = [formatWindow("5h", limit.primary), formatWindow("weekly", limit.secondary)].filter(Boolean);
148
+ lines.push(`- ${title}: ${parts.length ? parts.join("; ") : "no usage data"}`);
149
+ }
150
+ return lines.join("\n");
151
+ }