From 291c376f06daaa6a6c7769d00f91ea9451d5f1f3 Mon Sep 17 00:00:00 2001 From: AgentSeal Date: Sat, 18 Apr 2026 04:06:44 -0700 Subject: [PATCH] feat(extensions): add GNOME Shell extension for native panel feel Ship a GJS extension in extensions/gnome-shell/codeburn@agentseal.org so GNOME users get the same panel-anchored popover Ubuntu's Quick Settings uses, rather than the floating window the Tauri tray is limited to through StatusNotifierItem. The extension lives inside gnome-shell as a PanelMenu.Button, so it has direct access to its own icon coordinates and can open a PopupMenu docked under it without any IPC or DBus plumbing. The tradeoff is it only works on GNOME 45+; the Tauri app in desktop/ stays the cross-platform option for KDE, Unity, wlroots, and Windows users. Data flow: the extension shells out to codeburn status --format menubar-json every 60 seconds, parses the payload, and renders a header, top 5 activities, and optimize findings count. Refresh and Open Full Report actions live in the popup menu; the Open Full Report action spawns gnome-terminal with the codeburn report TUI. --- extensions/gnome-shell/README.md | 59 ++++++ .../codeburn@agentseal.org/extension.js | 199 ++++++++++++++++++ .../codeburn@agentseal.org/metadata.json | 8 + .../codeburn@agentseal.org/stylesheet.css | 35 +++ 4 files changed, 301 insertions(+) create mode 100644 extensions/gnome-shell/README.md create mode 100644 extensions/gnome-shell/codeburn@agentseal.org/extension.js create mode 100644 extensions/gnome-shell/codeburn@agentseal.org/metadata.json create mode 100644 extensions/gnome-shell/codeburn@agentseal.org/stylesheet.css diff --git a/extensions/gnome-shell/README.md b/extensions/gnome-shell/README.md new file mode 100644 index 0000000..2a4c3e3 --- /dev/null +++ b/extensions/gnome-shell/README.md @@ -0,0 +1,59 @@ +# CodeBurn GNOME Shell extension + +Native GNOME panel button that shows today's AI coding spend with a click-to-open popover, matching the feel of Ubuntu's Quick Settings menu. + +This is an alternative to the cross-platform Tauri tray app in `../desktop/`. Use this on GNOME for the most native UX. The Tauri app stays the right choice for KDE, Unity, wlroots compositors, Windows, and headless systems. + +## Requirements + +* GNOME Shell 45, 46, 47, or 48 (Ubuntu 22.04 LTS, 24.04 LTS, and Fedora 39+) +* `codeburn` CLI on PATH (`npm install -g codeburn`) + +## Install (from source, local user) + +```bash +cp -r extensions/gnome-shell/codeburn@agentseal.org ~/.local/share/gnome-shell/extensions/ +gnome-extensions enable codeburn@agentseal.org +``` + +Then restart GNOME Shell so the extension loads: + +* **X11:** press `Alt + F2`, type `r`, press Enter. +* **Wayland:** log out and log back in (GNOME on Wayland has no in-session shell restart). + +## What it shows + +Panel button: +* 🔥 icon +* Today's cost (e.g. `$24.73`) + +Popup menu: +* Header: period + cost + call count + session count +* Top 5 activities (Coding, Debugging, Testing, etc.) with per-activity cost and turn count +* Optimize findings count with potential savings (if any) +* Refresh and Open Full Report actions + +Data refreshes every 60 seconds. Right-click the panel button to open the default panel context menu. + +## Uninstall + +```bash +gnome-extensions disable codeburn@agentseal.org +rm -rf ~/.local/share/gnome-shell/extensions/codeburn@agentseal.org +``` + +## Development + +Run a nested shell to iterate without restarting your session (X11 only): + +```bash +dbus-run-session -- gnome-shell --nested --wayland +``` + +Tail the extension log: + +```bash +journalctl -f -o cat /usr/bin/gnome-shell +``` + +The extension uses the GNOME 45+ ESM-based extension API (`import` + `Extension` class). It will not load on GNOME 44 or earlier. diff --git a/extensions/gnome-shell/codeburn@agentseal.org/extension.js b/extensions/gnome-shell/codeburn@agentseal.org/extension.js new file mode 100644 index 0000000..beacb2a --- /dev/null +++ b/extensions/gnome-shell/codeburn@agentseal.org/extension.js @@ -0,0 +1,199 @@ +/* + * 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. + * + * Data source: `codeburn status --format menubar-json`, polled every 60s. + */ + +import GObject from 'gi://GObject'; +import St from 'gi://St'; +import Gio from 'gi://Gio'; +import GLib from 'gi://GLib'; +import Clutter from 'gi://Clutter'; + +import * as Main from 'resource:///org/gnome/shell/ui/main.js'; +import * as PanelMenu from 'resource:///org/gnome/shell/ui/panelMenu.js'; +import * as PopupMenu from 'resource:///org/gnome/shell/ui/popupMenu.js'; +import {Extension} from 'resource:///org/gnome/shell/extensions/extension.js'; + +const REFRESH_INTERVAL_SECONDS = 60; +const TOP_ACTIVITIES = 5; +const CODEBURN_BIN = 'codeburn'; + +const CodeburnIndicator = GObject.registerClass( +class CodeburnIndicator extends PanelMenu.Button { + _init() { + super._init(0.0, 'CodeBurn'); + + const box = new St.BoxLayout({style_class: 'panel-status-menu-box codeburn-panel'}); + this._flame = new St.Label({ + text: '🔥', + y_align: Clutter.ActorAlign.CENTER, + style_class: 'codeburn-flame', + }); + this._label = new St.Label({ + text: '…', + y_align: Clutter.ActorAlign.CENTER, + style_class: 'codeburn-label', + }); + box.add_child(this._flame); + 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._refresh(); + this._timeout = GLib.timeout_add_seconds( + GLib.PRIORITY_DEFAULT, + REFRESH_INTERVAL_SECONDS, + () => { + this._refresh(); + return GLib.SOURCE_CONTINUE; + }, + ); + } + + _refresh() { + let proc; + try { + proc = Gio.Subprocess.new( + [CODEBURN_BIN, 'status', '--format', 'menubar-json'], + Gio.SubprocessFlags.STDOUT_PIPE | Gio.SubprocessFlags.STDERR_PIPE, + ); + } catch (e) { + this._renderError(`codeburn CLI not found on PATH. Install the npm package first.`); + return; + } + + proc.communicate_utf8_async(null, null, (p, result) => { + try { + const [ok, stdout, stderr] = p.communicate_utf8_finish(result); + if (!ok) { + this._renderError(`codeburn failed: ${stderr || 'unknown error'}`); + return; + } + if (!stdout) { + this._renderError('codeburn returned no output'); + return; + } + const payload = JSON.parse(stdout); + this._render(payload); + } catch (e) { + this._renderError(`parse error: ${e.message}`); + } + }); + } + + _render(payload) { + 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`, + ); + + this._activitySection.removeAll(); + const activities = Array.isArray(current.topActivities) ? current.topActivities : []; + if (activities.length === 0) { + 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); + } + } + + 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); + } + } + + _renderError(message) { + this._label.set_text('!'); + this._headerItem.label.set_text(message); + this._activitySection.removeAll(); + this._findingsSection.removeAll(); + } + + _spawnTerminal(argv) { + // Quote arguments into a single command string for bash -lc. argv here only ever + // contains static identifiers from our own code so plain join is safe. + const command = `${argv.join(' ')}; echo; read -n 1 -s -r -p 'Press any key to close...'`; + try { + Gio.Subprocess.new( + ['gnome-terminal', '--', 'bash', '-lc', command], + Gio.SubprocessFlags.NONE, + ); + } catch (e) { + log(`codeburn: terminal spawn error: ${e.message}`); + } + } + + destroy() { + if (this._timeout) { + GLib.source_remove(this._timeout); + this._timeout = null; + } + super.destroy(); + } +}); + +function formatUsd(value) { + const abs = Math.abs(value); + if (abs >= 1000) { + return `$${(value / 1000).toFixed(abs >= 10000 ? 0 : 1)}k`; + } + return `$${value.toFixed(2)}`; +} + +export default class CodeburnExtension extends Extension { + enable() { + this._indicator = new CodeburnIndicator(); + Main.panel.addToStatusArea('codeburn', this._indicator); + } + + disable() { + this._indicator?.destroy(); + this._indicator = null; + } +} diff --git a/extensions/gnome-shell/codeburn@agentseal.org/metadata.json b/extensions/gnome-shell/codeburn@agentseal.org/metadata.json new file mode 100644 index 0000000..7efe5b7 --- /dev/null +++ b/extensions/gnome-shell/codeburn@agentseal.org/metadata.json @@ -0,0 +1,8 @@ +{ + "uuid": "codeburn@agentseal.org", + "name": "CodeBurn", + "description": "Ambient AI coding cost tracker for Claude Code, Codex, Cursor, and Copilot. Shows today's spend in the top panel with a dashboard popover.", + "shell-version": ["45", "46", "47", "48"], + "url": "https://github.com/AgentSeal/codeburn", + "session-modes": ["user"] +} diff --git a/extensions/gnome-shell/codeburn@agentseal.org/stylesheet.css b/extensions/gnome-shell/codeburn@agentseal.org/stylesheet.css new file mode 100644 index 0000000..f133c13 --- /dev/null +++ b/extensions/gnome-shell/codeburn@agentseal.org/stylesheet.css @@ -0,0 +1,35 @@ +.codeburn-panel { + spacing: 4px; +} + +.codeburn-flame { + font-size: 14px; +} + +.codeburn-label { + font-weight: 500; + padding-left: 2px; + padding-right: 2px; +} + +.codeburn-header { + font-weight: 600; + color: #ff8c42; +} + +.codeburn-activity { + font-family: monospace; + font-size: 11px; + padding-left: 8px; +} + +.codeburn-empty { + font-style: italic; + color: rgba(255, 255, 255, 0.5); + padding-left: 8px; +} + +.codeburn-findings { + color: #ff8c42; + font-weight: 500; +}