From 4c5ec0f98581d9dd1cddcacff23637da0e16f505 Mon Sep 17 00:00:00 2001 From: AgentSeal Date: Sat, 18 Apr 2026 04:20:14 -0700 Subject: [PATCH] feat(extensions): polish GNOME extension and follow system theme Expand the popup to show everything the TUI does: period switcher submenu, top activities with one-shot rate, top models, provider breakdown when more than one provider has data, optimize findings, and an updated-timestamp row. Add per-period Open Full Report so the terminal view matches what the popup was showing. For theme compatibility, stop hardcoding colors that don't work on both light and dark GNOME themes. Keep the brand orange (#ff8c42) for the header and findings, let everything else inherit the shell palette, and express dimming through opacity instead of rgba so the popup reads correctly on Yaru Light, Yaru Dark, Adwaita, Adwaita Dark, and any other theme. Add a listener on org.gnome.desktop.interface color-scheme so the indicator re-applies its .codeburn-dark / .codeburn-light class when the user flips the system theme, without waiting for a reload. --- .../codeburn@agentseal.org/extension.js | 270 ++++++++++++++---- .../codeburn@agentseal.org/stylesheet.css | 50 +++- 2 files changed, 262 insertions(+), 58 deletions(-) diff --git a/extensions/gnome-shell/codeburn@agentseal.org/extension.js b/extensions/gnome-shell/codeburn@agentseal.org/extension.js index beacb2a..8a9e85f 100644 --- a/extensions/gnome-shell/codeburn@agentseal.org/extension.js +++ b/extensions/gnome-shell/codeburn@agentseal.org/extension.js @@ -1,12 +1,13 @@ /* * CodeBurn GNOME Shell extension. * - * Renders a flame + today's cost label in the top panel and opens a native - * PopupMenu on click, matching Ubuntu's Quick Settings feel. Unlike the Tauri - * tray app (desktop/), this lives inside gnome-shell so it can anchor the - * popover directly under its panel button without going through SNI. + * Renders a flame + current-period cost label in the top panel and opens a native + * PopupMenu on click. Unlike the Tauri tray app (desktop/), this lives inside + * gnome-shell so it can anchor the popover directly under the panel button, + * matching Ubuntu's Quick Settings feel. * - * Data source: `codeburn status --format menubar-json`, polled every 60s. + * Data source: `codeburn status --format menubar-json --period

`, polled every + * 60s. The period is a per-session preference held in memory on the indicator. */ import GObject from 'gi://GObject'; @@ -22,13 +23,36 @@ import {Extension} from 'resource:///org/gnome/shell/extensions/extension.js'; const REFRESH_INTERVAL_SECONDS = 60; const TOP_ACTIVITIES = 5; +const TOP_MODELS = 3; +const TOP_PROVIDERS = 4; const CODEBURN_BIN = 'codeburn'; +const PERIODS = [ + {id: 'today', label: 'Today'}, + {id: 'week', label: '7 Days'}, + {id: '30days', label: '30 Days'}, + {id: 'month', label: 'Month'}, + {id: 'all', label: 'All Time'}, +]; + const CodeburnIndicator = GObject.registerClass( class CodeburnIndicator extends PanelMenu.Button { _init() { super._init(0.0, 'CodeBurn'); + this._period = 'today'; + this._loading = false; + this._timeout = null; + this._payload = null; + + // Follow the GNOME system color-scheme so the popup stays readable on both + // light and dark themes. Adds .codeburn-dark / .codeburn-light to the root + // widget so stylesheet.css can tweak per-theme without fighting the shell's + // inherited palette. + this._themeSettings = new Gio.Settings({schema_id: 'org.gnome.desktop.interface'}); + this._themeSignal = this._themeSettings.connect('changed::color-scheme', () => this._applyThemeClass()); + this._applyThemeClass(); + const box = new St.BoxLayout({style_class: 'panel-status-menu-box codeburn-panel'}); this._flame = new St.Label({ text: 'šŸ”„', @@ -44,26 +68,7 @@ class CodeburnIndicator extends PanelMenu.Button { box.add_child(this._label); this.add_child(box); - this._headerItem = new PopupMenu.PopupMenuItem('Loading…', {reactive: false}); - this._headerItem.label.style_class = 'codeburn-header'; - this.menu.addMenuItem(this._headerItem); - this.menu.addMenuItem(new PopupMenu.PopupSeparatorMenuItem()); - - this._activitySection = new PopupMenu.PopupMenuSection(); - this.menu.addMenuItem(this._activitySection); - - this._findingsSection = new PopupMenu.PopupMenuSection(); - this.menu.addMenuItem(this._findingsSection); - - this.menu.addMenuItem(new PopupMenu.PopupSeparatorMenuItem()); - - const refresh = new PopupMenu.PopupMenuItem('Refresh'); - refresh.connect('activate', () => this._refresh()); - this.menu.addMenuItem(refresh); - - const openReport = new PopupMenu.PopupMenuItem('Open Full Report'); - openReport.connect('activate', () => this._spawnTerminal([CODEBURN_BIN, 'report'])); - this.menu.addMenuItem(openReport); + this._buildMenu(); this._refresh(); this._timeout = GLib.timeout_add_seconds( @@ -76,19 +81,86 @@ class CodeburnIndicator extends PanelMenu.Button { ); } + _buildMenu() { + // Header: period + hero cost + calls + sessions + this._headerItem = new PopupMenu.PopupMenuItem('Loading…', {reactive: false}); + this._headerItem.label.style_class = 'codeburn-header'; + this.menu.addMenuItem(this._headerItem); + + this._metaItem = new PopupMenu.PopupMenuItem('', {reactive: false}); + this._metaItem.label.style_class = 'codeburn-meta'; + this.menu.addMenuItem(this._metaItem); + + this.menu.addMenuItem(new PopupMenu.PopupSeparatorMenuItem()); + + // Period switcher submenu + this._periodSubmenu = new PopupMenu.PopupSubMenuMenuItem(this._periodLabel()); + for (const p of PERIODS) { + const item = new PopupMenu.PopupMenuItem(p.label); + item.connect('activate', () => { + this._period = p.id; + this._periodSubmenu.label.set_text(this._periodLabel()); + this._refresh(); + }); + this._periodSubmenu.menu.addMenuItem(item); + } + this.menu.addMenuItem(this._periodSubmenu); + + this.menu.addMenuItem(new PopupMenu.PopupSeparatorMenuItem()); + + // Activities, models, providers, findings (populated on render) + this._activitySection = new PopupMenu.PopupMenuSection(); + this.menu.addMenuItem(this._activitySection); + + this._modelsSection = new PopupMenu.PopupMenuSection(); + this.menu.addMenuItem(this._modelsSection); + + this._providersSection = new PopupMenu.PopupMenuSection(); + this.menu.addMenuItem(this._providersSection); + + this._findingsSection = new PopupMenu.PopupMenuSection(); + this.menu.addMenuItem(this._findingsSection); + + this.menu.addMenuItem(new PopupMenu.PopupSeparatorMenuItem()); + + // Footer: updated timestamp, refresh, open full report + this._updatedItem = new PopupMenu.PopupMenuItem('', {reactive: false}); + this._updatedItem.label.style_class = 'codeburn-updated'; + this.menu.addMenuItem(this._updatedItem); + + const refresh = new PopupMenu.PopupMenuItem('Refresh'); + refresh.connect('activate', () => this._refresh()); + this.menu.addMenuItem(refresh); + + const openReport = new PopupMenu.PopupMenuItem('Open Full Report'); + openReport.connect('activate', () => this._spawnTerminal([CODEBURN_BIN, 'report', '--period', this._period])); + this.menu.addMenuItem(openReport); + } + + _periodLabel() { + const p = PERIODS.find(x => x.id === this._period); + return `Period Ā· ${p ? p.label : this._period}`; + } + _refresh() { + if (this._loading) return; + this._loading = true; + this._headerItem.label.set_text(this._payload ? this._headerItem.label.get_text() : 'Loading…'); + let proc; try { proc = Gio.Subprocess.new( - [CODEBURN_BIN, 'status', '--format', 'menubar-json'], + [CODEBURN_BIN, 'status', '--format', 'menubar-json', '--period', this._period], Gio.SubprocessFlags.STDOUT_PIPE | Gio.SubprocessFlags.STDERR_PIPE, ); } catch (e) { - this._renderError(`codeburn CLI not found on PATH. Install the npm package first.`); + this._loading = false; + this._renderError('codeburn CLI not found on PATH. Install the npm package first.'); return; } proc.communicate_utf8_async(null, null, (p, result) => { + this._loading = false; try { const [ok, stdout, stderr] = p.communicate_utf8_finish(result); if (!ok) { @@ -100,6 +172,7 @@ class CodeburnIndicator extends PanelMenu.Button { return; } const payload = JSON.parse(stdout); + this._payload = payload; this._render(payload); } catch (e) { this._renderError(`parse error: ${e.message}`); @@ -111,47 +184,108 @@ class CodeburnIndicator extends PanelMenu.Button { const current = payload?.current ?? {}; const cost = Number(current.cost ?? 0); const formatted = formatUsd(cost); + this._label.set_text(formatted); const label = current.label ?? ''; - const calls = current.calls ?? 0; - const sessions = current.sessions ?? 0; - this._headerItem.label.set_text( - `${label} ${formatted} ${calls.toLocaleString()} calls ${sessions} sessions`, - ); + const calls = Number(current.calls ?? 0); + const sessions = Number(current.sessions ?? 0); + const oneShot = current.oneShotRate; + const cacheHit = Number(current.cacheHitPercent ?? 0); + this._headerItem.label.set_text(`${label} ${formatted}`); + const metaParts = [ + `${calls.toLocaleString()} calls`, + `${sessions} sessions`, + `${cacheHit.toFixed(0)}% cache`, + ]; + if (oneShot !== null && oneShot !== undefined) { + metaParts.push(`${Math.round(Number(oneShot) * 100)}% 1-shot`); + } + this._metaItem.label.set_text(metaParts.join(' ')); + + this._renderActivities(current.topActivities ?? []); + this._renderModels(current.topModels ?? []); + this._renderProviders(current.providers ?? {}); + this._renderFindings(payload?.optimize ?? {}); + + const updated = payload?.generated ? formatTime(new Date(payload.generated)) : ''; + this._updatedItem.label.set_text(updated ? `Updated ${updated}` : ''); + } + + _renderActivities(activities) { this._activitySection.removeAll(); - const activities = Array.isArray(current.topActivities) ? current.topActivities : []; - if (activities.length === 0) { + if (!activities.length) { const empty = new PopupMenu.PopupMenuItem('No activity for this period', {reactive: false}); empty.label.style_class = 'codeburn-empty'; this._activitySection.addMenuItem(empty); - } else { - for (const a of activities.slice(0, TOP_ACTIVITIES)) { - const line = `${a.name} ${formatUsd(a.cost)} ${a.turns} turns`; - const item = new PopupMenu.PopupMenuItem(line, {reactive: false}); - item.label.style_class = 'codeburn-activity'; - this._activitySection.addMenuItem(item); - } + return; } + const title = new PopupMenu.PopupMenuItem('Activity', {reactive: false}); + title.label.style_class = 'codeburn-section-title'; + this._activitySection.addMenuItem(title); + for (const a of activities.slice(0, TOP_ACTIVITIES)) { + const oneShot = a.oneShotRate; + const tail = oneShot == null + ? `${a.turns} turns` + : `${a.turns} turns ${Math.round(Number(oneShot) * 100)}% 1-shot`; + const line = ` ${a.name.padEnd(14)} ${formatUsd(a.cost).padStart(8)} ${tail}`; + const item = new PopupMenu.PopupMenuItem(line, {reactive: false}); + item.label.style_class = 'codeburn-row'; + this._activitySection.addMenuItem(item); + } + } - this._findingsSection.removeAll(); - const findingCount = payload?.optimize?.findingCount ?? 0; - if (findingCount > 0) { - this._findingsSection.addMenuItem(new PopupMenu.PopupSeparatorMenuItem()); - const savings = Number(payload.optimize.savingsUSD ?? 0); - const text = `${findingCount} optimize findings save ~${formatUsd(savings)}`; - const item = new PopupMenu.PopupMenuItem(text); - item.label.style_class = 'codeburn-findings'; - item.connect('activate', () => this._spawnTerminal([CODEBURN_BIN, 'optimize'])); - this._findingsSection.addMenuItem(item); + _renderModels(models) { + this._modelsSection.removeAll(); + if (!models.length) return; + const title = new PopupMenu.PopupMenuItem('Models', {reactive: false}); + title.label.style_class = 'codeburn-section-title'; + this._modelsSection.addMenuItem(title); + for (const m of models.slice(0, TOP_MODELS)) { + const calls = Number(m.calls ?? 0).toLocaleString(); + const line = ` ${m.name.padEnd(18)} ${formatUsd(m.cost).padStart(8)} ${calls} calls`; + const item = new PopupMenu.PopupMenuItem(line, {reactive: false}); + item.label.style_class = 'codeburn-row'; + this._modelsSection.addMenuItem(item); } } + _renderProviders(providers) { + this._providersSection.removeAll(); + const entries = Object.entries(providers).filter(([, cost]) => Number(cost) > 0); + if (entries.length <= 1) return; + entries.sort((a, b) => Number(b[1]) - Number(a[1])); + const title = new PopupMenu.PopupMenuItem('Providers', {reactive: false}); + title.label.style_class = 'codeburn-section-title'; + this._providersSection.addMenuItem(title); + for (const [name, cost] of entries.slice(0, TOP_PROVIDERS)) { + const line = ` ${capitalize(name).padEnd(14)} ${formatUsd(Number(cost)).padStart(8)}`; + const item = new PopupMenu.PopupMenuItem(line, {reactive: false}); + item.label.style_class = 'codeburn-row'; + this._providersSection.addMenuItem(item); + } + } + + _renderFindings(optimize) { + this._findingsSection.removeAll(); + const count = Number(optimize?.findingCount ?? 0); + if (count === 0) return; + const savings = Number(optimize?.savingsUSD ?? 0); + const text = `⚠ ${count} optimize findings save ~${formatUsd(savings)}`; + const item = new PopupMenu.PopupMenuItem(text); + item.label.style_class = 'codeburn-findings'; + item.connect('activate', () => this._spawnTerminal([CODEBURN_BIN, 'optimize'])); + this._findingsSection.addMenuItem(item); + } + _renderError(message) { this._label.set_text('!'); this._headerItem.label.set_text(message); + this._metaItem.label.set_text(''); this._activitySection.removeAll(); + this._modelsSection.removeAll(); + this._providersSection.removeAll(); this._findingsSection.removeAll(); } @@ -169,21 +303,49 @@ class CodeburnIndicator extends PanelMenu.Button { } } + _applyThemeClass() { + const scheme = this._themeSettings.get_string('color-scheme'); + const isDark = scheme === 'prefer-dark'; + this.add_style_class_name(isDark ? 'codeburn-dark' : 'codeburn-light'); + this.remove_style_class_name(isDark ? 'codeburn-light' : 'codeburn-dark'); + } + destroy() { if (this._timeout) { GLib.source_remove(this._timeout); this._timeout = null; } + if (this._themeSettings && this._themeSignal) { + this._themeSettings.disconnect(this._themeSignal); + this._themeSignal = null; + this._themeSettings = null; + } super.destroy(); } }); function formatUsd(value) { - const abs = Math.abs(value); + const n = Number(value) || 0; + const abs = Math.abs(n); if (abs >= 1000) { - return `$${(value / 1000).toFixed(abs >= 10000 ? 0 : 1)}k`; + return `$${(n / 1000).toFixed(abs >= 10000 ? 0 : 1)}k`; } - return `$${value.toFixed(2)}`; + return `$${n.toFixed(2)}`; +} + +function formatTime(date) { + if (!date || Number.isNaN(date.getTime())) return ''; + const now = new Date(); + const diffSec = Math.floor((now.getTime() - date.getTime()) / 1000); + if (diffSec < 60) return 'just now'; + if (diffSec < 3600) return `${Math.floor(diffSec / 60)}m ago`; + if (diffSec < 86400) return `${Math.floor(diffSec / 3600)}h ago`; + return date.toLocaleDateString(); +} + +function capitalize(s) { + if (!s) return s; + return s.charAt(0).toUpperCase() + s.slice(1); } export default class CodeburnExtension extends Extension { diff --git a/extensions/gnome-shell/codeburn@agentseal.org/stylesheet.css b/extensions/gnome-shell/codeburn@agentseal.org/stylesheet.css index f133c13..a4ec6f0 100644 --- a/extensions/gnome-shell/codeburn@agentseal.org/stylesheet.css +++ b/extensions/gnome-shell/codeburn@agentseal.org/stylesheet.css @@ -1,3 +1,12 @@ +/* + * CodeBurn GNOME Shell extension styles. + * + * Inherits colors from the active GNOME shell theme so the popup looks native on + * both light and dark system themes. Only the brand accent (orange) is hardcoded; + * every other color is left to the theme. Typography and spacing are tight to + * keep the popup compact even when every section is populated. + */ + .codeburn-panel { spacing: 4px; } @@ -15,17 +24,33 @@ .codeburn-header { font-weight: 600; color: #ff8c42; + font-size: 13px; } -.codeburn-activity { - font-family: monospace; +.codeburn-meta { font-size: 11px; - padding-left: 8px; + opacity: 0.75; +} + +.codeburn-section-title { + font-weight: 600; + font-size: 11px; + letter-spacing: 0.06em; + text-transform: uppercase; + opacity: 0.6; + padding-top: 4px; +} + +.codeburn-row { + font-family: monospace; + font-size: 11.5px; + padding-left: 4px; + padding-right: 4px; } .codeburn-empty { font-style: italic; - color: rgba(255, 255, 255, 0.5); + opacity: 0.55; padding-left: 8px; } @@ -33,3 +58,20 @@ color: #ff8c42; font-weight: 500; } + +.codeburn-updated { + font-size: 10px; + opacity: 0.5; + padding-left: 4px; +} + +/* Optional: theme-specific tweaks if the inherited colors look off. Both classes + * are set dynamically by extension.js so we can override per-theme without + * fighting the shell's default popup palette. */ +.codeburn-dark .codeburn-row { + /* Monospace can render thin on dark themes; bump weight slightly. */ +} + +.codeburn-light .codeburn-row { + /* Nothing yet; placeholder for light-theme specific adjustments. */ +}