Merge pull request #212 from thameem-abbas/feat/gnome-extension
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:
Resham Joshi 2026-05-04 10:50:36 -07:00 committed by GitHub
commit 18335a1f9d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 893 additions and 0 deletions

70
gnome/README.md Normal file
View 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
View 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
View 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;
}
}

View 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
View 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
View 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
View 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
View 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);
}
}

View file

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