diff --git a/Makefile b/Makefile
index 9573b11..3d57006 100644
--- a/Makefile
+++ b/Makefile
@@ -42,6 +42,7 @@ reload:
gnome-extensions disable codex-meter@slobbe.github.io
$(MAKE) install
gnome-extensions enable codex-meter@slobbe.github.io
+ $(MAKE) clean
clean:
@rm -f $(ZIP)
diff --git a/README.md b/README.md
index e268a37..7a6d5a8 100644
--- a/README.md
+++ b/README.md
@@ -1,7 +1,7 @@
-# Codex Meter
-
-Monitor and manage Codex usage limits.
-
+
+
Codex Meter
+
Monitor and manage Codex usage limits.
+
diff --git a/docs/assets/screens/preferences.png b/docs/assets/screens/preferences.png
new file mode 100644
index 0000000..a5a83a9
Binary files /dev/null and b/docs/assets/screens/preferences.png differ
diff --git a/package-lock.json b/package-lock.json
index ce7125c..b3d6360 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "codex-meter",
- "version": "0.9.2",
+ "version": "0.9.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "codex-meter",
- "version": "0.9.2",
+ "version": "0.9.3",
"license": "GPL-3.0-or-later",
"devDependencies": {
"prettier": "^3.9.6",
diff --git a/package.json b/package.json
index 3efb19e..9c93f71 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "codex-meter",
- "version": "0.9.2",
+ "version": "0.9.3",
"description": "Monitor and manage Codex usage limits",
"type": "module",
"private": true,
diff --git a/src/background/monitor.js b/src/background/monitor.js
index 72ff6a6..c548321 100644
--- a/src/background/monitor.js
+++ b/src/background/monitor.js
@@ -12,9 +12,11 @@ import {
listCodexBankedResets,
readCachedCodexBankedResets,
redeemCodexBankedReset,
- redeemNextCodexBankedReset,
} from "../codex/banked-resets/api.js";
-import { selectCreditExpiringWithin } from "../codex/banked-resets/response.js";
+import {
+ selectCreditExpiringWithin,
+ selectCreditToRedeem,
+} from "../codex/banked-resets/response.js";
import { PeriodicTask } from "./periodic-task.js";
import { formatRefreshFailure } from "./refresh-error-message.js";
@@ -49,6 +51,8 @@ export class CodexMonitor {
this._lastBankedResetRefreshAt = null;
this._bankedResetRefreshPromise = null;
this._bankedResetRefreshCancellable = null;
+ this._preparingBankedReset = false;
+ this._prepareBankedResetCancellable = null;
this._redeemingBankedReset = false;
this._redeemBankedResetCancellable = null;
this._stopped = false;
@@ -63,6 +67,7 @@ export class CodexMonitor {
errorMessage: this._errorMessage,
cachedFailureMessage: this._cachedFailureMessage,
bankedResetCount: this._bankedResetCount,
+ preparingBankedReset: this._preparingBankedReset,
redeemingBankedReset: this._redeemingBankedReset,
};
}
@@ -85,6 +90,8 @@ export class CodexMonitor {
this._refreshCancellable = null;
this._bankedResetRefreshCancellable?.cancel();
this._bankedResetRefreshCancellable = null;
+ this._prepareBankedResetCancellable?.cancel();
+ this._prepareBankedResetCancellable = null;
this._redeemBankedResetCancellable?.cancel();
this._redeemBankedResetCancellable = null;
this._settings.disconnect(this._autoApplyChangedId);
@@ -126,18 +133,46 @@ export class CodexMonitor {
}
}
- async redeemBankedReset() {
- if (this._stopped || this._redeemingBankedReset) return;
- if (this._bankedResetCount !== null && this._bankedResetCount <= 0) {
- this._notify("No banked Codex resets are available to redeem.");
- return;
+ async prepareBankedReset() {
+ if (this._stopped || this._preparingBankedReset || this._redeemingBankedReset) return null;
+ const cancellable = new Gio.Cancellable();
+ this._preparingBankedReset = true;
+ this._prepareBankedResetCancellable = cancellable;
+ this._emitChange();
+ try {
+ const response = await listCodexBankedResets({ cancellable });
+ if (this._stopped) return null;
+ this._bankedResetCount = Math.max(0, response.available_count);
+ this._lastBankedResetRefreshAt = Math.floor(Date.now() / 1000);
+ const credit = selectCreditToRedeem(response.credits);
+ if (!credit) {
+ this._bankedResetCount = 0;
+ this._notify("No banked Codex resets are available to redeem.");
+ }
+ return credit;
+ } catch (error) {
+ if (isCancellationError(error) || this._stopped) return null;
+ this._notify(formatBankedResetPreparationFailure(error));
+ return null;
+ } finally {
+ if (this._prepareBankedResetCancellable === cancellable) {
+ this._prepareBankedResetCancellable = null;
+ }
+ if (!this._stopped) {
+ this._preparingBankedReset = false;
+ this._emitChange();
+ }
}
+ }
+
+ async redeemBankedReset(creditId) {
+ if (this._stopped || this._redeemingBankedReset) return;
const cancellable = new Gio.Cancellable();
this._redeemingBankedReset = true;
this._redeemBankedResetCancellable = cancellable;
this._emitChange();
try {
- await redeemNextCodexBankedReset({ cancellable });
+ await redeemCodexBankedReset(creditId, { cancellable });
if (this._stopped) return;
this._notify("Banked Codex reset redeemed.");
await this.refreshBankedResets(cancellable);
@@ -328,6 +363,11 @@ export class CodexMonitor {
}
}
+function formatBankedResetPreparationFailure(error) {
+ if (isCodexError(error)) return error.message;
+ return "Unable to load banked Codex reset details. Try again later.";
+}
+
function formatBankedResetFailure(error) {
if (isCodexError(error)) return error.message;
const message =
diff --git a/src/codex/banked-resets/api.js b/src/codex/banked-resets/api.js
index 6026955..d2068d5 100644
--- a/src/codex/banked-resets/api.js
+++ b/src/codex/banked-resets/api.js
@@ -1,9 +1,8 @@
import GLib from "gi://GLib";
import { fetchJson } from "../../io/http.js";
-import { CodexError } from "../error.js";
import { readBankedResetSnapshot, writeBankedResetSnapshot } from "./store.js";
import { getCodexCredentials } from "../auth/auth.js";
-import { selectCreditToRedeem, toListResponse } from "./response.js";
+import { toListResponse } from "./response.js";
const CODEX_BANKED_RESETS_URL = "https://chatgpt.com/backend-api/wham/rate-limit-reset-credits";
@@ -41,20 +40,6 @@ export async function readCachedCodexBankedResets() {
return await readBankedResetSnapshot();
}
-export async function redeemNextCodexBankedReset(options = {}) {
- const list = await listCodexBankedResets(options);
- const credit = selectCreditToRedeem(list.credits);
- if (!credit) {
- throw new CodexError(
- "unexpected-response",
- "No banked Codex resets are available to redeem.",
- "Codex banked reset redemption was requested with zero available credits.",
- );
- }
- await redeemCodexBankedReset(credit.id, options);
- return credit;
-}
-
export async function redeemCodexBankedReset(creditId, options = {}) {
const response = await consumeCodexBankedReset(creditId, options);
try {
diff --git a/src/metadata.json b/src/metadata.json
index b0793ab..c0c3c44 100644
--- a/src/metadata.json
+++ b/src/metadata.json
@@ -6,5 +6,5 @@
"settings-schema": "org.gnome.shell.extensions.codex-meter",
"url": "https://github.com/slobbe/codex-meter",
"license": "GPL-3.0-or-later",
- "version-name": "0.9.2"
+ "version-name": "0.9.3"
}
diff --git a/src/panel/indicator.js b/src/panel/indicator.js
index 8173cd1..aa567d7 100644
--- a/src/panel/indicator.js
+++ b/src/panel/indicator.js
@@ -6,6 +6,7 @@ import St from "gi://St";
import * as PanelMenu from "resource:///org/gnome/shell/ui/panelMenu.js";
import { readSettings } from "../preferences/settings.js";
import { CodexMeterPopupMenu } from "./menu.js";
+import { showBankedResetConfirmation } from "./reset-confirmation.js";
import { UsageBar } from "./usage-bar.js";
import { createMenuViewModel, createPanelBarViewModel } from "./view-model.js";
@@ -26,6 +27,7 @@ export class CodexMeterIndicator extends PanelMenu.Button {
};
this._refreshSpinId = 0;
this._menuSyncId = 0;
+ this._resetConfirmationDialog = null;
this._destroyed = false;
this._panelBox = new St.BoxLayout({
y_align: Clutter.ActorAlign.CENTER,
@@ -91,6 +93,8 @@ export class CodexMeterIndicator extends PanelMenu.Button {
this._settings.disconnect(this._settingsChangedId);
this._settingsChangedId = 0;
}
+ this._resetConfirmationDialog?.destroy();
+ this._resetConfirmationDialog = null;
super.destroy();
}
@@ -101,7 +105,7 @@ export class CodexMeterIndicator extends PanelMenu.Button {
void this._refreshUsage();
},
onRedeemBankedReset: () => {
- void this._redeemBankedReset();
+ void this._confirmBankedReset();
},
onOpenPreferences: () => {
this.menu.close();
@@ -147,11 +151,30 @@ export class CodexMeterIndicator extends PanelMenu.Button {
}
}
- async _redeemBankedReset() {
+ async _confirmBankedReset() {
+ if (this._destroyed || this._resetConfirmationDialog) return;
+ const credit = await this._monitor.prepareBankedReset();
+ if (this._destroyed || !credit) return;
+ this.menu.close();
+ let dialog = null;
+ const clearDialog = () => {
+ if (this._resetConfirmationDialog === dialog) this._resetConfirmationDialog = null;
+ };
+ dialog = showBankedResetConfirmation(credit, {
+ onConfirm: () => {
+ clearDialog();
+ void this._redeemBankedReset(credit.id);
+ },
+ onCancel: clearDialog,
+ });
+ this._resetConfirmationDialog = dialog;
+ }
+
+ async _redeemBankedReset(creditId) {
if (this._destroyed) return;
this._startRefreshSpin();
try {
- await this._monitor.redeemBankedReset();
+ await this._monitor.redeemBankedReset(creditId);
} finally {
if (!this._destroyed) this._stopRefreshSpin();
}
@@ -220,6 +243,7 @@ export class CodexMeterIndicator extends PanelMenu.Button {
errorMessage,
cachedFailureMessage,
bankedResetCount,
+ preparingBankedReset,
redeemingBankedReset,
} = this._monitor.state;
const viewModel = createMenuViewModel(
@@ -241,6 +265,7 @@ export class CodexMeterIndicator extends PanelMenu.Button {
this._popupMenu.setTrend(viewModel.trend);
this._popupMenu.setBankedResets({
count: bankedResetCount,
+ preparing: preparingBankedReset,
redeeming: redeemingBankedReset,
});
this._popupMenu.footerItem.planLabel.text = viewModel.plan;
diff --git a/src/panel/menu.js b/src/panel/menu.js
index be93b08..4333b93 100644
--- a/src/panel/menu.js
+++ b/src/panel/menu.js
@@ -91,15 +91,18 @@ export class CodexMeterPopupMenu {
this.statusItem.messageLabel.text = message ?? "";
}
- setBankedResets({ count, redeeming }) {
- const canRedeem = !redeeming && count !== null && count > 0;
+ setBankedResets({ count, preparing, redeeming }) {
+ const busy = preparing || redeeming;
+ const canRedeem = !busy && count !== null && count > 0;
this.headerItem.redeemButton.visible = count !== null;
this.headerItem.redeemButton.reactive = canRedeem;
this.headerItem.redeemButton.can_focus = canRedeem;
this.headerItem.redeemButton.opacity = canRedeem ? 255 : 150;
- this.headerItem.redeemButtonLabel.text = redeeming
- ? "Resetting…"
- : `Reset limits (${count ?? 0})`;
+ this.headerItem.redeemButtonLabel.text = preparing
+ ? "Loading…"
+ : redeeming
+ ? "Resetting…"
+ : `Reset limits (${count ?? 0})`;
}
_createUsageItem(title) {
diff --git a/src/panel/reset-confirmation.js b/src/panel/reset-confirmation.js
new file mode 100644
index 0000000..d6325a7
--- /dev/null
+++ b/src/panel/reset-confirmation.js
@@ -0,0 +1,106 @@
+import Clutter from "gi://Clutter";
+import St from "gi://St";
+import * as ModalDialog from "resource:///org/gnome/shell/ui/modalDialog.js";
+
+export function showBankedResetConfirmation(credit, { onConfirm, onCancel }) {
+ const dialog = new ModalDialog.ModalDialog({ destroyOnClose: true });
+ const content = new St.BoxLayout({
+ vertical: true,
+ style_class: "cx-reset-dialog-content",
+ });
+ const title = createWrappingLabel("Apply banked reset?", "cx-reset-dialog-title");
+ const message = createWrappingLabel(
+ "This will consume the following reset and reset your current Codex usage limits. This action cannot be undone.",
+ "cx-reset-dialog-message",
+ );
+ const creditTitle = createWrappingLabel(
+ credit.title || "Usage limit reset",
+ "cx-reset-dialog-credit-title",
+ );
+
+ content.add_child(title);
+ content.add_child(message);
+ content.add_child(creditTitle);
+
+ if (credit.description) {
+ content.add_child(createWrappingLabel(credit.description, "cx-reset-dialog-description"));
+ }
+
+ const details = new St.BoxLayout({
+ vertical: true,
+ style_class: "cx-reset-dialog-details",
+ });
+ addDetail(details, "Type", formatResetType(credit.reset_type));
+ addDetail(details, "Granted", formatDate(credit.granted_at));
+ addDetail(details, "Expires", formatDate(credit.expires_at));
+ if (details.get_n_children() > 0) content.add_child(details);
+
+ dialog.contentLayout.add_child(content);
+ dialog.setButtons([
+ {
+ label: "Cancel",
+ action: () => {
+ dialog.close();
+ onCancel();
+ },
+ key: Clutter.KEY_Escape,
+ },
+ {
+ label: "Apply reset",
+ action: () => {
+ dialog.close();
+ onConfirm();
+ },
+ default: true,
+ },
+ ]);
+ dialog.open();
+ return dialog;
+}
+
+function addDetail(container, label, value) {
+ if (!value) return;
+ const row = new St.BoxLayout({
+ style_class: "cx-reset-dialog-detail-row",
+ });
+ row.add_child(
+ new St.Label({
+ text: label,
+ y_align: Clutter.ActorAlign.START,
+ style_class: "cx-reset-dialog-detail-label",
+ }),
+ );
+ row.add_child(createWrappingLabel(value, "cx-reset-dialog-detail-value"));
+ container.add_child(row);
+}
+
+function createWrappingLabel(text, styleClass) {
+ const label = new St.Label({
+ text,
+ x_expand: true,
+ style_class: styleClass,
+ });
+ label.clutter_text.line_wrap = true;
+ label.clutter_text.ellipsize = 0;
+ return label;
+}
+
+function formatResetType(value) {
+ if (!value) return null;
+ const words = value.replaceAll("_", " ");
+ return `${words.charAt(0).toUpperCase()}${words.slice(1)}`;
+}
+
+function formatDate(value) {
+ if (!value) return null;
+ const date = new Date(value);
+ if (!Number.isFinite(date.getTime())) return null;
+ return date.toLocaleString("en-GB", {
+ day: "2-digit",
+ month: "short",
+ year: "numeric",
+ hour: "2-digit",
+ minute: "2-digit",
+ hour12: false,
+ });
+}
diff --git a/src/stylesheet.css b/src/stylesheet.css
index 4c542bb..20ce350 100644
--- a/src/stylesheet.css
+++ b/src/stylesheet.css
@@ -222,3 +222,39 @@
.cx-reset-button:active {
background-color: rgba(255, 255, 255, 0.24);
}
+
+.cx-reset-dialog-content {
+ width: 28em;
+ spacing: 12px;
+}
+
+.cx-reset-dialog-title {
+ font-size: 1.2em;
+ font-weight: 700;
+}
+
+.cx-reset-dialog-message,
+.cx-reset-dialog-description,
+.cx-reset-dialog-detail-label {
+ opacity: 0.75;
+}
+
+.cx-reset-dialog-credit-title {
+ font-weight: 700;
+}
+
+.cx-reset-dialog-details {
+ spacing: 6px;
+}
+
+.cx-reset-dialog-detail-row {
+ spacing: 12px;
+}
+
+.cx-reset-dialog-detail-label {
+ min-width: 5.5em;
+}
+
+.cx-reset-dialog-detail-value {
+ text-align: right;
+}