Add Cursor period meters to model-usage - #6591
Conversation
Surface Cursor Models / Other Models from state.vscdb + GetCurrentPeriodUsage, with panel icons, shared LIMITS reset, and hidden empty charts. Co-authored-by: Cursor <cursoragent@cursor.com>
Cover missing token, mocked GetCurrentPeriodUsage success, and HTTP 401 auth failure alongside the existing missing-DB and plan-mapping cases. Co-authored-by: Cursor <cursoragent@cursor.com>
There was a problem hiding this comment.
Pull request overview
Adds Cursor subscription meters to the model-usage plugin using Cursor’s local credentials and usage API.
Changes:
- Adds Cursor scanning, provider integration, branding, and tests.
- Adds shared reset headers and hides unavailable charts.
- Documents and enables Cursor by default.
Tip
If you aren't ready for review, convert to a draft PR.
Click "Convert to draft" or run gh pr ready --undo.
Click "Ready for review" or run gh pr ready to reengage.
Reviewed changes
Copilot reviewed 7 out of 9 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
test/shell.d/model-usage-cursor-scanner-test.sh |
Tests Cursor scanning and errors. |
shell/plugins/model-usage/scripts/cursor_usage_scanner.py |
Reads credentials and fetches usage. |
shell/plugins/model-usage/README.md |
Documents Cursor support. |
shell/plugins/model-usage/providers/Cursor.qml |
Implements the Cursor provider. |
shell/plugins/model-usage/Panel.qml |
Adds Cursor UI behavior and reset handling. |
shell/plugins/model-usage/manifest.json |
Enables and describes Cursor. |
shell/plugins/model-usage/Main.qml |
Registers Cursor with aggregation. |
shell/plugins/model-usage/assets/cursor.svg |
Adds dark-background branding. |
shell/plugins/model-usage/assets/cursor-light.svg |
Adds light-background branding. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| readonly property bool hasDayChart: !!provider | ||
| && Array.isArray(provider.recentDays) | ||
| && provider.recentDays.length > 0 |
Gate day/model chart sections on hasLocalStats, and report false from the Cursor scanner/provider so aggregateSnapshots' synthetic recentDays rows do not show an all-zero TOKENS BY DAY chart. Co-authored-by: Rene Limberger <rlimberger@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 7 out of 9 changed files in this pull request and generated no new comments.
Suppressed comments (4)
shell/plugins/model-usage/scripts/cursor_usage_scanner.py:78
- The comment claims
mode=roprevents any writes including WAL sidecars; however, SQLite can still create/require WAL-related sidecar files (e.g.,-shm) depending on the DB’s journal mode and existing WAL state. If the goal is to guarantee zero filesystem writes, consider using SQLite’simmutable=1URI parameter (or adjust the comment to avoid overstating the guarantee).
# Read-only URI so we never write, including WAL sidecars.
uri = state_db.resolve().as_uri() + "?mode=ro"
conn = sqlite3.connect(uri, uri=True, timeout=2)
shell/plugins/model-usage/scripts/cursor_usage_scanner.py:140
- When
billingCycleEndcan’t be parsed, this returns the raw input string, which may not be ISO-8601. Downstream QML date parsing can then produce an invalid date and break/blank the reset countdown. Prefer returning an empty string (or a known-safe ISO string) when parsing fails, and rely onusageStatusText/authHelpTextfor diagnostics.
def parse_billing_cycle_end(value):
ms = to_epoch_ms(value)
if ms is None:
text = str(value or "").strip()
return text
return datetime.fromtimestamp(ms / 1000.0, timezone.utc).isoformat()
shell/plugins/model-usage/scripts/cursor_usage_scanner.py:115
- The two thresholds (
> 1e12and> 1e10) currently do the same thing, which makes the intent unclear. Consider collapsing to a single threshold (with a brief comment) to distinguish ms vs seconds and avoid confusion for future maintenance.
if isinstance(value, (int, float)):
ms = float(value)
if ms > 1e12:
return int(ms)
if ms > 1e10:
return int(ms)
return int(ms * 1000.0)
shell/plugins/model-usage/Panel.qml:510
- The
limitsHeaderResetid is very close to theroot.limitsHeaderResetproperty name, which makes it easy to misread or accidentally reference the wrong one when editing. Renaming the Text id to something likelimitsHeaderResetTextwould reduce ambiguity.
Text {
id: limitsHeaderReset
visible: text !== ""
text: root.limitsHeaderReset
Keep SQLite open mode=ro with an accurate WAL note, collapse epoch ms/seconds detection, clear unparseable billingCycleEnd values, and rename the LIMITS header Text id so it no longer shadows the property. Co-authored-by: Rene Limberger <rlimberger@users.noreply.github.com>
Assert millisecond vs second detection and that unparseable billingCycleEnd values become an empty resetAt string. Co-authored-by: Rene Limberger <rlimberger@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 7 out of 9 changed files in this pull request and generated no new comments.
Suppressed comments (1)
shell/plugins/model-usage/providers/Cursor.qml:88
- This fixed five-minute timer bypasses the plugin's
refreshIntervalSecsetting and runs in addition to the scheduler inMain.qml:104-110. Consequently Cursor makes extra API requests (and toggles the widget's refreshing state) every five minutes even when the user configured a longer interval. The initial/enabled refresh is already covered byonEnabledChanged, so this duplicate timer should be removed or driven exclusively by the central scheduler.
Timer {
interval: 5 * 60 * 1000
running: root.enabled
repeat: true
triggeredOnStart: true
onTriggered: root.refresh()
}
Defer to Main.qml's refreshIntervalSec scheduler so Cursor does not hit GetCurrentPeriodUsage every five minutes on top of the plugin poll. Co-authored-by: Rene Limberger <rlimberger@users.noreply.github.com>
Automate signed-out hide, Claude vs Cursor LIMITS reset hoist, shared billing-cycle header text, and syncMode chart suppression. Co-authored-by: Rene Limberger <rlimberger@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 8 out of 10 changed files in this pull request and generated no new comments.
Suppressed comments (1)
shell/plugins/model-usage/Panel.qml:162
- This still renders an empty seven-row day chart whenever
recentDaysis a synthesized array of zero-value rows.aggregateSnapshots()always creates seven such rows, and the Codex scanner does the same even without recent usage, so providers with only limits or older activity contradict this PR's empty-chart hiding behavior. Determine visibility from actual positive day totals (and update the test that currently expects zero rows to count as history), rather than array length alone.
readonly property bool hasDayChart: !!provider
&& provider.hasLocalStats !== false
&& Array.isArray(provider.recentDays)
&& provider.recentDays.length > 0
Require a positive weekPeak for TOKENS BY DAY so syncMode and Codex seven-row zero paddings do not count as chart history. Co-authored-by: Rene Limberger <rlimberger@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 8 out of 10 changed files in this pull request and generated no new comments.
Suppressed comments (2)
shell/plugins/model-usage/scripts/cursor_usage_scanner.py:214
json.loads()can successfully returnnull, an array, or another non-object value. In that case this.get()raisesAttributeError, so the scanner emits no JSON and the panel retains stale limits instead of showing the intended “limits unavailable” state. Validate thatpayloadis a dictionary before readingplanUsage.
def build_rate_limits(credentials, payload):
plan = payload.get("planUsage")
shell/plugins/model-usage/providers/Cursor.qml:45
- Runtime QML paths must use the session-provided
OMARCHY_PATH; resolving a component-relative file URL bypasses that canonical checkout path. Build the scanner path fromQuickshell.env("OMARCHY_PATH")as required for Omarchy runtime code.
readonly property string scannerPath: String(Qt.resolvedUrl("../scripts/cursor_usage_scanner.py")).replace("file://", "")
Reject non-object GetCurrentPeriodUsage payloads so the panel shows limits unavailable instead of crashing, and resolve the scanner script through OMARCHY_PATH. Co-authored-by: Rene Limberger <rlimberger@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 11 out of 13 changed files in this pull request and generated no new comments.
Suppressed comments (1)
shell/plugins/model-usage/scripts/cursor_usage_scanner.py:142
- An out-of-range numeric
billingCycleEnd(for example, a timestamp in unexpected microseconds) passesto_epoch_ms()but makesdatetime.fromtimestamp()raiseValueError, so malformed API data crashes the scanner instead of following this function's stated empty-reset fallback. Catch conversion/range errors around both steps so the provider can still return valid JSON.
ms = to_epoch_ms(value)
if ms is None:
# Keep resetAt empty on unparseable input so QML date parsing stays valid.
return ""
return datetime.fromtimestamp(ms / 1000.0, timezone.utc).isoformat()
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 11 out of 13 changed files in this pull request and generated no new comments.
Suppressed comments (1)
shell/plugins/model-usage/scripts/cursor_usage_scanner.py:142
- A numeric but non-finite or out-of-range
billingCycleEndcan raise into_epoch_ms()ordatetime.fromtimestamp()(for example, JSON1e309becomes infinity). That terminates the scanner without JSON, so the provider can retain stale meters. Treat these conversion failures like other unparseable reset values.
def parse_billing_cycle_end(value):
ms = to_epoch_ms(value)
if ms is None:
# Keep resetAt empty on unparseable input so QML date parsing stays valid.
return ""
try:
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 11 out of 13 changed files in this pull request and generated no new comments.
Suppressed comments (1)
.github/instructions/bash.instructions.md:5
- This repository-wide Bash review policy is unrelated to the Cursor provider and panel work described by this PR, and it changes future automated-review behavior without being disclosed in the summary. Please move this developer-tooling policy to a separate, documented PR so the feature change remains atomic.
# Omarchy bash review rules
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 8 out of 10 changed files in this pull request and generated no new comments.
Suppressed comments (1)
shell/plugins/model-usage/scripts/cursor_usage_scanner.py:165
- Reject non-finite percentages here.
float("NaN")andfloat("Infinity")succeed, after whichjson.dumpsemits bareNaN/Infinity; QML's strictJSON.parsethen rejects the scanner's entire result instead of showing an unavailable meter. Validate withmath.isfinitebefore returning the fraction.
def percent_to_fraction(value):
if value is None:
return -1
try:
return float(value) / 100.0
except (TypeError, ValueError):
return -1
Catch fromtimestamp OverflowError/OSError/ValueError so malformed numeric API dates return an empty resetAt instead of crashing. Co-authored-by: Rene Limberger <rlimberger@users.noreply.github.com>
Treat inf/NaN and other conversion failures like unparseable reset dates so the scanner still emits JSON instead of crashing. Co-authored-by: Rene Limberger <rlimberger@users.noreply.github.com>
36ad860 to
08ddf40
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 8 out of 10 changed files in this pull request and generated no new comments.
Suppressed comments (1)
shell/plugins/model-usage/scripts/cursor_usage_scanner.py:165
- Reject non-finite percentage values before serializing the scanner result.
float("NaN"),float("Infinity"), and sufficiently large JSON numbers are accepted here, after whichjson.dumpsemitsNaN/Infinity; those are not valid JSON forJSON.parseinCursor.qml, so one malformed API value makes the entire refresh fail instead of leaving that meter unset.
def percent_to_fraction(value):
if value is None:
return -1
try:
return float(value) / 100.0
except (TypeError, ValueError):
return -1
Keep NaN/Infinity out of scanner JSON so QML JSON.parse does not fail the whole refresh when the API returns malformed percents. Co-authored-by: Rene Limberger <rlimberger@users.noreply.github.com>
Keep Cursor meters-only via hasLocalStats; do not require a positive weekPeak, which hid Codex's synthesized seven-day rows. Co-authored-by: Cursor <cursoragent@cursor.com>
|
Restored Claude/Codex TOKENS BY DAY visibility for all-zero weeks (Codex synthesizes seven day rows). Cursor charts stay hidden via |

Summary
state.vscdb(read-only) and mapsGetCurrentPeriodUsageplan pools onto Cursor Models / Other Models meters (+ tier)resetAt, and hide empty day/model chart sectionsTest plan
bash test/shell.d/model-usage-cursor-scanner-test.sh— missing DB, missing token, read-only plan mapping, mockedGetCurrentPeriodUsagesuccess, HTTP 401 auth failurebash test/shell.d/model-usage-claude-scanner-test.shbash test/shell.d/model-usage-codex-scanner-test.sh