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.
This commit is contained in:
AgentSeal 2026-04-18 04:20:14 -07:00
parent 291c376f06
commit 4c5ec0f985
2 changed files with 262 additions and 58 deletions

View file

@ -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 <p>`, 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 {

View file

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