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); }