diff --git a/gnome/README.md b/gnome/README.md new file mode 100644 index 0000000..3b76632 --- /dev/null +++ b/gnome/README.md @@ -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 +``` diff --git a/gnome/dataClient.js b/gnome/dataClient.js new file mode 100644 index 0000000..87d602d --- /dev/null +++ b/gnome/dataClient.js @@ -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(); + } +} diff --git a/gnome/extension.js b/gnome/extension.js new file mode 100644 index 0000000..031f7aa --- /dev/null +++ b/gnome/extension.js @@ -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; + } +} diff --git a/gnome/icons/codeburn-symbolic.svg b/gnome/icons/codeburn-symbolic.svg new file mode 100644 index 0000000..3a4ee85 --- /dev/null +++ b/gnome/icons/codeburn-symbolic.svg @@ -0,0 +1,4 @@ + + + + diff --git a/gnome/indicator.js b/gnome/indicator.js new file mode 100644 index 0000000..9fae16e --- /dev/null +++ b/gnome/indicator.js @@ -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(); + } +}); diff --git a/gnome/install.sh b/gnome/install.sh new file mode 100755 index 0000000..df03881 --- /dev/null +++ b/gnome/install.sh @@ -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}" diff --git a/gnome/metadata.json b/gnome/metadata.json new file mode 100644 index 0000000..050b0f5 --- /dev/null +++ b/gnome/metadata.json @@ -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" +} diff --git a/gnome/prefs.js b/gnome/prefs.js new file mode 100644 index 0000000..8d80679 --- /dev/null +++ b/gnome/prefs.js @@ -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); + } +} diff --git a/gnome/schemas/org.gnome.shell.extensions.codeburn.gschema.xml b/gnome/schemas/org.gnome.shell.extensions.codeburn.gschema.xml new file mode 100644 index 0000000..7031ab0 --- /dev/null +++ b/gnome/schemas/org.gnome.shell.extensions.codeburn.gschema.xml @@ -0,0 +1,56 @@ + + + + + + 30 + Refresh interval + Seconds between automatic data refreshes + + + + + 'today' + Default time period + Period shown when extension opens (today, week, 30days, month, all) + + + + 0.0 + Budget threshold + Daily budget threshold in USD. Set to 0 to disable. + + + + false + Enable budget alerts + Show warning when spending exceeds budget threshold + + + + false + Compact mode + Show only icon in panel, hide cost label + + + + '' + CodeBurn CLI path + Custom path to the codeburn executable. Leave empty to use PATH. + + + + 'all' + Default provider filter + Default provider to filter by (all shows everything) + + + + [] + Disabled providers + Providers excluded from cost accounting and display + + + + diff --git a/gnome/stylesheet.css b/gnome/stylesheet.css new file mode 100644 index 0000000..57489e0 --- /dev/null +++ b/gnome/stylesheet.css @@ -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; +}