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. */ +}