@firstpick/pi-package-webui 0.1.8 → 0.1.9

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 CHANGED
@@ -66,30 +66,18 @@ pi-webui --cwd /path/to/project
66
66
 
67
67
  ## Features
68
68
 
69
- - Browser chat with Pi over RPC
70
- - Isolated terminal tabs: each Web UI tab starts its own separate `pi --mode rpc` subprocess, event stream, session state, and prompt draft
71
- - Automatic tab naming from the first prompt on default-named tabs, plus `/name <title>` to manually sync the Pi session and browser tab name
72
- - Per-tab activity indicators for idle, working, blocked, and completed unseen work, with browser notifications when a tab needs an extension UI response and an optional side-panel toggle for agent-done notifications
73
- - Live assistant text streaming, including streamed thinking blocks when exposed by the provider
74
- - Prompt, steer, follow-up, abort, new session, and manual compact controls
75
- - Attachment button plus drag/drop and clipboard paste for images, documents, video, audio, and other files; uploaded files are saved to a server temp path and supported images are also sent through Pi RPC image attachments
76
- - Busy-session behavior selector for follow-up vs steer
77
- - Model and thinking-level controls
78
- - Browser-native selector dialogs for native slash commands such as `/model`, `/settings`, `/theme`, `/fork`, `/clone`, `/resume`, and `/tree`; `/login`/`/logout` currently show non-secret guidance rather than accepting credentials in the browser
79
- - Slash-command autocomplete while typing `/...`
80
- - `@` file/path references with live suggestions from the active tab cwd
81
- - Tool, process, compaction, queue, and extension event log
82
- - Collapsible side panel with independently collapsible sections for controls, optional features, session state, queue, available commands, events, local-network exposure status/control, and a theme picker
83
- - Pi-style footer with token, cache, estimated Pi-context tokens, speed, cost, context usage, clickable per-tab cwd picker with server-persisted fast picks, git branch, changes, runtime, model, and thinking level
84
- - Guided Git workflow: `git add .`, ask Pi to run `/git-staged-msg`, preview short/long messages, commit with the selected message, and `git push`
85
- - Hover-expand Publish workflow menu beside Git workflow, currently offering NPM Release and AUR Release
86
- - Basic rendering for user, assistant, tool result, bash execution, and thinking messages
87
- - Feedback reactions (`👍`, `👎`, `?`) on final assistant output plus tool/bash action cards, with queued post-run submission that asks Pi to create/update a LEARNING
88
- - Basic extension UI bridge for `notify`, `setStatus`, `setWidget`, `setTitle`, `set_editor_text`, `select`, `confirm`, `input`, and `editor`
89
- - Specialized `/release-npm` and `/release-aur` widget rendering with scrollable live logs plus toggle/abort actions
90
- - Side-panel theme picker backed by optional `@firstpick/pi-themes-bundle` themes when loaded
91
- - PWA metadata, icons, and service worker for install-to-home-screen support when served from a secure context
92
- - Static frontend: no bundler, no frontend install step
69
+ - Local browser chat over Pi RPC with isolated terminal tabs; each tab has its own `pi --mode rpc` subprocess, event stream, session state, prompt draft, cwd, and activity indicator.
70
+ - Automatic tab naming from the first prompt on default-named tabs, plus `/name <title>` to manually sync the Pi session and browser tab name.
71
+ - Live transcript with streamed assistant text/thinking, Markdown output, active-run status, tool/bash cards, queue/compaction events, jump-to-latest, sticky last-prompt navigation, and abort by button, Esc, or long press.
72
+ - Prompt composer for prompts, steer/follow-up, busy-session behavior, model/thinking controls, manual compact/new session, uploads by button/drag/drop/paste, slash-command autocomplete, and `@` file/path references with live suggestions.
73
+ - Browser-native selector dialogs for `/model`, `/settings`, `/theme`, `/fork`, `/clone`, `/resume`, `/tree`, and `/scoped-models`; `/login`/`/logout` show non-secret guidance instead of accepting credentials in the browser.
74
+ - Session/workspace helpers for per-tab cwd changes, a clickable footer cwd picker with server-persisted fast picks, fork/clone/resume/tree navigation, and restart-safe restoration of currently open tabs.
75
+ - Collapsible side-panel control deck for model/thinking/settings, optional features, Codex usage, session/queue/commands/events, local-network exposure, browser notifications, and Web UI themes/custom backgrounds.
76
+ - Pi-style footer with token/cache/context/cost/speed telemetry, estimated Pi-context tokens, cwd/git/runtime/model/thinking metadata, and a scoped-model picker.
77
+ - Optional companion management with capability-based enabled/disabled/install-needed status, localhost-only warned installs, Side-panel theme picker backed by optional `@firstpick/pi-themes-bundle` themes when loaded, guided Git commit/push workflow, NPM/AUR Publish menu, todo-progress rendering, and richer git/status/stats widgets.
78
+ - Extension UI bridge for `notify`, `setStatus`, `setWidget`, `setTitle`, `set_editor_text`, `select`, `confirm`, `input`, and `editor`, with browser notifications when a tab needs an extension UI response and an optional side-panel toggle for agent-done notifications.
79
+ - Feedback reactions (`👍`, `👎`, `?`) on final assistant output plus tool/bash action cards, with queued post-run submission that asks Pi to create/update a LEARNING.
80
+ - Mobile/PWA support: installable app shell, service worker/icons, backend-offline recovery panel, touch-friendly composer/tabs/footer, and a static frontend with no bundler or frontend install step.
93
81
 
94
82
  ## Mobile/PWA notes
95
83
 
package/bin/pi-webui.mjs CHANGED
@@ -8,7 +8,7 @@ import { homedir, networkInterfaces, tmpdir } from "node:os";
8
8
  import path from "node:path";
9
9
  import { StringDecoder } from "node:string_decoder";
10
10
  import { fileURLToPath } from "node:url";
11
- import { SessionManager } from "@earendil-works/pi-coding-agent";
11
+ import { AuthStorage, SessionManager } from "@earendil-works/pi-coding-agent";
12
12
 
13
13
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
14
14
  const require = createRequire(import.meta.url);
@@ -19,6 +19,10 @@ const packageJson = JSON.parse(await readFile(path.join(packageRoot, "package.js
19
19
  const DEFAULT_HOST = "127.0.0.1";
20
20
  const DEFAULT_PORT = 31415;
21
21
  const REQUEST_TIMEOUT_MS = 5 * 60 * 1000;
22
+ const CODEX_USAGE_TIMEOUT_MS = 15 * 1000;
23
+ const CODEX_TOKEN_REFRESH_SKEW_MS = 5 * 60 * 1000;
24
+ const OPENAI_CODEX_PROVIDER_ID = "openai-codex";
25
+ const OPENAI_CODEX_USAGE_ENDPOINT = process.env.PI_WEBUI_CODEX_USAGE_URL || "https://chatgpt.com/backend-api/wham/usage";
22
26
  const BODY_LIMIT_BYTES = 1024 * 1024;
23
27
  const PROMPT_BODY_LIMIT_BYTES = 24 * 1024 * 1024;
24
28
  const UPLOAD_BODY_LIMIT_BYTES = 96 * 1024 * 1024;
@@ -839,6 +843,302 @@ async function readJsonFileIfExists(filePath) {
839
843
  }
840
844
  }
841
845
 
846
+ function firstDefined(...values) {
847
+ return values.find((value) => value !== undefined && value !== null);
848
+ }
849
+
850
+ function numericValue(value) {
851
+ if (value === undefined || value === null || value === "") return undefined;
852
+ const number = Number(value);
853
+ return Number.isFinite(number) ? number : undefined;
854
+ }
855
+
856
+ function booleanValue(value) {
857
+ return typeof value === "boolean" ? value : undefined;
858
+ }
859
+
860
+ function isoTimestamp(value) {
861
+ const number = numericValue(value);
862
+ if (number !== undefined) {
863
+ const milliseconds = number > 1e12 ? number : number * 1000;
864
+ const date = new Date(milliseconds);
865
+ return Number.isFinite(date.getTime()) ? date.toISOString() : undefined;
866
+ }
867
+ if (typeof value === "string" && value.trim()) {
868
+ const date = new Date(value);
869
+ return Number.isFinite(date.getTime()) ? date.toISOString() : undefined;
870
+ }
871
+ return undefined;
872
+ }
873
+
874
+ function decodeJwtPayload(token) {
875
+ try {
876
+ const payload = String(token || "").split(".")[1];
877
+ if (!payload) return null;
878
+ const padded = `${payload}${"=".repeat((4 - (payload.length % 4)) % 4)}`;
879
+ return JSON.parse(Buffer.from(padded.replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf8"));
880
+ } catch {
881
+ return null;
882
+ }
883
+ }
884
+
885
+ function codexAccountIdFromAccessToken(accessToken) {
886
+ const payload = decodeJwtPayload(accessToken);
887
+ const auth = payload?.["https://api.openai.com/auth"];
888
+ const accountId = auth?.chatgpt_account_id;
889
+ return typeof accountId === "string" && accountId ? accountId : null;
890
+ }
891
+
892
+ function normalizeCodexRateLimitWindow(rawWindow) {
893
+ if (!rawWindow || typeof rawWindow !== "object") return null;
894
+ const windowDurationSeconds = firstDefined(
895
+ numericValue(rawWindow.windowDurationSeconds),
896
+ numericValue(rawWindow.limitWindowSeconds),
897
+ numericValue(rawWindow.limit_window_seconds),
898
+ numericValue(rawWindow.windowDurationMins) !== undefined ? numericValue(rawWindow.windowDurationMins) * 60 : undefined,
899
+ );
900
+ const windowDurationMins = firstDefined(
901
+ numericValue(rawWindow.windowDurationMins),
902
+ windowDurationSeconds !== undefined ? windowDurationSeconds / 60 : undefined,
903
+ );
904
+ const normalized = {
905
+ usedPercent: numericValue(firstDefined(rawWindow.usedPercent, rawWindow.used_percent)),
906
+ windowDurationSeconds,
907
+ windowDurationMins,
908
+ resetAfterSeconds: numericValue(firstDefined(rawWindow.resetAfterSeconds, rawWindow.reset_after_seconds)),
909
+ resetsAt: isoTimestamp(firstDefined(rawWindow.resetsAt, rawWindow.resetAt, rawWindow.reset_at)),
910
+ };
911
+ return Object.values(normalized).some((value) => value !== undefined) ? normalized : null;
912
+ }
913
+
914
+ function normalizeCodexCredits(rawCredits) {
915
+ if (!rawCredits || typeof rawCredits !== "object") return null;
916
+ return {
917
+ hasCredits: booleanValue(firstDefined(rawCredits.hasCredits, rawCredits.has_credits)),
918
+ unlimited: booleanValue(rawCredits.unlimited),
919
+ balance: firstDefined(rawCredits.balance),
920
+ approxLocalMessages: firstDefined(rawCredits.approxLocalMessages, rawCredits.approx_local_messages),
921
+ approxCloudMessages: firstDefined(rawCredits.approxCloudMessages, rawCredits.approx_cloud_messages),
922
+ };
923
+ }
924
+
925
+ function normalizeCodexRateLimitDetails(rawDetails) {
926
+ if (!rawDetails || typeof rawDetails !== "object") return { primary: null, secondary: null };
927
+ return {
928
+ allowed: booleanValue(rawDetails.allowed),
929
+ limitReached: booleanValue(firstDefined(rawDetails.limitReached, rawDetails.limit_reached)),
930
+ primary: normalizeCodexRateLimitWindow(firstDefined(rawDetails.primary, rawDetails.primaryWindow, rawDetails.primary_window)),
931
+ secondary: normalizeCodexRateLimitWindow(firstDefined(rawDetails.secondary, rawDetails.secondaryWindow, rawDetails.secondary_window)),
932
+ };
933
+ }
934
+
935
+ function normalizeCodexRateLimitReachedType(rawType) {
936
+ if (typeof rawType === "string" && rawType) return rawType;
937
+ if (rawType && typeof rawType === "object") {
938
+ const value = firstDefined(rawType.type, rawType.kind);
939
+ return typeof value === "string" && value ? value : null;
940
+ }
941
+ return null;
942
+ }
943
+
944
+ function makeCodexUsageSnapshot({ limitId, limitName, rateLimit, credits, planType, rateLimitReachedType }) {
945
+ const details = normalizeCodexRateLimitDetails(rateLimit);
946
+ return {
947
+ limitId: limitId || null,
948
+ limitName: limitName || null,
949
+ primary: details.primary,
950
+ secondary: details.secondary,
951
+ allowed: details.allowed,
952
+ limitReached: details.limitReached,
953
+ credits: normalizeCodexCredits(credits),
954
+ planType: planType || null,
955
+ rateLimitReachedType: rateLimitReachedType || null,
956
+ };
957
+ }
958
+
959
+ function normalizeCodexUsagePayload(rawPayload) {
960
+ const payload = rawPayload && typeof rawPayload === "object" ? rawPayload : {};
961
+ const planType = firstDefined(payload.planType, payload.plan_type, null);
962
+ const rateLimitReachedType = normalizeCodexRateLimitReachedType(firstDefined(payload.rateLimitReachedType, payload.rate_limit_reached_type));
963
+ const snapshotsByKey = new Map();
964
+ const addSnapshot = (snapshot) => {
965
+ if (!snapshot) return;
966
+ const key = snapshot.limitId || snapshot.limitName || `snapshot-${snapshotsByKey.size + 1}`;
967
+ if (!snapshotsByKey.has(key)) snapshotsByKey.set(key, snapshot);
968
+ };
969
+
970
+ const directRateLimits = firstDefined(payload.rateLimits, payload.rate_limits);
971
+ if (directRateLimits && typeof directRateLimits === "object" && (directRateLimits.primary || directRateLimits.primary_window || directRateLimits.primaryWindow)) {
972
+ addSnapshot(makeCodexUsageSnapshot({
973
+ limitId: firstDefined(directRateLimits.limitId, directRateLimits.limit_id, "codex"),
974
+ limitName: firstDefined(directRateLimits.limitName, directRateLimits.limit_name),
975
+ rateLimit: directRateLimits,
976
+ credits: firstDefined(directRateLimits.credits, payload.credits),
977
+ planType: firstDefined(directRateLimits.planType, directRateLimits.plan_type, planType),
978
+ rateLimitReachedType: firstDefined(directRateLimits.rateLimitReachedType, directRateLimits.rate_limit_reached_type, rateLimitReachedType),
979
+ }));
980
+ } else {
981
+ addSnapshot(makeCodexUsageSnapshot({
982
+ limitId: "codex",
983
+ rateLimit: firstDefined(payload.rateLimit, payload.rate_limit),
984
+ credits: payload.credits,
985
+ planType,
986
+ rateLimitReachedType,
987
+ }));
988
+ }
989
+
990
+ const byLimitId = firstDefined(payload.rateLimitsByLimitId, payload.rate_limits_by_limit_id);
991
+ if (byLimitId && typeof byLimitId === "object" && !Array.isArray(byLimitId)) {
992
+ for (const [limitId, rawSnapshot] of Object.entries(byLimitId)) {
993
+ if (!rawSnapshot || typeof rawSnapshot !== "object") continue;
994
+ addSnapshot(makeCodexUsageSnapshot({
995
+ limitId: firstDefined(rawSnapshot.limitId, rawSnapshot.limit_id, limitId),
996
+ limitName: firstDefined(rawSnapshot.limitName, rawSnapshot.limit_name),
997
+ rateLimit: rawSnapshot,
998
+ credits: rawSnapshot.credits,
999
+ planType: firstDefined(rawSnapshot.planType, rawSnapshot.plan_type, planType),
1000
+ rateLimitReachedType: firstDefined(rawSnapshot.rateLimitReachedType, rawSnapshot.rate_limit_reached_type),
1001
+ }));
1002
+ }
1003
+ }
1004
+
1005
+ const additionalRateLimits = firstDefined(payload.additionalRateLimits, payload.additional_rate_limits);
1006
+ if (Array.isArray(additionalRateLimits)) {
1007
+ for (const item of additionalRateLimits) {
1008
+ if (!item || typeof item !== "object") continue;
1009
+ addSnapshot(makeCodexUsageSnapshot({
1010
+ limitId: firstDefined(item.limitId, item.limit_id, item.meteredFeature, item.metered_feature, item.limitName, item.limit_name),
1011
+ limitName: firstDefined(item.limitName, item.limit_name),
1012
+ rateLimit: firstDefined(item.rateLimit, item.rate_limit),
1013
+ credits: item.credits,
1014
+ planType,
1015
+ }));
1016
+ }
1017
+ }
1018
+
1019
+ const snapshots = [...snapshotsByKey.values()];
1020
+ const selected = snapshots.find((snapshot) => snapshot.limitId === "codex") || snapshots[0] || null;
1021
+ const rateLimitsByLimitId = Object.fromEntries(snapshots.filter((snapshot) => snapshot.limitId).map((snapshot) => [snapshot.limitId, snapshot]));
1022
+ return {
1023
+ planType: planType || selected?.planType || null,
1024
+ rateLimitReachedType: rateLimitReachedType || selected?.rateLimitReachedType || null,
1025
+ credits: normalizeCodexCredits(payload.credits) || selected?.credits || null,
1026
+ selected,
1027
+ snapshots,
1028
+ rateLimits: selected,
1029
+ rateLimitsByLimitId,
1030
+ };
1031
+ }
1032
+
1033
+ async function getOpenAICodexUsageCredentials({ forceRefresh = false } = {}) {
1034
+ const authStorage = AuthStorage.create();
1035
+ const stored = authStorage.get(OPENAI_CODEX_PROVIDER_ID);
1036
+ const storedExpires = numericValue(stored?.expires);
1037
+ const shouldRefresh = stored?.type === "oauth" && (forceRefresh || storedExpires === undefined || Date.now() + CODEX_TOKEN_REFRESH_SKEW_MS >= storedExpires);
1038
+ let accessToken;
1039
+ let refreshed = false;
1040
+
1041
+ if (shouldRefresh) {
1042
+ try {
1043
+ const refreshResult = await authStorage.refreshOAuthTokenWithLock(OPENAI_CODEX_PROVIDER_ID);
1044
+ if (refreshResult?.apiKey) {
1045
+ accessToken = refreshResult.apiKey;
1046
+ refreshed = forceRefresh || refreshResult.newCredentials?.access !== stored?.access;
1047
+ }
1048
+ } catch (error) {
1049
+ if (forceRefresh || !storedExpires || Date.now() >= storedExpires) {
1050
+ throw makeHttpError(401, "OpenAI Codex OAuth token refresh failed. Run /login and choose ChatGPT Plus/Pro (Codex Subscription) to re-authenticate.");
1051
+ }
1052
+ console.warn(`OpenAI Codex token refresh warning: ${sanitizeError(error)}`);
1053
+ }
1054
+ }
1055
+
1056
+ if (!accessToken) {
1057
+ accessToken = await authStorage.getApiKey(OPENAI_CODEX_PROVIDER_ID, { includeFallback: false });
1058
+ }
1059
+ if (!accessToken) {
1060
+ const status = authStorage.getAuthStatus(OPENAI_CODEX_PROVIDER_ID);
1061
+ if (status.configured) throw makeHttpError(401, "OpenAI Codex OAuth token is expired or unavailable. Run /login to refresh credentials.");
1062
+ throw makeHttpError(401, "OpenAI Codex OAuth is not configured. Run /login and choose ChatGPT Plus/Pro (Codex Subscription).");
1063
+ }
1064
+
1065
+ const latest = authStorage.get(OPENAI_CODEX_PROVIDER_ID) || stored || {};
1066
+ const accountId = latest.accountId || codexAccountIdFromAccessToken(accessToken);
1067
+ if (!accountId) {
1068
+ throw makeHttpError(401, "OpenAI Codex account id is unavailable. Run /login and choose ChatGPT Plus/Pro (Codex Subscription) again.");
1069
+ }
1070
+
1071
+ return {
1072
+ accessToken,
1073
+ accountId,
1074
+ refreshed,
1075
+ source: latest.type === "oauth" ? "stored-oauth" : "api-key",
1076
+ expiresAt: numericValue(latest.expires) ? new Date(numericValue(latest.expires)).toISOString() : undefined,
1077
+ };
1078
+ }
1079
+
1080
+ async function fetchOpenAICodexUsagePayload(credentials) {
1081
+ const controller = new AbortController();
1082
+ const timer = setTimeout(() => controller.abort(), CODEX_USAGE_TIMEOUT_MS);
1083
+ timer.unref?.();
1084
+ try {
1085
+ const response = await fetch(OPENAI_CODEX_USAGE_ENDPOINT, {
1086
+ method: "GET",
1087
+ headers: {
1088
+ accept: "application/json",
1089
+ authorization: `Bearer ${credentials.accessToken}`,
1090
+ "chatgpt-account-id": credentials.accountId,
1091
+ originator: "pi-webui",
1092
+ },
1093
+ signal: controller.signal,
1094
+ });
1095
+ const text = await response.text().catch(() => "");
1096
+ if (!response.ok) {
1097
+ const error = makeHttpError(response.status === 401 ? 401 : 502, `OpenAI Codex usage request failed (${response.status}${response.statusText ? ` ${response.statusText}` : ""})`);
1098
+ error.openaiStatus = response.status;
1099
+ throw error;
1100
+ }
1101
+ try {
1102
+ return JSON.parse(text || "{}");
1103
+ } catch {
1104
+ throw makeHttpError(502, "OpenAI Codex usage response was not valid JSON");
1105
+ }
1106
+ } catch (error) {
1107
+ if (error?.name === "AbortError") throw makeHttpError(504, "OpenAI Codex usage request timed out");
1108
+ throw error;
1109
+ } finally {
1110
+ clearTimeout(timer);
1111
+ }
1112
+ }
1113
+
1114
+ async function getOpenAICodexUsageStatus({ forceRefresh = false } = {}) {
1115
+ let credentials = await getOpenAICodexUsageCredentials({ forceRefresh });
1116
+ let rawPayload;
1117
+ try {
1118
+ rawPayload = await fetchOpenAICodexUsagePayload(credentials);
1119
+ } catch (error) {
1120
+ if (error?.openaiStatus === 401 && !credentials.refreshed) {
1121
+ credentials = await getOpenAICodexUsageCredentials({ forceRefresh: true });
1122
+ rawPayload = await fetchOpenAICodexUsagePayload(credentials);
1123
+ } else {
1124
+ throw error;
1125
+ }
1126
+ }
1127
+
1128
+ return {
1129
+ available: true,
1130
+ providerId: OPENAI_CODEX_PROVIDER_ID,
1131
+ source: "chatgpt.com",
1132
+ fetchedAt: new Date().toISOString(),
1133
+ auth: {
1134
+ source: credentials.source,
1135
+ expiresAt: credentials.expiresAt,
1136
+ refreshed: credentials.refreshed,
1137
+ },
1138
+ ...normalizeCodexUsagePayload(rawPayload),
1139
+ };
1140
+ }
1141
+
842
1142
  async function configuredScopedModelPatterns(cwd = options.cwd) {
843
1143
  const cliPatterns = parseCliScopedModelPatterns();
844
1144
  if (cliPatterns !== undefined) return { patterns: cliPatterns, source: "cli" };
@@ -2954,6 +3254,16 @@ const server = createServer(async (req, res) => {
2954
3254
  return;
2955
3255
  }
2956
3256
 
3257
+ if (url.pathname === "/api/codex-usage" && req.method === "GET") {
3258
+ try {
3259
+ const forceRefresh = ["1", "true", "yes"].includes(String(url.searchParams.get("refresh") || "").toLowerCase());
3260
+ sendJson(res, 200, { ok: true, data: await getOpenAICodexUsageStatus({ forceRefresh }) });
3261
+ } catch (error) {
3262
+ sendJson(res, error?.statusCode || 500, { ok: false, error: error?.message || "Failed to read OpenAI Codex usage" });
3263
+ }
3264
+ return;
3265
+ }
3266
+
2957
3267
  if (url.pathname === "/api/network" && req.method === "GET") {
2958
3268
  sendJson(res, 200, { ok: true, data: networkStatus() });
2959
3269
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@firstpick/pi-package-webui",
3
- "version": "0.1.8",
3
+ "version": "0.1.9",
4
4
  "description": "Pi Web UI companion package with a local browser UI CLI plus /webui-start and /webui-status commands.",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/public/app.js CHANGED
@@ -63,6 +63,8 @@ const elements = {
63
63
  agentDoneNotificationsToggle: $("#agentDoneNotificationsToggle"),
64
64
  agentDoneNotificationsStatus: $("#agentDoneNotificationsStatus"),
65
65
  optionalFeaturesBox: $("#optionalFeaturesBox"),
66
+ codexUsageBox: $("#codexUsageBox"),
67
+ refreshCodexUsageButton: $("#refreshCodexUsageButton"),
66
68
  toggleSidePanelButton: $("#toggleSidePanelButton"),
67
69
  sidePanelExpandButton: $("#sidePanelExpandButton"),
68
70
  sidePanelBackdrop: $("#sidePanelBackdrop"),
@@ -146,6 +148,11 @@ let pathSuggestAbortController = null;
146
148
  let latestStats = null;
147
149
  let latestWorkspace = null;
148
150
  let latestNetwork = null;
151
+ let latestCodexUsage = null;
152
+ let codexUsageError = null;
153
+ let codexUsageLoading = false;
154
+ let refreshCodexUsageTimer = null;
155
+ let codexUsageRenderTimer = null;
149
156
  let backendOffline = false;
150
157
  let backendOfflineNoticeShown = false;
151
158
  let latestMessages = [];
@@ -220,6 +227,8 @@ const STICKY_USER_PROMPT_TOP_GAP_PX = 12;
220
227
  const CHAT_FOLLOW_SETTLE_DELAY_MS = 80;
221
228
  const CHAT_PROGRAMMATIC_SCROLL_GRACE_MS = 500;
222
229
  const CHAT_USER_SCROLL_INTENT_MS = 700;
230
+ const CODEX_USAGE_REFRESH_MS = 5 * 60 * 1000;
231
+ const CODEX_USAGE_RENDER_TICK_MS = 30 * 1000;
223
232
  const RUN_INDICATOR_TICK_MS = 1000;
224
233
  const RUN_INDICATOR_START_GRACE_MS = 2500;
225
234
  const RUN_INDICATOR_STATE_RECHECK_MS = 5000;
@@ -3584,6 +3593,197 @@ function scheduleRefreshFooter(delay = 300, tabContext = activeTabContext()) {
3584
3593
  }, delay);
3585
3594
  }
3586
3595
 
3596
+ function formatCodexPlanType(value) {
3597
+ const text = String(value || "").trim();
3598
+ if (!text) return "unknown plan";
3599
+ return text.replace(/[_-]+/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase());
3600
+ }
3601
+
3602
+ function formatCodexPercent(value) {
3603
+ const number = Number(value);
3604
+ return Number.isFinite(number) ? `${Math.max(0, Math.min(100, Math.round(number)))}%` : "—";
3605
+ }
3606
+
3607
+ function codexWindowDurationMinutes(window) {
3608
+ const minutes = Number(window?.windowDurationMins);
3609
+ if (Number.isFinite(minutes) && minutes > 0) return minutes;
3610
+ const seconds = Number(window?.windowDurationSeconds);
3611
+ return Number.isFinite(seconds) && seconds > 0 ? seconds / 60 : null;
3612
+ }
3613
+
3614
+ function formatCodexWindowDuration(window) {
3615
+ const minutes = codexWindowDurationMinutes(window);
3616
+ if (!minutes) return "window";
3617
+ if (minutes >= 280 && minutes <= 320) return "5h window";
3618
+ if (minutes >= 9500 && minutes <= 10550) return "weekly window";
3619
+ if (minutes >= 60 * 24) {
3620
+ const days = minutes / (60 * 24);
3621
+ return `${days >= 10 ? Math.round(days) : Number(days.toFixed(1))}d window`;
3622
+ }
3623
+ if (minutes >= 60) {
3624
+ const hours = minutes / 60;
3625
+ return `${Number.isInteger(hours) ? hours : Number(hours.toFixed(1))}h window`;
3626
+ }
3627
+ return `${Math.round(minutes)}m window`;
3628
+ }
3629
+
3630
+ function formatDurationParts(milliseconds) {
3631
+ if (!Number.isFinite(Number(milliseconds))) return "now";
3632
+ const totalMinutes = Math.max(0, Math.ceil(Number(milliseconds) / 60000));
3633
+ if (totalMinutes <= 1) return "<1m";
3634
+ if (totalMinutes < 60) return `${totalMinutes}m`;
3635
+ const hours = Math.floor(totalMinutes / 60);
3636
+ const minutes = totalMinutes % 60;
3637
+ if (hours < 48) return minutes ? `${hours}h ${minutes}m` : `${hours}h`;
3638
+ const days = Math.floor(hours / 24);
3639
+ const remHours = hours % 24;
3640
+ return remHours ? `${days}d ${remHours}h` : `${days}d`;
3641
+ }
3642
+
3643
+ function codexWindowResetDate(window) {
3644
+ const resetAt = window?.resetsAt ? new Date(window.resetsAt) : null;
3645
+ if (resetAt && Number.isFinite(resetAt.getTime())) return resetAt;
3646
+ const resetAfterSeconds = Number(window?.resetAfterSeconds);
3647
+ if (Number.isFinite(resetAfterSeconds) && resetAfterSeconds >= 0) return new Date(Date.now() + resetAfterSeconds * 1000);
3648
+ return null;
3649
+ }
3650
+
3651
+ function formatCodexReset(window) {
3652
+ const resetDate = codexWindowResetDate(window);
3653
+ if (!resetDate) return "reset unknown";
3654
+ const diff = resetDate.getTime() - Date.now();
3655
+ if (diff <= 0) return "resetting now";
3656
+ return `resets in ${formatDurationParts(diff)}`;
3657
+ }
3658
+
3659
+ function codexSnapshotName(snapshot) {
3660
+ return snapshot?.limitName || snapshot?.limitId || "codex";
3661
+ }
3662
+
3663
+ function codexUsageBuckets(data) {
3664
+ const buckets = [];
3665
+ const selected = data?.selected || data?.rateLimits || null;
3666
+ const snapshots = Array.isArray(data?.snapshots) ? data.snapshots : selected ? [selected] : [];
3667
+ const selectedKey = selected?.limitId || selected?.limitName || "codex";
3668
+ const pushWindow = (snapshot, kind, window, { prefix } = {}) => {
3669
+ if (!window) return;
3670
+ const durationLabel = formatCodexWindowDuration(window);
3671
+ const baseLabel = kind === "secondary" && durationLabel === "window" ? "secondary window" : durationLabel;
3672
+ buckets.push({
3673
+ key: `${snapshot?.limitId || snapshot?.limitName || buckets.length}-${kind}`,
3674
+ label: prefix ? `${prefix} · ${baseLabel}` : baseLabel,
3675
+ window,
3676
+ });
3677
+ };
3678
+
3679
+ if (selected) {
3680
+ pushWindow(selected, "primary", selected.primary);
3681
+ pushWindow(selected, "secondary", selected.secondary);
3682
+ }
3683
+ for (const snapshot of snapshots) {
3684
+ const key = snapshot?.limitId || snapshot?.limitName;
3685
+ if (!snapshot || snapshot === selected || key === selectedKey) continue;
3686
+ const name = codexSnapshotName(snapshot);
3687
+ pushWindow(snapshot, "primary", snapshot.primary, { prefix: name });
3688
+ pushWindow(snapshot, "secondary", snapshot.secondary, { prefix: name });
3689
+ }
3690
+ return buckets.slice(0, 6);
3691
+ }
3692
+
3693
+ function renderCodexUsage() {
3694
+ const box = elements.codexUsageBox;
3695
+ if (!box) return;
3696
+ if (elements.refreshCodexUsageButton) {
3697
+ elements.refreshCodexUsageButton.disabled = codexUsageLoading;
3698
+ elements.refreshCodexUsageButton.textContent = codexUsageLoading ? "Refreshing…" : "Refresh usage";
3699
+ }
3700
+
3701
+ box.replaceChildren();
3702
+ box.classList.toggle("muted", !latestCodexUsage);
3703
+
3704
+ if (!latestCodexUsage && codexUsageLoading) {
3705
+ box.textContent = "Checking Codex usage…";
3706
+ return;
3707
+ }
3708
+ if (!latestCodexUsage && codexUsageError) {
3709
+ const title = make("div", "codex-usage-unavailable", "Usage unavailable");
3710
+ const detail = make("div", "codex-usage-detail", codexUsageError.message || String(codexUsageError));
3711
+ box.append(title, detail);
3712
+ return;
3713
+ }
3714
+ if (!latestCodexUsage) {
3715
+ box.textContent = "Codex usage has not loaded yet.";
3716
+ return;
3717
+ }
3718
+
3719
+ const header = make("div", "codex-usage-summary");
3720
+ header.append(
3721
+ make("span", "codex-usage-plan", formatCodexPlanType(latestCodexUsage.planType)),
3722
+ make("span", "codex-usage-fetched", latestCodexUsage.fetchedAt ? `updated ${formatDurationParts(Date.now() - new Date(latestCodexUsage.fetchedAt).getTime())} ago` : "updated now"),
3723
+ );
3724
+ box.append(header);
3725
+
3726
+ const buckets = codexUsageBuckets(latestCodexUsage);
3727
+ if (buckets.length === 0) {
3728
+ box.append(make("div", "codex-usage-detail", "No Codex rate-limit windows were returned."));
3729
+ } else {
3730
+ for (const bucket of buckets) {
3731
+ const usedPercent = Number(bucket.window?.usedPercent);
3732
+ const fillPercent = Number.isFinite(usedPercent) ? Math.max(0, Math.min(100, usedPercent)) : 0;
3733
+ const item = make("div", "codex-usage-bucket");
3734
+ const row = make("div", "codex-usage-row");
3735
+ row.append(
3736
+ make("span", "codex-usage-label", bucket.label),
3737
+ make("strong", "codex-usage-percent", formatCodexPercent(bucket.window?.usedPercent)),
3738
+ );
3739
+ const meter = make("div", "codex-usage-meter");
3740
+ const fill = make("span", "codex-usage-meter-fill");
3741
+ fill.style.width = `${fillPercent}%`;
3742
+ meter.append(fill);
3743
+ item.append(row, meter, make("div", "codex-usage-reset", formatCodexReset(bucket.window)));
3744
+ box.append(item);
3745
+ }
3746
+ }
3747
+
3748
+ if (latestCodexUsage.rateLimitReachedType) {
3749
+ box.append(make("div", "codex-usage-warning", `Limit status: ${latestCodexUsage.rateLimitReachedType}`));
3750
+ }
3751
+ if (codexUsageError) {
3752
+ box.append(make("div", "codex-usage-detail", `Latest refresh failed: ${codexUsageError.message || codexUsageError}`));
3753
+ }
3754
+ }
3755
+
3756
+ async function refreshCodexUsage({ forceAuthRefresh = false } = {}) {
3757
+ if (codexUsageLoading) return;
3758
+ codexUsageLoading = true;
3759
+ renderCodexUsage();
3760
+ try {
3761
+ const suffix = forceAuthRefresh ? "?refresh=1" : "";
3762
+ const response = await api(`/api/codex-usage${suffix}`, { scoped: false });
3763
+ latestCodexUsage = response.data || null;
3764
+ codexUsageError = null;
3765
+ } catch (error) {
3766
+ codexUsageError = error;
3767
+ } finally {
3768
+ codexUsageLoading = false;
3769
+ renderCodexUsage();
3770
+ }
3771
+ }
3772
+
3773
+ function scheduleRefreshCodexUsage(delay = CODEX_USAGE_REFRESH_MS) {
3774
+ clearTimeout(refreshCodexUsageTimer);
3775
+ refreshCodexUsageTimer = setTimeout(() => {
3776
+ refreshCodexUsage().finally(() => scheduleRefreshCodexUsage());
3777
+ }, delay);
3778
+ }
3779
+
3780
+ function initializeCodexUsage() {
3781
+ renderCodexUsage();
3782
+ refreshCodexUsage().finally(() => scheduleRefreshCodexUsage());
3783
+ clearInterval(codexUsageRenderTimer);
3784
+ codexUsageRenderTimer = setInterval(renderCodexUsage, CODEX_USAGE_RENDER_TICK_MS);
3785
+ }
3786
+
3587
3787
  function renderStatus() {
3588
3788
  const state = currentState;
3589
3789
  updateComposerModeButtons();
@@ -8166,6 +8366,7 @@ function handleEvent(event) {
8166
8366
  scheduleRefreshState();
8167
8367
  scheduleRefreshMessages();
8168
8368
  scheduleRefreshFooter();
8369
+ scheduleRefreshCodexUsage(2200);
8169
8370
  renderFeedbackTray();
8170
8371
  {
8171
8372
  const workflowTabId = event.tabId || activeTabId;
@@ -8561,6 +8762,9 @@ window.addEventListener("keydown", (event) => {
8561
8762
  }
8562
8763
  });
8563
8764
 
8765
+ elements.refreshCodexUsageButton?.addEventListener("click", () => {
8766
+ refreshCodexUsage({ forceAuthRefresh: true }).finally(() => scheduleRefreshCodexUsage());
8767
+ });
8564
8768
  elements.pathPickerAddFastPickButton.addEventListener("click", () => addCurrentFastPick().catch((error) => addEvent(error.message, "error")));
8565
8769
  elements.pathPickerCancelButton.addEventListener("click", () => closePathPicker(null));
8566
8770
  elements.pathPickerChooseButton.addEventListener("click", () => closePathPicker(pathPickerState?.cwd || null));
@@ -8657,6 +8861,7 @@ restoreThinkingVisibilitySetting();
8657
8861
  restoreSidePanelSectionState();
8658
8862
  bindSidePanelSectionToggles();
8659
8863
  restoreSidePanelState();
8864
+ initializeCodexUsage();
8660
8865
  bindMobileViewChanges();
8661
8866
  registerPwaServiceWorker();
8662
8867
  renderServerOfflinePanel();
package/public/index.html CHANGED
@@ -240,6 +240,20 @@
240
240
  <div id="optionalFeaturesBox" class="optional-features-box muted">Checking optional features…</div>
241
241
  </div>
242
242
  </section>
243
+ <section class="side-panel-section" data-side-panel-section="codex-usage">
244
+ <h2>
245
+ <button id="sidePanelSectionToggleCodexUsage" class="side-panel-section-toggle" type="button" aria-expanded="true" aria-controls="sidePanelSectionCodexUsage" aria-label="Collapse Codex Usage section" title="Collapse Codex Usage section" data-side-panel-section-toggle="codex-usage">
246
+ <span class="side-panel-section-label">Codex Usage</span>
247
+ <span class="side-panel-section-chevron" aria-hidden="true">›</span>
248
+ </button>
249
+ </h2>
250
+ <div id="sidePanelSectionCodexUsage" class="side-panel-section-content">
251
+ <div class="codex-usage-actions">
252
+ <button id="refreshCodexUsageButton" type="button">Refresh usage</button>
253
+ </div>
254
+ <div id="codexUsageBox" class="codex-usage-box muted">Checking Codex usage…</div>
255
+ </div>
256
+ </section>
243
257
  <section class="side-panel-section" data-side-panel-section="session">
244
258
  <h2>
245
259
  <button id="sidePanelSectionToggleSession" class="side-panel-section-toggle" type="button" aria-expanded="true" aria-controls="sidePanelSectionSession" aria-label="Collapse Session section" title="Collapse Session section" data-side-panel-section-toggle="session">
package/public/styles.css CHANGED
@@ -808,6 +808,83 @@ body.side-panel-collapsed .terminal-tabs-shell {
808
808
  background: rgba(var(--ctp-crust-rgb), 0.46);
809
809
  box-shadow: inset 0 1px 0 rgba(255,255,255,0.035), 0 0 1rem rgba(148, 226, 213, 0.05);
810
810
  }
811
+ .codex-usage-actions {
812
+ display: flex;
813
+ justify-content: flex-end;
814
+ margin-bottom: 0.48rem;
815
+ }
816
+ .codex-usage-actions button {
817
+ min-height: 34px;
818
+ padding: 0.32rem 0.58rem;
819
+ font-size: 0.72rem;
820
+ }
821
+ .codex-usage-box {
822
+ display: grid;
823
+ gap: 0.58rem;
824
+ padding: 0.72rem;
825
+ border: 1px solid rgba(180, 190, 254, 0.16);
826
+ border-radius: 0.85rem;
827
+ background: rgba(var(--ctp-crust-rgb), 0.50);
828
+ box-shadow: inset 0 1px 0 rgba(255,255,255,0.035), 0 0 1rem rgba(203, 166, 247, 0.05);
829
+ }
830
+ .codex-usage-summary {
831
+ display: flex;
832
+ flex-wrap: wrap;
833
+ justify-content: space-between;
834
+ gap: 0.35rem 0.55rem;
835
+ align-items: baseline;
836
+ }
837
+ .codex-usage-plan {
838
+ color: rgba(var(--ctp-text-rgb), 0.94);
839
+ font-weight: 900;
840
+ }
841
+ .codex-usage-fetched,
842
+ .codex-usage-detail,
843
+ .codex-usage-reset {
844
+ color: rgba(var(--ctp-subtext-rgb), 0.70);
845
+ font-size: 0.72rem;
846
+ line-height: 1.35;
847
+ }
848
+ .codex-usage-unavailable,
849
+ .codex-usage-warning {
850
+ color: var(--ctp-yellow);
851
+ font-weight: 800;
852
+ }
853
+ .codex-usage-bucket {
854
+ display: grid;
855
+ gap: 0.32rem;
856
+ }
857
+ .codex-usage-row {
858
+ display: flex;
859
+ justify-content: space-between;
860
+ gap: 0.6rem;
861
+ align-items: center;
862
+ }
863
+ .codex-usage-label {
864
+ min-width: 0;
865
+ color: rgba(var(--ctp-text-rgb), 0.86);
866
+ font-size: 0.78rem;
867
+ font-weight: 800;
868
+ overflow-wrap: anywhere;
869
+ }
870
+ .codex-usage-percent {
871
+ color: var(--ctp-teal);
872
+ font-size: 0.82rem;
873
+ }
874
+ .codex-usage-meter {
875
+ overflow: hidden;
876
+ height: 0.5rem;
877
+ border: 1px solid rgba(180, 190, 254, 0.14);
878
+ border-radius: 999px;
879
+ background: rgba(var(--ctp-surface-rgb), 0.26);
880
+ }
881
+ .codex-usage-meter-fill {
882
+ display: block;
883
+ height: 100%;
884
+ border-radius: inherit;
885
+ background: linear-gradient(90deg, var(--ctp-green), var(--ctp-yellow), var(--ctp-peach));
886
+ box-shadow: 0 0 0.85rem rgba(148, 226, 213, 0.20);
887
+ }
811
888
  .optional-feature-row {
812
889
  display: grid;
813
890
  grid-template-columns: minmax(0, 1fr) auto;
@@ -51,6 +51,8 @@ assert.match(html, /id="thinkingVisibilityStatus"/, "thinking-output visibility
51
51
  assert.match(html, /id="nativeCommandDialog"/, "native slash selector UI should have a dedicated dialog");
52
52
  assert.match(html, /id="nativeCommandSearch"[^>]*type="search"/, "native slash selector dialog should expose a filter box");
53
53
  assert.match(html, /id="optionalFeaturesBox"/, "side panel should expose optional feature status and controls");
54
+ assert.match(html, /id="codexUsageBox"/, "side panel should expose Codex subscription usage status");
55
+ assert.match(html, /data-side-panel-section="codex-usage"/, "Codex usage should live in a collapsible side-panel section");
54
56
  assert.match(html, /id="serverOfflinePanel"/, "PWA/offline shell should expose a backend-offline recovery panel");
55
57
  assert.match(html, /id="copyServerCommandButton"/, "backend-offline recovery panel should expose a start-command copy button");
56
58
  assert.match(html, /id="retryServerConnectionButton"/, "backend-offline recovery panel should expose a retry button");
@@ -258,6 +260,8 @@ assert.match(app, /Optional feature detection intentionally checks loaded Pi cap
258
260
  assert.match(app, /function resetOptionalFeatureAvailability\(\)/, "optional feature state should reset across active-tab and reload boundaries");
259
261
  assert.match(app, /function renderOptionalFeaturePanel\(\)/, "side panel should render optional feature installed/enabled state");
260
262
  assert.match(app, /function setSidePanelSectionCollapsed\(record, collapsed/, "side panel sections should have explicit collapse/expand behavior");
263
+ assert.match(app, /function renderCodexUsage\(\)/, "frontend should render Codex usage buckets in the side panel");
264
+ assert.match(app, /api\(`\/api\/codex-usage\$\{suffix\}`, \{ scoped: false \}\)/, "Codex usage should load through a server endpoint without browser credentials");
261
265
  assert.match(app, /restoreSidePanelSectionState\(\);\nbindSidePanelSectionToggles\(\);/, "side panel section state should restore before toggles are bound");
262
266
  assert.match(app, /OPTIONAL_FEATURES_STORAGE_KEY/, "optional feature disable toggles should persist in browser storage");
263
267
  assert.match(app, /function renderOptionalFeatureDependentDisplays\(\)[\s\S]*renderOptionalFeatureControls\(\);[\s\S]*renderThemeSelect\(\);[\s\S]*renderWidgets\(\);[\s\S]*renderStatus\(\);[\s\S]*renderCommands\(\);[\s\S]*renderAllMessages\(\{ preserveScroll: true \}\);[\s\S]*if \(streamRawText\) renderStreamingAssistantText\(\);/, "optional feature toggles should immediately refresh visible controls, commands, transcript, and live stream displays");
@@ -490,6 +494,10 @@ assert.ok(icon512.length > icon192.length, "PWA 512px icon should be present and
490
494
  assert.ok(matrixBackground.length > 100000, "Matrix background image should be present as an optimized WebP asset");
491
495
  assert.ok(mochaBackground.length > 8000, "Catppuccin Mocha background image should be present as a compact PNG asset");
492
496
 
497
+ assert.match(server, /AuthStorage, SessionManager/, "server should import AuthStorage for safe OAuth token refresh");
498
+ assert.match(server, /const CODEX_TOKEN_REFRESH_SKEW_MS = 5 \* 60 \* 1000/, "server should refresh Codex OAuth tokens before they expire");
499
+ assert.match(server, /url\.pathname === "\/api\/codex-usage" && req\.method === "GET"/, "server should expose a sanitized Codex usage endpoint");
500
+ assert.match(server, /OPENAI_CODEX_USAGE_ENDPOINT/, "server should query Codex usage from the backend, not the browser");
493
501
  assert.match(server, /const NATIVE_SLASH_COMMANDS = \[/, "server should define Pi native slash commands for autocomplete");
494
502
  assert.match(server, /\{ name: "reload", description: "Reload keybindings, extensions, skills, prompts, and themes" \}/, "native /reload should be advertised for autocomplete");
495
503
  assert.match(server, /function parseSlashCommand\(message\)/, "server should parse native slash commands before prompt forwarding");