From 1fef15a57ceb260944b69b00db4ff8ec95e66331 Mon Sep 17 00:00:00 2001 From: iamtoruk Date: Mon, 4 May 2026 10:59:56 -0700 Subject: [PATCH 1/5] Rewrite GNOME extension UI with branded popover matching macOS menubar Combines PR #212's modular architecture (DataClient, GSettings, Libadwaita prefs) with the custom St widget UI from feat/tauri-menubar-win-linux. Adds: branded header, horizontal agent tabs, hero typography, period/insight pills, 19-day token histogram, 6 content views (Activity, Trend, Forecast, Pulse, Stats, Plan), currency switcher with FX conversion, findings CTA, budget alerts, theme detection, payload caching with TTL. --- gnome/indicator.js | 889 ++++++++++++++++++++++++++++++++----------- gnome/metadata.json | 2 +- gnome/stylesheet.css | 408 +++++++++++++++++++- 3 files changed, 1071 insertions(+), 228 deletions(-) diff --git a/gnome/indicator.js b/gnome/indicator.js index 9fae16e..526c836 100644 --- a/gnome/indicator.js +++ b/gnome/indicator.js @@ -1,13 +1,21 @@ 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 Pango from 'gi://Pango'; 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 = 60_000; +const TOP_ACTIVITIES = 5; +const CHART_HEIGHT = 52; +const CHART_BAR_WIDTH = 12; +const BAR_TRACK_WIDTH = 240; + const PERIODS = [ { id: 'today', label: 'Today' }, { id: 'week', label: '7 Days' }, @@ -16,174 +24,391 @@ 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' }, + { id: 'plan', label: 'Plan' }, +]; + +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) { + const n = (Number(value) || 0) * (Number(rate) || 1); + const abs = Math.abs(n); + const symbol = currency?.symbol || '$'; + if (abs >= 1000) return `${symbol}${(n / 1000).toFixed(abs >= 10000 ? 0 : 1)}k`; + return `${symbol}${n.toFixed(2)}`; } -function 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._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._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 -- + + _buildPopup() { + this.menu.box.add_style_class_name('codeburn-menu'); + this._popupHost = new PopupMenu.PopupBaseMenuItem({ reactive: false, can_focus: false }); + this._popupHost.add_style_class_name('codeburn-host'); + this.menu.addMenuItem(this._popupHost); + + this._root = new St.BoxLayout({ vertical: true, style_class: 'codeburn-root', x_expand: true }); + this._popupHost.add_child(this._root); + + this._buildBrandHeader(); + this._buildAgentTabs(); + this._buildHero(); + this._buildPeriodTabs(); + this._buildInsightPills(); + this._buildTokenChart(); + this._buildContentArea(); + this._buildBudgetAlert(); + this._buildFindingsSection(); + this._buildFooter(); + } + + _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._root.add_child(row); + return; + } - this._heroItem = this._addMenuItem('Loading...'); - this._heroItem.label.style_class = 'codeburn-hero-label'; + 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: true }); + btn.connect('clicked', () => { + this._provider = p.id; + this._updateAgentTabStyle(); + this._refresh(); + }); + this._agentTabRow.add_child(btn); + this._agentTabs.set(p.id, btn); + } + this._root.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._root.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._root.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._root.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() { + const chart = 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); + chart.add_child(header); + this._chartBars = new St.BoxLayout({ style_class: 'codeburn-chart-bars' }); + chart.add_child(this._chartBars); + this._root.add_child(chart); + } - this._modelsSection = new PopupMenu.PopupSubMenuMenuItem('Top Models'); - this.menu.addMenuItem(this._modelsSection); + _buildContentArea() { + this._contentArea = new St.BoxLayout({ vertical: true, style_class: 'codeburn-content' }); + this._root.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._root.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._root.add_child(this._findingsBtn); + } - this._budgetItem = this._addMenuItem(''); - this._budgetItem.visible = false; + _buildFooter() { + const footer = new St.BoxLayout({ style_class: 'codeburn-footer' }); - this.menu.addMenuItem(new PopupMenu.PopupSeparatorMenuItem()); + this._currencyBtn = new St.Button({ + label: `${this._currency.code} ⌄`, + style_class: 'codeburn-footer-btn codeburn-currency-btn', + can_focus: true, + }); + this._currencyBtn.connect('clicked', () => this._cycleCurrency()); + footer.add_child(this._currencyBtn); - const refreshItem = new PopupMenu.PopupMenuItem('Refresh'); - refreshItem.connect('activate', () => this._refresh()); - this.menu.addMenuItem(refreshItem); + 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 reportItem = new PopupMenu.PopupMenuItem('Open Full Report'); - reportItem.connect('activate', () => this._openReport()); - this.menu.addMenuItem(reportItem); + const reportBtn = new St.Button({ label: 'Open Full Report', style_class: 'codeburn-footer-btn codeburn-footer-cta', can_focus: true, x_expand: true }); + reportBtn.connect('clicked', () => this._spawnTerminal(['codeburn', 'report', '--period', this._period, '--provider', this._provider])); + footer.add_child(reportBtn); - const prefsItem = new PopupMenu.PopupMenuItem('Preferences'); - prefsItem.connect('activate', () => { + const prefsBtn = new St.Button({ label: 'Preferences', style_class: 'codeburn-footer-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('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 +425,414 @@ 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; - } + async _refresh(force = false) { + const key = this._cacheKey(); + const cached = this._payloadCache.get(key); + if (!force && cached && (Date.now() - cached.fetchedAt) < CACHE_TTL_MS) { + this._payload = cached.payload; + this._render(this._payload); + return; } - return { filtered, cost }; - } + if (this._inFlightKeys.has(key)) return; + this._inFlightKeys.add(key); + const gen = ++this._refreshGen; - _updatePanel(payload) { - if (!payload) { - this._panelLabel.text = '$?'; - return; + try { + const payload = await this._dataClient.fetch(this._period, this._provider); + this._inFlightKeys.delete(key); + if (gen !== this._refreshGen) return; + this._payloadCache.set(key, { payload, fetchedAt: Date.now() }); + if (this._cacheKey() === key) { + this._payload = payload; + this._render(this._payload); + } + } catch (e) { + this._inFlightKeys.delete(key); + if (gen !== this._refreshGen) return; + if (e.message?.includes('cancelled')) return; + log(`CodeBurn: refresh error: ${e.message}`); + if (!this._payload) this._renderError(e.message); } - const { cost } = this._filterProviders(payload.current?.providers); - let text = formatCost(cost); - if (this._isStale) - text += ' *'; - this._panelLabel.text = text; } - _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(formatCost(cost, this._currency, this._fxRate)); + this._heroLabel.set_text(current.label || ''); + this._heroAmount.set_text(formatCost(cost, this._currency, this._fxRate)); - 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._chartTotal.set_text('no history yet'); return; } + const totals = days.map(d => { + return (Number(d?.inputTokens) || 0) + (Number(d?.outputTokens) || 0) + + (Number(d?.cacheReadTokens) || 0) + (Number(d?.cacheWriteTokens) || 0); + }); + let maxTotal = 1; + let totalAll = 0; + for (const t of totals) { + if (t > maxTotal) maxTotal = t; + totalAll += t; + } + this._chartTotal.set_text(`${formatTokensCompact(totalAll)} tokens`); + 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' }); + 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(CHART_BAR_WIDTH); + bar.set_height(h); + col.add_child(spacer); + col.add_child(bar); + this._chartBars.add_child(col); + } + } - 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); - - 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(); + case 'plan': return this._renderPlanView(); + 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 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 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 ?? []; + this._contentArea.add_child(this._sectionTitle('Trend')); + 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 })); + row.add_child(new St.Label({ text: formatCost(d.cost, this._currency, this._fxRate), style_class: 'codeburn-trend-cost' })); + row.add_child(new St.Label({ text: `${Number(d.calls).toLocaleString()} calls`, style_class: 'codeburn-trend-calls' })); + 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 ?? []; + this._contentArea.add_child(this._sectionTitle('Forecast')); + 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', formatCost(avg, this._currency, this._fxRate))); + this._contentArea.add_child(this._kvRow('Yesterday', yesterday ? formatCost(yestCost, this._currency, this._fxRate) : '-')); + this._contentArea.add_child(this._kvRow('Day-over-day', `${dod > 0 ? '+' : ''}${dod.toFixed(1)}%`)); + this._contentArea.add_child(this._kvRow('Month projection', formatCost(avg * daysInMonth, this._currency, this._fxRate))); + 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(formatCost(current.cost, this._currency, this._fxRate), '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', formatCost(sumCost, this._currency, this._fxRate))); + this._contentArea.add_child(this._kvRow('Total calls', Number(sumCalls).toLocaleString())); + this._contentArea.add_child(this._kvRow('Peak day', `${peakDay?.date || '-'} ${formatCost(peakDay?.cost, this._currency, this._fxRate)}`)); + } + } + + _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} ${formatCost(peakDay.cost, this._currency, this._fxRate)}`)); } - _updateModels(models) { - this._modelsSection.menu.removeAll(); - if (!models || models.length === 0) { - this._modelsSection.visible = false; + _renderPlanView() { + this._contentArea.add_child(this._sectionTitle('Plan')); + const msg = new St.Label({ + text: 'Subscription tracking coming to Linux in a future release.', + style_class: 'codeburn-empty', + x_expand: true, + }); + msg.clutter_text.line_wrap = true; + msg.clutter_text.line_wrap_mode = Pango.WrapMode.WORD; + msg.clutter_text.ellipsize = Pango.EllipsizeMode.NONE; + this._contentArea.add_child(msg); + } + + _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 ~${formatCost(savings, this._currency, this._fxRate)}`); + 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: ${formatCost(cost, this._currency, 1)} / ${formatCost(thresholdConverted, this._currency, 1)}`); + 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'; - } else { - this._heroItem.label.text = 'Error loading data'; - this._statsItem.label.text = message?.substring(0, 80) || 'Unknown error'; + // -- 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]; + } + + _cycleCurrency() { + const idx = CURRENCIES.findIndex(c => c.code === this._currency.code); + const next = CURRENCIES[(idx + 1) % CURRENCIES.length]; + this._setCurrency(next.code); + } + + _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) => { + 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 */ } + }); + } + + // -- 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 })); + topLine.add_child(new St.Label({ text: formatCost(activity.cost, this._currency, this._fxRate), style_class: 'codeburn-activity-cost' })); + topLine.add_child(new St.Label({ text: `${Number(activity.turns) || 0}t`, style_class: 'codeburn-activity-turns' })); + if (activity.oneShotRate != null) { + topLine.add_child(new St.Label({ text: `${Math.round(Number(activity.oneShotRate) * 100)}%`, style_class: 'codeburn-activity-oneshot' })); } + 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 })); + row.add_child(new St.Label({ text: formatCost(model.cost, this._currency, this._fxRate), style_class: 'codeburn-model-cost' })); + row.add_child(new St.Label({ text: `${Number(model.calls || 0).toLocaleString()}`, style_class: 'codeburn-model-calls' })); + return row; } - _openReport() { + // -- Theme -- + + _applyThemeClass() { + const scheme = this._themeSettings.get_string('color-scheme'); + const isDark = scheme === 'prefer-dark'; + this.add_style_class_name(isDark ? 'codeburn-dark' : 'codeburn-light'); + this.remove_style_class_name(isDark ? 'codeburn-light' : 'codeburn-dark'); + } + + // -- 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() { 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(); + 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/stylesheet.css b/gnome/stylesheet.css index 57489e0..c2e0d13 100644 --- a/gnome/stylesheet.css +++ b/gnome/stylesheet.css @@ -1,23 +1,411 @@ -.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; + padding: 0; + spacing: 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; + letter-spacing: 0.3px; +} + +/* ---- 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-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; +} +.codeburn-activity-rows { + spacing: 8px; +} +.codeburn-activity-row { + spacing: 3px; +} +.codeburn-activity-top { + spacing: 6px; +} +.codeburn-activity-name { + font-size: 11.5px; + font-weight: 500; +} +.codeburn-activity-cost { + font-size: 11.5px; + font-family: monospace; + font-weight: 600; + color: #ffd700; +} +.codeburn-activity-turns { + font-size: 10.5px; + font-family: monospace; + opacity: 0.6; + min-width: 28px; + text-align: right; +} +.codeburn-activity-oneshot { + font-size: 10.5px; + font-family: monospace; + opacity: 0.8; + color: #5bf58c; + min-width: 36px; + text-align: right; +} +.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: linear-gradient(to right, #ff8c42, #c9521d); +} +.codeburn-empty { + font-style: italic; + opacity: 0.55; + padding: 6px 0; +} + +/* ---- 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; + border-top: 1px solid rgba(255, 255, 255, 0.06); +} +.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-btn { + font-family: monospace; + min-width: 62px; +} +.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; +} + +/* ---- 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; } -.codeburn-provider-cost { - margin-left: 16px; - font-variant-numeric: tabular-nums; +/* ---- 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 { + width: 12px; + height: 52px; +} +.codeburn-chart-spacer { + background: transparent; +} +.codeburn-chart-bar { + background: linear-gradient(to top, #c9521d, #ff8c42); + border-radius: 2px 2px 0 0; } +/* ---- 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; + text-align: right; +} + +/* ---- 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; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +/* ---- models rows ---- */ +.codeburn-models-rows { + spacing: 3px; + padding-top: 4px; +} +.codeburn-model-row { + spacing: 8px; + padding: 2px 0; +} +.codeburn-model-name { + font-size: 11.5px; +} +.codeburn-model-cost { + font-family: monospace; + font-size: 11.5px; + color: #ffd700; + min-width: 60px; + text-align: right; +} +.codeburn-model-calls { + font-family: monospace; + font-size: 10.5px; + opacity: 0.6; + min-width: 60px; + text-align: right; +} + +/* ---- 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 / light theme hooks ---- */ +.codeburn-light .codeburn-bar-track { + background-color: rgba(0, 0, 0, 0.08); +} +.codeburn-light .codeburn-footer-btn { + background: rgba(0, 0, 0, 0.04); +} +.codeburn-light .codeburn-footer-btn:hover { + background: rgba(0, 0, 0, 0.08); +} +.codeburn-light .codeburn-footer { + border-top-color: rgba(0, 0, 0, 0.08); } From 518a509934c313822202366b50b8d89fcf5198e4 Mon Sep 17 00:00:00 2001 From: iamtoruk Date: Mon, 4 May 2026 11:31:46 -0700 Subject: [PATCH 2/5] Add Main.panel.addToStatusArea call to extension entry point --- gnome/extension.js | 2 ++ 1 file changed, 2 insertions(+) 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() { From 2f6d44462c60a2ba198423d90781e0da154d4928 Mon Sep 17 00:00:00 2001 From: iamtoruk Date: Mon, 4 May 2026 11:52:39 -0700 Subject: [PATCH 3/5] Align activity/model rows as table with separators, gear icon for prefs --- gnome/indicator.js | 3 ++- gnome/stylesheet.css | 34 +++++++++++++++++++++++++++------- 2 files changed, 29 insertions(+), 8 deletions(-) diff --git a/gnome/indicator.js b/gnome/indicator.js index 526c836..66419a6 100644 --- a/gnome/indicator.js +++ b/gnome/indicator.js @@ -350,7 +350,8 @@ class CodeBurnIndicator extends PanelMenu.Button { reportBtn.connect('clicked', () => this._spawnTerminal(['codeburn', 'report', '--period', this._period, '--provider', this._provider])); footer.add_child(reportBtn); - const prefsBtn = new St.Button({ label: 'Preferences', style_class: 'codeburn-footer-btn', can_focus: true }); + const prefsBtn = new St.Button({ style_class: 'codeburn-footer-btn codeburn-prefs-btn', can_focus: true }); + prefsBtn.set_child(new St.Icon({ icon_name: 'emblem-system-symbolic', style_class: 'codeburn-prefs-icon' })); prefsBtn.connect('clicked', () => { this._extension.openPreferences(); this.menu.close(); diff --git a/gnome/stylesheet.css b/gnome/stylesheet.css index c2e0d13..c82ffb8 100644 --- a/gnome/stylesheet.css +++ b/gnome/stylesheet.css @@ -135,10 +135,12 @@ padding-bottom: 2px; } .codeburn-activity-rows { - spacing: 8px; + spacing: 0; } .codeburn-activity-row { spacing: 3px; + padding: 6px 0; + border-bottom: 1px solid rgba(255, 255, 255, 0.06); } .codeburn-activity-top { spacing: 6px; @@ -146,18 +148,21 @@ .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; + text-align: right; } .codeburn-activity-turns { font-size: 10.5px; font-family: monospace; opacity: 0.6; - min-width: 28px; + min-width: 40px; text-align: right; } .codeburn-activity-oneshot { @@ -165,7 +170,7 @@ font-family: monospace; opacity: 0.8; color: #5bf58c; - min-width: 36px; + min-width: 40px; text-align: right; } .codeburn-bar-track { @@ -363,31 +368,42 @@ /* ---- models rows ---- */ .codeburn-models-rows { - spacing: 3px; + spacing: 0; padding-top: 4px; } .codeburn-model-row { spacing: 8px; - padding: 2px 0; + padding: 6px 0; + border-bottom: 1px solid rgba(255, 255, 255, 0.06); } .codeburn-model-name { font-size: 11.5px; + min-width: 120px; } .codeburn-model-cost { font-family: monospace; font-size: 11.5px; color: #ffd700; - min-width: 60px; + min-width: 64px; text-align: right; } .codeburn-model-calls { font-family: monospace; font-size: 10.5px; opacity: 0.6; - min-width: 60px; + min-width: 50px; text-align: right; } +/* ---- settings gear button ---- */ +.codeburn-prefs-btn { + padding: 6px 8px; +} +.codeburn-prefs-icon { + icon-size: 14px; + opacity: 0.7; +} + /* ---- budget warning ---- */ .codeburn-budget-warning { color: #e5a50a; @@ -409,3 +425,7 @@ .codeburn-light .codeburn-footer { border-top-color: rgba(0, 0, 0, 0.08); } +.codeburn-light .codeburn-activity-row, +.codeburn-light .codeburn-model-row { + border-bottom-color: rgba(0, 0, 0, 0.06); +} From cebb89846b0ba7b197468b4d96dec1af5b89d4b5 Mon Sep 17 00:00:00 2001 From: iamtoruk Date: Mon, 4 May 2026 12:35:09 -0700 Subject: [PATCH 4/5] Add table column headers, oneshot placeholder, currency picker dropdown --- gnome/indicator.js | 41 ++++++++++++++++++++++++++++-------- gnome/stylesheet.css | 50 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 82 insertions(+), 9 deletions(-) diff --git a/gnome/indicator.js b/gnome/indicator.js index 66419a6..69bce6c 100644 --- a/gnome/indicator.js +++ b/gnome/indicator.js @@ -334,13 +334,28 @@ class CodeBurnIndicator extends PanelMenu.Button { _buildFooter() { const footer = new St.BoxLayout({ style_class: 'codeburn-footer' }); + const currencyBox = new St.BoxLayout({ vertical: true, style_class: 'codeburn-currency-box' }); this._currencyBtn = new St.Button({ label: `${this._currency.code} ⌄`, style_class: 'codeburn-footer-btn codeburn-currency-btn', can_focus: true, }); - this._currencyBtn.connect('clicked', () => this._cycleCurrency()); - footer.add_child(this._currencyBtn); + this._currencyBtn.connect('clicked', () => this._toggleCurrencyPicker()); + currencyBox.add_child(this._currencyBtn); + this._currencyPicker = new St.BoxLayout({ vertical: true, style_class: 'codeburn-currency-picker', visible: false }); + 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(); + this._currencyPicker.get_children().forEach(ch => ch.remove_style_class_name('codeburn-currency-item-active')); + item.add_style_class_name('codeburn-currency-item-active'); + }); + this._currencyPicker.add_child(item); + } + currencyBox.add_child(this._currencyPicker); + footer.add_child(currencyBox); const refreshBtn = new St.Button({ label: 'Refresh', style_class: 'codeburn-footer-btn', can_focus: true, x_expand: true }); refreshBtn.connect('clicked', () => this._refresh(true)); @@ -532,6 +547,12 @@ class CodeBurnIndicator extends PanelMenu.Button { _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', min_width: 64 })); + actHeader.add_child(new St.Label({ text: 'Turns', style_class: 'codeburn-th codeburn-th-right', min_width: 40 })); + actHeader.add_child(new St.Label({ text: '1-shot', style_class: 'codeburn-th codeburn-th-right', min_width: 40 })); + 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) { @@ -547,6 +568,11 @@ class CodeBurnIndicator extends PanelMenu.Button { 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', min_width: 64 })); + modHeader.add_child(new St.Label({ text: 'Calls', style_class: 'codeburn-th codeburn-th-right', min_width: 50 })); + 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); @@ -709,10 +735,8 @@ class CodeBurnIndicator extends PanelMenu.Button { return CURRENCIES[0]; } - _cycleCurrency() { - const idx = CURRENCIES.findIndex(c => c.code === this._currency.code); - const next = CURRENCIES[(idx + 1) % CURRENCIES.length]; - this._setCurrency(next.code); + _toggleCurrencyPicker() { + this._currencyPicker.visible = !this._currencyPicker.visible; } _setCurrency(code) { @@ -775,9 +799,8 @@ class CodeBurnIndicator extends PanelMenu.Button { topLine.add_child(new St.Label({ text: activity.name, style_class: 'codeburn-activity-name', x_expand: true })); topLine.add_child(new St.Label({ text: formatCost(activity.cost, this._currency, this._fxRate), style_class: 'codeburn-activity-cost' })); topLine.add_child(new St.Label({ text: `${Number(activity.turns) || 0}t`, style_class: 'codeburn-activity-turns' })); - if (activity.oneShotRate != null) { - topLine.add_child(new St.Label({ text: `${Math.round(Number(activity.oneShotRate) * 100)}%`, style_class: 'codeburn-activity-oneshot' })); - } + const osText = activity.oneShotRate != null ? `${Math.round(Number(activity.oneShotRate) * 100)}%` : '--'; + topLine.add_child(new St.Label({ text: osText, style_class: 'codeburn-activity-oneshot' })); row.add_child(topLine); const track = new St.BoxLayout({ style_class: 'codeburn-bar-track' }); diff --git a/gnome/stylesheet.css b/gnome/stylesheet.css index c82ffb8..2784188 100644 --- a/gnome/stylesheet.css +++ b/gnome/stylesheet.css @@ -134,6 +134,23 @@ opacity: 0.6; padding-bottom: 2px; } +/* ---- table headers ---- */ +.codeburn-table-header { + spacing: 6px; + padding: 2px 0 4px 0; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); +} +.codeburn-th { + font-size: 10px; + font-weight: 600; + opacity: 0.45; + text-transform: uppercase; + letter-spacing: 0.04em; +} +.codeburn-th-right { + text-align: right; +} + .codeburn-activity-rows { spacing: 0; } @@ -235,10 +252,37 @@ .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; + spacing: 1px; + max-height: 200px; +} +.codeburn-currency-item { + padding: 4px 10px; + border-radius: 4px; + font-size: 11px; + font-family: monospace; + background: transparent; + border: none; + text-align: left; +} +.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; @@ -429,3 +473,9 @@ .codeburn-light .codeburn-model-row { border-bottom-color: rgba(0, 0, 0, 0.06); } +.codeburn-light .codeburn-currency-picker { + background: rgba(245, 245, 245, 0.98); +} +.codeburn-light .codeburn-table-header { + border-bottom-color: rgba(0, 0, 0, 0.1); +} From 2ba222aa93abdbde1ff88756b5205c7401782efd Mon Sep 17 00:00:00 2001 From: iamtoruk Date: Mon, 4 May 2026 18:01:13 -0700 Subject: [PATCH 5/5] Enhance GNOME extension with scrollable UI, dark mode, charts, and performance fixes - Add vertical scroll for popup content and horizontal scroll for 6+ provider tabs - Add token histogram chart with hover tooltips showing date, in/out tokens, cost - Add skeleton loading animation with stale-while-revalidate caching (5min TTL) - Add dark/light theme support with force-dark-mode setting - Add exact costs toggle for full decimal values - Add right-aligned columns for cost, turns, oneshot, and model data - Remove unsupported St CSS properties (text-align, letter-spacing) - Fix post-destroy crash: guard async callbacks, abort Soup session on teardown - Fix dataClient double-resolve race with settled guard - Expand PATH resolution for volta, bun, cargo, asdf, fnm, pnpm - Reduce CLI timeout from 45s to 15s and cache augmented PATH - Remove unused imports (Pango, Main) and dead constants - Show 10 top activities, remove Plan pill --- gnome/dataClient.js | 50 ++- gnome/indicator.js | 370 ++++++++++++------ gnome/prefs.js | 14 + ...nome.shell.extensions.codeburn.gschema.xml | 12 + gnome/stylesheet.css | 209 ++++++++-- 5 files changed, 485 insertions(+), 170 deletions(-) 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/indicator.js b/gnome/indicator.js index 69bce6c..64199c1 100644 --- a/gnome/indicator.js +++ b/gnome/indicator.js @@ -4,16 +4,13 @@ import Gio from 'gi://Gio'; import GLib from 'gi://GLib'; import Clutter from 'gi://Clutter'; import Soup from 'gi://Soup?version=3.0'; -import Pango from 'gi://Pango'; 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 = 60_000; -const TOP_ACTIVITIES = 5; +const CACHE_TTL_MS = 300_000; +const TOP_ACTIVITIES = 10; const CHART_HEIGHT = 52; -const CHART_BAR_WIDTH = 12; const BAR_TRACK_WIDTH = 240; const PERIODS = [ @@ -30,7 +27,6 @@ const INSIGHTS = [ { id: 'forecast', label: 'Forecast' }, { id: 'pulse', label: 'Pulse' }, { id: 'stats', label: 'Stats' }, - { id: 'plan', label: 'Plan' }, ]; const PROVIDERS = [ @@ -76,12 +72,14 @@ const PROVIDER_PATHS = { pi: '.pi/agent/sessions', }; -function formatCost(value, currency, rate = 1) { +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 (abs >= 1000) return `${symbol}${(n / 1000).toFixed(abs >= 10000 ? 0 : 1)}k`; - return `${symbol}${n.toFixed(2)}`; + 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 formatTokensCompact(n) { @@ -118,6 +116,7 @@ class CodeBurnIndicator extends PanelMenu.Button { 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(); @@ -126,6 +125,8 @@ class CodeBurnIndicator extends PanelMenu.Button { 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()); @@ -162,24 +163,40 @@ class CodeBurnIndicator extends PanelMenu.Button { // -- Popup -- _buildPopup() { - this.menu.box.add_style_class_name('codeburn-menu'); - this._popupHost = new PopupMenu.PopupBaseMenuItem({ reactive: false, can_focus: false }); - this._popupHost.add_style_class_name('codeburn-host'); - this.menu.addMenuItem(this._popupHost); - - this._root = new St.BoxLayout({ vertical: true, style_class: 'codeburn-root', x_expand: true }); - this._popupHost.add_child(this._root); - - this._buildBrandHeader(); - this._buildAgentTabs(); - this._buildHero(); - this._buildPeriodTabs(); - this._buildInsightPills(); - this._buildTokenChart(); - this._buildContentArea(); - this._buildBudgetAlert(); - this._buildFindingsSection(); - this._buildFooter(); + 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() { @@ -207,13 +224,14 @@ class CodeBurnIndicator extends PanelMenu.Button { 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._root.add_child(row); + 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: true }); + 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(); @@ -222,7 +240,17 @@ class CodeBurnIndicator extends PanelMenu.Button { this._agentTabRow.add_child(btn); this._agentTabs.set(p.id, btn); } - this._root.add_child(this._agentTabRow); + 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(); } @@ -245,7 +273,7 @@ class CodeBurnIndicator extends PanelMenu.Button { hero.add_child(topLine); hero.add_child(this._heroAmount); hero.add_child(this._heroMeta); - this._root.add_child(hero); + this._scrollContent.add_child(hero); } _buildPeriodTabs() { @@ -261,7 +289,7 @@ class CodeBurnIndicator extends PanelMenu.Button { row.add_child(btn); this._periodTabs.set(p.id, btn); } - this._root.add_child(row); + this._scrollContent.add_child(row); this._updatePeriodTabStyle(); } @@ -285,7 +313,7 @@ class CodeBurnIndicator extends PanelMenu.Button { row.add_child(btn); this._insightPills.set(i.id, btn); } - this._root.add_child(row); + this._scrollContent.add_child(row); this._updateInsightPillStyle(); } @@ -297,26 +325,27 @@ class CodeBurnIndicator extends PanelMenu.Button { } _buildTokenChart() { - const chart = new St.BoxLayout({ vertical: true, style_class: 'codeburn-chart' }); + 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); - chart.add_child(header); + this._chartContainer.add_child(header); this._chartBars = new St.BoxLayout({ style_class: 'codeburn-chart-bars' }); - chart.add_child(this._chartBars); - this._root.add_child(chart); + this._chartContainer.add_child(this._chartBars); + this._scrollContent.add_child(this._chartContainer); } _buildContentArea() { + this._scrollContent.add_child(new St.Widget({ style_class: 'codeburn-divider' })); this._contentArea = new St.BoxLayout({ vertical: true, style_class: 'codeburn-content' }); - this._root.add_child(this._contentArea); + this._scrollContent.add_child(this._contentArea); } _buildBudgetAlert() { this._budgetLabel = new St.Label({ text: '', style_class: 'codeburn-budget-warning', visible: false }); - this._root.add_child(this._budgetLabel); + this._scrollContent.add_child(this._budgetLabel); } _buildFindingsSection() { @@ -328,45 +357,87 @@ class CodeBurnIndicator extends PanelMenu.Button { box.add_child(this._findingsSavings); this._findingsBtn.set_child(box); this._findingsBtn.connect('clicked', () => this._spawnTerminal(['codeburn', 'optimize'])); - this._root.add_child(this._findingsBtn); + this._scrollContent.add_child(this._findingsBtn); } - _buildFooter() { - const footer = new St.BoxLayout({ style_class: 'codeburn-footer' }); + _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); + } + + _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 currencyBox = new St.BoxLayout({ vertical: true, style_class: 'codeburn-currency-box' }); - this._currencyBtn = new St.Button({ - label: `${this._currency.code} ⌄`, - style_class: 'codeburn-footer-btn codeburn-currency-btn', - can_focus: true, + _hideLoading() { + if (!this._loadingBox) return; + this._loadingBox.visible = false; + this._loadingBox.get_children().forEach(bar => { + bar.remove_all_transitions(); + bar.opacity = 255; }); - this._currencyBtn.connect('clicked', () => this._toggleCurrencyPicker()); - currencyBox.add_child(this._currencyBtn); - this._currencyPicker = new St.BoxLayout({ vertical: true, style_class: 'codeburn-currency-picker', visible: false }); + } + + _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 }); + 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(); - this._currencyPicker.get_children().forEach(ch => ch.remove_style_class_name('codeburn-currency-item-active')); + pickerList.get_children().forEach(ch => ch.remove_style_class_name('codeburn-currency-item-active')); item.add_style_class_name('codeburn-currency-item-active'); }); - this._currencyPicker.add_child(item); + pickerList.add_child(item); } - currencyBox.add_child(this._currencyPicker); - footer.add_child(currencyBox); + 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 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: 'Open Full Report', style_class: 'codeburn-footer-btn codeburn-footer-cta', can_focus: true, x_expand: true }); + 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({ style_class: 'codeburn-footer-btn codeburn-prefs-btn', can_focus: true }); - prefsBtn.set_child(new St.Icon({ icon_name: 'emblem-system-symbolic', style_class: 'codeburn-prefs-icon' })); + 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(); @@ -398,6 +469,11 @@ class CodeBurnIndicator extends PanelMenu.Button { }); 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._payload) this._render(this._payload); }); @@ -450,26 +526,42 @@ class CodeBurnIndicator extends PanelMenu.Button { async _refresh(force = false) { const key = this._cacheKey(); const cached = this._payloadCache.get(key); - if (!force && cached && (Date.now() - cached.fetchedAt) < CACHE_TTL_MS) { + const cacheAge = cached ? Date.now() - cached.fetchedAt : Infinity; + + if (!force && cached && cacheAge < CACHE_TTL_MS) { this._payload = cached.payload; this._render(this._payload); return; } + 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 (gen !== this._refreshGen) return; + 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}`); @@ -483,9 +575,9 @@ class CodeBurnIndicator extends PanelMenu.Button { const current = payload?.current ?? {}; const cost = Number(current.cost ?? 0); - this._panelLabel.set_text(formatCost(cost, this._currency, this._fxRate)); + this._panelLabel.set_text(this._fmt(cost)); this._heroLabel.set_text(current.label || ''); - this._heroAmount.set_text(formatCost(cost, this._currency, this._fxRate)); + this._heroAmount.set_text(this._fmt(cost)); const calls = Number(current.calls ?? 0); const sessions = Number(current.sessions ?? 0); @@ -504,30 +596,65 @@ class CodeBurnIndicator extends PanelMenu.Button { this._chartBars.destroy_all_children(); const days = Array.isArray(daily) ? daily.slice(-19) : []; if (days.length === 0) { - this._chartTotal.set_text('no history yet'); + this._chartContainer.visible = false; return; } - const totals = days.map(d => { - return (Number(d?.inputTokens) || 0) + (Number(d?.outputTokens) || 0) + - (Number(d?.cacheReadTokens) || 0) + (Number(d?.cacheWriteTokens) || 0); - }); + 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 totalAll = 0; - for (const t of totals) { - if (t > maxTotal) maxTotal = t; - totalAll += t; + 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]; } - this._chartTotal.set_text(`${formatTokensCompact(totalAll)} tokens`); + 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' }); + 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(CHART_BAR_WIDTH); + 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._chartBars.add_child(col); } } @@ -539,7 +666,6 @@ class CodeBurnIndicator extends PanelMenu.Button { case 'forecast': return this._renderForecastView(); case 'pulse': return this._renderPulseView(); case 'stats': return this._renderStatsView(); - case 'plan': return this._renderPlanView(); default: return this._renderActivityView(); } } @@ -549,9 +675,9 @@ class CodeBurnIndicator extends PanelMenu.Button { 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', min_width: 64 })); - actHeader.add_child(new St.Label({ text: 'Turns', style_class: 'codeburn-th codeburn-th-right', min_width: 40 })); - actHeader.add_child(new St.Label({ text: '1-shot', style_class: 'codeburn-th codeburn-th-right', min_width: 40 })); + 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 : []; @@ -570,8 +696,8 @@ class CodeBurnIndicator extends PanelMenu.Button { 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', min_width: 64 })); - modHeader.add_child(new St.Label({ text: 'Calls', style_class: 'codeburn-th codeburn-th-right', min_width: 50 })); + 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)); @@ -581,7 +707,6 @@ class CodeBurnIndicator extends PanelMenu.Button { _renderTrendView() { const daily = this._payload?.history?.daily ?? []; - this._contentArea.add_child(this._sectionTitle('Trend')); if (!daily.length) { this._contentArea.add_child(new St.Label({ text: 'Not enough history yet', style_class: 'codeburn-empty' })); return; @@ -589,15 +714,18 @@ class CodeBurnIndicator extends PanelMenu.Button { 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 })); - row.add_child(new St.Label({ text: formatCost(d.cost, this._currency, this._fxRate), style_class: 'codeburn-trend-cost' })); - row.add_child(new St.Label({ text: `${Number(d.calls).toLocaleString()} calls`, style_class: 'codeburn-trend-calls' })); + 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); } } _renderForecastView() { const daily = this._payload?.history?.daily ?? []; - this._contentArea.add_child(this._sectionTitle('Forecast')); if (daily.length < 3) { this._contentArea.add_child(new St.Label({ text: 'Need at least 3 days of history', style_class: 'codeburn-empty' })); return; @@ -612,10 +740,10 @@ class CodeBurnIndicator extends PanelMenu.Button { const dayOfMonth = now.getUTCDate(); const daysInMonth = new Date(now.getUTCFullYear(), now.getUTCMonth() + 1, 0).getUTCDate(); - this._contentArea.add_child(this._kvRow('7-day avg', formatCost(avg, this._currency, this._fxRate))); - this._contentArea.add_child(this._kvRow('Yesterday', yesterday ? formatCost(yestCost, this._currency, this._fxRate) : '-')); + 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', formatCost(avg * daysInMonth, this._currency, this._fxRate))); + this._contentArea.add_child(this._kvRow('Month projection', this._fmt(avg * daysInMonth))); this._contentArea.add_child(this._kvRow('Days elapsed', `${dayOfMonth} of ${daysInMonth}`)); } @@ -624,7 +752,7 @@ class CodeBurnIndicator extends PanelMenu.Button { 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(formatCost(current.cost, this._currency, this._fxRate), 'cost')); + 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); @@ -635,9 +763,9 @@ class CodeBurnIndicator extends PanelMenu.Button { 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', formatCost(sumCost, this._currency, this._fxRate))); + 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 || '-'} ${formatCost(peakDay?.cost, this._currency, this._fxRate)}`)); + this._contentArea.add_child(this._kvRow('Peak day', `${peakDay?.date || '-'} ${this._fmt(peakDay?.cost)}`)); } } @@ -657,20 +785,7 @@ class CodeBurnIndicator extends PanelMenu.Button { 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} ${formatCost(peakDay.cost, this._currency, this._fxRate)}`)); - } - - _renderPlanView() { - this._contentArea.add_child(this._sectionTitle('Plan')); - const msg = new St.Label({ - text: 'Subscription tracking coming to Linux in a future release.', - style_class: 'codeburn-empty', - x_expand: true, - }); - msg.clutter_text.line_wrap = true; - msg.clutter_text.line_wrap_mode = Pango.WrapMode.WORD; - msg.clutter_text.ellipsize = Pango.EllipsizeMode.NONE; - this._contentArea.add_child(msg); + if (peakDay) this._contentArea.add_child(this._kvRow('Peak day', `${peakDay.date} ${this._fmt(peakDay.cost)}`)); } _renderFindings(optimize) { @@ -681,7 +796,7 @@ class CodeBurnIndicator extends PanelMenu.Button { } const savings = Number(optimize?.savingsUSD ?? 0); this._findingsCount.set_text(`${count} optimize findings`); - this._findingsSavings.set_text(`save ~${formatCost(savings, this._currency, this._fxRate)}`); + this._findingsSavings.set_text(`save ~${this._fmt(savings)}`); this._findingsBtn.show(); } @@ -710,7 +825,7 @@ class CodeBurnIndicator extends PanelMenu.Button { const cost = Number(this._payload.current.cost ?? 0) * this._fxRate; const thresholdConverted = threshold * this._fxRate; if (cost >= thresholdConverted) { - this._budgetLabel.set_text(`Budget exceeded: ${formatCost(cost, this._currency, 1)} / ${formatCost(thresholdConverted, this._currency, 1)}`); + this._budgetLabel.set_text(`Budget exceeded: ${this._fmt(cost)} / ${this._fmt(thresholdConverted)}`); this._budgetLabel.visible = true; } else { this._budgetLabel.visible = false; @@ -759,6 +874,7 @@ class CodeBurnIndicator extends PanelMenu.Button { 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; @@ -773,6 +889,10 @@ class CodeBurnIndicator extends PanelMenu.Button { }); } + _fmt(value) { + return formatCost(value, this._currency, this._fxRate, this._exactCosts); + } + // -- UI helpers -- _sectionTitle(text) { @@ -797,10 +917,16 @@ class CodeBurnIndicator extends PanelMenu.Button { 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 })); - topLine.add_child(new St.Label({ text: formatCost(activity.cost, this._currency, this._fxRate), style_class: 'codeburn-activity-cost' })); - topLine.add_child(new St.Label({ text: `${Number(activity.turns) || 0}t`, style_class: 'codeburn-activity-turns' })); + 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)}%` : '--'; - topLine.add_child(new St.Label({ text: osText, style_class: 'codeburn-activity-oneshot' })); + 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' }); @@ -815,18 +941,28 @@ class CodeBurnIndicator extends PanelMenu.Button { _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 })); - row.add_child(new St.Label({ text: formatCost(model.cost, this._currency, this._fxRate), style_class: 'codeburn-model-cost' })); - row.add_child(new St.Label({ text: `${Number(model.calls || 0).toLocaleString()}`, style_class: 'codeburn-model-calls' })); + 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 = scheme === 'prefer-dark'; - this.add_style_class_name(isDark ? 'codeburn-dark' : 'codeburn-light'); - this.remove_style_class_name(isDark ? 'codeburn-light' : 'codeburn-dark'); + 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._root?.add_style_class_name('codeburn-light'); + this._root?.remove_style_class_name('codeburn-dark'); + } } // -- Terminal spawning -- @@ -844,6 +980,7 @@ class CodeBurnIndicator extends PanelMenu.Button { // -- Cleanup -- destroy() { + this._destroyed = true; if (this._refreshSourceId) { GLib.Source.remove(this._refreshSourceId); this._refreshSourceId = 0; @@ -856,7 +993,10 @@ class CodeBurnIndicator extends PanelMenu.Button { for (const id of this._settingsChangedIds) this._settings.disconnect(id); this._settingsChangedIds = []; this._dataClient?.destroy(); - this._soupSession = null; + if (this._soupSession) { + this._soupSession.abort(); + this._soupSession = null; + } super.destroy(); } }); 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 2784188..74bf896 100644 --- a/gnome/stylesheet.css +++ b/gnome/stylesheet.css @@ -29,9 +29,13 @@ } .codeburn-root { width: 340px; + height: 540px; padding: 0; spacing: 0; } +.codeburn-scroll { + padding: 0; +} /* ---- brand header ---- */ .codeburn-brand-header { @@ -53,7 +57,6 @@ .codeburn-brand-subhead { font-size: 10.5px; opacity: 0.55; - letter-spacing: 0.3px; } /* ---- tab rows ---- */ @@ -88,6 +91,9 @@ opacity: 1; font-weight: 600; } +.codeburn-agent-scroll { + padding: 0; +} .codeburn-agent-badge { padding: 3px 10px; border-radius: 10px; @@ -138,17 +144,20 @@ .codeburn-table-header { spacing: 6px; padding: 2px 0 4px 0; - border-bottom: 1px solid rgba(255, 255, 255, 0.1); } .codeburn-th { font-size: 10px; font-weight: 600; opacity: 0.45; - text-transform: uppercase; - letter-spacing: 0.04em; } -.codeburn-th-right { - text-align: right; +.codeburn-th-cost { + min-width: 64px; +} +.codeburn-th-turns { + min-width: 40px; +} +.codeburn-th-calls { + min-width: 50px; } .codeburn-activity-rows { @@ -157,7 +166,6 @@ .codeburn-activity-row { spacing: 3px; padding: 6px 0; - border-bottom: 1px solid rgba(255, 255, 255, 0.06); } .codeburn-activity-top { spacing: 6px; @@ -173,22 +181,18 @@ font-weight: 600; color: #ffd700; min-width: 64px; - text-align: right; } .codeburn-activity-turns { font-size: 10.5px; font-family: monospace; opacity: 0.6; min-width: 40px; - text-align: right; } .codeburn-activity-oneshot { font-size: 10.5px; font-family: monospace; - opacity: 0.8; - color: #5bf58c; + color: #4ec972; min-width: 40px; - text-align: right; } .codeburn-bar-track { height: 4px; @@ -199,7 +203,7 @@ .codeburn-bar-fill { height: 4px; border-radius: 2px; - background: linear-gradient(to right, #ff8c42, #c9521d); + background-color: #ff8c42; } .codeburn-empty { font-style: italic; @@ -207,6 +211,19 @@ 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; @@ -238,7 +255,6 @@ .codeburn-footer { padding: 10px 12px; spacing: 6px; - border-top: 1px solid rgba(255, 255, 255, 0.06); } .codeburn-footer-btn { padding: 6px 10px; @@ -263,8 +279,10 @@ background: rgba(30, 30, 30, 0.95); border-radius: 8px; padding: 4px; + height: 180px; +} +.codeburn-currency-list { spacing: 1px; - max-height: 200px; } .codeburn-currency-item { padding: 4px 10px; @@ -273,7 +291,6 @@ font-family: monospace; background: transparent; border: none; - text-align: left; } .codeburn-currency-item:hover { background: rgba(255, 140, 66, 0.12); @@ -346,16 +363,26 @@ height: 52px; } .codeburn-chart-col { - width: 12px; height: 52px; } .codeburn-chart-spacer { background: transparent; } .codeburn-chart-bar { - background: linear-gradient(to top, #c9521d, #ff8c42); + 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 { @@ -383,7 +410,6 @@ font-size: 10.5px; opacity: 0.6; min-width: 62px; - text-align: right; } /* ---- pulse tiles ---- */ @@ -406,8 +432,6 @@ .codeburn-pulse-label { font-size: 10px; opacity: 0.6; - text-transform: uppercase; - letter-spacing: 0.04em; } /* ---- models rows ---- */ @@ -418,7 +442,6 @@ .codeburn-model-row { spacing: 8px; padding: 6px 0; - border-bottom: 1px solid rgba(255, 255, 255, 0.06); } .codeburn-model-name { font-size: 11.5px; @@ -429,23 +452,18 @@ font-size: 11.5px; color: #ffd700; min-width: 64px; - text-align: right; } .codeburn-model-calls { font-family: monospace; font-size: 10.5px; opacity: 0.6; min-width: 50px; - text-align: right; } /* ---- settings gear button ---- */ .codeburn-prefs-btn { padding: 6px 8px; -} -.codeburn-prefs-icon { - icon-size: 14px; - opacity: 0.7; + font-size: 14px; } /* ---- budget warning ---- */ @@ -456,26 +474,137 @@ padding: 6px 16px; } -/* ---- dark / light theme hooks ---- */ +/* ---- 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-footer-btn { - background: rgba(0, 0, 0, 0.04); +.codeburn-light .codeburn-bar-fill { + background-color: #c9521d; } -.codeburn-light .codeburn-footer-btn:hover { - background: rgba(0, 0, 0, 0.08); +.codeburn-light .codeburn-chart-bar { + background-color: #c9521d; } -.codeburn-light .codeburn-footer { - border-top-color: rgba(0, 0, 0, 0.08); +.codeburn-light .codeburn-footer-btn { + background: rgba(0, 0, 0, 0.06); + color: #1a1a1a; } -.codeburn-light .codeburn-activity-row, -.codeburn-light .codeburn-model-row { - border-bottom-color: rgba(0, 0, 0, 0.06); +.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-table-header { - border-bottom-color: rgba(0, 0, 0, 0.1); +.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); }