diff --git a/gnome/dataClient.js b/gnome/dataClient.js
index 87d602d..4d0056b 100644
--- a/gnome/dataClient.js
+++ b/gnome/dataClient.js
@@ -1,17 +1,33 @@
import GLib from 'gi://GLib';
import Gio from 'gi://Gio';
-const TIMEOUT_SECONDS = 45;
+const TIMEOUT_SECONDS = 15;
const SAFE_ARG_RE = /^[A-Za-z0-9 ._/\-]+$/;
-const ADDITIONAL_PATH_ENTRIES = ['/usr/local/bin', `${GLib.get_home_dir()}/.local/bin`, `${GLib.get_home_dir()}/.npm-global/bin`];
+
+function buildAdditionalPaths() {
+ const home = GLib.get_home_dir();
+ return [
+ '/usr/local/bin',
+ `${home}/.local/bin`,
+ `${home}/.npm-global/bin`,
+ `${home}/.volta/bin`,
+ `${home}/.bun/bin`,
+ `${home}/.cargo/bin`,
+ `${home}/.asdf/shims`,
+ `${home}/.local/share/fnm/aliases/default/bin`,
+ `${home}/.local/share/pnpm`,
+ ];
+}
export class DataClient {
_cache = new Map();
_inFlight = null;
_codeburnPath;
+ _augmentedPath;
constructor(codeburnPath) {
this._codeburnPath = codeburnPath || '';
+ this._augmentedPath = this._buildAugmentedPath();
}
setCodeburnPath(path) {
@@ -69,39 +85,43 @@ export class DataClient {
return args;
}
- _augmentedEnv() {
+ _buildAugmentedPath() {
const currentPath = GLib.getenv('PATH') || '/usr/bin:/bin';
const parts = currentPath.split(':');
- for (const extra of ADDITIONAL_PATH_ENTRIES) {
+ for (const extra of buildAdditionalPaths()) {
if (!parts.includes(extra))
parts.push(extra);
}
- return [`PATH=${parts.join(':')}`];
+ return parts.join(':');
}
_spawn(period, provider, cancellable) {
return new Promise((resolve, reject) => {
const argv = this._buildArgv(period, provider);
+ let settled = false;
+
+ const settle = (fn, value) => {
+ if (settled) return;
+ settled = true;
+ fn(value);
+ };
let proc;
try {
const launcher = Gio.SubprocessLauncher.new(
Gio.SubprocessFlags.STDOUT_PIPE | Gio.SubprocessFlags.STDERR_PIPE
);
- for (const entry of this._augmentedEnv()) {
- const [key, val] = entry.split('=', 2);
- launcher.setenv(key, val, true);
- }
+ launcher.setenv('PATH', this._augmentedPath, true);
proc = launcher.spawnv(argv);
} catch (e) {
- reject(new Error(`CLI not found: ${e.message}`));
+ settle(reject, new Error(`CLI not found: ${e.message}`));
return;
}
let timeoutId = GLib.timeout_add_seconds(GLib.PRIORITY_DEFAULT, TIMEOUT_SECONDS, () => {
timeoutId = 0;
proc.force_exit();
- reject(new Error('CLI timeout'));
+ settle(reject, new Error('CLI timeout'));
return GLib.SOURCE_REMOVE;
});
@@ -116,19 +136,19 @@ export class DataClient {
if (!_proc.get_successful()) {
const msg = stderr?.trim() || 'CLI exited with error';
- reject(new Error(msg));
+ settle(reject, new Error(msg));
return;
}
if (!stdout || stdout.trim().length === 0) {
- reject(new Error('CLI returned empty output'));
+ settle(reject, new Error('CLI returned empty output'));
return;
}
const payload = JSON.parse(stdout);
- resolve(payload);
+ settle(resolve, payload);
} catch (e) {
- reject(e);
+ settle(reject, e);
}
});
});
diff --git a/gnome/extension.js b/gnome/extension.js
index 031f7aa..fba94fd 100644
--- a/gnome/extension.js
+++ b/gnome/extension.js
@@ -1,4 +1,5 @@
import { Extension } from 'resource:///org/gnome/shell/extensions/extension.js';
+import * as Main from 'resource:///org/gnome/shell/ui/main.js';
import { CodeBurnIndicator } from './indicator.js';
export default class CodeBurnExtension extends Extension {
@@ -6,6 +7,7 @@ export default class CodeBurnExtension extends Extension {
enable() {
this._indicator = new CodeBurnIndicator(this);
+ Main.panel.addToStatusArea('codeburn-indicator', this._indicator);
}
disable() {
diff --git a/gnome/indicator.js b/gnome/indicator.js
index 9fae16e..64199c1 100644
--- a/gnome/indicator.js
+++ b/gnome/indicator.js
@@ -1,13 +1,18 @@
import GObject from 'gi://GObject';
-import GLib from 'gi://GLib';
-import Gio from 'gi://Gio';
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 PanelMenu from 'resource:///org/gnome/shell/ui/panelMenu.js';
import * as PopupMenu from 'resource:///org/gnome/shell/ui/popupMenu.js';
-import * as Main from 'resource:///org/gnome/shell/ui/main.js';
import { DataClient } from './dataClient.js';
+const CACHE_TTL_MS = 300_000;
+const TOP_ACTIVITIES = 10;
+const CHART_HEIGHT = 52;
+const BAR_TRACK_WIDTH = 240;
+
const PERIODS = [
{ id: 'today', label: 'Today' },
{ id: 'week', label: '7 Days' },
@@ -16,174 +21,486 @@ const PERIODS = [
{ id: 'all', label: 'All' },
];
-function formatCost(cost) {
- if (cost == null || isNaN(cost)) return '$?';
- return `$${cost.toFixed(2)}`;
+const INSIGHTS = [
+ { id: 'activity', label: 'Activity' },
+ { id: 'trend', label: 'Trend' },
+ { id: 'forecast', label: 'Forecast' },
+ { id: 'pulse', label: 'Pulse' },
+ { id: 'stats', label: 'Stats' },
+];
+
+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' },
+ { id: 'droid', label: 'Droid' },
+ { id: 'gemini', label: 'Gemini' },
+ { id: 'kilo-code', label: 'Kilo Code' },
+ { id: 'kiro', label: 'Kiro' },
+ { id: 'roo-code', label: 'Roo Code' },
+];
+
+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 PROVIDER_PATHS = {
+ claude: '.claude/projects',
+ codex: '.codex/sessions',
+ cursor: '.config/Cursor/User/globalStorage/state.vscdb',
+ copilot: '.copilot/session-state',
+ pi: '.pi/agent/sessions',
+};
+
+function formatCost(value, currency, rate = 1, exact = false) {
+ const n = (Number(value) || 0) * (Number(rate) || 1);
+ const abs = Math.abs(n);
+ const symbol = currency?.symbol || '$';
+ if (!exact && abs >= 1000) return `${symbol}${(n / 1000).toFixed(abs >= 10000 ? 0 : 1)}k`;
+ const parts = n.toFixed(2).split('.');
+ parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ',');
+ return `${symbol}${parts.join('.')}`;
}
-function formatPercent(val) {
- if (val == null || isNaN(val)) return '—';
- return `${(val * 100).toFixed(0)}%`;
+function formatTokensCompact(n) {
+ const v = Number(n) || 0;
+ if (v >= 1_000_000_000) return `${(v / 1_000_000_000).toFixed(1)}B`;
+ if (v >= 1_000_000) return `${(v / 1_000_000).toFixed(1)}M`;
+ if (v >= 1000) return `${(v / 1000).toFixed(1)}k`;
+ return String(v);
}
-function formatPercentDirect(val) {
- if (val == null || isNaN(val)) return '—';
- return `${val.toFixed(1)}%`;
+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 const CodeBurnIndicator = GObject.registerClass(
class CodeBurnIndicator extends PanelMenu.Button {
- _extension;
- _settings;
- _dataClient;
- _refreshSourceId = 0;
- _panelLabel;
- _panelIcon;
- _currentPeriod = 'today';
- _currentProvider = 'all';
- _lastPayload = null;
- _isStale = false;
- _settingsChangedIds = [];
-
_init(extension) {
- super._init(0.5, 'CodeBurn Monitor', false);
+ super._init(0.0, 'CodeBurn');
+
this._extension = extension;
this._settings = extension.getSettings();
this._dataClient = new DataClient(this._settings.get_string('codeburn-path'));
- this._currentPeriod = this._settings.get_string('default-period') || 'today';
+ this._settingsChangedIds = [];
- this._buildPanelButton();
- this._buildMenu();
- Main.panel.addToStatusArea('codeburn-indicator', this);
+ this._period = this._settings.get_string('default-period') || 'today';
+ this._insight = 'activity';
+ this._availableProviders = this._detectProviders();
+ this._provider = this._availableProviders.length === 1 ? this._availableProviders[0] : 'all';
+
+ this._currency = this._loadCurrency();
+ this._exactCosts = this._settings.get_boolean('show-exact-costs');
+ this._fxRate = 1;
+ this._fxCache = { USD: 1 };
+ this._soupSession = new Soup.Session();
+ this._payload = null;
+ this._payloadCache = new Map();
+ this._inFlightKeys = new Set();
+ this._refreshGen = 0;
+ this._refreshSourceId = 0;
+ this._chartSummaryText = '';
+ this._destroyed = false;
+
+ this._themeSettings = new Gio.Settings({ schema_id: 'org.gnome.desktop.interface' });
+ this._themeSignal = this._themeSettings.connect('changed::color-scheme', () => this._applyThemeClass());
+ this._applyThemeClass();
+ this._updateFxRate();
+ this._buildPanelButton();
+ this._buildPopup();
this._connectSettings();
this._startRefreshLoop();
this._refresh();
}
- _buildPanelButton() {
- const box = new St.BoxLayout({ style_class: 'panel-button' });
+ // -- Panel button --
- this._panelIcon = new St.Icon({
- icon_name: 'codeburn-symbolic',
- style_class: 'system-status-icon',
+ _buildPanelButton() {
+ const box = new St.BoxLayout({ style_class: 'panel-status-menu-box codeburn-panel' });
+ this._panelIcon = new St.Label({
+ text: '🔥',
+ y_align: Clutter.ActorAlign.CENTER,
+ style_class: 'codeburn-flame',
});
-
this._panelLabel = new St.Label({
- text: '$—',
- y_expand: true,
+ text: '...',
y_align: Clutter.ActorAlign.CENTER,
- style_class: 'codeburn-panel-label',
+ style_class: 'codeburn-label',
});
-
box.add_child(this._panelIcon);
box.add_child(this._panelLabel);
this._panelLabel.visible = !this._settings.get_boolean('compact-mode');
-
this.add_child(box);
}
- _buildMenu() {
- this.menu.removeAll();
+ // -- Popup --
- this._heroItem = this._addMenuItem('Loading...');
- this._heroItem.label.style_class = 'codeburn-hero-label';
+ _buildPopup() {
+ try {
+ 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._scrollView = new St.ScrollView({
+ style_class: 'codeburn-scroll',
+ hscrollbar_policy: St.PolicyType.NEVER,
+ vscrollbar_policy: St.PolicyType.AUTOMATIC,
+ y_expand: true,
+ });
+ this._scrollContent = new St.BoxLayout({ vertical: true, x_expand: true });
+ this._scrollView.set_child(this._scrollContent);
+ this._root.add_child(this._scrollView);
+
+ this._buildAgentTabs();
+ this._buildHero();
+ this._buildPeriodTabs();
+ this._buildInsightPills();
+ this._buildTokenChart();
+ this._buildLoadingIndicator();
+ this._buildContentArea();
+ this._buildBudgetAlert();
+ this._buildFindingsSection();
+ this._buildFooter();
+ } catch (e) {
+ log(`CodeBurn: popup build error: ${e.message}\n${e.stack}`);
+ }
+ }
+
+ _buildBrandHeader() {
+ const header = new St.BoxLayout({ vertical: true, style_class: 'codeburn-brand-header' });
+ const title = new St.BoxLayout({ style_class: 'codeburn-brand-row' });
+ title.add_child(new St.Label({ text: 'Code', style_class: 'codeburn-brand-primary' }));
+ title.add_child(new St.Label({ text: 'Burn', style_class: 'codeburn-brand-accent' }));
+ header.add_child(title);
+ header.add_child(new St.Label({ text: 'AI Coding Cost Tracker', style_class: 'codeburn-brand-subhead' }));
+ this._root.add_child(header);
+ }
+
+ _buildAgentTabs() {
+ const detected = this._availableProviders;
+ this._agentTabs = new Map();
+ this._agentTabRow = null;
+ if (detected.length === 0) return;
+
+ const disabled = this._getDisabledProviders();
+ const tabs = detected.length === 1
+ ? PROVIDERS.filter(p => p.id === detected[0])
+ : [PROVIDERS[0], ...PROVIDERS.slice(1).filter(p => detected.includes(p.id) && !disabled.has(p.id))];
+
+ if (tabs.length === 1) {
+ const badge = new St.Label({ text: tabs[0].label, style_class: 'codeburn-agent-badge' });
+ const row = new St.BoxLayout({ style_class: 'codeburn-tab-row' });
+ row.add_child(badge);
+ this._scrollContent.add_child(row);
+ return;
+ }
+
+ const useScroll = tabs.length > 5;
+ this._agentTabRow = new St.BoxLayout({ style_class: 'codeburn-tab-row' });
+ for (const p of tabs) {
+ const btn = new St.Button({ label: p.label, style_class: 'codeburn-tab', can_focus: true, x_expand: !useScroll });
+ btn.connect('clicked', () => {
+ this._provider = p.id;
+ this._updateAgentTabStyle();
+ this._refresh();
+ });
+ this._agentTabRow.add_child(btn);
+ this._agentTabs.set(p.id, btn);
+ }
+ if (useScroll) {
+ const agentScroll = new St.ScrollView({
+ style_class: 'codeburn-agent-scroll',
+ hscrollbar_policy: St.PolicyType.AUTOMATIC,
+ vscrollbar_policy: St.PolicyType.NEVER,
+ });
+ agentScroll.set_child(this._agentTabRow);
+ this._scrollContent.add_child(agentScroll);
+ } else {
+ this._scrollContent.add_child(this._agentTabRow);
+ }
+ this._updateAgentTabStyle();
+ }
- this._statsItem = this._addMenuItem('');
+ _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');
+ }
+ }
- this.menu.addMenuItem(new PopupMenu.PopupSeparatorMenuItem());
+ _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._scrollContent.add_child(hero);
+ }
- this._periodSection = new PopupMenu.PopupSubMenuMenuItem('Period: Today');
- this.menu.addMenuItem(this._periodSection);
+ _buildPeriodTabs() {
+ const row = new St.BoxLayout({ style_class: 'codeburn-tab-row codeburn-period-row' });
+ this._periodTabs = new Map();
for (const p of PERIODS) {
- const item = new PopupMenu.PopupMenuItem(p.label);
- item.connect('activate', () => {
- this._currentPeriod = p.id;
- this._periodSection.label.text = `Period: ${p.label}`;
+ 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();
});
- this._periodSection.menu.addMenuItem(item);
+ row.add_child(btn);
+ this._periodTabs.set(p.id, btn);
}
+ this._scrollContent.add_child(row);
+ this._updatePeriodTabStyle();
+ }
- this.menu.addMenuItem(new PopupMenu.PopupSeparatorMenuItem());
+ _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');
+ }
+ }
- this._providerHeader = this._addMenuItem('Providers');
- this._providerHeader.setSensitive(false);
- this._providerItems = [];
+ _buildInsightPills() {
+ const row = new St.BoxLayout({ style_class: 'codeburn-insight-row' });
+ this._insightPills = new Map();
+ for (const i of INSIGHTS) {
+ const btn = new St.Button({ label: i.label, style_class: 'codeburn-insight-pill', can_focus: true, x_expand: true });
+ btn.connect('clicked', () => {
+ this._insight = i.id;
+ this._updateInsightPillStyle();
+ this._renderContent();
+ });
+ row.add_child(btn);
+ this._insightPills.set(i.id, btn);
+ }
+ this._scrollContent.add_child(row);
+ this._updateInsightPillStyle();
+ }
- this.menu.addMenuItem(new PopupMenu.PopupSeparatorMenuItem(''));
- this._providerSeparator = this.menu._getMenuItems().at(-1);
+ _updateInsightPillStyle() {
+ for (const [id, btn] of this._insightPills) {
+ if (id === this._insight) btn.add_style_class_name('codeburn-insight-pill-active');
+ else btn.remove_style_class_name('codeburn-insight-pill-active');
+ }
+ }
- this._activitiesSection = new PopupMenu.PopupSubMenuMenuItem('Top Activities');
- this.menu.addMenuItem(this._activitiesSection);
+ _buildTokenChart() {
+ this._chartContainer = new St.BoxLayout({ vertical: true, style_class: 'codeburn-chart' });
+ const header = new St.BoxLayout({ style_class: 'codeburn-chart-header' });
+ this._chartLabel = new St.Label({ text: 'Tokens', style_class: 'codeburn-chart-label', x_expand: true });
+ this._chartTotal = new St.Label({ text: '', style_class: 'codeburn-chart-total' });
+ header.add_child(this._chartLabel);
+ header.add_child(this._chartTotal);
+ this._chartContainer.add_child(header);
+ this._chartBars = new St.BoxLayout({ style_class: 'codeburn-chart-bars' });
+ this._chartContainer.add_child(this._chartBars);
+ this._scrollContent.add_child(this._chartContainer);
+ }
- this._modelsSection = new PopupMenu.PopupSubMenuMenuItem('Top Models');
- this.menu.addMenuItem(this._modelsSection);
+ _buildContentArea() {
+ this._scrollContent.add_child(new St.Widget({ style_class: 'codeburn-divider' }));
+ this._contentArea = new St.BoxLayout({ vertical: true, style_class: 'codeburn-content' });
+ this._scrollContent.add_child(this._contentArea);
+ }
- this.menu.addMenuItem(new PopupMenu.PopupSeparatorMenuItem());
+ _buildBudgetAlert() {
+ this._budgetLabel = new St.Label({ text: '', style_class: 'codeburn-budget-warning', visible: false });
+ this._scrollContent.add_child(this._budgetLabel);
+ }
- this._cacheItem = this._addMenuItem('Cache Hit: —');
- this._oneShotItem = this._addMenuItem('One-shot Rate: —');
+ _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', 'optimize']));
+ this._scrollContent.add_child(this._findingsBtn);
+ }
- this._budgetItem = this._addMenuItem('');
- this._budgetItem.visible = false;
+ _buildLoadingIndicator() {
+ this._loadingBox = new St.BoxLayout({ vertical: true, style_class: 'codeburn-loading', visible: false, x_expand: true });
+ const widths = [0.85, 0.6, 0.92, 0.5, 0.75, 0.45];
+ for (const w of widths) {
+ const bar = new St.Widget({ style_class: 'codeburn-skeleton-bar', x_expand: false });
+ bar.set_width(Math.round(308 * w));
+ bar.set_height(10);
+ this._loadingBox.add_child(bar);
+ }
+ this._scrollContent.add_child(this._loadingBox);
+ }
- this.menu.addMenuItem(new PopupMenu.PopupSeparatorMenuItem());
+ _showLoading() {
+ if (!this._loadingBox) return;
+ this._loadingBox.visible = true;
+ this._loadingBox.get_children().forEach((bar, i) => {
+ bar.opacity = 255;
+ bar.ease({
+ opacity: 60,
+ duration: 900,
+ delay: i * 120,
+ mode: Clutter.AnimationMode.EASE_IN_OUT_SINE,
+ repeatCount: -1,
+ autoReverse: true,
+ });
+ });
+ }
- const refreshItem = new PopupMenu.PopupMenuItem('Refresh');
- refreshItem.connect('activate', () => this._refresh());
- this.menu.addMenuItem(refreshItem);
+ _hideLoading() {
+ if (!this._loadingBox) return;
+ this._loadingBox.visible = false;
+ this._loadingBox.get_children().forEach(bar => {
+ bar.remove_all_transitions();
+ bar.opacity = 255;
+ });
+ }
- const reportItem = new PopupMenu.PopupMenuItem('Open Full Report');
- reportItem.connect('activate', () => this._openReport());
- this.menu.addMenuItem(reportItem);
+ _buildFooter() {
+ this._currencyPicker = new St.ScrollView({
+ style_class: 'codeburn-currency-picker',
+ visible: false,
+ hscrollbar_policy: St.PolicyType.NEVER,
+ vscrollbar_policy: St.PolicyType.AUTOMATIC,
+ });
+ const pickerList = new St.BoxLayout({ vertical: true, style_class: 'codeburn-currency-list' });
+ for (const c of CURRENCIES) {
+ const item = new St.Button({ label: `${c.symbol} ${c.code}`, style_class: 'codeburn-currency-item', can_focus: true });
+ if (c.code === this._currency.code) item.add_style_class_name('codeburn-currency-item-active');
+ item.connect('clicked', () => {
+ this._setCurrency(c.code);
+ this._currencyPicker.hide();
+ pickerList.get_children().forEach(ch => ch.remove_style_class_name('codeburn-currency-item-active'));
+ item.add_style_class_name('codeburn-currency-item-active');
+ });
+ pickerList.add_child(item);
+ }
+ this._currencyPicker.set_child(pickerList);
+ this._root.add_child(this._currencyPicker);
+
+ 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._toggleCurrencyPicker());
+ footer.add_child(this._currencyBtn);
- const prefsItem = new PopupMenu.PopupMenuItem('Preferences');
- prefsItem.connect('activate', () => {
+ const refreshBtn = new St.Button({ label: 'Refresh', style_class: 'codeburn-footer-btn', can_focus: true, x_expand: true });
+ refreshBtn.connect('clicked', () => this._refresh(true));
+ footer.add_child(refreshBtn);
+
+ const reportBtn = new St.Button({ label: 'Full Report', style_class: 'codeburn-footer-btn codeburn-footer-cta', can_focus: true, x_expand: true });
+ reportBtn.connect('clicked', () => this._spawnTerminal(['codeburn', 'report', '--period', this._period, '--provider', this._provider]));
+ footer.add_child(reportBtn);
+
+ const prefsBtn = new St.Button({ label: '⚙', style_class: 'codeburn-footer-btn codeburn-prefs-btn', can_focus: true });
+ prefsBtn.connect('clicked', () => {
this._extension.openPreferences();
+ this.menu.close();
});
- this.menu.addMenuItem(prefsItem);
- }
+ footer.add_child(prefsBtn);
- _addMenuItem(text) {
- const item = new PopupMenu.PopupMenuItem(text);
- item.setSensitive(false);
- this.menu.addMenuItem(item);
- return item;
+ this._root.add_child(footer);
+ this._updatedLabel = new St.Label({ text: '', style_class: 'codeburn-updated' });
+ this._root.add_child(this._updatedLabel);
}
+ // -- Settings --
+
_connectSettings() {
const watch = (key, cb) => {
const id = this._settings.connect(`changed::${key}`, cb);
this._settingsChangedIds.push(id);
};
-
watch('refresh-interval', () => this._restartRefreshLoop());
- watch('compact-mode', () => this._rebuildPanelButton());
+ watch('compact-mode', () => { this._panelLabel.visible = !this._settings.get_boolean('compact-mode'); });
watch('codeburn-path', () => {
this._dataClient.setCodeburnPath(this._settings.get_string('codeburn-path'));
- this._refresh();
+ this._refresh(true);
});
watch('default-period', () => {
- this._currentPeriod = this._settings.get_string('default-period');
+ this._period = this._settings.get_string('default-period');
+ this._updatePeriodTabStyle();
this._refresh();
});
watch('budget-threshold', () => this._updateBudget());
watch('budget-alert-enabled', () => this._updateBudget());
+ watch('force-dark-mode', () => this._applyThemeClass());
+ watch('show-exact-costs', () => {
+ this._exactCosts = this._settings.get_boolean('show-exact-costs');
+ if (this._payload) this._render(this._payload);
+ });
watch('disabled-providers', () => {
- if (this._lastPayload) {
- this._updatePanel(this._lastPayload);
- this._updateMenu(this._lastPayload);
- }
+ if (this._payload) this._render(this._payload);
});
}
- _rebuildPanelButton() {
- const compact = this._settings.get_boolean('compact-mode');
- this._panelLabel.visible = !compact;
- this._updatePanel(this._lastPayload);
+ _getDisabledProviders() {
+ return new Set(this._settings.get_strv('disabled-providers'));
}
+ // -- Provider detection --
+
+ _detectProviders() {
+ const home = GLib.get_home_dir();
+ const xdgData = GLib.getenv('XDG_DATA_HOME') || `${home}/.local/share`;
+ const checks = Object.fromEntries(
+ Object.entries(PROVIDER_PATHS).map(([id, rel]) => [id, `${home}/${rel}`])
+ );
+ checks.opencode = `${xdgData}/opencode`;
+ const out = [];
+ for (const [id, path] of Object.entries(checks)) {
+ if (Gio.File.new_for_path(path).query_exists(null)) out.push(id);
+ }
+ return out;
+ }
+
+ // -- Refresh loop --
+
_startRefreshLoop() {
const interval = this._settings.get_uint('refresh-interval') || 30;
this._refreshSourceId = GLib.timeout_add_seconds(GLib.PRIORITY_DEFAULT, interval, () => {
@@ -200,184 +517,486 @@ class CodeBurnIndicator extends PanelMenu.Button {
this._startRefreshLoop();
}
- async _refresh() {
- try {
- const payload = await this._dataClient.fetch(this._currentPeriod, this._currentProvider);
- this._lastPayload = payload;
- this._isStale = false;
- this._updatePanel(payload);
- this._updateMenu(payload);
- } catch (e) {
- if (e.message?.includes('cancelled')) return;
- log(`CodeBurn: refresh error: ${e.message}`);
- this._isStale = true;
- if (!this._lastPayload)
- this._showError(e.message);
- else
- this._updatePanel(this._lastPayload);
- }
- }
+ // -- Data fetching with cache --
- _getDisabledProviders() {
- return new Set(this._settings.get_strv('disabled-providers'));
+ _cacheKey() {
+ return `${this._period}|${this._provider}`;
}
- _filterProviders(providers) {
- if (!providers) return { filtered: {}, cost: 0 };
- const disabled = this._getDisabledProviders();
- const filtered = {};
- let cost = 0;
- for (const [name, val] of Object.entries(providers)) {
- if (!disabled.has(name)) {
- filtered[name] = val;
- cost += val;
- }
- }
- return { filtered, cost };
- }
+ async _refresh(force = false) {
+ const key = this._cacheKey();
+ const cached = this._payloadCache.get(key);
+ const cacheAge = cached ? Date.now() - cached.fetchedAt : Infinity;
- _updatePanel(payload) {
- if (!payload) {
- this._panelLabel.text = '$?';
+ if (!force && cached && cacheAge < CACHE_TTL_MS) {
+ this._payload = cached.payload;
+ this._render(this._payload);
return;
}
- const { cost } = this._filterProviders(payload.current?.providers);
- let text = formatCost(cost);
- if (this._isStale)
- text += ' *';
- this._panelLabel.text = text;
+
+ if (this._inFlightKeys.has(key)) return;
+ this._inFlightKeys.add(key);
+ const gen = ++this._refreshGen;
+
+ if (cached) {
+ this._payload = cached.payload;
+ this._render(this._payload);
+ } else {
+ this._showLoading();
+ if (this._contentArea) this._contentArea.opacity = 120;
+ }
+
+ try {
+ const payload = await this._dataClient.fetch(this._period, this._provider);
+ this._inFlightKeys.delete(key);
+ if (this._destroyed || gen !== this._refreshGen) return;
+ this._payloadCache.set(key, { payload, fetchedAt: Date.now() });
+ if (this._cacheKey() === key) {
+ this._payload = payload;
+ this._hideLoading();
+ if (this._contentArea) this._contentArea.opacity = 255;
+ this._render(this._payload);
+ }
+ } catch (e) {
+ this._inFlightKeys.delete(key);
+ if (this._destroyed) return;
+ this._hideLoading();
+ if (this._contentArea) this._contentArea.opacity = 255;
+ if (gen !== this._refreshGen) return;
+ if (e.message?.includes('cancelled')) return;
+ log(`CodeBurn: refresh error: ${e.message}`);
+ if (!this._payload) this._renderError(e.message);
+ }
}
- _updateMenu(payload) {
- if (!payload?.current) return;
- const c = payload.current;
- const { filtered, cost } = this._filterProviders(c.providers);
+ // -- Rendering --
- this._heroItem.label.text = `${formatCost(cost)} ${c.label || this._currentPeriod}`;
- this._statsItem.label.text = `${c.calls ?? 0} calls · ${c.sessions ?? 0} sessions`;
+ _render(payload) {
+ const current = payload?.current ?? {};
+ const cost = Number(current.cost ?? 0);
- this._updateProviders(filtered);
- this._updateActivities(c.topActivities);
- this._updateModels(c.topModels);
+ this._panelLabel.set_text(this._fmt(cost));
+ this._heroLabel.set_text(current.label || '');
+ this._heroAmount.set_text(this._fmt(cost));
- this._cacheItem.label.text = `Cache Hit: ${formatPercentDirect(c.cacheHitPercent)}`;
- this._oneShotItem.label.text = `One-shot Rate: ${c.oneShotRate != null ? formatPercent(c.oneShotRate) : '—'}`;
+ const calls = Number(current.calls ?? 0);
+ const sessions = Number(current.sessions ?? 0);
+ this._heroMeta.set_text(`${calls.toLocaleString()} calls ${sessions} sessions`);
+ this._renderChart(payload?.history?.daily ?? []);
+ this._renderContent();
+ this._renderFindings(payload?.optimize ?? {});
this._updateBudget();
- }
- _updateProviders(providers) {
- for (const item of this._providerItems)
- item.destroy();
- this._providerItems = [];
+ const updated = payload?.generated ? formatTime(new Date(payload.generated)) : '';
+ this._updatedLabel.set_text(updated ? `Updated ${updated}` : '');
+ }
- if (!providers || Object.keys(providers).length === 0) {
- this._providerHeader.visible = false;
- this._providerSeparator.visible = false;
+ _renderChart(daily) {
+ this._chartBars.destroy_all_children();
+ const days = Array.isArray(daily) ? daily.slice(-19) : [];
+ if (days.length === 0) {
+ this._chartContainer.visible = false;
return;
}
+ const inTotals = days.map(d => Number(d?.inputTokens) || 0);
+ const outTotals = days.map(d => Number(d?.outputTokens) || 0);
+ const totals = inTotals.map((v, i) => v + outTotals[i]);
+ let maxTotal = 1;
+ let totalIn = 0;
+ let totalOut = 0;
+ let hasAnyTokens = false;
+ for (let i = 0; i < days.length; i++) {
+ if (totals[i] > maxTotal) maxTotal = totals[i];
+ if (totals[i] > 0) hasAnyTokens = true;
+ totalIn += inTotals[i];
+ totalOut += outTotals[i];
+ }
+ if (!hasAnyTokens) {
+ this._chartContainer.visible = false;
+ return;
+ }
+ this._chartContainer.visible = true;
+ const summaryText = `In: ${formatTokensCompact(totalIn)} Out: ${formatTokensCompact(totalOut)}`;
+ this._chartTotal.set_text(summaryText);
+ this._chartSummaryText = summaryText;
+
+ const chartWidth = 308;
+ const gap = 2;
+ const barW = Math.max(4, Math.floor((chartWidth - gap * (days.length - 1)) / days.length));
+
+ for (let i = 0; i < days.length; i++) {
+ const h = Math.max(2, Math.round((totals[i] / maxTotal) * CHART_HEIGHT));
+ const col = new St.BoxLayout({ vertical: true, style_class: 'codeburn-chart-col', reactive: true });
+ col.set_width(barW);
+ col.set_height(CHART_HEIGHT);
+ const spacer = new St.Widget({ style_class: 'codeburn-chart-spacer' });
+ spacer.set_height(CHART_HEIGHT - h);
+ const bar = new St.Widget({ style_class: 'codeburn-chart-bar' });
+ bar.set_width(barW);
+ bar.set_height(h);
+ col.add_child(spacer);
+ col.add_child(bar);
+
+ const date = days[i]?.date || '';
+ const inTok = formatTokensCompact(inTotals[i]);
+ const outTok = formatTokensCompact(outTotals[i]);
+ const cost = days[i]?.cost != null ? this._fmt(days[i].cost) : '';
+ col.connect('enter-event', () => {
+ this._chartTotal.set_text(`${date} ${inTok}/${outTok} ${cost}`);
+ this._chartTotal.add_style_class_name('codeburn-chart-total-hover');
+ bar.add_style_class_name('codeburn-chart-bar-hover');
+ return Clutter.EVENT_PROPAGATE;
+ });
+ col.connect('leave-event', () => {
+ this._chartTotal.set_text(this._chartSummaryText);
+ this._chartTotal.remove_style_class_name('codeburn-chart-total-hover');
+ bar.remove_style_class_name('codeburn-chart-bar-hover');
+ return Clutter.EVENT_PROPAGATE;
+ });
- this._providerHeader.visible = true;
- this._providerSeparator.visible = true;
-
- const sorted = Object.entries(providers).sort((a, b) => b[1] - a[1]);
- const headerIndex = this.menu._getMenuItems().indexOf(this._providerHeader);
+ this._chartBars.add_child(col);
+ }
+ }
- for (let i = 0; i < sorted.length; i++) {
- const [name, cost] = sorted[i];
- const item = new PopupMenu.PopupMenuItem(` ${name}`);
- item.setSensitive(false);
+ _renderContent() {
+ this._contentArea.destroy_all_children();
+ switch (this._insight) {
+ case 'trend': return this._renderTrendView();
+ case 'forecast': return this._renderForecastView();
+ case 'pulse': return this._renderPulseView();
+ case 'stats': return this._renderStatsView();
+ default: return this._renderActivityView();
+ }
+ }
- const costLabel = new St.Label({
- text: formatCost(cost),
- x_expand: true,
- x_align: Clutter.ActorAlign.END,
- style_class: 'codeburn-provider-cost',
- });
- item.add_child(costLabel);
+ _renderActivityView() {
+ const current = this._payload?.current ?? {};
+ this._contentArea.add_child(this._sectionTitle('Activity'));
+ const actHeader = new St.BoxLayout({ style_class: 'codeburn-table-header' });
+ actHeader.add_child(new St.Label({ text: 'Name', style_class: 'codeburn-th', x_expand: true }));
+ actHeader.add_child(new St.Label({ text: 'Cost', style_class: 'codeburn-th codeburn-th-right codeburn-th-cost' }));
+ actHeader.add_child(new St.Label({ text: 'Turns', style_class: 'codeburn-th codeburn-th-right codeburn-th-turns' }));
+ actHeader.add_child(new St.Label({ text: '1-shot', style_class: 'codeburn-th codeburn-th-right codeburn-th-turns' }));
+ this._contentArea.add_child(actHeader);
+ const rows = new St.BoxLayout({ vertical: true, style_class: 'codeburn-activity-rows' });
+ const activities = Array.isArray(current.topActivities) ? current.topActivities : [];
+ if (!activities.length) {
+ rows.add_child(new St.Label({ text: 'No activity for this period', style_class: 'codeburn-empty' }));
+ } else {
+ const maxCost = activities.reduce((m, a) => Math.max(m, Number(a.cost) || 0), 0) || 1;
+ for (const a of activities.slice(0, TOP_ACTIVITIES)) {
+ rows.add_child(this._buildActivityRow(a, maxCost));
+ }
+ }
+ this._contentArea.add_child(rows);
+
+ const models = Array.isArray(current.topModels) ? current.topModels : [];
+ if (models.length) {
+ this._contentArea.add_child(this._sectionTitle('Models'));
+ const modHeader = new St.BoxLayout({ style_class: 'codeburn-table-header' });
+ modHeader.add_child(new St.Label({ text: 'Model', style_class: 'codeburn-th', x_expand: true }));
+ modHeader.add_child(new St.Label({ text: 'Cost', style_class: 'codeburn-th codeburn-th-right codeburn-th-cost' }));
+ modHeader.add_child(new St.Label({ text: 'Calls', style_class: 'codeburn-th codeburn-th-right codeburn-th-calls' }));
+ this._contentArea.add_child(modHeader);
+ const mrows = new St.BoxLayout({ vertical: true, style_class: 'codeburn-models-rows' });
+ for (const m of models.slice(0, 3)) mrows.add_child(this._buildModelRow(m));
+ this._contentArea.add_child(mrows);
+ }
+ }
- this.menu.addMenuItem(item, headerIndex + 1 + i);
- this._providerItems.push(item);
+ _renderTrendView() {
+ const daily = this._payload?.history?.daily ?? [];
+ if (!daily.length) {
+ this._contentArea.add_child(new St.Label({ text: 'Not enough history yet', style_class: 'codeburn-empty' }));
+ return;
+ }
+ for (const d of daily.slice(-7).reverse()) {
+ const row = new St.BoxLayout({ style_class: 'codeburn-trend-row' });
+ row.add_child(new St.Label({ text: d.date, style_class: 'codeburn-trend-date', x_expand: true }));
+ const costLabel = new St.Label({ text: this._fmt(d.cost), style_class: 'codeburn-trend-cost' });
+ costLabel.clutter_text.x_align = Clutter.ActorAlign.END;
+ row.add_child(costLabel);
+ const callsLabel = new St.Label({ text: `${Number(d.calls).toLocaleString()} calls`, style_class: 'codeburn-trend-calls' });
+ callsLabel.clutter_text.x_align = Clutter.ActorAlign.END;
+ row.add_child(callsLabel);
+ this._contentArea.add_child(row);
}
}
- _updateActivities(activities) {
- this._activitiesSection.menu.removeAll();
- if (!activities || activities.length === 0) {
- this._activitiesSection.visible = false;
+ _renderForecastView() {
+ const daily = this._payload?.history?.daily ?? [];
+ if (daily.length < 3) {
+ this._contentArea.add_child(new St.Label({ text: 'Need at least 3 days of history', style_class: 'codeburn-empty' }));
return;
}
- this._activitiesSection.visible = true;
- for (const act of activities.slice(0, 5)) {
- const item = new PopupMenu.PopupMenuItem(`${act.name} ${formatCost(act.cost)}`);
- item.setSensitive(false);
- this._activitiesSection.menu.addMenuItem(item);
+ const last7 = daily.slice(-7);
+ const avg = last7.reduce((s, d) => s + Number(d.cost || 0), 0) / last7.length;
+ const yesterday = daily.at(-2);
+ const yestCost = Number(yesterday?.cost || 0);
+ const todCost = Number(daily.at(-1)?.cost || 0);
+ const dod = yestCost > 0 ? ((todCost - yestCost) / yestCost) * 100 : 0;
+ const now = new Date();
+ const dayOfMonth = now.getUTCDate();
+ const daysInMonth = new Date(now.getUTCFullYear(), now.getUTCMonth() + 1, 0).getUTCDate();
+
+ this._contentArea.add_child(this._kvRow('7-day avg', this._fmt(avg)));
+ this._contentArea.add_child(this._kvRow('Yesterday', yesterday ? this._fmt(yestCost) : '-'));
+ this._contentArea.add_child(this._kvRow('Day-over-day', `${dod > 0 ? '+' : ''}${dod.toFixed(1)}%`));
+ this._contentArea.add_child(this._kvRow('Month projection', this._fmt(avg * daysInMonth)));
+ this._contentArea.add_child(this._kvRow('Days elapsed', `${dayOfMonth} of ${daysInMonth}`));
+ }
+
+ _renderPulseView() {
+ const current = this._payload?.current ?? {};
+ const daily = this._payload?.history?.daily ?? [];
+ this._contentArea.add_child(this._sectionTitle('Pulse'));
+ const row = new St.BoxLayout({ style_class: 'codeburn-pulse-row' });
+ row.add_child(this._pulseTile(this._fmt(current.cost), 'cost'));
+ row.add_child(this._pulseTile(Number(current.calls || 0).toLocaleString(), 'calls'));
+ row.add_child(this._pulseTile(`${Number(current.cacheHitPercent || 0).toFixed(0)}%`, 'cache hit'));
+ this._contentArea.add_child(row);
+
+ if (daily.length) {
+ this._contentArea.add_child(this._sectionTitle('Last 7 days'));
+ const last7 = daily.slice(-7);
+ const sumCost = last7.reduce((s, d) => s + Number(d.cost || 0), 0);
+ const sumCalls = last7.reduce((s, d) => s + Number(d.calls || 0), 0);
+ const peakDay = last7.reduce((best, d) => Number(d.cost || 0) > Number(best.cost || 0) ? d : best, last7[0]);
+ this._contentArea.add_child(this._kvRow('Total spend', this._fmt(sumCost)));
+ this._contentArea.add_child(this._kvRow('Total calls', Number(sumCalls).toLocaleString()));
+ this._contentArea.add_child(this._kvRow('Peak day', `${peakDay?.date || '-'} ${this._fmt(peakDay?.cost)}`));
}
}
- _updateModels(models) {
- this._modelsSection.menu.removeAll();
- if (!models || models.length === 0) {
- this._modelsSection.visible = false;
+ _renderStatsView() {
+ const current = this._payload?.current ?? {};
+ const daily = this._payload?.history?.daily ?? [];
+ this._contentArea.add_child(this._sectionTitle('Stats'));
+ const models = Array.isArray(current.topModels) ? current.topModels : [];
+ const favModel = models[0]?.name ?? '-';
+ const activeDays = daily.filter(d => Number(d.cost || 0) > 0).length;
+ const peakDay = daily.reduce((best, d) => Number(d.cost || 0) > Number((best || {}).cost || 0) ? d : best, null);
+ let streak = 0;
+ for (let i = daily.length - 1; i >= 0; i--) {
+ if (Number(daily[i].cost || 0) > 0) streak++;
+ else break;
+ }
+ this._contentArea.add_child(this._kvRow('Favorite model', favModel));
+ this._contentArea.add_child(this._kvRow('Active days', `${activeDays}`));
+ this._contentArea.add_child(this._kvRow('Current streak', `${streak} days`));
+ if (peakDay) this._contentArea.add_child(this._kvRow('Peak day', `${peakDay.date} ${this._fmt(peakDay.cost)}`));
+ }
+
+ _renderFindings(optimize) {
+ const count = Number(optimize?.findingCount ?? 0);
+ if (count === 0) {
+ this._findingsBtn.hide();
return;
}
- this._modelsSection.visible = true;
- for (const model of models.slice(0, 5)) {
- const item = new PopupMenu.PopupMenuItem(`${model.name} ${formatCost(model.cost)}`);
- item.setSensitive(false);
- this._modelsSection.menu.addMenuItem(item);
+ const savings = Number(optimize?.savingsUSD ?? 0);
+ this._findingsCount.set_text(`${count} optimize findings`);
+ this._findingsSavings.set_text(`save ~${this._fmt(savings)}`);
+ this._findingsBtn.show();
+ }
+
+ _renderError(message) {
+ this._panelLabel.set_text('!');
+ if (message?.includes('not found') || message?.includes('No such file')) {
+ this._heroLabel.set_text('CodeBurn CLI not found');
+ this._heroMeta.set_text('Install: npm i -g codeburn');
+ } else {
+ this._heroLabel.set_text('Error loading data');
+ this._heroMeta.set_text(message?.substring(0, 80) || 'Unknown error');
}
+ this._heroAmount.set_text('');
+ this._findingsBtn.hide();
}
+ // -- Budget --
+
_updateBudget() {
const enabled = this._settings.get_boolean('budget-alert-enabled');
const threshold = this._settings.get_double('budget-threshold');
-
- if (!enabled || threshold <= 0 || !this._lastPayload?.current) {
- this._budgetItem.visible = false;
+ if (!enabled || threshold <= 0 || !this._payload?.current) {
+ this._budgetLabel.visible = false;
return;
}
-
- const cost = this._lastPayload.current.cost;
- if (cost >= threshold) {
- this._budgetItem.label.text = `⚠ Budget exceeded: ${formatCost(cost)} / ${formatCost(threshold)}`;
- this._budgetItem.visible = true;
+ const cost = Number(this._payload.current.cost ?? 0) * this._fxRate;
+ const thresholdConverted = threshold * this._fxRate;
+ if (cost >= thresholdConverted) {
+ this._budgetLabel.set_text(`Budget exceeded: ${this._fmt(cost)} / ${this._fmt(thresholdConverted)}`);
+ this._budgetLabel.visible = true;
} else {
- this._budgetItem.label.text = `Budget: ${formatCost(cost)} / ${formatCost(threshold)}`;
- this._budgetItem.visible = true;
+ this._budgetLabel.visible = false;
}
}
- _showError(message) {
- this._panelLabel.text = '$?';
- if (message?.includes('not found') || message?.includes('No such file')) {
- this._heroItem.label.text = 'CodeBurn CLI not found';
- this._statsItem.label.text = 'Install: npm i -g codeburn';
+ // -- Currency --
+
+ _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 (_) { /* default */ }
+ return CURRENCIES[0];
+ }
+
+ _toggleCurrencyPicker() {
+ this._currencyPicker.visible = !this._currencyPicker.visible;
+ }
+
+ _setCurrency(code) {
+ try {
+ Gio.Subprocess.new(['codeburn', 'currency', code], Gio.SubprocessFlags.STDOUT_PIPE | Gio.SubprocessFlags.STDERR_PIPE);
+ } catch (_) { /* CLI missing */ }
+ const known = CURRENCIES.find(c => c.code === code);
+ this._currency = known || { code, symbol: `${code} ` };
+ this._currencyBtn.set_label(`${this._currency.code} ⌄`);
+ this._updateFxRate();
+ }
+
+ _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) => {
+ if (this._destroyed) return;
+ 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 */ }
+ });
+ }
+
+ _fmt(value) {
+ return formatCost(value, this._currency, this._fxRate, this._exactCosts);
+ }
+
+ // -- UI helpers --
+
+ _sectionTitle(text) {
+ return new St.Label({ text, style_class: 'codeburn-section-title' });
+ }
+
+ _kvRow(label, value) {
+ const row = new St.BoxLayout({ style_class: 'codeburn-kv-row' });
+ row.add_child(new St.Label({ text: label, style_class: 'codeburn-kv-label', x_expand: true }));
+ row.add_child(new St.Label({ text: String(value ?? '-'), style_class: 'codeburn-kv-value' }));
+ return row;
+ }
+
+ _pulseTile(value, label) {
+ const tile = new St.BoxLayout({ vertical: true, style_class: 'codeburn-pulse-tile', x_expand: true });
+ tile.add_child(new St.Label({ text: value, style_class: 'codeburn-pulse-value' }));
+ tile.add_child(new St.Label({ text: label, style_class: 'codeburn-pulse-label' }));
+ return tile;
+ }
+
+ _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' });
+ topLine.add_child(new St.Label({ text: activity.name, style_class: 'codeburn-activity-name', x_expand: true }));
+ const costLabel = new St.Label({ text: this._fmt(activity.cost), style_class: 'codeburn-activity-cost' });
+ costLabel.clutter_text.x_align = Clutter.ActorAlign.END;
+ topLine.add_child(costLabel);
+ const turnsLabel = new St.Label({ text: `${Number(activity.turns) || 0}`, style_class: 'codeburn-activity-turns' });
+ turnsLabel.clutter_text.x_align = Clutter.ActorAlign.END;
+ topLine.add_child(turnsLabel);
+ const osText = activity.oneShotRate != null ? `${Math.round(Number(activity.oneShotRate) * 100)}%` : '--';
+ const osLabel = new St.Label({ text: osText, style_class: 'codeburn-activity-oneshot' });
+ osLabel.clutter_text.x_align = Clutter.ActorAlign.END;
+ topLine.add_child(osLabel);
+ row.add_child(topLine);
+
+ const track = new St.BoxLayout({ style_class: 'codeburn-bar-track' });
+ const pct = 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(BAR_TRACK_WIDTH * pct));
+ track.add_child(fill);
+ row.add_child(track);
+ return row;
+ }
+
+ _buildModelRow(model) {
+ const row = new St.BoxLayout({ style_class: 'codeburn-model-row' });
+ row.add_child(new St.Label({ text: model.name, style_class: 'codeburn-model-name', x_expand: true }));
+ const mc = new St.Label({ text: this._fmt(model.cost), style_class: 'codeburn-model-cost' });
+ mc.clutter_text.x_align = Clutter.ActorAlign.END;
+ row.add_child(mc);
+ const mcalls = new St.Label({ text: `${Number(model.calls || 0).toLocaleString()}`, style_class: 'codeburn-model-calls' });
+ mcalls.clutter_text.x_align = Clutter.ActorAlign.END;
+ row.add_child(mcalls);
+ return row;
+ }
+
+ // -- Theme --
+
+ _applyThemeClass() {
+ const forceDark = this._settings.get_boolean('force-dark-mode');
+ const scheme = this._themeSettings.get_string('color-scheme');
+ const isDark = forceDark || scheme === 'prefer-dark';
+ if (isDark) {
+ this._root?.add_style_class_name('codeburn-dark');
+ this._root?.remove_style_class_name('codeburn-light');
} else {
- this._heroItem.label.text = 'Error loading data';
- this._statsItem.label.text = message?.substring(0, 80) || 'Unknown error';
+ this._root?.add_style_class_name('codeburn-light');
+ this._root?.remove_style_class_name('codeburn-dark');
}
}
- _openReport() {
+ // -- Terminal spawning --
+
+ _spawnTerminal(argv) {
+ const command = `${argv.join(' ')}; echo; read -n 1 -s -r -p 'Press any key to close...'`;
try {
- const argv = ['codeburn', 'report'];
- const launcher = Gio.SubprocessLauncher.new(Gio.SubprocessFlags.NONE);
- launcher.spawnv(argv);
+ Gio.Subprocess.new(['gnome-terminal', '--', 'bash', '-lc', command], Gio.SubprocessFlags.NONE);
} catch (e) {
- log(`CodeBurn: failed to open report: ${e.message}`);
+ log(`CodeBurn: terminal spawn error: ${e.message}`);
}
+ this.menu.close();
}
+ // -- Cleanup --
+
destroy() {
+ this._destroyed = true;
if (this._refreshSourceId) {
GLib.Source.remove(this._refreshSourceId);
this._refreshSourceId = 0;
}
- this._dataClient?.destroy();
- for (const id of this._settingsChangedIds)
- this._settings.disconnect(id);
+ if (this._themeSettings && this._themeSignal) {
+ this._themeSettings.disconnect(this._themeSignal);
+ this._themeSignal = null;
+ this._themeSettings = null;
+ }
+ for (const id of this._settingsChangedIds) this._settings.disconnect(id);
this._settingsChangedIds = [];
+ this._dataClient?.destroy();
+ if (this._soupSession) {
+ this._soupSession.abort();
+ this._soupSession = null;
+ }
super.destroy();
}
});
diff --git a/gnome/metadata.json b/gnome/metadata.json
index 050b0f5..be8d2c0 100644
--- a/gnome/metadata.json
+++ b/gnome/metadata.json
@@ -3,6 +3,6 @@
"description": "Monitor AI coding assistant token usage and costs",
"uuid": "codeburn@codeburn.dev",
"shell-version": ["45", "46", "47", "48", "49", "50"],
- "url": "https://github.com/anthropics/codeburn",
+ "url": "https://github.com/getagentseal/codeburn",
"settings-schema": "org.gnome.shell.extensions.codeburn"
}
diff --git a/gnome/prefs.js b/gnome/prefs.js
index 8d80679..41a0e50 100644
--- a/gnome/prefs.js
+++ b/gnome/prefs.js
@@ -66,6 +66,20 @@ export default class CodeBurnPreferences extends ExtensionPreferences {
settings.bind('compact-mode', compactRow, 'active', Gio.SettingsBindFlags.DEFAULT);
displayGroup.add(compactRow);
+ const darkModeRow = new Adw.SwitchRow({
+ title: 'Force Dark Mode',
+ subtitle: 'Always use dark theme for the popup',
+ });
+ settings.bind('force-dark-mode', darkModeRow, 'active', Gio.SettingsBindFlags.DEFAULT);
+ displayGroup.add(darkModeRow);
+
+ const exactCostsRow = new Adw.SwitchRow({
+ title: 'Show Exact Costs',
+ subtitle: 'Show full values like $2,655.23 instead of $2.7k',
+ });
+ settings.bind('show-exact-costs', exactCostsRow, 'active', Gio.SettingsBindFlags.DEFAULT);
+ displayGroup.add(exactCostsRow);
+
const periodModel = new Gtk.StringList();
for (const p of PERIODS)
periodModel.append(p.label);
diff --git a/gnome/schemas/org.gnome.shell.extensions.codeburn.gschema.xml b/gnome/schemas/org.gnome.shell.extensions.codeburn.gschema.xml
index 7031ab0..e122cd8 100644
--- a/gnome/schemas/org.gnome.shell.extensions.codeburn.gschema.xml
+++ b/gnome/schemas/org.gnome.shell.extensions.codeburn.gschema.xml
@@ -34,6 +34,18 @@
Show only icon in panel, hide cost label
+
+ false
+ Force dark mode
+ Always use dark theme for the popup, regardless of system theme
+
+
+
+ false
+ Show exact costs
+ Show full decimal values instead of compact notation (e.g. $2,655.23 instead of $2.7k)
+
+
''
CodeBurn CLI path
diff --git a/gnome/stylesheet.css b/gnome/stylesheet.css
index 57489e0..74bf896 100644
--- a/gnome/stylesheet.css
+++ b/gnome/stylesheet.css
@@ -1,23 +1,610 @@
-.codeburn-panel-label {
- margin-left: 4px;
+/* ---- panel button ---- */
+.codeburn-panel {
+ spacing: 4px;
+}
+.codeburn-flame {
+ font-size: 14px;
+}
+.codeburn-label {
+ font-weight: 500;
+ padding-left: 2px;
+ padding-right: 2px;
+}
+
+/* ---- popup host ---- */
+.codeburn-menu {
+ padding: 0;
+}
+.codeburn-host {
+ padding: 0;
+ margin: 0;
+ background: transparent;
+ border: none;
+}
+.codeburn-host:hover,
+.codeburn-host:focus,
+.codeburn-host:active,
+.codeburn-host:selected {
+ background: transparent;
+}
+.codeburn-root {
+ width: 340px;
+ height: 540px;
+ padding: 0;
+ spacing: 0;
+}
+.codeburn-scroll {
+ padding: 0;
}
+/* ---- brand header ---- */
+.codeburn-brand-header {
+ padding: 14px 16px 10px 16px;
+ spacing: 2px;
+}
+.codeburn-brand-row {
+ spacing: 0;
+}
+.codeburn-brand-primary {
+ font-weight: 700;
+ font-size: 18px;
+}
+.codeburn-brand-accent {
+ font-weight: 700;
+ font-size: 18px;
+ color: #ff8c42;
+}
+.codeburn-brand-subhead {
+ font-size: 10.5px;
+ opacity: 0.55;
+}
+
+/* ---- tab rows ---- */
+.codeburn-tab-row {
+ padding: 4px 10px 8px 10px;
+ spacing: 4px;
+}
+.codeburn-period-row {
+ padding-top: 0;
+ padding-bottom: 10px;
+}
+.codeburn-tab,
+.codeburn-period {
+ padding: 5px 6px;
+ border-radius: 6px;
+ font-size: 11px;
+ font-weight: 500;
+ background: transparent;
+ border: none;
+ opacity: 0.7;
+ transition-duration: 80ms;
+}
+.codeburn-tab:hover,
+.codeburn-period:hover {
+ background: rgba(255, 140, 66, 0.08);
+ opacity: 1;
+}
+.codeburn-tab-active,
+.codeburn-period-active {
+ background: rgba(255, 140, 66, 0.18);
+ color: #ff8c42;
+ opacity: 1;
+ font-weight: 600;
+}
+.codeburn-agent-scroll {
+ padding: 0;
+}
+.codeburn-agent-badge {
+ padding: 3px 10px;
+ border-radius: 10px;
+ background: rgba(255, 140, 66, 0.12);
+ color: #ff8c42;
+ font-size: 10.5px;
+ font-weight: 500;
+}
+
+/* ---- hero ---- */
+.codeburn-hero {
+ padding: 4px 16px 10px 16px;
+ spacing: 2px;
+}
+.codeburn-hero-top {
+ spacing: 6px;
+}
+.codeburn-hero-dot {
+ width: 6px;
+ height: 6px;
+ border-radius: 3px;
+ background-color: #ff8c42;
+ margin-top: 7px;
+}
.codeburn-hero-label {
- font-size: 1.2em;
- font-weight: bold;
+ font-size: 11px;
+ opacity: 0.65;
+ font-weight: 500;
+}
+.codeburn-hero-amount {
+ font-size: 28px;
+ font-weight: 700;
+ color: #ffd700;
+}
+.codeburn-hero-meta {
+ font-size: 11px;
+ opacity: 0.6;
+}
+
+/* ---- activity section ---- */
+.codeburn-section-title {
+ font-weight: 600;
+ font-size: 11px;
+ opacity: 0.6;
+ padding-bottom: 2px;
+}
+/* ---- table headers ---- */
+.codeburn-table-header {
+ spacing: 6px;
+ padding: 2px 0 4px 0;
+}
+.codeburn-th {
+ font-size: 10px;
+ font-weight: 600;
+ opacity: 0.45;
+}
+.codeburn-th-cost {
+ min-width: 64px;
+}
+.codeburn-th-turns {
+ min-width: 40px;
+}
+.codeburn-th-calls {
+ min-width: 50px;
+}
+
+.codeburn-activity-rows {
+ spacing: 0;
+}
+.codeburn-activity-row {
+ spacing: 3px;
+ padding: 6px 0;
+}
+.codeburn-activity-top {
+ spacing: 6px;
+}
+.codeburn-activity-name {
+ font-size: 11.5px;
+ font-weight: 500;
+ min-width: 120px;
+}
+.codeburn-activity-cost {
+ font-size: 11.5px;
+ font-family: monospace;
+ font-weight: 600;
+ color: #ffd700;
+ min-width: 64px;
+}
+.codeburn-activity-turns {
+ font-size: 10.5px;
+ font-family: monospace;
+ opacity: 0.6;
+ min-width: 40px;
+}
+.codeburn-activity-oneshot {
+ font-size: 10.5px;
+ font-family: monospace;
+ color: #4ec972;
+ min-width: 40px;
+}
+.codeburn-bar-track {
+ height: 4px;
+ border-radius: 2px;
+ background-color: rgba(255, 255, 255, 0.08);
+ width: 240px;
+}
+.codeburn-bar-fill {
+ height: 4px;
+ border-radius: 2px;
+ background-color: #ff8c42;
+}
+.codeburn-empty {
+ font-style: italic;
+ opacity: 0.55;
+ padding: 6px 0;
+}
+
+/* ---- loading skeleton ---- */
+.codeburn-loading {
+ padding: 10px 16px;
+ spacing: 10px;
+}
+.codeburn-skeleton-bar {
+ background-color: rgba(255, 140, 66, 0.15);
+ border-radius: 4px;
+}
+.codeburn-light .codeburn-skeleton-bar {
+ background-color: rgba(200, 80, 30, 0.12);
+}
+
+/* ---- findings CTA ---- */
+.codeburn-findings {
+ margin: 2px 16px 10px 16px;
+ padding: 9px 11px;
+ border-radius: 8px;
+ background: rgba(255, 140, 66, 0.12);
+ border: none;
+ transition-duration: 120ms;
+}
+.codeburn-findings:hover {
+ background: rgba(255, 140, 66, 0.2);
+}
+.codeburn-findings-inner {
+ spacing: 8px;
+}
+.codeburn-findings-count {
+ font-size: 11.5px;
+ font-weight: 600;
+ color: #ff8c42;
+}
+.codeburn-findings-savings {
+ font-size: 11.5px;
+ font-weight: 500;
+ color: #ff8c42;
+ opacity: 0.8;
+}
+
+/* ---- footer ---- */
+.codeburn-footer {
+ padding: 10px 12px;
+ spacing: 6px;
+}
+.codeburn-footer-btn {
+ padding: 6px 10px;
+ border-radius: 6px;
+ background: rgba(255, 255, 255, 0.05);
+ border: none;
+ font-size: 11px;
+ font-weight: 500;
+ transition-duration: 80ms;
+}
+.codeburn-footer-btn:hover {
+ background: rgba(255, 255, 255, 0.1);
+}
+.codeburn-currency-box {
+ spacing: 2px;
+}
+.codeburn-currency-btn {
+ font-family: monospace;
+ min-width: 62px;
+}
+.codeburn-currency-picker {
+ background: rgba(30, 30, 30, 0.95);
+ border-radius: 8px;
+ padding: 4px;
+ height: 180px;
+}
+.codeburn-currency-list {
+ spacing: 1px;
+}
+.codeburn-currency-item {
+ padding: 4px 10px;
+ border-radius: 4px;
+ font-size: 11px;
+ font-family: monospace;
+ background: transparent;
+ border: none;
+}
+.codeburn-currency-item:hover {
+ background: rgba(255, 140, 66, 0.12);
+}
+.codeburn-currency-item-active {
+ background: rgba(255, 140, 66, 0.2);
+ color: #ff8c42;
+ font-weight: 600;
+}
+.codeburn-footer-cta {
+ background: #c9521d;
+ color: #ffffff;
+}
+.codeburn-footer-cta:hover {
+ background: #ff8c42;
+}
+.codeburn-updated {
+ font-size: 10px;
+ opacity: 0.45;
+ padding: 0 16px 10px 16px;
}
-.codeburn-provider-cost {
- margin-left: 16px;
- font-variant-numeric: tabular-nums;
+/* ---- insight pills row ---- */
+.codeburn-insight-row {
+ padding: 4px 10px 8px 10px;
+ spacing: 4px;
+}
+.codeburn-insight-pill {
+ padding: 4px 4px;
+ border-radius: 6px;
+ font-size: 10.5px;
+ font-weight: 500;
+ background: transparent;
+ border: none;
+ opacity: 0.65;
+ transition-duration: 80ms;
+}
+.codeburn-insight-pill:hover {
+ background: rgba(255, 140, 66, 0.08);
+ opacity: 1;
+}
+.codeburn-insight-pill-active {
+ background: rgba(255, 140, 66, 0.18);
+ color: #ff8c42;
+ opacity: 1;
+ font-weight: 600;
+}
+
+/* ---- token histogram chart ---- */
+.codeburn-chart {
+ padding: 0 16px 10px 16px;
+ spacing: 4px;
+}
+.codeburn-chart-header {
+ spacing: 6px;
+}
+.codeburn-chart-label {
+ font-weight: 600;
+ font-size: 11px;
+ opacity: 0.6;
+}
+.codeburn-chart-total {
+ font-family: monospace;
+ font-size: 11px;
+ opacity: 0.7;
+ color: #ff8c42;
+}
+.codeburn-chart-bars {
+ spacing: 2px;
+ height: 52px;
+}
+.codeburn-chart-col {
+ height: 52px;
+}
+.codeburn-chart-spacer {
+ background: transparent;
+}
+.codeburn-chart-bar {
+ background-color: #ff8c42;
+ border-radius: 2px 2px 0 0;
+}
+.codeburn-chart-bar-hover {
+ background-color: #ffa94d;
+}
+.codeburn-chart-total-hover {
+ font-weight: 600;
+}
+.codeburn-divider {
+ height: 1px;
+ background-color: rgba(255, 255, 255, 0.08);
+ margin: 4px 16px;
+}
+
+/* ---- trend, pulse, stats, kv rows ---- */
+.codeburn-content {
+ padding: 6px 16px 10px 16px;
+ spacing: 6px;
+}
+.codeburn-trend-row,
+.codeburn-kv-row {
+ padding: 4px 0;
+ spacing: 8px;
+}
+.codeburn-trend-date,
+.codeburn-kv-label {
+ font-size: 11.5px;
+ font-weight: 500;
+}
+.codeburn-trend-cost,
+.codeburn-kv-value {
+ font-family: monospace;
+ font-size: 11.5px;
+ font-weight: 600;
+ color: #ffd700;
+}
+.codeburn-trend-calls {
+ font-size: 10.5px;
+ opacity: 0.6;
+ min-width: 62px;
+}
+
+/* ---- pulse tiles ---- */
+.codeburn-pulse-row {
+ spacing: 6px;
+ padding: 4px 0;
+}
+.codeburn-pulse-tile {
+ padding: 10px 8px;
+ border-radius: 8px;
+ background: rgba(255, 140, 66, 0.08);
+ spacing: 2px;
+}
+.codeburn-pulse-value {
+ font-size: 16px;
+ font-weight: 700;
+ color: #ff8c42;
+ font-family: monospace;
+}
+.codeburn-pulse-label {
+ font-size: 10px;
+ opacity: 0.6;
+}
+
+/* ---- models rows ---- */
+.codeburn-models-rows {
+ spacing: 0;
+ padding-top: 4px;
+}
+.codeburn-model-row {
+ spacing: 8px;
+ padding: 6px 0;
+}
+.codeburn-model-name {
+ font-size: 11.5px;
+ min-width: 120px;
+}
+.codeburn-model-cost {
+ font-family: monospace;
+ font-size: 11.5px;
+ color: #ffd700;
+ min-width: 64px;
+}
+.codeburn-model-calls {
+ font-family: monospace;
+ font-size: 10.5px;
+ opacity: 0.6;
+ min-width: 50px;
+}
+
+/* ---- settings gear button ---- */
+.codeburn-prefs-btn {
+ padding: 6px 8px;
+ font-size: 14px;
}
+/* ---- budget warning ---- */
.codeburn-budget-warning {
color: #e5a50a;
font-weight: bold;
+ font-size: 11.5px;
+ padding: 6px 16px;
}
-.codeburn-stale-indicator {
- opacity: 0.6;
- font-style: italic;
+/* ---- dark theme ---- */
+.codeburn-dark {
+ background-color: rgba(30, 30, 30, 0.98);
+ color: #e0e0e0;
+ border-radius: 12px;
+}
+.codeburn-dark .codeburn-brand-primary {
+ color: #ffffff;
+}
+.codeburn-dark .codeburn-brand-subhead {
+ color: rgba(255, 255, 255, 0.55);
+}
+.codeburn-dark .codeburn-hero-label,
+.codeburn-dark .codeburn-hero-meta {
+ color: rgba(255, 255, 255, 0.65);
+}
+.codeburn-dark .codeburn-section-title,
+.codeburn-dark .codeburn-th,
+.codeburn-dark .codeburn-chart-label {
+ color: rgba(255, 255, 255, 0.5);
+}
+.codeburn-dark .codeburn-activity-name,
+.codeburn-dark .codeburn-model-name,
+.codeburn-dark .codeburn-trend-date,
+.codeburn-dark .codeburn-kv-label {
+ color: #e0e0e0;
+}
+.codeburn-dark .codeburn-activity-turns,
+.codeburn-dark .codeburn-model-calls,
+.codeburn-dark .codeburn-trend-calls {
+ color: rgba(255, 255, 255, 0.5);
+}
+.codeburn-dark .codeburn-footer-btn {
+ background: rgba(255, 255, 255, 0.08);
+ color: #e0e0e0;
+}
+.codeburn-dark .codeburn-footer-btn:hover {
+ background: rgba(255, 255, 255, 0.14);
+}
+.codeburn-dark .codeburn-currency-picker {
+ background: rgba(20, 20, 20, 0.98);
+}
+.codeburn-dark .codeburn-currency-item {
+ color: #e0e0e0;
+}
+.codeburn-dark .codeburn-tab,
+.codeburn-dark .codeburn-period,
+.codeburn-dark .codeburn-insight-pill {
+ color: rgba(255, 255, 255, 0.7);
+}
+.codeburn-dark .codeburn-updated {
+ color: rgba(255, 255, 255, 0.45);
+}
+
+/* ---- light theme ---- */
+.codeburn-light {
+ background-color: rgba(255, 255, 255, 0.98);
+ color: #1a1a1a;
+ border-radius: 12px;
+}
+.codeburn-light .codeburn-brand-primary {
+ color: #1a1a1a;
+}
+.codeburn-light .codeburn-brand-subhead {
+ color: rgba(0, 0, 0, 0.5);
+}
+.codeburn-light .codeburn-hero-label,
+.codeburn-light .codeburn-hero-meta {
+ color: rgba(0, 0, 0, 0.6);
+}
+.codeburn-light .codeburn-hero-amount {
+ color: #c9521d;
+}
+.codeburn-light .codeburn-section-title,
+.codeburn-light .codeburn-th,
+.codeburn-light .codeburn-chart-label {
+ color: rgba(0, 0, 0, 0.45);
+}
+.codeburn-light .codeburn-activity-name,
+.codeburn-light .codeburn-model-name,
+.codeburn-light .codeburn-trend-date,
+.codeburn-light .codeburn-kv-label {
+ color: #1a1a1a;
+}
+.codeburn-light .codeburn-activity-cost,
+.codeburn-light .codeburn-model-cost,
+.codeburn-light .codeburn-trend-cost,
+.codeburn-light .codeburn-kv-value {
+ color: #c9521d;
+}
+.codeburn-light .codeburn-activity-turns,
+.codeburn-light .codeburn-model-calls,
+.codeburn-light .codeburn-trend-calls {
+ color: rgba(0, 0, 0, 0.5);
+}
+.codeburn-light .codeburn-activity-oneshot {
+ color: #1b7a35;
+}
+.codeburn-light .codeburn-bar-track {
+ background-color: rgba(0, 0, 0, 0.08);
+}
+.codeburn-light .codeburn-bar-fill {
+ background-color: #c9521d;
+}
+.codeburn-light .codeburn-chart-bar {
+ background-color: #c9521d;
+}
+.codeburn-light .codeburn-footer-btn {
+ background: rgba(0, 0, 0, 0.06);
+ color: #1a1a1a;
+}
+.codeburn-light .codeburn-footer-btn:hover {
+ background: rgba(0, 0, 0, 0.1);
+}
+.codeburn-light .codeburn-currency-picker {
+ background: rgba(245, 245, 245, 0.98);
+}
+.codeburn-light .codeburn-currency-item {
+ color: #1a1a1a;
+}
+.codeburn-light .codeburn-tab,
+.codeburn-light .codeburn-period,
+.codeburn-light .codeburn-insight-pill {
+ color: rgba(0, 0, 0, 0.65);
+}
+.codeburn-light .codeburn-pulse-tile {
+ background: rgba(255, 140, 66, 0.1);
+}
+.codeburn-light .codeburn-updated {
+ color: rgba(0, 0, 0, 0.4);
+}
+.codeburn-light .codeburn-divider {
+ background-color: rgba(0, 0, 0, 0.1);
}