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.
This commit is contained in:
AgentSeal 2026-04-18 05:21:09 -07:00
parent ce94786185
commit 6358100d3d
2 changed files with 69 additions and 28 deletions

View file

@ -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) {

View file

@ -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;