@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 +12 -24
- package/bin/pi-webui.mjs +311 -1
- package/package.json +1 -1
- package/public/app.js +205 -0
- package/public/index.html +14 -0
- package/public/styles.css +77 -0
- package/tests/mobile-static.test.mjs +8 -0
package/README.md
CHANGED
|
@@ -66,30 +66,18 @@ pi-webui --cwd /path/to/project
|
|
|
66
66
|
|
|
67
67
|
## Features
|
|
68
68
|
|
|
69
|
-
-
|
|
70
|
-
-
|
|
71
|
-
-
|
|
72
|
-
-
|
|
73
|
-
-
|
|
74
|
-
-
|
|
75
|
-
-
|
|
76
|
-
-
|
|
77
|
-
-
|
|
78
|
-
-
|
|
79
|
-
-
|
|
80
|
-
-
|
|
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
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");
|