mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-05-16 19:44:14 +00:00
Merge pull request #212 from thameem-abbas/feat/gnome-extension
Some checks are pending
CI / semgrep (push) Waiting to run
Some checks are pending
CI / semgrep (push) Waiting to run
Merging GNOME Shell extension as foundation - will enhance UI in follow-up
This commit is contained in:
commit
18335a1f9d
10 changed files with 893 additions and 0 deletions
70
gnome/README.md
Normal file
70
gnome/README.md
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
# CodeBurn GNOME Extension
|
||||
|
||||
Monitor AI coding assistant token usage and costs from your GNOME desktop panel.
|
||||
|
||||
## Requirements
|
||||
|
||||
- GNOME Shell 45 or later
|
||||
- CodeBurn CLI installed (`npm i -g codeburn`)
|
||||
- `glib-compile-schemas` (usually part of `glib2-devel` or `libglib2.0-dev`)
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
cd gnome
|
||||
chmod +x install.sh
|
||||
./install.sh
|
||||
```
|
||||
|
||||
Then restart GNOME Shell:
|
||||
- **Wayland:** Log out and back in
|
||||
- **X11:** Press `Alt+F2`, type `r`, press Enter
|
||||
|
||||
Enable the extension:
|
||||
|
||||
```bash
|
||||
gnome-extensions enable codeburn@codeburn.dev
|
||||
```
|
||||
|
||||
## Configure
|
||||
|
||||
Open preferences:
|
||||
|
||||
```bash
|
||||
gnome-extensions prefs codeburn@codeburn.dev
|
||||
```
|
||||
|
||||
Or use the GNOME Extensions app.
|
||||
|
||||
### Settings
|
||||
|
||||
| Setting | Default | Description |
|
||||
|---------|---------|-------------|
|
||||
| Refresh Interval | 30s | How often to poll CodeBurn CLI |
|
||||
| Default Period | Today | Period shown on open |
|
||||
| Compact Mode | Off | Hide cost label, show icon only |
|
||||
| Budget Threshold | $0 | Daily budget alert (0 = disabled) |
|
||||
| Budget Alerts | Off | Show warning when budget exceeded |
|
||||
| CLI Path | (auto) | Custom path to `codeburn` binary |
|
||||
|
||||
## Uninstall
|
||||
|
||||
```bash
|
||||
gnome-extensions disable codeburn@codeburn.dev
|
||||
rm -r ~/.local/share/gnome-shell/extensions/codeburn@codeburn.dev
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
Test changes without installing:
|
||||
|
||||
```bash
|
||||
# Compile schemas locally
|
||||
glib-compile-schemas schemas/
|
||||
|
||||
# Symlink for development
|
||||
ln -sf "$(pwd)" ~/.local/share/gnome-shell/extensions/codeburn@codeburn.dev
|
||||
|
||||
# Watch logs
|
||||
journalctl -f -o cat /usr/bin/gnome-shell
|
||||
```
|
||||
141
gnome/dataClient.js
Normal file
141
gnome/dataClient.js
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
import GLib from 'gi://GLib';
|
||||
import Gio from 'gi://Gio';
|
||||
|
||||
const TIMEOUT_SECONDS = 45;
|
||||
const SAFE_ARG_RE = /^[A-Za-z0-9 ._/\-]+$/;
|
||||
const ADDITIONAL_PATH_ENTRIES = ['/usr/local/bin', `${GLib.get_home_dir()}/.local/bin`, `${GLib.get_home_dir()}/.npm-global/bin`];
|
||||
|
||||
export class DataClient {
|
||||
_cache = new Map();
|
||||
_inFlight = null;
|
||||
_codeburnPath;
|
||||
|
||||
constructor(codeburnPath) {
|
||||
this._codeburnPath = codeburnPath || '';
|
||||
}
|
||||
|
||||
setCodeburnPath(path) {
|
||||
this._codeburnPath = path || '';
|
||||
}
|
||||
|
||||
cancelInFlight() {
|
||||
if (this._inFlight) {
|
||||
this._inFlight.cancellable.cancel();
|
||||
this._inFlight = null;
|
||||
}
|
||||
}
|
||||
|
||||
getCached(period, provider) {
|
||||
const key = `${period}:${provider}`;
|
||||
return this._cache.get(key) ?? null;
|
||||
}
|
||||
|
||||
async fetch(period, provider) {
|
||||
this.cancelInFlight();
|
||||
|
||||
const cancellable = new Gio.Cancellable();
|
||||
this._inFlight = { cancellable };
|
||||
|
||||
try {
|
||||
const payload = await this._spawn(period, provider, cancellable);
|
||||
const key = `${period}:${provider}`;
|
||||
this._cache.set(key, payload);
|
||||
return payload;
|
||||
} finally {
|
||||
if (this._inFlight?.cancellable === cancellable)
|
||||
this._inFlight = null;
|
||||
}
|
||||
}
|
||||
|
||||
_buildArgv(period, provider) {
|
||||
let base;
|
||||
if (this._codeburnPath && SAFE_ARG_RE.test(this._codeburnPath)) {
|
||||
base = this._codeburnPath.split(' ').filter(s => s.length > 0);
|
||||
} else {
|
||||
base = ['codeburn'];
|
||||
}
|
||||
|
||||
const args = [
|
||||
...base,
|
||||
'status',
|
||||
'--format', 'menubar-json',
|
||||
'--period', period,
|
||||
'--no-optimize',
|
||||
];
|
||||
|
||||
if (provider && provider !== 'all')
|
||||
args.push('--provider', provider);
|
||||
|
||||
return args;
|
||||
}
|
||||
|
||||
_augmentedEnv() {
|
||||
const currentPath = GLib.getenv('PATH') || '/usr/bin:/bin';
|
||||
const parts = currentPath.split(':');
|
||||
for (const extra of ADDITIONAL_PATH_ENTRIES) {
|
||||
if (!parts.includes(extra))
|
||||
parts.push(extra);
|
||||
}
|
||||
return [`PATH=${parts.join(':')}`];
|
||||
}
|
||||
|
||||
_spawn(period, provider, cancellable) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const argv = this._buildArgv(period, provider);
|
||||
|
||||
let proc;
|
||||
try {
|
||||
const launcher = Gio.SubprocessLauncher.new(
|
||||
Gio.SubprocessFlags.STDOUT_PIPE | Gio.SubprocessFlags.STDERR_PIPE
|
||||
);
|
||||
for (const entry of this._augmentedEnv()) {
|
||||
const [key, val] = entry.split('=', 2);
|
||||
launcher.setenv(key, val, true);
|
||||
}
|
||||
proc = launcher.spawnv(argv);
|
||||
} catch (e) {
|
||||
reject(new Error(`CLI not found: ${e.message}`));
|
||||
return;
|
||||
}
|
||||
|
||||
let timeoutId = GLib.timeout_add_seconds(GLib.PRIORITY_DEFAULT, TIMEOUT_SECONDS, () => {
|
||||
timeoutId = 0;
|
||||
proc.force_exit();
|
||||
reject(new Error('CLI timeout'));
|
||||
return GLib.SOURCE_REMOVE;
|
||||
});
|
||||
|
||||
proc.communicate_utf8_async(null, cancellable, (_proc, res) => {
|
||||
if (timeoutId) {
|
||||
GLib.Source.remove(timeoutId);
|
||||
timeoutId = 0;
|
||||
}
|
||||
|
||||
try {
|
||||
const [, stdout, stderr] = _proc.communicate_utf8_finish(res);
|
||||
|
||||
if (!_proc.get_successful()) {
|
||||
const msg = stderr?.trim() || 'CLI exited with error';
|
||||
reject(new Error(msg));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!stdout || stdout.trim().length === 0) {
|
||||
reject(new Error('CLI returned empty output'));
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = JSON.parse(stdout);
|
||||
resolve(payload);
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.cancelInFlight();
|
||||
this._cache.clear();
|
||||
}
|
||||
}
|
||||
15
gnome/extension.js
Normal file
15
gnome/extension.js
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import { Extension } from 'resource:///org/gnome/shell/extensions/extension.js';
|
||||
import { CodeBurnIndicator } from './indicator.js';
|
||||
|
||||
export default class CodeBurnExtension extends Extension {
|
||||
_indicator = null;
|
||||
|
||||
enable() {
|
||||
this._indicator = new CodeBurnIndicator(this);
|
||||
}
|
||||
|
||||
disable() {
|
||||
this._indicator?.destroy();
|
||||
this._indicator = null;
|
||||
}
|
||||
}
|
||||
4
gnome/icons/codeburn-symbolic.svg
Normal file
4
gnome/icons/codeburn-symbolic.svg
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
|
||||
<path fill="currentColor" d="M8 1C6.5 3.5 4 5 4 8c0 2.2 1.8 4 4 4s4-1.8 4-4c0-3-2.5-4.5-4-7zm0 9.5c-1 0-1.5-.7-1.5-1.5 0-1.2 1-2 1.5-3 .5 1 1.5 1.8 1.5 3 0 .8-.5 1.5-1.5 1.5z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 310 B |
383
gnome/indicator.js
Normal file
383
gnome/indicator.js
Normal file
|
|
@ -0,0 +1,383 @@
|
|||
import GObject from 'gi://GObject';
|
||||
import GLib from 'gi://GLib';
|
||||
import Gio from 'gi://Gio';
|
||||
import St from 'gi://St';
|
||||
import Clutter from 'gi://Clutter';
|
||||
import * as PanelMenu from 'resource:///org/gnome/shell/ui/panelMenu.js';
|
||||
import * as PopupMenu from 'resource:///org/gnome/shell/ui/popupMenu.js';
|
||||
import * as Main from 'resource:///org/gnome/shell/ui/main.js';
|
||||
import { DataClient } from './dataClient.js';
|
||||
|
||||
const PERIODS = [
|
||||
{ id: 'today', label: 'Today' },
|
||||
{ id: 'week', label: '7 Days' },
|
||||
{ id: '30days', label: '30 Days' },
|
||||
{ id: 'month', label: 'Month' },
|
||||
{ id: 'all', label: 'All' },
|
||||
];
|
||||
|
||||
function formatCost(cost) {
|
||||
if (cost == null || isNaN(cost)) return '$?';
|
||||
return `$${cost.toFixed(2)}`;
|
||||
}
|
||||
|
||||
function formatPercent(val) {
|
||||
if (val == null || isNaN(val)) return '—';
|
||||
return `${(val * 100).toFixed(0)}%`;
|
||||
}
|
||||
|
||||
function formatPercentDirect(val) {
|
||||
if (val == null || isNaN(val)) return '—';
|
||||
return `${val.toFixed(1)}%`;
|
||||
}
|
||||
|
||||
export const CodeBurnIndicator = GObject.registerClass(
|
||||
class CodeBurnIndicator extends PanelMenu.Button {
|
||||
_extension;
|
||||
_settings;
|
||||
_dataClient;
|
||||
_refreshSourceId = 0;
|
||||
_panelLabel;
|
||||
_panelIcon;
|
||||
_currentPeriod = 'today';
|
||||
_currentProvider = 'all';
|
||||
_lastPayload = null;
|
||||
_isStale = false;
|
||||
_settingsChangedIds = [];
|
||||
|
||||
_init(extension) {
|
||||
super._init(0.5, 'CodeBurn Monitor', false);
|
||||
this._extension = extension;
|
||||
this._settings = extension.getSettings();
|
||||
this._dataClient = new DataClient(this._settings.get_string('codeburn-path'));
|
||||
this._currentPeriod = this._settings.get_string('default-period') || 'today';
|
||||
|
||||
this._buildPanelButton();
|
||||
this._buildMenu();
|
||||
Main.panel.addToStatusArea('codeburn-indicator', this);
|
||||
|
||||
this._connectSettings();
|
||||
this._startRefreshLoop();
|
||||
this._refresh();
|
||||
}
|
||||
|
||||
_buildPanelButton() {
|
||||
const box = new St.BoxLayout({ style_class: 'panel-button' });
|
||||
|
||||
this._panelIcon = new St.Icon({
|
||||
icon_name: 'codeburn-symbolic',
|
||||
style_class: 'system-status-icon',
|
||||
});
|
||||
|
||||
this._panelLabel = new St.Label({
|
||||
text: '$—',
|
||||
y_expand: true,
|
||||
y_align: Clutter.ActorAlign.CENTER,
|
||||
style_class: 'codeburn-panel-label',
|
||||
});
|
||||
|
||||
box.add_child(this._panelIcon);
|
||||
box.add_child(this._panelLabel);
|
||||
this._panelLabel.visible = !this._settings.get_boolean('compact-mode');
|
||||
|
||||
this.add_child(box);
|
||||
}
|
||||
|
||||
_buildMenu() {
|
||||
this.menu.removeAll();
|
||||
|
||||
this._heroItem = this._addMenuItem('Loading...');
|
||||
this._heroItem.label.style_class = 'codeburn-hero-label';
|
||||
|
||||
this._statsItem = this._addMenuItem('');
|
||||
|
||||
this.menu.addMenuItem(new PopupMenu.PopupSeparatorMenuItem());
|
||||
|
||||
this._periodSection = new PopupMenu.PopupSubMenuMenuItem('Period: Today');
|
||||
this.menu.addMenuItem(this._periodSection);
|
||||
for (const p of PERIODS) {
|
||||
const item = new PopupMenu.PopupMenuItem(p.label);
|
||||
item.connect('activate', () => {
|
||||
this._currentPeriod = p.id;
|
||||
this._periodSection.label.text = `Period: ${p.label}`;
|
||||
this._refresh();
|
||||
});
|
||||
this._periodSection.menu.addMenuItem(item);
|
||||
}
|
||||
|
||||
this.menu.addMenuItem(new PopupMenu.PopupSeparatorMenuItem());
|
||||
|
||||
this._providerHeader = this._addMenuItem('Providers');
|
||||
this._providerHeader.setSensitive(false);
|
||||
this._providerItems = [];
|
||||
|
||||
this.menu.addMenuItem(new PopupMenu.PopupSeparatorMenuItem(''));
|
||||
this._providerSeparator = this.menu._getMenuItems().at(-1);
|
||||
|
||||
this._activitiesSection = new PopupMenu.PopupSubMenuMenuItem('Top Activities');
|
||||
this.menu.addMenuItem(this._activitiesSection);
|
||||
|
||||
this._modelsSection = new PopupMenu.PopupSubMenuMenuItem('Top Models');
|
||||
this.menu.addMenuItem(this._modelsSection);
|
||||
|
||||
this.menu.addMenuItem(new PopupMenu.PopupSeparatorMenuItem());
|
||||
|
||||
this._cacheItem = this._addMenuItem('Cache Hit: —');
|
||||
this._oneShotItem = this._addMenuItem('One-shot Rate: —');
|
||||
|
||||
this._budgetItem = this._addMenuItem('');
|
||||
this._budgetItem.visible = false;
|
||||
|
||||
this.menu.addMenuItem(new PopupMenu.PopupSeparatorMenuItem());
|
||||
|
||||
const refreshItem = new PopupMenu.PopupMenuItem('Refresh');
|
||||
refreshItem.connect('activate', () => this._refresh());
|
||||
this.menu.addMenuItem(refreshItem);
|
||||
|
||||
const reportItem = new PopupMenu.PopupMenuItem('Open Full Report');
|
||||
reportItem.connect('activate', () => this._openReport());
|
||||
this.menu.addMenuItem(reportItem);
|
||||
|
||||
const prefsItem = new PopupMenu.PopupMenuItem('Preferences');
|
||||
prefsItem.connect('activate', () => {
|
||||
this._extension.openPreferences();
|
||||
});
|
||||
this.menu.addMenuItem(prefsItem);
|
||||
}
|
||||
|
||||
_addMenuItem(text) {
|
||||
const item = new PopupMenu.PopupMenuItem(text);
|
||||
item.setSensitive(false);
|
||||
this.menu.addMenuItem(item);
|
||||
return item;
|
||||
}
|
||||
|
||||
_connectSettings() {
|
||||
const watch = (key, cb) => {
|
||||
const id = this._settings.connect(`changed::${key}`, cb);
|
||||
this._settingsChangedIds.push(id);
|
||||
};
|
||||
|
||||
watch('refresh-interval', () => this._restartRefreshLoop());
|
||||
watch('compact-mode', () => this._rebuildPanelButton());
|
||||
watch('codeburn-path', () => {
|
||||
this._dataClient.setCodeburnPath(this._settings.get_string('codeburn-path'));
|
||||
this._refresh();
|
||||
});
|
||||
watch('default-period', () => {
|
||||
this._currentPeriod = this._settings.get_string('default-period');
|
||||
this._refresh();
|
||||
});
|
||||
watch('budget-threshold', () => this._updateBudget());
|
||||
watch('budget-alert-enabled', () => this._updateBudget());
|
||||
watch('disabled-providers', () => {
|
||||
if (this._lastPayload) {
|
||||
this._updatePanel(this._lastPayload);
|
||||
this._updateMenu(this._lastPayload);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
_rebuildPanelButton() {
|
||||
const compact = this._settings.get_boolean('compact-mode');
|
||||
this._panelLabel.visible = !compact;
|
||||
this._updatePanel(this._lastPayload);
|
||||
}
|
||||
|
||||
_startRefreshLoop() {
|
||||
const interval = this._settings.get_uint('refresh-interval') || 30;
|
||||
this._refreshSourceId = GLib.timeout_add_seconds(GLib.PRIORITY_DEFAULT, interval, () => {
|
||||
this._refresh();
|
||||
return GLib.SOURCE_CONTINUE;
|
||||
});
|
||||
}
|
||||
|
||||
_restartRefreshLoop() {
|
||||
if (this._refreshSourceId) {
|
||||
GLib.Source.remove(this._refreshSourceId);
|
||||
this._refreshSourceId = 0;
|
||||
}
|
||||
this._startRefreshLoop();
|
||||
}
|
||||
|
||||
async _refresh() {
|
||||
try {
|
||||
const payload = await this._dataClient.fetch(this._currentPeriod, this._currentProvider);
|
||||
this._lastPayload = payload;
|
||||
this._isStale = false;
|
||||
this._updatePanel(payload);
|
||||
this._updateMenu(payload);
|
||||
} catch (e) {
|
||||
if (e.message?.includes('cancelled')) return;
|
||||
log(`CodeBurn: refresh error: ${e.message}`);
|
||||
this._isStale = true;
|
||||
if (!this._lastPayload)
|
||||
this._showError(e.message);
|
||||
else
|
||||
this._updatePanel(this._lastPayload);
|
||||
}
|
||||
}
|
||||
|
||||
_getDisabledProviders() {
|
||||
return new Set(this._settings.get_strv('disabled-providers'));
|
||||
}
|
||||
|
||||
_filterProviders(providers) {
|
||||
if (!providers) return { filtered: {}, cost: 0 };
|
||||
const disabled = this._getDisabledProviders();
|
||||
const filtered = {};
|
||||
let cost = 0;
|
||||
for (const [name, val] of Object.entries(providers)) {
|
||||
if (!disabled.has(name)) {
|
||||
filtered[name] = val;
|
||||
cost += val;
|
||||
}
|
||||
}
|
||||
return { filtered, cost };
|
||||
}
|
||||
|
||||
_updatePanel(payload) {
|
||||
if (!payload) {
|
||||
this._panelLabel.text = '$?';
|
||||
return;
|
||||
}
|
||||
const { cost } = this._filterProviders(payload.current?.providers);
|
||||
let text = formatCost(cost);
|
||||
if (this._isStale)
|
||||
text += ' *';
|
||||
this._panelLabel.text = text;
|
||||
}
|
||||
|
||||
_updateMenu(payload) {
|
||||
if (!payload?.current) return;
|
||||
const c = payload.current;
|
||||
const { filtered, cost } = this._filterProviders(c.providers);
|
||||
|
||||
this._heroItem.label.text = `${formatCost(cost)} ${c.label || this._currentPeriod}`;
|
||||
this._statsItem.label.text = `${c.calls ?? 0} calls · ${c.sessions ?? 0} sessions`;
|
||||
|
||||
this._updateProviders(filtered);
|
||||
this._updateActivities(c.topActivities);
|
||||
this._updateModels(c.topModels);
|
||||
|
||||
this._cacheItem.label.text = `Cache Hit: ${formatPercentDirect(c.cacheHitPercent)}`;
|
||||
this._oneShotItem.label.text = `One-shot Rate: ${c.oneShotRate != null ? formatPercent(c.oneShotRate) : '—'}`;
|
||||
|
||||
this._updateBudget();
|
||||
}
|
||||
|
||||
_updateProviders(providers) {
|
||||
for (const item of this._providerItems)
|
||||
item.destroy();
|
||||
this._providerItems = [];
|
||||
|
||||
if (!providers || Object.keys(providers).length === 0) {
|
||||
this._providerHeader.visible = false;
|
||||
this._providerSeparator.visible = false;
|
||||
return;
|
||||
}
|
||||
|
||||
this._providerHeader.visible = true;
|
||||
this._providerSeparator.visible = true;
|
||||
|
||||
const sorted = Object.entries(providers).sort((a, b) => b[1] - a[1]);
|
||||
const headerIndex = this.menu._getMenuItems().indexOf(this._providerHeader);
|
||||
|
||||
for (let i = 0; i < sorted.length; i++) {
|
||||
const [name, cost] = sorted[i];
|
||||
const item = new PopupMenu.PopupMenuItem(` ${name}`);
|
||||
item.setSensitive(false);
|
||||
|
||||
const costLabel = new St.Label({
|
||||
text: formatCost(cost),
|
||||
x_expand: true,
|
||||
x_align: Clutter.ActorAlign.END,
|
||||
style_class: 'codeburn-provider-cost',
|
||||
});
|
||||
item.add_child(costLabel);
|
||||
|
||||
this.menu.addMenuItem(item, headerIndex + 1 + i);
|
||||
this._providerItems.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
_updateActivities(activities) {
|
||||
this._activitiesSection.menu.removeAll();
|
||||
if (!activities || activities.length === 0) {
|
||||
this._activitiesSection.visible = false;
|
||||
return;
|
||||
}
|
||||
this._activitiesSection.visible = true;
|
||||
for (const act of activities.slice(0, 5)) {
|
||||
const item = new PopupMenu.PopupMenuItem(`${act.name} ${formatCost(act.cost)}`);
|
||||
item.setSensitive(false);
|
||||
this._activitiesSection.menu.addMenuItem(item);
|
||||
}
|
||||
}
|
||||
|
||||
_updateModels(models) {
|
||||
this._modelsSection.menu.removeAll();
|
||||
if (!models || models.length === 0) {
|
||||
this._modelsSection.visible = false;
|
||||
return;
|
||||
}
|
||||
this._modelsSection.visible = true;
|
||||
for (const model of models.slice(0, 5)) {
|
||||
const item = new PopupMenu.PopupMenuItem(`${model.name} ${formatCost(model.cost)}`);
|
||||
item.setSensitive(false);
|
||||
this._modelsSection.menu.addMenuItem(item);
|
||||
}
|
||||
}
|
||||
|
||||
_updateBudget() {
|
||||
const enabled = this._settings.get_boolean('budget-alert-enabled');
|
||||
const threshold = this._settings.get_double('budget-threshold');
|
||||
|
||||
if (!enabled || threshold <= 0 || !this._lastPayload?.current) {
|
||||
this._budgetItem.visible = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const cost = this._lastPayload.current.cost;
|
||||
if (cost >= threshold) {
|
||||
this._budgetItem.label.text = `⚠ Budget exceeded: ${formatCost(cost)} / ${formatCost(threshold)}`;
|
||||
this._budgetItem.visible = true;
|
||||
} else {
|
||||
this._budgetItem.label.text = `Budget: ${formatCost(cost)} / ${formatCost(threshold)}`;
|
||||
this._budgetItem.visible = true;
|
||||
}
|
||||
}
|
||||
|
||||
_showError(message) {
|
||||
this._panelLabel.text = '$?';
|
||||
if (message?.includes('not found') || message?.includes('No such file')) {
|
||||
this._heroItem.label.text = 'CodeBurn CLI not found';
|
||||
this._statsItem.label.text = 'Install: npm i -g codeburn';
|
||||
} else {
|
||||
this._heroItem.label.text = 'Error loading data';
|
||||
this._statsItem.label.text = message?.substring(0, 80) || 'Unknown error';
|
||||
}
|
||||
}
|
||||
|
||||
_openReport() {
|
||||
try {
|
||||
const argv = ['codeburn', 'report'];
|
||||
const launcher = Gio.SubprocessLauncher.new(Gio.SubprocessFlags.NONE);
|
||||
launcher.spawnv(argv);
|
||||
} catch (e) {
|
||||
log(`CodeBurn: failed to open report: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (this._refreshSourceId) {
|
||||
GLib.Source.remove(this._refreshSourceId);
|
||||
this._refreshSourceId = 0;
|
||||
}
|
||||
this._dataClient?.destroy();
|
||||
for (const id of this._settingsChangedIds)
|
||||
this._settings.disconnect(id);
|
||||
this._settingsChangedIds = [];
|
||||
super.destroy();
|
||||
}
|
||||
});
|
||||
38
gnome/install.sh
Executable file
38
gnome/install.sh
Executable file
|
|
@ -0,0 +1,38 @@
|
|||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
UUID="codeburn@codeburn.dev"
|
||||
INSTALL_DIR="${HOME}/.local/share/gnome-shell/extensions/${UUID}"
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
|
||||
echo "Installing CodeBurn GNOME extension..."
|
||||
|
||||
# Compile GSettings schema
|
||||
echo "Compiling schemas..."
|
||||
glib-compile-schemas "${SCRIPT_DIR}/schemas/"
|
||||
|
||||
# Create install directory
|
||||
mkdir -p "${INSTALL_DIR}"
|
||||
|
||||
# Copy extension files
|
||||
cp "${SCRIPT_DIR}/metadata.json" "${INSTALL_DIR}/"
|
||||
cp "${SCRIPT_DIR}/extension.js" "${INSTALL_DIR}/"
|
||||
cp "${SCRIPT_DIR}/indicator.js" "${INSTALL_DIR}/"
|
||||
cp "${SCRIPT_DIR}/dataClient.js" "${INSTALL_DIR}/"
|
||||
cp "${SCRIPT_DIR}/prefs.js" "${INSTALL_DIR}/"
|
||||
cp "${SCRIPT_DIR}/stylesheet.css" "${INSTALL_DIR}/"
|
||||
|
||||
# Copy schemas
|
||||
mkdir -p "${INSTALL_DIR}/schemas"
|
||||
cp "${SCRIPT_DIR}/schemas/"* "${INSTALL_DIR}/schemas/"
|
||||
|
||||
# Copy icons
|
||||
mkdir -p "${INSTALL_DIR}/icons"
|
||||
cp "${SCRIPT_DIR}/icons/"* "${INSTALL_DIR}/icons/"
|
||||
|
||||
echo "Extension installed to ${INSTALL_DIR}"
|
||||
echo ""
|
||||
echo "Next steps:"
|
||||
echo " 1. Restart GNOME Shell (log out and back in on Wayland)"
|
||||
echo " 2. Enable: gnome-extensions enable ${UUID}"
|
||||
echo " 3. Configure: gnome-extensions prefs ${UUID}"
|
||||
8
gnome/metadata.json
Normal file
8
gnome/metadata.json
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"name": "CodeBurn Monitor",
|
||||
"description": "Monitor AI coding assistant token usage and costs",
|
||||
"uuid": "codeburn@codeburn.dev",
|
||||
"shell-version": ["45", "46", "47", "48", "49", "50"],
|
||||
"url": "https://github.com/anthropics/codeburn",
|
||||
"settings-schema": "org.gnome.shell.extensions.codeburn"
|
||||
}
|
||||
155
gnome/prefs.js
Normal file
155
gnome/prefs.js
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
import Adw from 'gi://Adw';
|
||||
import Gtk from 'gi://Gtk';
|
||||
import Gio from 'gi://Gio';
|
||||
import { ExtensionPreferences } from 'resource:///org/gnome/Shell/Extensions/js/extensions/prefs.js';
|
||||
|
||||
const PROVIDERS = [
|
||||
{ id: 'claude', label: 'Claude' },
|
||||
{ id: 'codex', label: 'Codex' },
|
||||
{ id: 'copilot', label: 'Copilot' },
|
||||
{ id: 'cursor', label: 'Cursor' },
|
||||
{ id: 'droid', label: 'Droid' },
|
||||
{ id: 'gemini', label: 'Gemini' },
|
||||
{ id: 'goose', label: 'Goose' },
|
||||
{ id: 'kilo-code', label: 'Kilo Code' },
|
||||
{ id: 'kiro', label: 'Kiro' },
|
||||
{ id: 'openclaw', label: 'OpenClaw' },
|
||||
{ id: 'opencode', label: 'OpenCode' },
|
||||
{ id: 'pi', label: 'Pi' },
|
||||
{ id: 'qwen', label: 'Qwen' },
|
||||
{ id: 'roo-code', label: 'Roo Code' },
|
||||
{ id: 'antigravity', label: 'Antigravity' },
|
||||
];
|
||||
|
||||
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' },
|
||||
];
|
||||
|
||||
export default class CodeBurnPreferences extends ExtensionPreferences {
|
||||
fillPreferencesWindow(window) {
|
||||
const settings = this.getSettings();
|
||||
|
||||
const displayPage = new Adw.PreferencesPage({
|
||||
title: 'Display',
|
||||
icon_name: 'preferences-desktop-display-symbolic',
|
||||
});
|
||||
window.add(displayPage);
|
||||
|
||||
const displayGroup = new Adw.PreferencesGroup({
|
||||
title: 'Display',
|
||||
description: 'Configure how CodeBurn appears in the panel',
|
||||
});
|
||||
displayPage.add(displayGroup);
|
||||
|
||||
const refreshRow = new Adw.SpinRow({
|
||||
title: 'Refresh Interval',
|
||||
subtitle: 'Seconds between data refreshes',
|
||||
adjustment: new Gtk.Adjustment({
|
||||
lower: 5,
|
||||
upper: 300,
|
||||
step_increment: 5,
|
||||
page_increment: 30,
|
||||
value: settings.get_uint('refresh-interval'),
|
||||
}),
|
||||
});
|
||||
settings.bind('refresh-interval', refreshRow, 'value', Gio.SettingsBindFlags.DEFAULT);
|
||||
displayGroup.add(refreshRow);
|
||||
|
||||
const compactRow = new Adw.SwitchRow({
|
||||
title: 'Compact Mode',
|
||||
subtitle: 'Show only the icon, hide the cost label',
|
||||
});
|
||||
settings.bind('compact-mode', compactRow, 'active', Gio.SettingsBindFlags.DEFAULT);
|
||||
displayGroup.add(compactRow);
|
||||
|
||||
const periodModel = new Gtk.StringList();
|
||||
for (const p of PERIODS)
|
||||
periodModel.append(p.label);
|
||||
|
||||
const periodRow = new Adw.ComboRow({
|
||||
title: 'Default Period',
|
||||
subtitle: 'Time period shown when extension opens',
|
||||
model: periodModel,
|
||||
});
|
||||
const currentPeriod = settings.get_string('default-period');
|
||||
const periodIndex = PERIODS.findIndex(p => p.id === currentPeriod);
|
||||
periodRow.set_selected(periodIndex >= 0 ? periodIndex : 0);
|
||||
periodRow.connect('notify::selected', () => {
|
||||
const idx = periodRow.get_selected();
|
||||
if (idx >= 0 && idx < PERIODS.length)
|
||||
settings.set_string('default-period', PERIODS[idx].id);
|
||||
});
|
||||
displayGroup.add(periodRow);
|
||||
|
||||
const alertsGroup = new Adw.PreferencesGroup({
|
||||
title: 'Budget Alerts',
|
||||
description: 'Get warned when spending exceeds a threshold',
|
||||
});
|
||||
displayPage.add(alertsGroup);
|
||||
|
||||
const budgetEnabledRow = new Adw.SwitchRow({
|
||||
title: 'Enable Budget Alerts',
|
||||
subtitle: 'Show a warning when daily spending exceeds the threshold',
|
||||
});
|
||||
settings.bind('budget-alert-enabled', budgetEnabledRow, 'active', Gio.SettingsBindFlags.DEFAULT);
|
||||
alertsGroup.add(budgetEnabledRow);
|
||||
|
||||
const budgetRow = new Adw.SpinRow({
|
||||
title: 'Daily Budget (USD)',
|
||||
subtitle: 'Set to 0 to disable',
|
||||
adjustment: new Gtk.Adjustment({
|
||||
lower: 0,
|
||||
upper: 1000,
|
||||
step_increment: 1,
|
||||
page_increment: 10,
|
||||
value: settings.get_double('budget-threshold'),
|
||||
}),
|
||||
digits: 2,
|
||||
});
|
||||
settings.bind('budget-threshold', budgetRow, 'value', Gio.SettingsBindFlags.DEFAULT);
|
||||
alertsGroup.add(budgetRow);
|
||||
|
||||
const providersGroup = new Adw.PreferencesGroup({
|
||||
title: 'Providers',
|
||||
description: 'Toggle providers on/off for cost accounting',
|
||||
});
|
||||
displayPage.add(providersGroup);
|
||||
|
||||
const disabledProviders = settings.get_strv('disabled-providers');
|
||||
|
||||
for (const provider of PROVIDERS) {
|
||||
const row = new Adw.SwitchRow({
|
||||
title: provider.label,
|
||||
active: !disabledProviders.includes(provider.id),
|
||||
});
|
||||
row.connect('notify::active', () => {
|
||||
const current = settings.get_strv('disabled-providers');
|
||||
if (row.get_active()) {
|
||||
settings.set_strv('disabled-providers', current.filter(p => p !== provider.id));
|
||||
} else {
|
||||
if (!current.includes(provider.id))
|
||||
settings.set_strv('disabled-providers', [...current, provider.id]);
|
||||
}
|
||||
});
|
||||
providersGroup.add(row);
|
||||
}
|
||||
|
||||
const advancedGroup = new Adw.PreferencesGroup({
|
||||
title: 'Advanced',
|
||||
});
|
||||
displayPage.add(advancedGroup);
|
||||
|
||||
const pathRow = new Adw.EntryRow({
|
||||
title: 'CodeBurn CLI Path',
|
||||
text: settings.get_string('codeburn-path'),
|
||||
});
|
||||
pathRow.connect('changed', () => {
|
||||
settings.set_string('codeburn-path', pathRow.get_text());
|
||||
});
|
||||
advancedGroup.add(pathRow);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<schemalist gettext-domain="codeburn">
|
||||
<schema id="org.gnome.shell.extensions.codeburn"
|
||||
path="/org/gnome/shell/extensions/codeburn/">
|
||||
|
||||
<key name="refresh-interval" type="u">
|
||||
<default>30</default>
|
||||
<summary>Refresh interval</summary>
|
||||
<description>Seconds between automatic data refreshes</description>
|
||||
<range min="5" max="300"/>
|
||||
</key>
|
||||
|
||||
<key name="default-period" type="s">
|
||||
<default>'today'</default>
|
||||
<summary>Default time period</summary>
|
||||
<description>Period shown when extension opens (today, week, 30days, month, all)</description>
|
||||
</key>
|
||||
|
||||
<key name="budget-threshold" type="d">
|
||||
<default>0.0</default>
|
||||
<summary>Budget threshold</summary>
|
||||
<description>Daily budget threshold in USD. Set to 0 to disable.</description>
|
||||
</key>
|
||||
|
||||
<key name="budget-alert-enabled" type="b">
|
||||
<default>false</default>
|
||||
<summary>Enable budget alerts</summary>
|
||||
<description>Show warning when spending exceeds budget threshold</description>
|
||||
</key>
|
||||
|
||||
<key name="compact-mode" type="b">
|
||||
<default>false</default>
|
||||
<summary>Compact mode</summary>
|
||||
<description>Show only icon in panel, hide cost label</description>
|
||||
</key>
|
||||
|
||||
<key name="codeburn-path" type="s">
|
||||
<default>''</default>
|
||||
<summary>CodeBurn CLI path</summary>
|
||||
<description>Custom path to the codeburn executable. Leave empty to use PATH.</description>
|
||||
</key>
|
||||
|
||||
<key name="provider-filter" type="s">
|
||||
<default>'all'</default>
|
||||
<summary>Default provider filter</summary>
|
||||
<description>Default provider to filter by (all shows everything)</description>
|
||||
</key>
|
||||
|
||||
<key name="disabled-providers" type="as">
|
||||
<default>[]</default>
|
||||
<summary>Disabled providers</summary>
|
||||
<description>Providers excluded from cost accounting and display</description>
|
||||
</key>
|
||||
|
||||
</schema>
|
||||
</schemalist>
|
||||
23
gnome/stylesheet.css
Normal file
23
gnome/stylesheet.css
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
.codeburn-panel-label {
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.codeburn-hero-label {
|
||||
font-size: 1.2em;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.codeburn-provider-cost {
|
||||
margin-left: 16px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.codeburn-budget-warning {
|
||||
color: #e5a50a;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.codeburn-stale-indicator {
|
||||
opacity: 0.6;
|
||||
font-style: italic;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue