mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-05-19 16:13:56 +00:00
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:
parent
5281a08e06
commit
291c376f06
4 changed files with 301 additions and 0 deletions
59
extensions/gnome-shell/README.md
Normal file
59
extensions/gnome-shell/README.md
Normal 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.
|
||||
199
extensions/gnome-shell/codeburn@agentseal.org/extension.js
Normal file
199
extensions/gnome-shell/codeburn@agentseal.org/extension.js
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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"]
|
||||
}
|
||||
35
extensions/gnome-shell/codeburn@agentseal.org/stylesheet.css
Normal file
35
extensions/gnome-shell/codeburn@agentseal.org/stylesheet.css
Normal 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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue