From 6358100d3dc62076347c4e1be22fbdc57074255e Mon Sep 17 00:00:00 2001 From: AgentSeal Date: Sat, 18 Apr 2026 05:21:09 -0700 Subject: [PATCH] fix(extensions): period switching and currency conversion on GNOME Period bug: _refresh() returned early when a previous fetch was still in flight, so clicking a new period tab while the initial load was running silently dropped the second click. Swap the loading-guard for a generation counter: every refresh increments a counter, and only the callback whose generation matches the latest value applies the result. Older responses are dropped, newer ones win. Currency bug: codeburn status --format menubar-json is a raw USD payload; the CLI does not convert it. The popup was only changing the symbol prefix, not the values. Fetch the USD->target rate from Frankfurter via Soup and apply it in formatCost(). Rate is cached per-session so tab switches don't hit the network; on currency change we kick off a fresh fetch (or reuse the cached rate) and re-render with the new multiplier. Other small fixes: * Show a single-provider tab row (when exactly one provider is installed) instead of hiding it, so the user still sees which agent the numbers are for. * Activity bar chart track is now a BoxLayout so the fill width takes effect; previously every bar rendered as 100% because the St.Widget track stretched its only child. * Findings CTA no longer runs labels together ("findingssave"). Adds 8px spacing between count and savings. --- .../codeburn@agentseal.org/extension.js | 95 +++++++++++++------ .../codeburn@agentseal.org/stylesheet.css | 2 +- 2 files changed, 69 insertions(+), 28 deletions(-) diff --git a/extensions/gnome-shell/codeburn@agentseal.org/extension.js b/extensions/gnome-shell/codeburn@agentseal.org/extension.js index 23faefc..ac953dc 100644 --- a/extensions/gnome-shell/codeburn@agentseal.org/extension.js +++ b/extensions/gnome-shell/codeburn@agentseal.org/extension.js @@ -16,6 +16,7 @@ import St from 'gi://St'; import Gio from 'gi://Gio'; import GLib from 'gi://GLib'; import Clutter from 'gi://Clutter'; +import Soup from 'gi://Soup?version=3.0'; import * as Main from 'resource:///org/gnome/shell/ui/main.js'; import * as PanelMenu from 'resource:///org/gnome/shell/ui/panelMenu.js'; @@ -70,12 +71,20 @@ class CodeburnIndicator extends PanelMenu.Button { super._init(0.0, 'CodeBurn'); this._period = 'today'; - this._provider = 'all'; + this._availableProviders = this._detectAvailableProviders(); + // If only one provider is installed, use it directly so the popup doesn't + // pretend to be filtering when there's nothing to filter. Otherwise start + // on All so the user sees aggregate data. + this._provider = this._availableProviders.length === 1 ? this._availableProviders[0] : 'all'; this._currency = this._loadCurrency(); + this._fxRate = 1; + this._fxCache = {USD: 1}; + this._soupSession = new Soup.Session(); this._loading = false; + this._refreshGen = 0; this._timeout = null; this._payload = null; - this._availableProviders = this._detectAvailableProviders(); + this._updateFxRate(); this._themeSettings = new Gio.Settings({schema_id: 'org.gnome.desktop.interface'}); this._themeSignal = this._themeSettings.connect('changed::color-scheme', () => this._applyThemeClass()); @@ -145,20 +154,18 @@ class CodeburnIndicator extends PanelMenu.Button { } _buildAgentTabs() { - // Only show the tab row when at least two providers have data on this - // machine. A single provider (or none) means there's nothing to filter, - // so we skip the row entirely and leave this._provider = 'all'. + // Hide the tab row only when nothing is installed. A single provider + // gets shown as a lone tab so the user still sees which agent the + // numbers come from (no "mystery data" state). Multiple providers + // get All + each detected tab in our preferred order. const detected = this._availableProviders; - if (detected.length < 2) { + if (detected.length === 0) { this._agentTabs = new Map(); return; } - // Build tab list: "All" first, then every detected provider in our - // preferred order. - const tabs = [PROVIDERS[0]]; // 'All' - for (const p of PROVIDERS.slice(1)) { - if (detected.includes(p.id)) tabs.push(p); - } + const tabs = detected.length === 1 + ? PROVIDERS.filter(p => p.id === detected[0]) + : [PROVIDERS[0], ...PROVIDERS.slice(1).filter(p => detected.includes(p.id))]; const row = new St.BoxLayout({style_class: 'codeburn-tab-row'}); this._agentTabs = new Map(); @@ -347,12 +354,46 @@ class CodeburnIndicator extends PanelMenu.Button { proc.wait_async(null, () => { this._currency = this._loadCurrency(); this._currencyBtn.set_label(`${this._currency.code} ⌄`); - this._refresh(); + this._updateFxRate(); + }); + } + + /// menubar-json payloads stay in USD regardless of the user's configured + /// currency, so we apply the FX conversion client-side. Frankfurter serves + /// the same ECB rates the CLI uses, cached per-session so a tab switch or + /// a period switch doesn't hit the network again. + _updateFxRate() { + const code = this._currency?.code || 'USD'; + if (this._fxCache[code] !== undefined) { + this._fxRate = this._fxCache[code]; + if (this._payload) this._render(this._payload); + return; + } + const url = `https://api.frankfurter.app/latest?from=USD&to=${code}`; + const msg = Soup.Message.new('GET', url); + this._soupSession.send_and_read_async(msg, GLib.PRIORITY_DEFAULT, null, (session, result) => { + try { + const bytes = session.send_and_read_finish(result); + if (!bytes) return; + const json = JSON.parse(new TextDecoder().decode(bytes.get_data())); + const rate = json?.rates?.[code]; + if (typeof rate === 'number' && rate > 0) { + this._fxCache[code] = rate; + this._fxRate = rate; + if (this._payload) this._render(this._payload); + } + } catch (_) { + // FX fetch failed; leave rate at previous value. + } }); } _refresh() { - if (this._loading) return; + // Generation counter: a click while a previous fetch is in flight still + // fires a new process; the older response is dropped instead of racing + // to overwrite the new one. Solves the "first click does nothing" bug + // where the initial load was still running when the user tapped a tab. + const gen = ++this._refreshGen; this._loading = true; let proc; @@ -368,6 +409,7 @@ class CodeburnIndicator extends PanelMenu.Button { } proc.communicate_utf8_async(null, null, (p, result) => { + if (gen !== this._refreshGen) return; this._loading = false; try { const [ok, stdout, stderr] = p.communicate_utf8_finish(result); @@ -391,9 +433,9 @@ class CodeburnIndicator extends PanelMenu.Button { const current = payload?.current ?? {}; const cost = Number(current.cost ?? 0); - this._label.set_text(formatCost(cost, this._currency)); + this._label.set_text(formatCost(cost, this._currency, this._fxRate)); this._heroLabel.set_text(current.label || ''); - this._heroAmount.set_text(formatCost(cost, this._currency)); + this._heroAmount.set_text(formatCost(cost, this._currency, this._fxRate)); const calls = Number(current.calls ?? 0); const sessions = Number(current.sessions ?? 0); @@ -429,7 +471,7 @@ class CodeburnIndicator extends PanelMenu.Button { x_expand: true, }); const cost = new St.Label({ - text: formatCost(activity.cost, this._currency), + text: formatCost(activity.cost, this._currency, this._fxRate), style_class: 'codeburn-activity-cost', }); const turns = new St.Label({ @@ -448,14 +490,13 @@ class CodeburnIndicator extends PanelMenu.Button { } row.add_child(topLine); - // Bar chart: track + filled portion. Width is proportional to this activity's - // share of the top cost. St widgets let us just set widths in pixels. - const track = new St.Widget({style_class: 'codeburn-bar-track', y_expand: false}); + // Bar chart: proportional to this activity's share of the top cost. The + // track is a BoxLayout so the fill child lays out horizontally instead of + // stretching to fill the parent (which made every bar look 100%). + const track = new St.BoxLayout({style_class: 'codeburn-bar-track'}); const filledPct = Math.max(0.02, Math.min(1, Number(activity.cost) / maxCost)); - const fill = new St.Widget({ - style_class: 'codeburn-bar-fill', - width: Math.round(240 * filledPct), - }); + const fill = new St.Widget({style_class: 'codeburn-bar-fill'}); + fill.set_width(Math.round(240 * filledPct)); track.add_child(fill); row.add_child(track); @@ -470,7 +511,7 @@ class CodeburnIndicator extends PanelMenu.Button { } const savings = Number(optimize?.savingsUSD ?? 0); this._findingsCount.set_text(`⚠ ${count} optimize findings`); - this._findingsSavings.set_text(`save ~${formatCost(savings, this._currency)}`); + this._findingsSavings.set_text(`save ~${formatCost(savings, this._currency, this._fxRate)}`); this._findingsBtn.show(); } @@ -517,8 +558,8 @@ class CodeburnIndicator extends PanelMenu.Button { } }); -function formatCost(value, currency) { - const n = Number(value) || 0; +function formatCost(value, currency, rate = 1) { + const n = (Number(value) || 0) * (Number(rate) || 1); const abs = Math.abs(n); const symbol = currency?.symbol || '$'; if (abs >= 1000) { diff --git a/extensions/gnome-shell/codeburn@agentseal.org/stylesheet.css b/extensions/gnome-shell/codeburn@agentseal.org/stylesheet.css index a0c1d56..8554d3e 100644 --- a/extensions/gnome-shell/codeburn@agentseal.org/stylesheet.css +++ b/extensions/gnome-shell/codeburn@agentseal.org/stylesheet.css @@ -206,7 +206,7 @@ background: rgba(255, 140, 66, 0.2); } .codeburn-findings-inner { - spacing: 0; + spacing: 8px; } .codeburn-findings-count { font-size: 11.5px;