diff --git a/CHANGELOG.md b/CHANGELOG.md index 945cc76..897a5af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,28 @@ # Changelog +## 0.3.8 — 2026-09-04 + +- Keep the `Limit resets` label, separator, expiry text and countdown in + the same muted treatment as `Credits:` while retaining the bright reset + count. +- Add native hover tooltips to every 5h and 7d reset-countdown circle, showing + the elapsed percentage of that circle's own reset window and its remaining + time. +- Add the explicit earned-reset confirmation dialog to the README as a + privacy-safe, tightly cropped product screenshot. + +## 0.3.7 — 2026-09-04 + +- Make an available earned rate-limit reset explicitly actionable from the + popup, with a native confirmation dialog showing the available count and + selected expiry when supplied. +- Consume a reset only after the user confirms, using the local Codex + app-server, one UUID idempotency key per attempt and the selected opaque + credit ID when detail rows are available. +- Refresh the full usage snapshot after every terminal consume response and + keep success, stale-count, no-credit and error outcomes visible without + adding background redemption or credential access. + ## 0.3.6 — 2026-09-04 - Show the local expiry date and time of the next available earned rate-limit diff --git a/README.md b/README.md index 905fbd0..f024d0e 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,12 @@

The account-wide Codex 5h ring appears only when the signed-in account exposes that quota.

+

Explicit earned-reset confirmation

+

+ Native confirmation dialog before using an earned limit reset +

+

An available reset is consumed only after this native confirmation.

+ | Precise hourly bucket details | Every active quota at a glance | | --------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- | | ![Hourly bucket hover details beside both panel blocks](docs/model-limits/bucket-tooltip.png) | ![Compact panel hover summary beside both panel blocks](docs/model-limits/panel-tooltip.png) | @@ -50,16 +56,18 @@ - Mirrors account-level and named model-specific limits exposed by ChatGPT: remaining usage, reset times, credits and earned resets with the next expiry - date, time and remaining countdown when available. The expiry timestamp - turns orange within seven days and uses a neon-pink breathing alert within - 24 hours; a zero reset count stays compact and shows only `0`. + date, time and remaining countdown when available. An available earned reset + can be selected in the popup and is consumed only after an explicit native + confirmation; a zero reset count stays compact and shows only `0`. - Detects dedicated GPT-5.3-Codex-Spark 5h and 7d quotas automatically when they are enabled for the signed-in account; model-specific limits stay in the popup by default, carry a distinct amber marker and can optionally be added to the panel. - Shows paired circular available-quota and reset-countdown indicators for every active 5h and 7d window without polling the API more often. Untouched - 100%-remaining cycles stay at their exact full duration until usage begins. + 100%-remaining cycles stay at their exact full duration until usage begins; + hovering a reset-countdown circle shows the elapsed percentage of its own + 5h or 7d reset window. - Tracks observed consumption for the last 1h and 4h plus an exact rolling 24h total for active 5h quotas; weekly quotas retain 12h and Today context. The compact timeline offers per-bucket hover details for the last 24 hours, and @@ -98,7 +106,7 @@ cd cinnamon-chatgpt-usage Then open **System Settings → Applets** and add **ChatGPT Usage** to a panel. Requirements: Cinnamon 5.8+, Python 3 and a current [Codex CLI](https://learn.chatgpt.com/docs/codex/cli#getting-started) signed in -with ChatGPT. Version 0.3.6 is tested on Cinnamon 6.6.9 with Codex CLI 0.152.0. +with ChatGPT. Version 0.3.8 is tested on Cinnamon 6.6.9 with Codex CLI 0.153.2. Run `./install.sh` again after updates. `./uninstall.sh` removes the applet while retaining its settings. @@ -106,8 +114,11 @@ retaining its settings. ## How it works The helper makes one read-only `account/rateLimits/read` request through the -official local Codex app-server and exits. There is no HTML scraping, API key, -browser access, background daemon or reset-credit write. To calculate recent +official local Codex app-server for normal refreshes and exits. An earned reset +is never consumed in the background: the confirmed popup action starts a +separate `account/rateLimitResetCredit/consume` request with one UUID +idempotency key and then refetches the complete usage snapshot. There is no +HTML scraping, API key, browser access or background daemon. To calculate recent consumption, it stores only timestamps, window durations, percentages and reset IDs for eight days in `$XDG_STATE_HOME/cinnamon-chatgpt-usage/history.json` (normally `~/.local/state/...`, mode `0600`). No prompts, credit details or diff --git a/applet.js b/applet.js index 74d5b2e..3de5b31 100644 --- a/applet.js +++ b/applet.js @@ -56,6 +56,7 @@ const RESET_EXPIRY_WARNING_SECONDS = 7 * 24 * 60 * 60; const RESET_EXPIRY_CRITICAL_SECONDS = 24 * 60 * 60; const RESET_EXPIRY_BREATHING_OPACITY = 150; const RESET_EXPIRY_BREATHING_DURATION_MS = 1100; +const POPUP_SECONDARY_TEXT_COLOR = "rgba(255,255,255,0.68)"; const AUTH_REQUIRED_TITLE = "No ChatGPT login found"; const AUTH_REQUIRED_DESCRIPTION = "Sign in to ChatGPT with the Codex App/CLI, then choose Refresh now."; @@ -86,6 +87,7 @@ class ChatGptUsageApplet extends Applet.Applet { this._rightPanelMenuBaseMarginRight = 0; this._refreshConfirmationTimeoutId = 0; this._refreshSpinnerTimeoutId = 0; + this._resetCancellable = null; this._menuRebuildTimeoutId = 0; this._rightPanelPopupClosedId = 0; this._rightPanelPopupOpenStateChangedId = 0; @@ -101,6 +103,10 @@ class ChatGptUsageApplet extends Applet.Applet { this._actionColumn = null; this._actionColumnWidth = POPUP_ACTION_GRID_WIDTH; this._installHelpDialog = null; + this._resetConfirmationDialog = null; + this._resetConsumeBusy = false; + this._resetFeedback = null; + this._refreshQueued = false; this._refreshConfirmed = false; this._busy = false; this._cancellable = null; @@ -741,6 +747,12 @@ class ChatGptUsageApplet extends Applet.Applet { this.menu.addMenuItem(new PopupMenu.PopupSeparatorMenuItem()); this._addCreditItems(); + if (this._resetFeedback) { + this._addStatusItem( + this._resetFeedback.title, + this._resetFeedback.description + ); + } } else if (this._authenticationRequired) { this._addStatusItem(AUTH_REQUIRED_TITLE, AUTH_REQUIRED_DESCRIPTION); } else { @@ -965,7 +977,9 @@ class ChatGptUsageApplet extends Applet.Applet { const actor = new St.Widget({ layout_manager: new Clutter.BinLayout(), width: size, - height: size + height: size, + reactive: true, + track_hover: true }); actor.translation_x = -POPUP_RESET_RING_LEFT_SHIFT; const area = new St.DrawingArea({ width: size, height: size }); @@ -985,8 +999,17 @@ class ChatGptUsageApplet extends Applet.Applet { }); actor.add_child(area); actor.add_child(label); - this._countdownWidgets.push({ area, label, window }); - this._updateResetCountdown({ area, label, window }); + const tooltipText = UsageFormat.formatResetCountdownTooltip(window); + actor.accessible_name = UsageFormat.formatAccessibleTooltip(tooltipText); + const entry = { + actor, + area, + label, + window, + tooltip: this._createPositionedTooltip(actor, tooltipText) + }; + this._countdownWidgets.push(entry); + this._updateResetCountdown(entry); return actor; } @@ -1076,6 +1099,11 @@ class ChatGptUsageApplet extends Applet.Applet { const model = UsageFormat.buildResetCountdown(entry.window); entry.label.set_text(model.label); entry.area.queue_repaint(); + if (entry.tooltip) { + const tooltipText = UsageFormat.formatResetCountdownTooltip(entry.window); + entry.tooltip.set_text(tooltipText); + entry.actor.accessible_name = UsageFormat.formatAccessibleTooltip(tooltipText); + } } _updateResetCountdowns() { @@ -1672,25 +1700,47 @@ class ChatGptUsageApplet extends Applet.Applet { credits, this._use24HourClock ); + const resetConfirmation = UsageFormat.buildResetCreditConfirmation( + credits, + this._use24HourClock + ); const resetExpiryColor = resetDisplay.suffix ? this._resetExpiryColor(resetDisplay.expiresAt) : null; this._addCreditItem( - "Rate-limit resets", + "Limit resets", resetDisplay.count, true, resetDisplay.suffix, - resetExpiryColor + resetExpiryColor, + resetConfirmation.available && !this._resetConsumeBusy + ? () => this._showResetConfirmation() + : null ); } - _addCreditItem(label, value, emphasized = false, suffix = null, suffixColor = null) { + _addCreditItem( + label, + value, + emphasized = false, + suffix = null, + suffixColor = null, + action = null + ) { + const interactive = typeof action === "function"; const item = new PopupMenu.PopupBaseMenuItem({ - reactive: false, - activate: false + reactive: interactive, + activate: interactive }); + if (interactive) { + item.connect("activate", () => { + if (!this._resetConsumeBusy) action(); + }); + } const row = new St.BoxLayout({ vertical: false }); - row.add_child(new St.Label({ text: `${label}:` })); + const labelActor = new St.Label({ text: `${label}:` }); + labelActor.style = `color: ${POPUP_SECONDARY_TEXT_COLOR};`; + row.add_child(labelActor); const valueLabel = new St.Label({ text: value }); if (emphasized) { valueLabel.style = this._emphasizedValueStyle(this.normalColor, 4); @@ -1704,16 +1754,23 @@ class ChatGptUsageApplet extends Applet.Applet { text: "•", y_align: Clutter.ActorAlign.CENTER }); - separatorLabel.style = "padding-left: 6px; padding-right: 6px;"; + separatorLabel.style = [ + "padding-left: 6px", + "padding-right: 6px", + "font-size: 80%", + `color: ${POPUP_SECONDARY_TEXT_COLOR}` + ].join("; ") + ";"; + separatorLabel.translation_y = 2; const expiresLabel = new St.Label({ text: "expires ", y_align: Clutter.ActorAlign.CENTER }); + expiresLabel.style = `color: ${POPUP_SECONDARY_TEXT_COLOR};`; const expiryDateLabel = new St.Label({ text: suffix, y_align: Clutter.ActorAlign.CENTER }); - if (suffixColor) expiryDateLabel.style = `color: ${suffixColor};`; + expiryDateLabel.style = `color: ${suffixColor || POPUP_SECONDARY_TEXT_COLOR};`; if (suffixColor === RESET_EXPIRY_CRITICAL_COLOR) { this._resetExpiryBreathingLabels.push(expiryDateLabel); expiryDateLabel.connect("notify::mapped", () => { @@ -1728,6 +1785,176 @@ class ChatGptUsageApplet extends Applet.Applet { this.menu.addMenuItem(item); } + _showResetConfirmation() { + if (this._destroyed || this._resetConsumeBusy || !this._snapshot) return; + + const details = UsageFormat.buildResetCreditConfirmation( + this._snapshot.credits, + this._use24HourClock + ); + if (!details.available) return; + if (this.menu && this.menu.isOpen) this.menu.close(false); + if (this._resetConfirmationDialog) this._resetConfirmationDialog.destroy(); + + const content = new Dialog.MessageDialogContent({ + title: "Use one limit reset now?", + description: [ + `Available reset credits: ${details.count}`, + `Next expiry: ${details.expiryText || "unavailable"}`, + "", + "One reset credit will be consumed." + ].join("\n") + }); + const dialog = new ModalDialog.ModalDialog(); + dialog.contentLayout.add_child(content); + dialog.connect("destroy", () => { + if (this._resetConfirmationDialog === dialog) { + this._resetConfirmationDialog = null; + } + }); + const cancelButton = dialog.addButton({ + label: "Cancel", + action: () => dialog.destroy(), + key: Clutter.KEY_Escape, + default: true + }); + const useButton = dialog.addButton({ + label: "Use reset now", + action: () => this._consumeResetCredit( + details, + dialog, + content, + [cancelButton, useButton] + ), + default: false, + destructive_action: true + }); + this._resetConfirmationDialog = dialog; + dialog.open(); + } + + _setResetConfirmationBusy(dialog, content, buttons) { + for (const button of buttons) { + button.reactive = false; + button.can_focus = false; + button.add_style_pseudo_class("insensitive"); + } + content.description = `${content.description}\n\nUsing reset…`; + dialog.buttonLayout.get_children().forEach(button => { + button.reactive = false; + button.can_focus = false; + }); + } + + _resetOutcomeFeedback(outcome) { + const feedback = UsageFormat.buildResetConsumeFeedback(outcome); + if (!feedback) { + throw new Error(`Unexpected reset outcome: ${outcome || "missing"}`); + } + return feedback; + } + + _resetErrorFeedback(message) { + const detail = String(message || "The reset request failed.").slice(0, 180); + return { + title: "Reset failed", + description: `${detail} Usage limits are refreshing.` + }; + } + + _finishResetConsume(dialog, feedback, refresh) { + this._resetConsumeBusy = false; + this._resetCancellable = null; + if (dialog && !dialog.is_finalized()) dialog.destroy(); + if (!feedback || this._destroyed) return; + + this._resetFeedback = feedback; + this._rebuildPanel(); + this._scheduleMenuRebuild(); + if (refresh) this._refreshUsage(); + } + + _consumeResetCredit(details, dialog, content, buttons) { + if (this._destroyed || this._resetConsumeBusy) return; + + this._resetConsumeBusy = true; + this._resetFeedback = null; + this._setResetConfirmationBusy(dialog, content, buttons); + const python = GLib.find_program_in_path("python3"); + const helper = `${this.metadata.path}/chatgpt_usage.py`; + const codex = this._resolveCodexPath(); + if (!python || !codex) { + this._finishResetConsume( + dialog, + this._resetErrorFeedback( + !python + ? "python3 was not found" + : "Codex CLI was not found; configure its path in the applet settings" + ), + false + ); + return; + } + + const idempotencyKey = GLib.uuid_string_random(); + const argv = [ + python, + helper, + "--codex", + codex, + "--timeout", + "25", + "--consume-reset", + "--idempotency-key", + idempotencyKey + ]; + if (details.creditId) argv.push("--credit-id", details.creditId); + + this._resetCancellable = new Gio.Cancellable(); + try { + const process = new Gio.Subprocess({ + argv, + flags: Gio.SubprocessFlags.STDOUT_PIPE | Gio.SubprocessFlags.STDERR_PIPE + }); + process.init(null); + process.communicate_utf8_async( + null, + this._resetCancellable, + (source, result) => { + let feedback = null; + let cancelled = false; + try { + const [ok, stdout, stderr] = source.communicate_utf8_finish(result); + if (!ok || source.get_exit_status() !== 0) { + const helperError = UsageFormat.parseUsageHelperError(stderr); + const error = new Error(helperError.message); + error.authenticationRequired = helperError.authenticationRequired; + throw error; + } + const payload = JSON.parse(String(stdout || "").trim()); + feedback = this._resetOutcomeFeedback(payload && payload.outcome); + } catch (error) { + cancelled = typeof error.matches === "function" && + error.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.CANCELLED); + if (!cancelled) { + global.logError(`${UUID}: reset consume failed: ${error}`); + feedback = this._resetErrorFeedback(error.message || error); + } + } + + this._finishResetConsume( + cancelled ? null : dialog, + feedback, + !cancelled + ); + } + ); + } catch (error) { + global.logError(`${UUID}: could not start reset consume: ${error}`); + this._finishResetConsume(dialog, this._resetErrorFeedback(error), false); + } + } + _addHistoryItems() { const history = this._snapshot ? this._snapshot.history : null; if (!history || !Array.isArray(history.windows) || history.windows.length === 0) { @@ -2171,7 +2398,11 @@ class ChatGptUsageApplet extends Applet.Applet { } _refreshUsage(showConfirmation = false) { - if (this._destroyed || this._busy) return; + if (this._destroyed) return; + if (this._busy) { + this._refreshQueued = true; + return; + } const python = GLib.find_program_in_path("python3"); const helper = `${this.metadata.path}/chatgpt_usage.py`; @@ -2250,6 +2481,10 @@ class ChatGptUsageApplet extends Applet.Applet { if (showConfirmation && succeeded) this._showRefreshConfirmation(); this._rebuildPanel(); this._scheduleMenuRebuild(); + if (this._refreshQueued) { + this._refreshQueued = false; + this._refreshUsage(); + } } }); } catch (error) { @@ -2430,6 +2665,16 @@ class ChatGptUsageApplet extends Applet.Applet { this._cancellable.cancel(); this._cancellable = null; } + this._refreshQueued = false; + if (this._resetCancellable) { + this._resetCancellable.cancel(); + this._resetCancellable = null; + } + this._resetConsumeBusy = false; + if (this._resetConfirmationDialog) { + this._resetConfirmationDialog.destroy(); + this._resetConfirmationDialog = null; + } if (this._installHelpDialog) { this._installHelpDialog.destroy(); this._installHelpDialog = null; diff --git a/chatgpt_usage.py b/chatgpt_usage.py index 72509fe..a5734f5 100755 --- a/chatgpt_usage.py +++ b/chatgpt_usage.py @@ -102,6 +102,36 @@ def _next_reset_expiry(resets: Any) -> int | None: return min(expiries, default=None) +def _reset_credit_details(resets: Any) -> list[dict[str, Any]] | None: + """Return selectable available reset details without persisting them.""" + + if not isinstance(resets, dict) or not isinstance(resets.get("credits"), list): + return None + + details = [] + for credit in resets["credits"]: + if not isinstance(credit, dict) or str(credit.get("status") or "").casefold() != "available": + continue + credit_id = str(credit.get("id") or "").strip() + if not credit_id: + continue + expires_at = _number(credit.get("expiresAt"), -1) + details.append( + { + "id": credit_id, + "expiresAt": int(expires_at) if math.isfinite(expires_at) and expires_at > 0 else None, + } + ) + + details.sort( + key=lambda credit: ( + credit["expiresAt"] is None, + credit["expiresAt"] or 0, + ) + ) + return details + + def normalise_rate_limits(result: dict[str, Any], now: int | None = None) -> dict[str, Any]: """Convert user-facing account and named model limits into an applet snapshot.""" @@ -156,6 +186,7 @@ def normalise_rate_limits(result: dict[str, Any], now: int | None = None) -> dic credits = { "availableResetCount": available_resets, "nextResetExpiresAt": _next_reset_expiry(resets), + "resetCredits": _reset_credit_details(resets), "balance": None, "hasCredits": False, "unlimited": False, @@ -414,8 +445,14 @@ def resolve_codex(explicit: str | None) -> str: raise UsageError("Codex CLI was not found") -def fetch_rate_limits(codex: str, timeout: float) -> dict[str, Any]: - """Perform one initialized, read-only app-server request.""" +def _run_app_server_request( + codex: str, + timeout: float, + request: dict[str, Any], + empty_result_message: str, + timeout_message: str, +) -> dict[str, Any]: + """Perform one initialized app-server request and return its result.""" process = subprocess.Popen( [codex, "app-server", "--listen", "stdio://"], @@ -433,7 +470,7 @@ def fetch_rate_limits(codex: str, timeout: float) -> dict[str, Any]: messages = ( {"method": "initialize", "id": 1, "params": {"clientInfo": CLIENT_INFO}}, {"method": "initialized", "params": {}}, - {"method": "account/rateLimits/read", "id": 2}, + request, ) for message in messages: process.stdin.write(json.dumps(message, separators=(",", ":")) + "\n") @@ -453,7 +490,7 @@ def fetch_rate_limits(codex: str, timeout: float) -> dict[str, Any]: message = json.loads(line) except json.JSONDecodeError: continue - if message.get("id") != 2: + if message.get("id") != request.get("id"): continue if "error" in message: error = message["error"] @@ -463,9 +500,9 @@ def fetch_rate_limits(codex: str, timeout: float) -> dict[str, Any]: raise UsageError(f"Codex app-server rejected the request: {detail}") result = message.get("result") if not isinstance(result, dict): - raise UsageError("Codex app-server returned no usage data") + raise UsageError(empty_result_message) return result - raise UsageError("Timed out while reading ChatGPT usage limits") + raise UsageError(timeout_message) finally: try: process.stdin.close() @@ -480,10 +517,65 @@ def fetch_rate_limits(codex: str, timeout: float) -> dict[str, Any]: process.wait(timeout=2) +def fetch_rate_limits(codex: str, timeout: float) -> dict[str, Any]: + """Perform one initialized, read-only app-server request.""" + + return _run_app_server_request( + codex, + timeout, + {"method": "account/rateLimits/read", "id": 2}, + "Codex app-server returned no usage data", + "Timed out while reading ChatGPT usage limits", + ) + + +def consume_rate_limit_reset( + codex: str, + timeout: float, + idempotency_key: str, + credit_id: str | None = None, +) -> dict[str, Any]: + """Consume one reset credit through an explicitly requested app-server action.""" + + key = str(idempotency_key or "").strip() + if not key: + raise UsageError("Reset consume requires a non-empty idempotency key") + + params: dict[str, str] = {"idempotencyKey": key} + selected_credit_id = str(credit_id or "").strip() + if selected_credit_id: + params["creditId"] = selected_credit_id + + result = _run_app_server_request( + codex, + timeout, + { + "method": "account/rateLimitResetCredit/consume", + "id": 2, + "params": params, + }, + "Codex app-server returned no reset outcome", + "Timed out while consuming a rate-limit reset", + ) + if not isinstance(result.get("outcome"), str) or not result["outcome"]: + raise UsageError("Codex app-server returned an invalid reset outcome") + return result + + def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--codex", help="Path to the Codex CLI") parser.add_argument("--timeout", type=float, default=25, help="Request timeout in seconds") + parser.add_argument( + "--consume-reset", + action="store_true", + help="Explicitly consume one earned rate-limit reset credit", + ) + parser.add_argument( + "--idempotency-key", + help="Stable key for one explicit reset-redemption attempt", + ) + parser.add_argument("--credit-id", help="Optional opaque reset-credit ID") parser.add_argument("--history-file", type=Path, help="Override the local history path") parser.add_argument( "--activity-bucket-minutes", @@ -500,6 +592,16 @@ def main() -> int: args = parse_args() try: codex = resolve_codex(args.codex) + if args.consume_reset: + result = consume_rate_limit_reset( + codex, + max(1.0, args.timeout), + args.idempotency_key or "", + args.credit_id, + ) + print(json.dumps(result, separators=(",", ":"))) + return 0 + result = fetch_rate_limits(codex, max(1.0, args.timeout)) snapshot = normalise_rate_limits(result) if not args.no_history: diff --git a/docs/model-limits/bucket-tooltip.png b/docs/model-limits/bucket-tooltip.png index d6f94d0..643dd35 100644 Binary files a/docs/model-limits/bucket-tooltip.png and b/docs/model-limits/bucket-tooltip.png differ diff --git a/docs/model-limits/reset-confirmation.png b/docs/model-limits/reset-confirmation.png new file mode 100644 index 0000000..d2329bf Binary files /dev/null and b/docs/model-limits/reset-confirmation.png differ diff --git a/docs/model-limits/usage-menu-four-rings.png b/docs/model-limits/usage-menu-four-rings.png index 5616626..74b7d6b 100644 Binary files a/docs/model-limits/usage-menu-four-rings.png and b/docs/model-limits/usage-menu-four-rings.png differ diff --git a/docs/model-limits/usage-menu-spark.png b/docs/model-limits/usage-menu-spark.png index 8eb1d6b..3f82bb8 100644 Binary files a/docs/model-limits/usage-menu-spark.png and b/docs/model-limits/usage-menu-spark.png differ diff --git a/docs/model-limits/usage-menu.png b/docs/model-limits/usage-menu.png index 486e595..09a70ad 100644 Binary files a/docs/model-limits/usage-menu.png and b/docs/model-limits/usage-menu.png differ diff --git a/metadata.json b/metadata.json index 8cecd4c..1dc54c7 100644 --- a/metadata.json +++ b/metadata.json @@ -4,7 +4,7 @@ "description": "A tiny Cinnamon companion that keeps ChatGPT Work and Codex usage beautifully in view.", "comments": "Made with love by Claudiu & Codex. 🩷\n\nhttps://github.com/oss-singularity/cinnamon-chatgpt-usage", "contributors": "OSS Singularity — open-source home of the project,Claudiu Schuster — creator and maintainer,OpenAI Codex — design and engineering collaborator", - "version": "0.3.6", + "version": "0.3.8", "author": "OSS Singularity", "max-instances": 1, "cinnamon-version": ["5.8", "6.0", "6.2", "6.4", "6.6"], diff --git a/tests/test-usage-format.js b/tests/test-usage-format.js index fa36478..c39a160 100644 --- a/tests/test-usage-format.js +++ b/tests/test-usage-format.js @@ -128,6 +128,14 @@ assertEqual(fiveHourCountdown.remainingSeconds, 9000, "Five-hour seconds remaini assertEqual(fiveHourCountdown.fractionRemaining, 0.5, "Five-hour ring fraction"); assertEqual(fiveHourCountdown.fractionElapsed, 0.5, "Five-hour elapsed fraction"); assertEqual(fiveHourCountdown.label, "2h\n30m", "Five-hour countdown label"); +assertEqual( + UsageFormat.formatResetCountdownTooltip( + { durationMinutes: 300, resetsAt: 10000 }, + 1000 + ), + "Reset window: 5h\nElapsed: 50%\nRemaining: 2h 30m", + "Five-hour reset tooltip" +); const earlyFiveHourCountdown = UsageFormat.buildResetCountdown( { durationMinutes: 300, resetsAt: 1000 + (4 * 3600) }, @@ -157,6 +165,18 @@ assertEqual( 0, "Unused five-hour cycle has no reset progress" ); +assertEqual( + UsageFormat.formatResetCountdownTooltip( + { + durationMinutes: 300, + remainingPercent: 100, + resetsAt: 1000 + (5 * 3600) + }, + 1002 + ), + "Reset window: 5h\nElapsed: 0%\nRemaining: 5h", + "Unused five-hour reset tooltip" +); const unusedWeeklyCountdown = UsageFormat.buildResetCountdown( { @@ -216,6 +236,14 @@ assertEqual( false, "Missing reset countdown" ); +assertEqual( + UsageFormat.formatResetCountdownTooltip( + { durationMinutes: 10080, resetsAt: null }, + 1000 + ), + "Reset window: 7d\nElapsed: unavailable", + "Invalid reset tooltip" +); const weeklyQuota = UsageFormat.buildQuotaIndicator({ durationMinutes: 10080, remainingPercent: 60 @@ -838,6 +866,57 @@ assertEqual( 1000 + 10 * 86400 + 5 * 3600, "Available reset keeps expiry timestamp" ); +const selectedReset = UsageFormat.buildResetCreditConfirmation( + { + availableResetCount: 2, + nextResetExpiresAt: 1000 + 8 * 86400, + resetCredits: [ + { id: "reset-later", expiresAt: 1000 + 8 * 86400 }, + { id: "reset-next", expiresAt: 1000 + 2 * 86400 } + ] + }, + true, + 1000 +); +assertEqual(selectedReset.available, true, "Available reset opens confirmation"); +assertEqual(selectedReset.count, "2", "Confirmation shows available reset count"); +assertEqual(selectedReset.creditId, "reset-next", "Confirmation selects earliest expiry"); +assertEqual( + selectedReset.expiresAt, + 1000 + 2 * 86400, + "Confirmation keeps selected credit expiry" +); +if (!selectedReset.expiryText || !/\(~2d0h\)$/.test(selectedReset.expiryText)) { + throw new Error(`Expected selected reset expiry, got ${selectedReset.expiryText}`); +} +const countOnlyReset = UsageFormat.buildResetCreditConfirmation( + { availableResetCount: 1, resetCredits: null }, + true, + 1000 +); +assertEqual(countOnlyReset.available, true, "Count-only reset still opens confirmation"); +assertEqual(countOnlyReset.creditId, null, "Count-only reset omits credit ID"); +assertEqual(countOnlyReset.expiryText, null, "Count-only reset shows no unknown expiry"); +assertEqual( + UsageFormat.buildResetCreditConfirmation( + { availableResetCount: 0, resetCredits: [] }, + true, + 1000 + ).available, + false, + "Zero reset count stays non-reactive" +); +for (const outcome of ["reset", "alreadyRedeemed", "nothingToReset", "noCredit"]) { + const feedback = UsageFormat.buildResetConsumeFeedback(outcome); + if (!feedback || !feedback.title || !feedback.description) { + throw new Error(`Missing user feedback for reset outcome ${outcome}`); + } +} +assertEqual( + UsageFormat.buildResetConsumeFeedback("unexpected"), + null, + "Unexpected reset outcome is not treated as success" +); assertEqual( UsageFormat.formatRelativeTime(1000, 1000), "just now", diff --git a/tests/test_chatgpt_usage.py b/tests/test_chatgpt_usage.py index a7fb955..cf89b9d 100755 --- a/tests/test_chatgpt_usage.py +++ b/tests/test_chatgpt_usage.py @@ -4,16 +4,119 @@ from __future__ import annotations import datetime as dt +import json import unittest from pathlib import Path from tempfile import TemporaryDirectory +from unittest.mock import patch from chatgpt_usage import ( build_usage_history, + consume_rate_limit_reset, is_authentication_error, normalise_rate_limits, update_usage_history, ) +from chatgpt_usage import UsageError + + +class FakeStdin: + def __init__(self) -> None: + self.messages: list[dict] = [] + + def write(self, payload: str) -> int: + self.messages.append(json.loads(payload)) + return len(payload) + + def flush(self) -> None: + pass + + def close(self) -> None: + pass + + +class FakeStdout: + def __init__(self, response: dict) -> None: + self._lines = [json.dumps(response)] + + @property + def has_data(self) -> bool: + return bool(self._lines) + + def readline(self) -> str: + return f"{self._lines.pop(0)}\n" if self._lines else "" + + +class FakeProcess: + def __init__(self, response: dict) -> None: + self.stdin = FakeStdin() + self.stdout = FakeStdout(response) + self._returncode = None + + def poll(self) -> int | None: + return self._returncode + + def terminate(self) -> None: + self._returncode = 0 + + def wait(self, timeout: float | None = None) -> int: + self._returncode = 0 + return 0 + + def kill(self) -> None: + self._returncode = -9 + + +class ResetConsumeRequestTests(unittest.TestCase): + def test_consume_sends_idempotent_request_with_selected_credit(self) -> None: + process = FakeProcess({"id": 2, "result": {"outcome": "reset"}}) + + def select_ready(streams, _, __, timeout): + return (streams if process.stdout.has_data else [], [], []) + + with ( + patch("chatgpt_usage.subprocess.Popen", return_value=process), + patch("chatgpt_usage.select.select", side_effect=select_ready), + ): + result = consume_rate_limit_reset("/usr/bin/codex", 5, "attempt-123", "credit-456") + + self.assertEqual(result["outcome"], "reset") + self.assertEqual(process.stdin.messages[0]["method"], "initialize") + self.assertEqual(process.stdin.messages[1], {"method": "initialized", "params": {}}) + self.assertEqual( + process.stdin.messages[2], + { + "method": "account/rateLimitResetCredit/consume", + "id": 2, + "params": { + "idempotencyKey": "attempt-123", + "creditId": "credit-456", + }, + }, + ) + + def test_consume_omits_missing_credit_id_and_rejects_empty_key(self) -> None: + process = FakeProcess({"id": 2, "result": {"outcome": "noCredit"}}) + + def select_ready(streams, _, __, timeout): + return (streams if process.stdout.has_data else [], [], []) + + with ( + patch("chatgpt_usage.subprocess.Popen", return_value=process), + patch("chatgpt_usage.select.select", side_effect=select_ready), + ): + result = consume_rate_limit_reset("/usr/bin/codex", 5, "attempt-789") + + self.assertEqual(result["outcome"], "noCredit") + self.assertEqual( + process.stdin.messages[2]["params"], + {"idempotencyKey": "attempt-789"}, + ) + + with patch("chatgpt_usage.subprocess.Popen") as popen: + with self.assertRaises(UsageError): + consume_rate_limit_reset("/usr/bin/codex", 5, " ") + popen.assert_not_called() class AuthenticationErrorTests(unittest.TestCase): @@ -85,9 +188,9 @@ def test_named_model_specific_limits_are_included(self) -> None: "rateLimitResetCredits": { "availableCount": 2, "credits": [ - {"status": "available", "expiresAt": 1791079502}, - {"status": "available", "expiresAt": 1789000000}, - {"status": "redeemed", "expiresAt": 1788000000}, + {"id": "reset-later", "status": "available", "expiresAt": 1791079502}, + {"id": "reset-next", "status": "available", "expiresAt": 1789000000}, + {"id": "reset-used", "status": "redeemed", "expiresAt": 1788000000}, ], }, } @@ -108,6 +211,13 @@ def test_named_model_specific_limits_are_included(self) -> None: self.assertEqual(snapshot["credits"]["balance"], "0") self.assertEqual(snapshot["credits"]["availableResetCount"], 2) self.assertEqual(snapshot["credits"]["nextResetExpiresAt"], 1789000000) + self.assertEqual( + snapshot["credits"]["resetCredits"], + [ + {"id": "reset-next", "expiresAt": 1789000000}, + {"id": "reset-later", "expiresAt": 1791079502}, + ], + ) def test_unnamed_internal_bucket_is_ignored(self) -> None: result = { @@ -146,6 +256,16 @@ def test_legacy_single_bucket_and_missing_five_hour_window(self) -> None: self.assertEqual(snapshot["limits"][0]["windows"][0]["remainingPercent"], 61) self.assertEqual(snapshot["credits"]["availableResetCount"], 0) self.assertIsNone(snapshot["credits"]["nextResetExpiresAt"]) + self.assertIsNone(snapshot["credits"]["resetCredits"]) + + def test_reset_detail_rows_preserve_count_only_and_empty_states(self) -> None: + base = {"rateLimits": {"primary": {"usedPercent": 10, "windowDurationMins": 10080}}} + + count_only = dict(base, rateLimitResetCredits={"availableCount": 2, "credits": None}) + self.assertIsNone(normalise_rate_limits(count_only)["credits"]["resetCredits"]) + + no_details = dict(base, rateLimitResetCredits={"availableCount": 0, "credits": []}) + self.assertEqual(normalise_rate_limits(no_details)["credits"]["resetCredits"], []) def test_optional_five_hour_window_when_canonical_api_exposes_it(self) -> None: result = { diff --git a/usage-format.js b/usage-format.js index b941637..e084fcd 100644 --- a/usage-format.js +++ b/usage-format.js @@ -160,6 +160,23 @@ function buildResetCountdown(window, nowSeconds = null) { }; } +function formatResetCountdownTooltip(window, nowSeconds = null) { + const durationLabel = formatDuration(window && window.durationMinutes); + const model = buildResetCountdown(window, nowSeconds); + if (!model.valid) { + return `Reset window: ${durationLabel}\nElapsed: unavailable`; + } + + const remaining = [model.primary, model.secondary] + .filter(Boolean) + .join(" "); + return [ + `Reset window: ${durationLabel}`, + `Elapsed: ${formatPercent(model.fractionElapsed * 100)}`, + `Remaining: ${remaining}` + ].join("\n"); +} + function buildQuotaIndicator(window) { const durationMinutes = Number(window && window.durationMinutes); const remainingPercent = Number(window && window.remainingPercent); @@ -561,6 +578,92 @@ function buildResetCreditDisplay(credits, use24Hour, nowSeconds = null) { }; } +function selectResetCredit(credits) { + if (!credits || !Array.isArray(credits.resetCredits)) return null; + + const candidates = []; + for (const credit of credits.resetCredits) { + const id = String(credit && credit.id || "").trim(); + if (!id) continue; + const expiresAt = Number(credit.expiresAt); + candidates.push({ + id, + expiresAt: Number.isFinite(expiresAt) && expiresAt > 0 + ? Math.floor(expiresAt) + : null + }); + } + candidates.sort((left, right) => { + if (left.expiresAt === right.expiresAt) return 0; + if (left.expiresAt === null) return 1; + if (right.expiresAt === null) return -1; + return left.expiresAt - right.expiresAt; + }); + return candidates[0] || null; +} + +function buildResetCreditConfirmation(credits, use24Hour, nowSeconds = null) { + const count = credits + ? formatWholeNumber(credits.availableResetCount) + : "unavailable"; + const availableCount = Number(credits && credits.availableResetCount); + if (!Number.isFinite(availableCount) || availableCount <= 0) { + return { + available: false, + count, + creditId: null, + expiresAt: null, + expiryText: null + }; + } + + const selected = selectResetCredit(credits); + const fallbackExpiresAt = Number(credits.nextResetExpiresAt); + const expiresAt = selected + ? selected.expiresAt + : Number.isFinite(fallbackExpiresAt) && fallbackExpiresAt > 0 + ? Math.floor(fallbackExpiresAt) + : null; + const timestamp = formatTimestamp(expiresAt, use24Hour); + const countdown = formatExpiryCountdown(expiresAt, nowSeconds); + return { + available: true, + count, + creditId: selected ? selected.id : null, + expiresAt, + expiryText: timestamp === "unknown" + ? null + : countdown ? `${timestamp} (${countdown})` : timestamp + }; +} + +function buildResetConsumeFeedback(outcome) { + switch (outcome) { + case "reset": + return { + title: "Reset applied", + description: "One reset credit was consumed. Usage limits are refreshing." + }; + case "alreadyRedeemed": + return { + title: "Reset already applied", + description: "This redemption was already completed. Usage limits are refreshing." + }; + case "nothingToReset": + return { + title: "Reset not applied", + description: "No usage window was eligible for this reset. Usage limits are refreshing." + }; + case "noCredit": + return { + title: "No reset credit available", + description: "The displayed count was stale. The available reset count is refreshing." + }; + default: + return null; + } +} + function formatLocalDate(epochSeconds) { const seconds = Number(epochSeconds); if (!Number.isFinite(seconds) || seconds <= 0) return null; @@ -628,6 +731,7 @@ module.exports = { formatPercent, formatConsumedPercent, buildResetCountdown, + formatResetCountdownTooltip, buildQuotaIndicator, buildActivityChart, formatWholeNumber, @@ -644,6 +748,9 @@ module.exports = { formatTimestamp, formatExpiryCountdown, buildResetCreditDisplay, + selectResetCredit, + buildResetCreditConfirmation, + buildResetConsumeFeedback, formatLocalDate, formatChatGptVersionDate, formatRelativeTime,