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.

+

Codex Meter popup preview

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; +}