diff --git a/shell/plugins/model-usage/Main.qml b/shell/plugins/model-usage/Main.qml index 625759ba..223c6fe4 100644 --- a/shell/plugins/model-usage/Main.qml +++ b/shell/plugins/model-usage/Main.qml @@ -27,18 +27,33 @@ Item { property var providers: [claudeProvider, codexProvider] - // A subscription earns a place in the panel by being both switched on in - // settings and actually present on this machine — nobody wants a Codex tab - // full of zeroes on a box that has never run Codex. + // A subscription earns a place in the bar and the panel by being switched on + // in settings and having actually produced numbers — locally or on a synced + // device. Presence on disk is not enough: a box that installed Codex and + // never ran it would get a tab full of zeroes. With nothing to show, the + // whole module collapses out of the bar rather than sitting there dimmed. property var enabledProviders: { var rev = syncRevision var running = syncRunning var result = [] - if (claudeProvider.enabled && claudeProvider.installed) result.push(displayProvider(claudeProvider)) - if (codexProvider.enabled && codexProvider.installed) result.push(displayProvider(codexProvider)) + if (claudeProvider.enabled) { + var claude = displayProvider(claudeProvider) + if (providerHasData(claude)) result.push(claude) + } + if (codexProvider.enabled) { + var codex = displayProvider(codexProvider) + if (providerHasData(codex)) result.push(codex) + } return result } + // All-time, not today: a quiet day is not the same as an absent provider. + function providerHasData(p) { + return numberValue(p.totalPrompts) > 0 || numberValue(p.totalSessions) > 0 + || numberValue(p.activeDays) > 0 || Number(p.rateLimitPercent) >= 0 + || Number(p.secondaryRateLimitPercent) >= 0 + } + property bool refreshing: claudeProvider.refreshing || codexProvider.refreshing || syncRunning property double aggregateUpdatedAtMs: aggregateData && aggregateData.updatedAtMs ? Number(aggregateData.updatedAtMs) : 0 property double lastRefreshedAtMs: Math.max(aggregateUpdatedAtMs, claudeProvider.lastRefreshedAtMs || 0, codexProvider.lastRefreshedAtMs || 0) @@ -523,6 +538,16 @@ Item { scheduleSync() } + // Opening the panel wants the numbers that go stale on the wire, not another + // walk over every transcript on disk. Forcing a whole refresh would do both, + // and re-opening the panel would then rescan the lot each time. + function refreshLimits() { + for (var i = 0; i < providers.length; i++) { + var p = providers[i] + if (p.enabled && typeof p.refreshLimits === "function") p.refreshLimits() + } + } + function formatTokenCount(n) { if (n === undefined || n === null) return "0" if (n >= 1e9) return (n / 1e9).toFixed(1) + "B" diff --git a/shell/plugins/model-usage/Panel.qml b/shell/plugins/model-usage/Panel.qml index 1458cd17..c9646730 100644 --- a/shell/plugins/model-usage/Panel.qml +++ b/shell/plugins/model-usage/Panel.qml @@ -19,10 +19,16 @@ Panel { readonly property string fontFamily: bar ? bar.fontFamily : Style.font.family readonly property var providers: usage.enabledProviders - property int providerIndex: 0 - readonly property var provider: providers.length > 0 - ? providers[Math.max(0, Math.min(providerIndex, providers.length - 1))] - : null + // The selection follows the provider, not the slot it happens to sit in: a + // provider whose first scan lands while the panel is open would otherwise + // shift the list underneath you and swap out what you were reading. + property string selectedProviderId: "" + readonly property int providerIndex: { + for (var i = 0; i < providers.length; i++) + if (providers[i].providerId === selectedProviderId) return i + return 0 + } + readonly property var provider: providers.length > 0 ? providers[providerIndex] : null property bool cursorActive: false @@ -39,11 +45,9 @@ Panel { function alpha(c, a) { return Qt.rgba(c.r, c.g, c.b, a) } function selectProvider(index) { - if (providers.length === 0) { - providerIndex = 0 - return - } - providerIndex = ((index % providers.length) + providers.length) % providers.length + if (providers.length === 0) return + var wrapped = ((index % providers.length) + providers.length) % providers.length + selectedProviderId = providers[wrapped].providerId } function refreshNow() { @@ -317,16 +321,20 @@ Panel { return "" } + // Nothing to report, nothing in the bar: Bar.qml collapses a slot whose item + // is invisible, so the icon appears the moment the first scan finds usage and + // stays away entirely on a machine that has never run either CLI. + visible: providers.length > 0 implicitWidth: button.implicitWidth implicitHeight: button.implicitHeight - onProvidersChanged: if (providerIndex >= providers.length) selectProvider(0) onProviderIndexChanged: if (panelFlick) panelFlick.contentY = 0 onOpenedChanged: if (opened) { cursorActive = false nowMs = Date.now() if (panelFlick) panelFlick.contentY = 0 usage.refreshAll() + usage.refreshLimits() Qt.callLater(function() { keyCatcher.forceActiveFocus() }) } @@ -361,7 +369,6 @@ Panel { bar: root.bar text: "󱚣" active: root.alarming - dimmed: root.providers.length === 0 onPressed: function(buttonCode) { if (buttonCode === Qt.RightButton) root.refreshNow() else if (buttonCode === Qt.MiddleButton) root.selectProvider(root.providerIndex + 1) @@ -441,7 +448,7 @@ Panel { visible: root.providers.length === 0 width: parent.width topPadding: Style.space(24) - text: "No AI coding subscriptions found.\nClaude Code and Codex show up here once installed." + text: "No AI coding subscriptions found.\nClaude Code and Codex show up here once you've used them." color: root.dim font.family: root.fontFamily font.pixelSize: Style.font.body diff --git a/shell/plugins/model-usage/README.md b/shell/plugins/model-usage/README.md index e4d0401c..3d587682 100644 --- a/shell/plugins/model-usage/README.md +++ b/shell/plugins/model-usage/README.md @@ -22,9 +22,11 @@ adapter per subscription. tokens per model with the bar behind each row showing its share of the heaviest one. Hover for the input / output / cache split. -A subscription appears only when it is both enabled in settings and actually -present on this machine (state directory or CLI). With one installed there is -no switch row at all; with none, the panel says so and the bar icon dims. +A subscription appears only when it is enabled in settings and has actually +recorded usage — on this machine or on a synced one. With one such provider +there is no switch row at all; with none, the module leaves the bar entirely +rather than sitting there with nothing to say. A CLI installed mid-session +shows up at the next refresh, so nothing polls the disk waiting for it. ## Providers diff --git a/shell/plugins/model-usage/providers/Claude.qml b/shell/plugins/model-usage/providers/Claude.qml index 7ef19dc3..07eb1f22 100644 --- a/shell/plugins/model-usage/providers/Claude.qml +++ b/shell/plugins/model-usage/providers/Claude.qml @@ -38,9 +38,6 @@ Item { property string tierLabel: "" property string authHelpText: "Run `claude auth login` to restore authoritative usage." property bool hasLocalStats: true - // Optimistic until the probe answers, so a present provider never blinks - // out of the panel on startup. - property bool installed: true property bool hasProjectStats: false property string oauthAccessToken: "" @@ -50,8 +47,14 @@ Item { property string rateLimitTier: "" property bool hasAuthoritativeRateLimit: false + property bool probeInFlight: false property double lastProbeAtMs: 0 property int probeMinIntervalMs: 15 * 60 * 1000 + // Opening the panel asks for fresh limits, so a forced probe skips the + // background interval — but not so freely that flicking the panel open and + // shut turns into a request per flick. + property int probeForcedMinIntervalMs: 15 * 1000 + property int probeRetryMs: 30 * 1000 property bool projectScanRerunForce: false property var providerSettings: ({}) @@ -104,17 +107,6 @@ Item { } } - // Claude Code is "here" if its state directory or CLI exists. Cheap enough - // to re-run on every refresh, so installing it mid-session shows up. - Process { - id: presenceProbe - running: true - command: ["bash", "-c", "[[ -d \"$HOME/.claude\" ]] || command -v claude >/dev/null"] - onExited: function (exitCode) { - root.installed = exitCode === 0; - } - } - Process { id: projectScanner running: false @@ -146,6 +138,23 @@ Item { onTriggered: root.probeRateLimits(false) } + // The first probe fires seconds after login, often before DHCP has handed + // out a route, and comes back as a transport failure rather than an answer + // from Anthropic. Try again shortly instead of showing "limits unavailable" + // until the next background poll a quarter of an hour later. + Timer { + id: probeRetry + interval: root.probeRetryMs + repeat: false + // Straight to the probe: a retry that answered to the same throttle + // that spaces out ordinary polls would never get off the ground. + onTriggered: if (root.enabled && root.oauthAccessToken && !root.oauthTokenExpired()) root.probeOAuthUsage() + } + + // Credentials load whether or not the panel wants this provider, so a + // switched-off Claude must not keep knocking on a downed network forever. + onEnabledChanged: if (!enabled) probeRetry.stop() + function localDateString() { const now = new Date(); const y = now.getFullYear(); @@ -278,7 +287,10 @@ Item { if (root.oauthAccessToken && !root.oauthTokenExpired()) { if (root.usageStatusText === "Waiting for auth") root.clearUsageStatus(); - root.probeRateLimits(false); + // A fresh token just wiped the limits it replaced, so don't let + // the old token's throttle keep them blank for the next quarter + // of an hour. + root.probeRateLimits(tokenChanged); } else if (!root.oauthAccessToken) { root.usageStatusText = "Waiting for auth"; root.clearAuthoritativeRateLimits(); @@ -425,7 +437,13 @@ Item { } function probeOAuthUsage() { + // One probe at a time. The retry timer and a forced panel-open refresh + // can otherwise both be in flight, and the slower answer wins. + if (root.probeInFlight) + return; + root.probeInFlight = true; root.refreshing = true; + root.lastProbeAtMs = Date.now(); const xhr = new XMLHttpRequest(); xhr.open("GET", "https://api.anthropic.com/api/oauth/usage"); xhr.setRequestHeader("Authorization", "Bearer " + root.oauthAccessToken); @@ -434,6 +452,7 @@ Item { xhr.onreadystatechange = function () { if (xhr.readyState !== XMLHttpRequest.DONE) return; + root.probeInFlight = false; if (xhr.status >= 200 && xhr.status < 300) { try { @@ -442,6 +461,7 @@ Item { const sessionBucket = root.oauthUsageBucket(payload, "five_hour"); if (root.applyAuthoritativeRateLimits(weeklyBucket?.utilization, weeklyBucket?.resets_at, sessionBucket?.utilization, sessionBucket?.resets_at, "")) { + probeRetry.stop(); root.clearUsageStatus(); root.finishRefresh(); return; @@ -456,10 +476,20 @@ Item { console.warn("model-usage/claude", "OAuth usage probe unavailable (status " + xhr.status + ")" + (body ? " body=" + body : "")); if (!root.hasAuthoritativeRateLimit) { root.usageStatusText = "Claude limits unavailable"; - root.authHelpText = xhr.status === 429 + root.authHelpText = xhr.status === 0 + ? "Couldn't reach Anthropic's usage endpoint. Retrying shortly. Local Claude Code stats are still shown." + : xhr.status === 429 ? "Anthropic's usage endpoint is rate limiting checks right now" + (retryAfter ? " (retry after " + retryAfter + "s)" : "") + ". Local Claude Code stats are still shown." : "Anthropic's usage endpoint returned status " + xhr.status + ". Local Claude Code stats are still shown."; } + // Status 0 is a transport failure — no route, no DNS, no server + // reached. Nothing to be a good citizen about, so keep knocking. + // Any real answer, including 429, disarms the retry: a server that + // replied is a server we should stop pestering. + if (xhr.status === 0) + probeRetry.restart(); + else + probeRetry.stop(); root.finishRefresh(); }; xhr.send(); @@ -482,8 +512,6 @@ Item { function refresh(force) { root.refreshing = true; - if (!presenceProbe.running) - presenceProbe.running = true; statsFile.reload(); historyFile.reload(); credentialsFile.reload(); @@ -493,6 +521,12 @@ Item { root.probeRateLimits(force === true); } + // The cheap half of a refresh: Anthropic's numbers without the disk walk. + function refreshLimits() { + if (root.oauthAccessToken && root.authMode === "oauth" && !root.oauthTokenExpired()) + root.probeRateLimits(true); + } + function formatResetTime(isoTimestamp) { if (!isoTimestamp) return ""; @@ -524,12 +558,11 @@ Item { return; } - const nowMs = Date.now(); - if (force !== true && root.lastProbeAtMs > 0 && (nowMs - root.lastProbeAtMs) < root.probeMinIntervalMs) { + const minIntervalMs = force === true ? root.probeForcedMinIntervalMs : root.probeMinIntervalMs; + if (root.lastProbeAtMs > 0 && (Date.now() - root.lastProbeAtMs) < minIntervalMs) { root.finishRefresh(); return; } - root.lastProbeAtMs = nowMs; root.probeOAuthUsage(); } diff --git a/shell/plugins/model-usage/providers/Codex.qml b/shell/plugins/model-usage/providers/Codex.qml index f3070df4..1656ce90 100644 --- a/shell/plugins/model-usage/providers/Codex.qml +++ b/shell/plugins/model-usage/providers/Codex.qml @@ -35,9 +35,6 @@ Item { property string tierLabel: "" property string usageStatusText: "" - // Optimistic until the probe answers, so a present provider never blinks - // out of the panel on startup. - property bool installed: true property string authHelpText: "Run `codex login` to authenticate." property bool hasLocalStats: true @@ -46,17 +43,6 @@ Item { readonly property string scannerPath: String(Qt.resolvedUrl("../scripts/codex_usage_scanner.py")).replace("file://", "") - // Codex is "here" if its state directory, its session store, or the CLI - // exists. Re-runs on refresh so installing it mid-session shows up. - Process { - id: presenceProbe - running: true - command: ["bash", "-c", "[[ -d \"$HOME/.codex\" ]] || [[ -d \"$HOME/.pi/agent/sessions\" ]] || command -v codex >/dev/null"] - onExited: function (exitCode) { - root.installed = exitCode === 0 - } - } - Process { id: usageScanner command: ["python3", root.scannerPath] @@ -89,14 +75,16 @@ Item { } function refresh(force) { - if (!presenceProbe.running) - presenceProbe.running = true if (usageScanner.running) return root.refreshing = true usageScanner.running = true } + // Codex reports limits and local stats from the same scanner run, and that + // run has no expensive mode to skip, so there is nothing cheaper to do. + function refreshLimits() { refresh() } + function parseScannerOutput(output) { const raw = String(output || "").trim() if (raw === "")