Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,15 @@
# Changelog

## 0.3.6 — 2026-09-04

- Show the local expiry date and time of the next available earned rate-limit
reset on the same compact line as its reset count, keeping the timestamp in
the same regular text treatment as `expires` and adding a remaining
`(~XdYh)` countdown when details are supplied.
- Color the expiry timestamp orange inside seven days and neon pink with a
breathing alert inside 24 hours; omit the separator, timestamp and countdown
entirely when no reset credit is available.

## 0.3.5 — 2026-09-01

- Interpret the ChatGPT desktop version date segment (`YY.MDD`) for the
Expand Down
7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,10 @@
## Highlights

- Mirrors account-level and named model-specific limits exposed by ChatGPT:
remaining usage, reset times, credits and earned resets.
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`.
- 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
Expand Down Expand Up @@ -95,7 +98,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.5 is tested on Cinnamon 6.6.9 with Codex CLI 0.152.0.
with ChatGPT. Version 0.3.6 is tested on Cinnamon 6.6.9 with Codex CLI 0.152.0.

Run `./install.sh` again after updates. `./uninstall.sh` removes the applet while
retaining its settings.
Expand Down
93 changes: 88 additions & 5 deletions applet.js
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,12 @@ const COMPACT_QUOTA_RING_SIZE = 40;
const POPUP_HEADER_RING_LEFT_SHIFT = 13;
const POPUP_RESET_RING_LEFT_SHIFT = 14;
const SPARK_BADGE_COLOR = "#f2a15f";
const RESET_EXPIRY_WARNING_COLOR = "#f2a15f";
const RESET_EXPIRY_CRITICAL_COLOR = "#ff4d8d";
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 AUTH_REQUIRED_TITLE = "No ChatGPT login found";
const AUTH_REQUIRED_DESCRIPTION =
"Sign in to ChatGPT with the Codex App/CLI, then choose Refresh now.";
Expand All @@ -67,6 +73,7 @@ class ChatGptUsageApplet extends Applet.Applet {
this._timeoutId = 0;
this._countdownTimeoutId = 0;
this._countdownWidgets = [];
this._resetExpiryBreathingLabels = [];
this._quotaWidgets = [];
this._activityTooltips = [];
this._popupRightInsetRows = [];
Expand Down Expand Up @@ -375,7 +382,9 @@ class ChatGptUsageApplet extends Applet.Applet {
if (open) {
this._rightPanelPopupCloseSeq++;
this._rightPanelPopupCloseInProgress = false;
this._startResetExpiryBreathing();
}
if (!open) this._stopResetExpiryBreathing();
if (!open) this._rightPanelPopupLockedWidth = 0;
if (!open && !this._rightPanelPopupCloseInProgress) {
this._normalizeRightPanelPopupCloseWidth();
Expand Down Expand Up @@ -644,11 +653,51 @@ class ChatGptUsageApplet extends Applet.Applet {
return this.normalColor;
}

_resetExpiryColor(expiresAt) {
const expiry = Number(expiresAt);
if (!Number.isFinite(expiry) || expiry <= 0) return null;
const remaining = expiry - GLib.get_real_time() / 1000000;
if (remaining <= RESET_EXPIRY_CRITICAL_SECONDS) {
return RESET_EXPIRY_CRITICAL_COLOR;
}
if (remaining <= RESET_EXPIRY_WARNING_SECONDS) {
return RESET_EXPIRY_WARNING_COLOR;
}
return null;
}

_startResetExpiryBreathing() {
for (const label of this._resetExpiryBreathingLabels) {
if (!label || !label.mapped || label._resetExpiryBreathing) continue;
label._resetExpiryBreathing = true;
label.opacity = 255;
label.ease({
opacity: RESET_EXPIRY_BREATHING_OPACITY,
duration: RESET_EXPIRY_BREATHING_DURATION_MS,
mode: Clutter.AnimationMode.EASE_IN_OUT_QUAD,
repeatCount: -1,
autoReverse: true,
animationRequired: false
});
}
}

_stopResetExpiryBreathing() {
for (const label of this._resetExpiryBreathingLabels) {
if (!label) continue;
label.remove_transition("opacity");
label.opacity = 255;
label._resetExpiryBreathing = false;
}
}

_rebuildMenu() {
if (!this.menu) return;
const wasOpen = this.menu.isOpen;
const expandedSubmenus = new Set();
if (wasOpen) this.actor.grab_key_focus();
this._stopResetExpiryBreathing();
this._resetExpiryBreathingLabels = [];
for (const entry of this._historySubmenus) {
if (!entry.submenu.menu.isOpen) continue;
expandedSubmenus.add(entry.id);
Expand Down Expand Up @@ -1619,16 +1668,23 @@ class ChatGptUsageApplet extends Applet.Applet {
: "unavailable";
if (credits && credits.unlimited) balance = "unlimited";
this._addCreditItem("Credits", balance, true);
const resetDisplay = UsageFormat.buildResetCreditDisplay(
credits,
this._use24HourClock
);
const resetExpiryColor = resetDisplay.suffix
? this._resetExpiryColor(resetDisplay.expiresAt)
: null;
this._addCreditItem(
"Rate-limit resets",
credits
? UsageFormat.formatWholeNumber(credits.availableResetCount)
: "unavailable",
true
resetDisplay.count,
true,
resetDisplay.suffix,
resetExpiryColor
);
}

_addCreditItem(label, value, emphasized = false) {
_addCreditItem(label, value, emphasized = false, suffix = null, suffixColor = null) {
const item = new PopupMenu.PopupBaseMenuItem({
reactive: false,
activate: false
Expand All @@ -1643,6 +1699,31 @@ class ChatGptUsageApplet extends Applet.Applet {
valueLabel.style = "padding-left: 4px;";
}
row.add_child(valueLabel);
if (suffix) {
const separatorLabel = new St.Label({
text: "•",
y_align: Clutter.ActorAlign.CENTER
});
separatorLabel.style = "padding-left: 6px; padding-right: 6px;";
const expiresLabel = new St.Label({
text: "expires ",
y_align: Clutter.ActorAlign.CENTER
});
const expiryDateLabel = new St.Label({
text: suffix,
y_align: Clutter.ActorAlign.CENTER
});
if (suffixColor) expiryDateLabel.style = `color: ${suffixColor};`;
if (suffixColor === RESET_EXPIRY_CRITICAL_COLOR) {
this._resetExpiryBreathingLabels.push(expiryDateLabel);
expiryDateLabel.connect("notify::mapped", () => {
if (expiryDateLabel.mapped) this._startResetExpiryBreathing();
});
}
row.add_child(separatorLabel);
row.add_child(expiresLabel);
row.add_child(expiryDateLabel);
}
item.addActor(row);
this.menu.addMenuItem(item);
}
Expand Down Expand Up @@ -2357,6 +2438,8 @@ class ChatGptUsageApplet extends Applet.Applet {
this._clockSettings.disconnect(this._clockChangedId);
this._clockChangedId = 0;
}
this._stopResetExpiryBreathing();
this._resetExpiryBreathingLabels = [];
this._clockSettings = null;
if (this.settings) this.settings.finalize();
}
Expand Down
21 changes: 21 additions & 0 deletions chatgpt_usage.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import argparse
import datetime as dt
import json
import math
import os
import select
import shutil
Expand Down Expand Up @@ -82,6 +83,25 @@ def _normalise_window(window: Any) -> dict[str, Any] | None:
}


def _next_reset_expiry(resets: Any) -> int | None:
"""Return the earliest expiry among available reset-credit details."""

if not isinstance(resets, dict) or not isinstance(resets.get("credits"), list):
return None

expiries = []
for credit in resets["credits"]:
if not isinstance(credit, dict):
continue
status = str(credit.get("status") or "").casefold()
if status and status != "available":
continue
expires_at = _number(credit.get("expiresAt"), -1)
if math.isfinite(expires_at) and expires_at > 0:
expiries.append(int(expires_at))
return min(expiries, default=None)


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."""

Expand Down Expand Up @@ -135,6 +155,7 @@ def normalise_rate_limits(result: dict[str, Any], now: int | None = None) -> dic

credits = {
"availableResetCount": available_resets,
"nextResetExpiresAt": _next_reset_expiry(resets),
"balance": None,
"hasCredits": False,
"unlimited": False,
Expand Down
Binary file modified docs/model-limits/bucket-tooltip.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/model-limits/usage-menu-four-rings.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/model-limits/usage-menu-spark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/model-limits/usage-menu.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion metadata.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.5",
"version": "0.3.6",
"author": "OSS Singularity",
"max-instances": 1,
"cinnamon-version": ["5.8", "6.0", "6.2", "6.4", "6.6"],
Expand Down
56 changes: 56 additions & 0 deletions tests/test-usage-format.js
Original file line number Diff line number Diff line change
Expand Up @@ -782,6 +782,62 @@ const timestamp24h = UsageFormat.formatTimestamp(1700000000, true);
if (/AM|PM/.test(timestamp24h) || !/:/.test(timestamp24h)) {
throw new Error(`Expected system-local 24-hour timestamp, got ${timestamp24h}`);
}
assertEqual(
UsageFormat.formatExpiryCountdown(1000 + 10 * 86400 + 5 * 3600 + 59 * 60, 1000),
"~10d5h",
"Expiry countdown days and hours"
);
assertEqual(
UsageFormat.formatExpiryCountdown(1000 + 23 * 3600 + 59 * 60, 1000),
"~23h",
"Expiry countdown under one day"
);
assertEqual(
UsageFormat.formatExpiryCountdown(1000 - 1, 1000),
"~0h",
"Expired countdown"
);
assertEqual(
UsageFormat.formatExpiryCountdown(null, 1000),
null,
"Invalid expiry countdown"
);
const noResetDisplay = UsageFormat.buildResetCreditDisplay(
{
availableResetCount: 0,
nextResetExpiresAt: 1000 + 10 * 86400
},
true,
1000
);
assertEqual(noResetDisplay.count, "0", "No reset count stays visible");
assertEqual(
noResetDisplay.suffix,
null,
"No reset count has no expiry suffix"
);
assertEqual(
noResetDisplay.expiresAt,
null,
"No reset count has no expiry timestamp"
);
const resetDisplay = UsageFormat.buildResetCreditDisplay(
{
availableResetCount: 1,
nextResetExpiresAt: 1000 + 10 * 86400 + 5 * 3600
},
true,
1000
);
assertEqual(resetDisplay.count, "1", "Available reset count is formatted");
if (!resetDisplay.suffix || !/\(~10d5h\)$/.test(resetDisplay.suffix)) {
throw new Error(`Expected expiry suffix with countdown, got ${resetDisplay.suffix}`);
}
assertEqual(
resetDisplay.expiresAt,
1000 + 10 * 86400 + 5 * 3600,
"Available reset keeps expiry timestamp"
);
assertEqual(
UsageFormat.formatRelativeTime(1000, 1000),
"just now",
Expand Down
11 changes: 10 additions & 1 deletion tests/test_chatgpt_usage.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,14 @@ def test_named_model_specific_limits_are_included(self) -> None:
},
},
},
"rateLimitResetCredits": {"availableCount": 2, "credits": []},
"rateLimitResetCredits": {
"availableCount": 2,
"credits": [
{"status": "available", "expiresAt": 1791079502},
{"status": "available", "expiresAt": 1789000000},
{"status": "redeemed", "expiresAt": 1788000000},
],
},
}

snapshot = normalise_rate_limits(result, now=123)
Expand All @@ -100,6 +107,7 @@ 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)

def test_unnamed_internal_bucket_is_ignored(self) -> None:
result = {
Expand Down Expand Up @@ -137,6 +145,7 @@ def test_legacy_single_bucket_and_missing_five_hour_window(self) -> None:
self.assertEqual(len(snapshot["limits"][0]["windows"]), 1)
self.assertEqual(snapshot["limits"][0]["windows"][0]["remainingPercent"], 61)
self.assertEqual(snapshot["credits"]["availableResetCount"], 0)
self.assertIsNone(snapshot["credits"]["nextResetExpiresAt"])

def test_optional_five_hour_window_when_canonical_api_exposes_it(self) -> None:
result = {
Expand Down
43 changes: 43 additions & 0 deletions usage-format.js
Original file line number Diff line number Diff line change
Expand Up @@ -520,6 +520,47 @@ function formatTimestamp(epochSeconds, use24Hour) {
return dateTime.format(`%x ${timeFormat}`);
}

function formatExpiryCountdown(expiresAt, nowSeconds = null) {
const expiry = Number(expiresAt);
const current = nowSeconds === null
? GLib.get_real_time() / 1000000
: Number(nowSeconds);
if (
!Number.isFinite(expiry) || expiry <= 0 ||
!Number.isFinite(current)
) {
return null;
}

const remainingSeconds = Math.max(0, Math.floor(expiry - current));
const days = Math.floor(remainingSeconds / 86400);
const hours = Math.floor((remainingSeconds % 86400) / 3600);
return days > 0 ? `~${days}d${hours}h` : `~${hours}h`;
}

function buildResetCreditDisplay(credits, use24Hour, nowSeconds = null) {
const count = credits
? formatWholeNumber(credits.availableResetCount)
: "unavailable";
const availableCount = Number(credits && credits.availableResetCount);
if (!Number.isFinite(availableCount) || availableCount <= 0) {
return { count, suffix: null, expiresAt: null };
}

const expiresAt = Number(credits.nextResetExpiresAt);
const timestamp = formatTimestamp(expiresAt, use24Hour);
if (timestamp === "unknown") {
return { count, suffix: null, expiresAt: null };
}

const countdown = formatExpiryCountdown(expiresAt, nowSeconds);
return {
count,
suffix: countdown ? `${timestamp} (${countdown})` : timestamp,
expiresAt
};
}

function formatLocalDate(epochSeconds) {
const seconds = Number(epochSeconds);
if (!Number.isFinite(seconds) || seconds <= 0) return null;
Expand Down Expand Up @@ -601,6 +642,8 @@ module.exports = {
formatActivityBucketTooltip,
formatAccessibleTooltip,
formatTimestamp,
formatExpiryCountdown,
buildResetCreditDisplay,
formatLocalDate,
formatChatGptVersionDate,
formatRelativeTime,
Expand Down