codeburn/extensions/gnome-shell/codeburn@agentseal.org/extension.js
AgentSeal 6358100d3d 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.
2026-04-18 05:21:09 -07:00

591 lines
23 KiB
JavaScript

/*
* CodeBurn GNOME Shell extension.
*
* Ships a native GNOME panel button whose popup mirrors the macOS app pixel for
* pixel, built out of raw St widgets instead of the stock PopupMenu text-item
* list. Horizontal agent tabs, a branded header, hero cost typography, inline
* bar-chart activity rows, and a pill-styled footer -- same primitives GNOME's
* own Quick Settings panel uses.
*
* Data source: `codeburn status --format menubar-json --period <p> --provider <pr>`,
* polled every 60 seconds. Period, provider and currency are per-session state.
*/
import GObject from 'gi://GObject';
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';
import * as PopupMenu from 'resource:///org/gnome/shell/ui/popupMenu.js';
import {Extension} from 'resource:///org/gnome/shell/extensions/extension.js';
const REFRESH_INTERVAL_SECONDS = 60;
const TOP_ACTIVITIES = 5;
const CODEBURN_BIN = 'codeburn';
const PERIODS = [
{id: 'today', label: 'Today'},
{id: 'week', label: '7 Days'},
{id: '30days', label: '30 Days'},
{id: 'month', label: 'Month'},
{id: 'all', label: 'All'},
];
const PROVIDERS = [
{id: 'all', label: 'All'},
{id: 'claude', label: 'Claude'},
{id: 'codex', label: 'Codex'},
{id: 'cursor', label: 'Cursor'},
{id: 'copilot', label: 'Copilot'},
{id: 'opencode', label: 'OpenCode'},
{id: 'pi', label: 'Pi'},
];
const CURRENCIES = [
{code: 'USD', symbol: '$'},
{code: 'EUR', symbol: '€'},
{code: 'GBP', symbol: '£'},
{code: 'CAD', symbol: 'C$'},
{code: 'AUD', symbol: 'A$'},
{code: 'JPY', symbol: '¥'},
{code: 'INR', symbol: '₹'},
{code: 'BRL', symbol: 'R$'},
{code: 'CHF', symbol: 'CHF '},
{code: 'SEK', symbol: 'kr '},
{code: 'SGD', symbol: 'S$'},
{code: 'HKD', symbol: 'HK$'},
{code: 'KRW', symbol: '₩'},
{code: 'MXN', symbol: 'MX$'},
{code: 'ZAR', symbol: 'R '},
{code: 'DKK', symbol: 'kr '},
{code: 'CNY', symbol: '¥'},
];
const CodeburnIndicator = GObject.registerClass(
class CodeburnIndicator extends PanelMenu.Button {
_init() {
super._init(0.0, 'CodeBurn');
this._period = 'today';
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._updateFxRate();
this._themeSettings = new Gio.Settings({schema_id: 'org.gnome.desktop.interface'});
this._themeSignal = this._themeSettings.connect('changed::color-scheme', () => this._applyThemeClass());
this._applyThemeClass();
// Panel button: flame + live cost label
const panel = new St.BoxLayout({style_class: 'panel-status-menu-box codeburn-panel'});
this._flame = new St.Label({
text: '🔥',
y_align: Clutter.ActorAlign.CENTER,
style_class: 'codeburn-flame',
});
this._label = new St.Label({
text: '…',
y_align: Clutter.ActorAlign.CENTER,
style_class: 'codeburn-label',
});
panel.add_child(this._flame);
panel.add_child(this._label);
this.add_child(panel);
// Replace the default PopupMenu item list with a single container that we
// paint with custom St widgets so the layout can be horizontal tabs + hero
// + bar charts + footer, not a vertical text list.
this.menu.box.add_style_class_name('codeburn-menu');
this._popupHost = new PopupMenu.PopupBaseMenuItem({reactive: false, can_focus: false});
this._popupHost.add_style_class_name('codeburn-host');
this.menu.addMenuItem(this._popupHost);
this._root = new St.BoxLayout({
vertical: true,
style_class: 'codeburn-root',
x_expand: true,
});
this._popupHost.add_child(this._root);
this._buildBrandHeader();
this._buildAgentTabs();
this._buildHero();
this._buildPeriodTabs();
this._buildActivitySection();
this._buildFindingsSection();
this._buildFooter();
this._refresh();
this._timeout = GLib.timeout_add_seconds(
GLib.PRIORITY_DEFAULT,
REFRESH_INTERVAL_SECONDS,
() => {
this._refresh();
return GLib.SOURCE_CONTINUE;
},
);
}
_buildBrandHeader() {
const header = new St.BoxLayout({vertical: true, style_class: 'codeburn-brand-header'});
const title = new St.BoxLayout({style_class: 'codeburn-brand-row'});
const titleLeft = new St.Label({text: 'Code', style_class: 'codeburn-brand-primary'});
const titleRight = new St.Label({text: 'Burn', style_class: 'codeburn-brand-accent'});
title.add_child(titleLeft);
title.add_child(titleRight);
const subhead = new St.Label({text: 'AI Coding Cost Tracker', style_class: 'codeburn-brand-subhead'});
header.add_child(title);
header.add_child(subhead);
this._root.add_child(header);
}
_buildAgentTabs() {
// 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 === 0) {
this._agentTabs = new Map();
return;
}
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();
for (const p of tabs) {
const btn = new St.Button({
label: p.label,
style_class: 'codeburn-tab',
can_focus: true,
x_expand: true,
});
btn.connect('clicked', () => {
this._provider = p.id;
this._updateAgentTabStyle();
this._refresh();
});
row.add_child(btn);
this._agentTabs.set(p.id, btn);
}
this._root.add_child(row);
this._updateAgentTabStyle();
}
/// Scan the home directory for provider session stores so the agent tab row
/// can only offer providers the user actually runs. Checks file/dir existence
/// only; the CLI still owns real "has usable data" semantics.
_detectAvailableProviders() {
const home = GLib.get_home_dir();
const xdgData = GLib.getenv('XDG_DATA_HOME') || `${home}/.local/share`;
const paths = {
claude: `${home}/.claude/projects`,
codex: `${home}/.codex/sessions`,
cursor: `${home}/.config/Cursor/User/globalStorage/state.vscdb`,
copilot: `${home}/.copilot/session-state`,
opencode: `${xdgData}/opencode`,
pi: `${home}/.pi/agent/sessions`,
};
const out = [];
for (const [id, path] of Object.entries(paths)) {
const file = Gio.File.new_for_path(path);
if (file.query_exists(null)) out.push(id);
}
return out;
}
_updateAgentTabStyle() {
for (const [id, btn] of this._agentTabs) {
if (id === this._provider) btn.add_style_class_name('codeburn-tab-active');
else btn.remove_style_class_name('codeburn-tab-active');
}
}
_buildHero() {
const hero = new St.BoxLayout({vertical: true, style_class: 'codeburn-hero'});
const topLine = new St.BoxLayout({style_class: 'codeburn-hero-top'});
this._heroDot = new St.Widget({style_class: 'codeburn-hero-dot'});
this._heroLabel = new St.Label({text: 'Loading…', style_class: 'codeburn-hero-label'});
topLine.add_child(this._heroDot);
topLine.add_child(this._heroLabel);
this._heroAmount = new St.Label({text: '$0.00', style_class: 'codeburn-hero-amount'});
this._heroMeta = new St.Label({text: '', style_class: 'codeburn-hero-meta'});
hero.add_child(topLine);
hero.add_child(this._heroAmount);
hero.add_child(this._heroMeta);
this._root.add_child(hero);
}
_buildPeriodTabs() {
const row = new St.BoxLayout({style_class: 'codeburn-tab-row codeburn-period-row'});
this._periodTabs = new Map();
for (const p of PERIODS) {
const btn = new St.Button({
label: p.label,
style_class: 'codeburn-period',
can_focus: true,
x_expand: true,
});
btn.connect('clicked', () => {
this._period = p.id;
this._updatePeriodTabStyle();
this._refresh();
});
row.add_child(btn);
this._periodTabs.set(p.id, btn);
}
this._root.add_child(row);
this._updatePeriodTabStyle();
}
_updatePeriodTabStyle() {
for (const [id, btn] of this._periodTabs) {
if (id === this._period) btn.add_style_class_name('codeburn-period-active');
else btn.remove_style_class_name('codeburn-period-active');
}
}
_buildActivitySection() {
const section = new St.BoxLayout({vertical: true, style_class: 'codeburn-activity'});
const title = new St.Label({text: 'Activity', style_class: 'codeburn-section-title'});
section.add_child(title);
this._activityRows = new St.BoxLayout({vertical: true, style_class: 'codeburn-activity-rows'});
section.add_child(this._activityRows);
this._root.add_child(section);
}
_buildFindingsSection() {
this._findingsBtn = new St.Button({style_class: 'codeburn-findings', visible: false});
const box = new St.BoxLayout({style_class: 'codeburn-findings-inner'});
this._findingsCount = new St.Label({text: '', style_class: 'codeburn-findings-count'});
this._findingsSavings = new St.Label({text: '', style_class: 'codeburn-findings-savings'});
box.add_child(this._findingsCount);
box.add_child(this._findingsSavings);
this._findingsBtn.set_child(box);
this._findingsBtn.connect('clicked', () => this._spawnTerminal([CODEBURN_BIN, 'optimize']));
this._root.add_child(this._findingsBtn);
}
_buildFooter() {
const footer = new St.BoxLayout({style_class: 'codeburn-footer'});
this._currencyBtn = new St.Button({
label: `${this._currency.code}`,
style_class: 'codeburn-footer-btn codeburn-currency-btn',
can_focus: true,
});
this._currencyBtn.connect('clicked', () => this._cycleCurrency());
footer.add_child(this._currencyBtn);
const refreshBtn = new St.Button({
label: 'Refresh',
style_class: 'codeburn-footer-btn',
can_focus: true,
x_expand: true,
});
refreshBtn.connect('clicked', () => this._refresh());
footer.add_child(refreshBtn);
const reportBtn = new St.Button({
label: 'Open Full Report',
style_class: 'codeburn-footer-btn codeburn-footer-cta',
can_focus: true,
x_expand: true,
});
reportBtn.connect('clicked', () => this._spawnTerminal([CODEBURN_BIN, 'report', '--period', this._period, '--provider', this._provider]));
footer.add_child(reportBtn);
this._root.add_child(footer);
this._updatedLabel = new St.Label({text: '', style_class: 'codeburn-updated'});
this._root.add_child(this._updatedLabel);
}
_cycleCurrency() {
const idx = CURRENCIES.findIndex(c => c.code === this._currency.code);
const next = CURRENCIES[(idx + 1) % CURRENCIES.length];
this._setCurrency(next.code);
}
_loadCurrency() {
const configPath = GLib.build_filenamev([GLib.get_home_dir(), '.config', 'codeburn', 'config.json']);
try {
const [ok, contents] = GLib.file_get_contents(configPath);
if (ok) {
const config = JSON.parse(new TextDecoder().decode(contents));
if (config.currency?.code) {
const known = CURRENCIES.find(c => c.code === config.currency.code);
if (known) return known;
return {code: config.currency.code, symbol: config.currency.symbol || `${config.currency.code} `};
}
}
} catch (_) {
// fall through to default
}
return CURRENCIES[0];
}
_setCurrency(code) {
let proc;
try {
proc = Gio.Subprocess.new(
[CODEBURN_BIN, 'currency', code],
Gio.SubprocessFlags.STDOUT_PIPE | Gio.SubprocessFlags.STDERR_PIPE,
);
} catch (_) {
return;
}
proc.wait_async(null, () => {
this._currency = this._loadCurrency();
this._currencyBtn.set_label(`${this._currency.code}`);
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() {
// 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;
try {
proc = Gio.Subprocess.new(
[CODEBURN_BIN, 'status', '--format', 'menubar-json', '--period', this._period, '--provider', this._provider],
Gio.SubprocessFlags.STDOUT_PIPE | Gio.SubprocessFlags.STDERR_PIPE,
);
} catch (_) {
this._loading = false;
this._renderError('codeburn CLI not found on PATH');
return;
}
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);
if (!ok) {
this._renderError(`codeburn failed: ${stderr || 'unknown error'}`);
return;
}
if (!stdout) {
this._renderError('codeburn returned no output');
return;
}
this._payload = JSON.parse(stdout);
this._render(this._payload);
} catch (e) {
this._renderError(`parse error: ${e.message}`);
}
});
}
_render(payload) {
const current = payload?.current ?? {};
const cost = Number(current.cost ?? 0);
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._fxRate));
const calls = Number(current.calls ?? 0);
const sessions = Number(current.sessions ?? 0);
this._heroMeta.set_text(`${calls.toLocaleString()} calls ${sessions} sessions`);
this._renderActivity(Array.isArray(current.topActivities) ? current.topActivities : []);
this._renderFindings(payload?.optimize ?? {});
const updated = payload?.generated ? formatTime(new Date(payload.generated)) : '';
this._updatedLabel.set_text(updated ? `Updated ${updated}` : '');
}
_renderActivity(activities) {
this._activityRows.destroy_all_children();
if (!activities.length) {
const empty = new St.Label({text: 'No activity for this period', style_class: 'codeburn-empty'});
this._activityRows.add_child(empty);
return;
}
const maxCost = activities.reduce((m, a) => Math.max(m, Number(a.cost) || 0), 0) || 1;
for (const a of activities.slice(0, TOP_ACTIVITIES)) {
this._activityRows.add_child(this._buildActivityRow(a, maxCost));
}
}
_buildActivityRow(activity, maxCost) {
const row = new St.BoxLayout({vertical: true, style_class: 'codeburn-activity-row'});
const topLine = new St.BoxLayout({style_class: 'codeburn-activity-top'});
const name = new St.Label({
text: activity.name,
style_class: 'codeburn-activity-name',
x_expand: true,
});
const cost = new St.Label({
text: formatCost(activity.cost, this._currency, this._fxRate),
style_class: 'codeburn-activity-cost',
});
const turns = new St.Label({
text: `${Number(activity.turns) || 0}t`,
style_class: 'codeburn-activity-turns',
});
topLine.add_child(name);
topLine.add_child(cost);
topLine.add_child(turns);
if (activity.oneShotRate !== null && activity.oneShotRate !== undefined) {
const oneShot = new St.Label({
text: `${Math.round(Number(activity.oneShotRate) * 100)}%`,
style_class: 'codeburn-activity-oneshot',
});
topLine.add_child(oneShot);
}
row.add_child(topLine);
// 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'});
fill.set_width(Math.round(240 * filledPct));
track.add_child(fill);
row.add_child(track);
return row;
}
_renderFindings(optimize) {
const count = Number(optimize?.findingCount ?? 0);
if (count === 0) {
this._findingsBtn.hide();
return;
}
const savings = Number(optimize?.savingsUSD ?? 0);
this._findingsCount.set_text(`${count} optimize findings`);
this._findingsSavings.set_text(`save ~${formatCost(savings, this._currency, this._fxRate)}`);
this._findingsBtn.show();
}
_renderError(message) {
this._label.set_text('!');
this._heroLabel.set_text(message);
this._heroAmount.set_text('');
this._heroMeta.set_text('');
this._activityRows.destroy_all_children();
this._findingsBtn.hide();
}
_spawnTerminal(argv) {
const command = `${argv.join(' ')}; echo; read -n 1 -s -r -p 'Press any key to close...'`;
try {
Gio.Subprocess.new(
['gnome-terminal', '--', 'bash', '-lc', command],
Gio.SubprocessFlags.NONE,
);
} catch (e) {
log(`codeburn: terminal spawn error: ${e.message}`);
}
this.menu.close();
}
_applyThemeClass() {
const scheme = this._themeSettings.get_string('color-scheme');
const isDark = scheme === 'prefer-dark';
this.add_style_class_name(isDark ? 'codeburn-dark' : 'codeburn-light');
this.remove_style_class_name(isDark ? 'codeburn-light' : 'codeburn-dark');
}
destroy() {
if (this._timeout) {
GLib.source_remove(this._timeout);
this._timeout = null;
}
if (this._themeSettings && this._themeSignal) {
this._themeSettings.disconnect(this._themeSignal);
this._themeSignal = null;
this._themeSettings = null;
}
super.destroy();
}
});
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) {
return `${symbol}${(n / 1000).toFixed(abs >= 10000 ? 0 : 1)}k`;
}
return `${symbol}${n.toFixed(2)}`;
}
function formatTime(date) {
if (!date || Number.isNaN(date.getTime())) return '';
const now = new Date();
const diffSec = Math.floor((now.getTime() - date.getTime()) / 1000);
if (diffSec < 60) return 'just now';
if (diffSec < 3600) return `${Math.floor(diffSec / 60)}m ago`;
if (diffSec < 86400) return `${Math.floor(diffSec / 3600)}h ago`;
return date.toLocaleDateString();
}
export default class CodeburnExtension extends Extension {
enable() {
this._indicator = new CodeburnIndicator();
Main.panel.addToStatusArea('codeburn', this._indicator);
}
disable() {
this._indicator?.destroy();
this._indicator = null;
}
}