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.
This commit is contained in:
AgentSeal 2026-04-18 04:06:44 -07:00
parent 5281a08e06
commit 291c376f06
4 changed files with 301 additions and 0 deletions

View file

@ -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.

View file

@ -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;
}
}

View file

@ -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"]
}

View file

@ -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;
}