codeburn/gnome/indicator.js
2026-05-11 19:02:28 +03:00

1004 lines
38 KiB
JavaScript

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 Soup from 'gi://Soup?version=3.0';
import * as PanelMenu from 'resource:///org/gnome/shell/ui/panelMenu.js';
import * as PopupMenu from 'resource:///org/gnome/shell/ui/popupMenu.js';
import { DataClient } from './dataClient.js';
const CACHE_TTL_MS = 300_000;
const TOP_ACTIVITIES = 10;
const CHART_HEIGHT = 52;
const BAR_TRACK_WIDTH = 240;
const PERIODS = [
{ id: 'today', label: 'Today' },
{ id: 'week', label: '7 Days' },
{ id: '30days', label: '30 Days' },
{ id: 'month', label: 'Month' },
{ id: 'all', label: '6 Months' },
];
const INSIGHTS = [
{ id: 'activity', label: 'Activity' },
{ id: 'trend', label: 'Trend' },
{ id: 'forecast', label: 'Forecast' },
{ id: 'pulse', label: 'Pulse' },
{ id: 'stats', label: 'Stats' },
];
const PROVIDERS = [
{ id: 'all', label: 'All' },
{ id: 'claude', label: 'Claude' },
{ id: 'codex', label: 'Codex' },
{ id: 'cursor', label: 'Cursor' },
{ id: 'copilot', label: 'Copilot' },
{ id: 'opencode', label: 'OpenCode' },
{ id: 'pi', label: 'Pi' },
{ id: 'droid', label: 'Droid' },
{ id: 'gemini', label: 'Gemini' },
{ id: 'kilo-code', label: 'Kilo Code' },
{ id: 'kiro', label: 'Kiro' },
{ id: 'kimi', label: 'Kimi' },
{ id: 'roo-code', label: 'Roo Code' },
];
const CURRENCIES = [
{ code: 'USD', symbol: '$' },
{ code: 'EUR', symbol: '€' },
{ code: 'GBP', symbol: '£' },
{ code: 'CAD', symbol: 'C$' },
{ code: 'AUD', symbol: 'A$' },
{ code: 'JPY', symbol: '¥' },
{ code: 'INR', symbol: '₹' },
{ code: 'BRL', symbol: 'R$' },
{ code: 'CHF', symbol: 'CHF ' },
{ code: 'SEK', symbol: 'kr ' },
{ code: 'SGD', symbol: 'S$' },
{ code: 'HKD', symbol: 'HK$' },
{ code: 'KRW', symbol: '₩' },
{ code: 'MXN', symbol: 'MX$' },
{ code: 'ZAR', symbol: 'R ' },
{ code: 'DKK', symbol: 'kr ' },
{ code: 'CNY', symbol: '¥' },
];
const PROVIDER_PATHS = {
claude: '.claude/projects',
codex: '.codex/sessions',
cursor: '.config/Cursor/User/globalStorage/state.vscdb',
copilot: '.copilot/session-state',
kimi: '.kimi/sessions',
pi: '.pi/agent/sessions',
};
function formatCost(value, currency, rate = 1, exact = false) {
const n = (Number(value) || 0) * (Number(rate) || 1);
const abs = Math.abs(n);
const symbol = currency?.symbol || '$';
if (!exact && abs >= 1000) return `${symbol}${(n / 1000).toFixed(abs >= 10000 ? 0 : 1)}k`;
const parts = n.toFixed(2).split('.');
parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ',');
return `${symbol}${parts.join('.')}`;
}
function formatTokensCompact(n) {
const v = Number(n) || 0;
if (v >= 1_000_000_000) return `${(v / 1_000_000_000).toFixed(1)}B`;
if (v >= 1_000_000) return `${(v / 1_000_000).toFixed(1)}M`;
if (v >= 1000) return `${(v / 1000).toFixed(1)}k`;
return String(v);
}
function formatTime(date) {
if (!date || Number.isNaN(date.getTime())) return '';
const now = new Date();
const diffSec = Math.floor((now.getTime() - date.getTime()) / 1000);
if (diffSec < 60) return 'just now';
if (diffSec < 3600) return `${Math.floor(diffSec / 60)}m ago`;
if (diffSec < 86400) return `${Math.floor(diffSec / 3600)}h ago`;
return date.toLocaleDateString();
}
export const CodeBurnIndicator = GObject.registerClass(
class CodeBurnIndicator extends PanelMenu.Button {
_init(extension) {
super._init(0.0, 'CodeBurn');
this._extension = extension;
this._settings = extension.getSettings();
this._dataClient = new DataClient(this._settings.get_string('codeburn-path'));
this._settingsChangedIds = [];
this._period = this._settings.get_string('default-period') || 'today';
this._insight = 'activity';
this._availableProviders = this._detectProviders();
this._provider = this._availableProviders.length === 1 ? this._availableProviders[0] : 'all';
this._currency = this._loadCurrency();
this._exactCosts = this._settings.get_boolean('show-exact-costs');
this._fxRate = 1;
this._fxCache = { USD: 1 };
this._soupSession = new Soup.Session();
this._payload = null;
this._payloadCache = new Map();
this._inFlightKeys = new Set();
this._refreshGen = 0;
this._refreshSourceId = 0;
this._chartSummaryText = '';
this._destroyed = false;
this._themeSettings = new Gio.Settings({ schema_id: 'org.gnome.desktop.interface' });
this._themeSignal = this._themeSettings.connect('changed::color-scheme', () => this._applyThemeClass());
this._applyThemeClass();
this._updateFxRate();
this._buildPanelButton();
this._buildPopup();
this._connectSettings();
this._startRefreshLoop();
this._refresh();
}
// -- Panel button --
_buildPanelButton() {
const box = new St.BoxLayout({ style_class: 'panel-status-menu-box codeburn-panel' });
this._panelIcon = new St.Label({
text: '🔥',
y_align: Clutter.ActorAlign.CENTER,
style_class: 'codeburn-flame',
});
this._panelLabel = new St.Label({
text: '...',
y_align: Clutter.ActorAlign.CENTER,
style_class: 'codeburn-label',
});
box.add_child(this._panelIcon);
box.add_child(this._panelLabel);
this._panelLabel.visible = !this._settings.get_boolean('compact-mode');
this.add_child(box);
}
// -- Popup --
_buildPopup() {
try {
this.menu.box.add_style_class_name('codeburn-menu');
this._popupHost = new PopupMenu.PopupBaseMenuItem({ reactive: false, can_focus: false });
this._popupHost.add_style_class_name('codeburn-host');
this.menu.addMenuItem(this._popupHost);
this._root = new St.BoxLayout({ vertical: true, style_class: 'codeburn-root', x_expand: true });
this._popupHost.add_child(this._root);
this._buildBrandHeader();
this._scrollView = new St.ScrollView({
style_class: 'codeburn-scroll',
hscrollbar_policy: St.PolicyType.NEVER,
vscrollbar_policy: St.PolicyType.AUTOMATIC,
y_expand: true,
});
this._scrollContent = new St.BoxLayout({ vertical: true, x_expand: true });
this._scrollView.set_child(this._scrollContent);
this._root.add_child(this._scrollView);
this._buildAgentTabs();
this._buildHero();
this._buildPeriodTabs();
this._buildInsightPills();
this._buildTokenChart();
this._buildLoadingIndicator();
this._buildContentArea();
this._buildBudgetAlert();
this._buildFindingsSection();
this._buildFooter();
} catch (e) {
log(`CodeBurn: popup build error: ${e.message}\n${e.stack}`);
}
}
_buildBrandHeader() {
const header = new St.BoxLayout({ vertical: true, style_class: 'codeburn-brand-header' });
const title = new St.BoxLayout({ style_class: 'codeburn-brand-row' });
title.add_child(new St.Label({ text: 'Code', style_class: 'codeburn-brand-primary' }));
title.add_child(new St.Label({ text: 'Burn', style_class: 'codeburn-brand-accent' }));
header.add_child(title);
header.add_child(new St.Label({ text: 'AI Coding Cost Tracker', style_class: 'codeburn-brand-subhead' }));
this._root.add_child(header);
}
_buildAgentTabs() {
const detected = this._availableProviders;
this._agentTabs = new Map();
this._agentTabRow = null;
if (detected.length === 0) return;
const disabled = this._getDisabledProviders();
const tabs = detected.length === 1
? PROVIDERS.filter(p => p.id === detected[0])
: [PROVIDERS[0], ...PROVIDERS.slice(1).filter(p => detected.includes(p.id) && !disabled.has(p.id))];
if (tabs.length === 1) {
const badge = new St.Label({ text: tabs[0].label, style_class: 'codeburn-agent-badge' });
const row = new St.BoxLayout({ style_class: 'codeburn-tab-row' });
row.add_child(badge);
this._scrollContent.add_child(row);
return;
}
const useScroll = tabs.length > 5;
this._agentTabRow = new St.BoxLayout({ style_class: 'codeburn-tab-row' });
for (const p of tabs) {
const btn = new St.Button({ label: p.label, style_class: 'codeburn-tab', can_focus: true, x_expand: !useScroll });
btn.connect('clicked', () => {
this._provider = p.id;
this._updateAgentTabStyle();
this._refresh();
});
this._agentTabRow.add_child(btn);
this._agentTabs.set(p.id, btn);
}
if (useScroll) {
const agentScroll = new St.ScrollView({
style_class: 'codeburn-agent-scroll',
hscrollbar_policy: St.PolicyType.AUTOMATIC,
vscrollbar_policy: St.PolicyType.NEVER,
});
agentScroll.set_child(this._agentTabRow);
this._scrollContent.add_child(agentScroll);
} else {
this._scrollContent.add_child(this._agentTabRow);
}
this._updateAgentTabStyle();
}
_updateAgentTabStyle() {
for (const [id, btn] of this._agentTabs) {
if (id === this._provider) btn.add_style_class_name('codeburn-tab-active');
else btn.remove_style_class_name('codeburn-tab-active');
}
}
_buildHero() {
const hero = new St.BoxLayout({ vertical: true, style_class: 'codeburn-hero' });
const topLine = new St.BoxLayout({ style_class: 'codeburn-hero-top' });
this._heroDot = new St.Widget({ style_class: 'codeburn-hero-dot' });
this._heroLabel = new St.Label({ text: 'Loading...', style_class: 'codeburn-hero-label' });
topLine.add_child(this._heroDot);
topLine.add_child(this._heroLabel);
this._heroAmount = new St.Label({ text: '$0.00', style_class: 'codeburn-hero-amount' });
this._heroMeta = new St.Label({ text: '', style_class: 'codeburn-hero-meta' });
hero.add_child(topLine);
hero.add_child(this._heroAmount);
hero.add_child(this._heroMeta);
this._scrollContent.add_child(hero);
}
_buildPeriodTabs() {
const row = new St.BoxLayout({ style_class: 'codeburn-tab-row codeburn-period-row' });
this._periodTabs = new Map();
for (const p of PERIODS) {
const btn = new St.Button({ label: p.label, style_class: 'codeburn-period', can_focus: true, x_expand: true });
btn.connect('clicked', () => {
this._period = p.id;
this._updatePeriodTabStyle();
this._refresh();
});
row.add_child(btn);
this._periodTabs.set(p.id, btn);
}
this._scrollContent.add_child(row);
this._updatePeriodTabStyle();
}
_updatePeriodTabStyle() {
for (const [id, btn] of this._periodTabs) {
if (id === this._period) btn.add_style_class_name('codeburn-period-active');
else btn.remove_style_class_name('codeburn-period-active');
}
}
_buildInsightPills() {
const row = new St.BoxLayout({ style_class: 'codeburn-insight-row' });
this._insightPills = new Map();
for (const i of INSIGHTS) {
const btn = new St.Button({ label: i.label, style_class: 'codeburn-insight-pill', can_focus: true, x_expand: true });
btn.connect('clicked', () => {
this._insight = i.id;
this._updateInsightPillStyle();
this._renderContent();
});
row.add_child(btn);
this._insightPills.set(i.id, btn);
}
this._scrollContent.add_child(row);
this._updateInsightPillStyle();
}
_updateInsightPillStyle() {
for (const [id, btn] of this._insightPills) {
if (id === this._insight) btn.add_style_class_name('codeburn-insight-pill-active');
else btn.remove_style_class_name('codeburn-insight-pill-active');
}
}
_buildTokenChart() {
this._chartContainer = new St.BoxLayout({ vertical: true, style_class: 'codeburn-chart' });
const header = new St.BoxLayout({ style_class: 'codeburn-chart-header' });
this._chartLabel = new St.Label({ text: 'Tokens', style_class: 'codeburn-chart-label', x_expand: true });
this._chartTotal = new St.Label({ text: '', style_class: 'codeburn-chart-total' });
header.add_child(this._chartLabel);
header.add_child(this._chartTotal);
this._chartContainer.add_child(header);
this._chartBars = new St.BoxLayout({ style_class: 'codeburn-chart-bars' });
this._chartContainer.add_child(this._chartBars);
this._scrollContent.add_child(this._chartContainer);
}
_buildContentArea() {
this._scrollContent.add_child(new St.Widget({ style_class: 'codeburn-divider' }));
this._contentArea = new St.BoxLayout({ vertical: true, style_class: 'codeburn-content' });
this._scrollContent.add_child(this._contentArea);
}
_buildBudgetAlert() {
this._budgetLabel = new St.Label({ text: '', style_class: 'codeburn-budget-warning', visible: false });
this._scrollContent.add_child(this._budgetLabel);
}
_buildFindingsSection() {
this._findingsBtn = new St.Button({ style_class: 'codeburn-findings', visible: false });
const box = new St.BoxLayout({ style_class: 'codeburn-findings-inner' });
this._findingsCount = new St.Label({ text: '', style_class: 'codeburn-findings-count' });
this._findingsSavings = new St.Label({ text: '', style_class: 'codeburn-findings-savings' });
box.add_child(this._findingsCount);
box.add_child(this._findingsSavings);
this._findingsBtn.set_child(box);
this._findingsBtn.connect('clicked', () => this._spawnTerminal(['codeburn', 'optimize']));
this._scrollContent.add_child(this._findingsBtn);
}
_buildLoadingIndicator() {
this._loadingBox = new St.BoxLayout({ vertical: true, style_class: 'codeburn-loading', visible: false, x_expand: true });
const widths = [0.85, 0.6, 0.92, 0.5, 0.75, 0.45];
for (const w of widths) {
const bar = new St.Widget({ style_class: 'codeburn-skeleton-bar', x_expand: false });
bar.set_width(Math.round(308 * w));
bar.set_height(10);
this._loadingBox.add_child(bar);
}
this._scrollContent.add_child(this._loadingBox);
}
_showLoading() {
if (!this._loadingBox) return;
this._loadingBox.visible = true;
this._loadingBox.get_children().forEach((bar, i) => {
bar.opacity = 255;
bar.ease({
opacity: 60,
duration: 900,
delay: i * 120,
mode: Clutter.AnimationMode.EASE_IN_OUT_SINE,
repeatCount: -1,
autoReverse: true,
});
});
}
_hideLoading() {
if (!this._loadingBox) return;
this._loadingBox.visible = false;
this._loadingBox.get_children().forEach(bar => {
bar.remove_all_transitions();
bar.opacity = 255;
});
}
_buildFooter() {
this._currencyPicker = new St.ScrollView({
style_class: 'codeburn-currency-picker',
visible: false,
hscrollbar_policy: St.PolicyType.NEVER,
vscrollbar_policy: St.PolicyType.AUTOMATIC,
});
const pickerList = new St.BoxLayout({ vertical: true, style_class: 'codeburn-currency-list' });
for (const c of CURRENCIES) {
const item = new St.Button({ label: `${c.symbol} ${c.code}`, style_class: 'codeburn-currency-item', can_focus: true });
if (c.code === this._currency.code) item.add_style_class_name('codeburn-currency-item-active');
item.connect('clicked', () => {
this._setCurrency(c.code);
this._currencyPicker.hide();
pickerList.get_children().forEach(ch => ch.remove_style_class_name('codeburn-currency-item-active'));
item.add_style_class_name('codeburn-currency-item-active');
});
pickerList.add_child(item);
}
this._currencyPicker.set_child(pickerList);
this._root.add_child(this._currencyPicker);
const footer = new St.BoxLayout({ style_class: 'codeburn-footer' });
this._currencyBtn = new St.Button({
label: `${this._currency.code}`,
style_class: 'codeburn-footer-btn codeburn-currency-btn',
can_focus: true,
});
this._currencyBtn.connect('clicked', () => this._toggleCurrencyPicker());
footer.add_child(this._currencyBtn);
const refreshBtn = new St.Button({ label: 'Refresh', style_class: 'codeburn-footer-btn', can_focus: true, x_expand: true });
refreshBtn.connect('clicked', () => this._refresh(true));
footer.add_child(refreshBtn);
const reportBtn = new St.Button({ label: 'Full Report', style_class: 'codeburn-footer-btn codeburn-footer-cta', can_focus: true, x_expand: true });
reportBtn.connect('clicked', () => this._spawnTerminal(['codeburn', 'report', '--period', this._period, '--provider', this._provider]));
footer.add_child(reportBtn);
const prefsBtn = new St.Button({ label: '⚙', style_class: 'codeburn-footer-btn codeburn-prefs-btn', can_focus: true });
prefsBtn.connect('clicked', () => {
this._extension.openPreferences();
this.menu.close();
});
footer.add_child(prefsBtn);
this._root.add_child(footer);
this._updatedLabel = new St.Label({ text: '', style_class: 'codeburn-updated' });
this._root.add_child(this._updatedLabel);
}
// -- Settings --
_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._panelLabel.visible = !this._settings.get_boolean('compact-mode'); });
watch('codeburn-path', () => {
this._dataClient.setCodeburnPath(this._settings.get_string('codeburn-path'));
this._refresh(true);
});
watch('default-period', () => {
this._period = this._settings.get_string('default-period');
this._updatePeriodTabStyle();
this._refresh();
});
watch('budget-threshold', () => this._updateBudget());
watch('budget-alert-enabled', () => this._updateBudget());
watch('force-dark-mode', () => this._applyThemeClass());
watch('show-exact-costs', () => {
this._exactCosts = this._settings.get_boolean('show-exact-costs');
if (this._payload) this._render(this._payload);
});
watch('disabled-providers', () => {
if (this._payload) this._render(this._payload);
});
}
_getDisabledProviders() {
return new Set(this._settings.get_strv('disabled-providers'));
}
// -- Provider detection --
_detectProviders() {
const home = GLib.get_home_dir();
const xdgData = GLib.getenv('XDG_DATA_HOME') || `${home}/.local/share`;
const checks = Object.fromEntries(
Object.entries(PROVIDER_PATHS).map(([id, rel]) => [id, `${home}/${rel}`])
);
checks.opencode = `${xdgData}/opencode`;
const out = [];
for (const [id, path] of Object.entries(checks)) {
if (Gio.File.new_for_path(path).query_exists(null)) out.push(id);
}
return out;
}
// -- Refresh loop --
_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();
}
// -- Data fetching with cache --
_cacheKey() {
return `${this._period}|${this._provider}`;
}
async _refresh(force = false) {
const key = this._cacheKey();
const cached = this._payloadCache.get(key);
const cacheAge = cached ? Date.now() - cached.fetchedAt : Infinity;
if (!force && cached && cacheAge < CACHE_TTL_MS) {
this._payload = cached.payload;
this._render(this._payload);
return;
}
if (this._inFlightKeys.has(key)) return;
this._inFlightKeys.add(key);
const gen = ++this._refreshGen;
if (cached) {
this._payload = cached.payload;
this._render(this._payload);
} else {
this._showLoading();
if (this._contentArea) this._contentArea.opacity = 120;
}
try {
const payload = await this._dataClient.fetch(this._period, this._provider);
this._inFlightKeys.delete(key);
if (this._destroyed || gen !== this._refreshGen) return;
this._payloadCache.set(key, { payload, fetchedAt: Date.now() });
if (this._cacheKey() === key) {
this._payload = payload;
this._hideLoading();
if (this._contentArea) this._contentArea.opacity = 255;
this._render(this._payload);
}
} catch (e) {
this._inFlightKeys.delete(key);
if (this._destroyed) return;
this._hideLoading();
if (this._contentArea) this._contentArea.opacity = 255;
if (gen !== this._refreshGen) return;
if (e.message?.includes('cancelled')) return;
log(`CodeBurn: refresh error: ${e.message}`);
if (!this._payload) this._renderError(e.message);
}
}
// -- Rendering --
_render(payload) {
const current = payload?.current ?? {};
const cost = Number(current.cost ?? 0);
this._panelLabel.set_text(this._fmt(cost));
this._heroLabel.set_text(current.label || '');
this._heroAmount.set_text(this._fmt(cost));
const calls = Number(current.calls ?? 0);
const sessions = Number(current.sessions ?? 0);
this._heroMeta.set_text(`${calls.toLocaleString()} calls ${sessions} sessions`);
this._renderChart(payload?.history?.daily ?? []);
this._renderContent();
this._renderFindings(payload?.optimize ?? {});
this._updateBudget();
const updated = payload?.generated ? formatTime(new Date(payload.generated)) : '';
this._updatedLabel.set_text(updated ? `Updated ${updated}` : '');
}
_renderChart(daily) {
this._chartBars.destroy_all_children();
const days = Array.isArray(daily) ? daily.slice(-19) : [];
if (days.length === 0) {
this._chartContainer.visible = false;
return;
}
const inTotals = days.map(d => Number(d?.inputTokens) || 0);
const outTotals = days.map(d => Number(d?.outputTokens) || 0);
const totals = inTotals.map((v, i) => v + outTotals[i]);
let maxTotal = 1;
let totalIn = 0;
let totalOut = 0;
let hasAnyTokens = false;
for (let i = 0; i < days.length; i++) {
if (totals[i] > maxTotal) maxTotal = totals[i];
if (totals[i] > 0) hasAnyTokens = true;
totalIn += inTotals[i];
totalOut += outTotals[i];
}
if (!hasAnyTokens) {
this._chartContainer.visible = false;
return;
}
this._chartContainer.visible = true;
const summaryText = `In: ${formatTokensCompact(totalIn)} Out: ${formatTokensCompact(totalOut)}`;
this._chartTotal.set_text(summaryText);
this._chartSummaryText = summaryText;
const chartWidth = 308;
const gap = 2;
const barW = Math.max(4, Math.floor((chartWidth - gap * (days.length - 1)) / days.length));
for (let i = 0; i < days.length; i++) {
const h = Math.max(2, Math.round((totals[i] / maxTotal) * CHART_HEIGHT));
const col = new St.BoxLayout({ vertical: true, style_class: 'codeburn-chart-col', reactive: true });
col.set_width(barW);
col.set_height(CHART_HEIGHT);
const spacer = new St.Widget({ style_class: 'codeburn-chart-spacer' });
spacer.set_height(CHART_HEIGHT - h);
const bar = new St.Widget({ style_class: 'codeburn-chart-bar' });
bar.set_width(barW);
bar.set_height(h);
col.add_child(spacer);
col.add_child(bar);
const date = days[i]?.date || '';
const inTok = formatTokensCompact(inTotals[i]);
const outTok = formatTokensCompact(outTotals[i]);
const cost = days[i]?.cost != null ? this._fmt(days[i].cost) : '';
col.connect('enter-event', () => {
this._chartTotal.set_text(`${date} ${inTok}/${outTok} ${cost}`);
this._chartTotal.add_style_class_name('codeburn-chart-total-hover');
bar.add_style_class_name('codeburn-chart-bar-hover');
return Clutter.EVENT_PROPAGATE;
});
col.connect('leave-event', () => {
this._chartTotal.set_text(this._chartSummaryText);
this._chartTotal.remove_style_class_name('codeburn-chart-total-hover');
bar.remove_style_class_name('codeburn-chart-bar-hover');
return Clutter.EVENT_PROPAGATE;
});
this._chartBars.add_child(col);
}
}
_renderContent() {
this._contentArea.destroy_all_children();
switch (this._insight) {
case 'trend': return this._renderTrendView();
case 'forecast': return this._renderForecastView();
case 'pulse': return this._renderPulseView();
case 'stats': return this._renderStatsView();
default: return this._renderActivityView();
}
}
_renderActivityView() {
const current = this._payload?.current ?? {};
this._contentArea.add_child(this._sectionTitle('Activity'));
const actHeader = new St.BoxLayout({ style_class: 'codeburn-table-header' });
actHeader.add_child(new St.Label({ text: 'Name', style_class: 'codeburn-th', x_expand: true }));
actHeader.add_child(new St.Label({ text: 'Cost', style_class: 'codeburn-th codeburn-th-right codeburn-th-cost' }));
actHeader.add_child(new St.Label({ text: 'Turns', style_class: 'codeburn-th codeburn-th-right codeburn-th-turns' }));
actHeader.add_child(new St.Label({ text: '1-shot', style_class: 'codeburn-th codeburn-th-right codeburn-th-turns' }));
this._contentArea.add_child(actHeader);
const rows = new St.BoxLayout({ vertical: true, style_class: 'codeburn-activity-rows' });
const activities = Array.isArray(current.topActivities) ? current.topActivities : [];
if (!activities.length) {
rows.add_child(new St.Label({ text: 'No activity for this period', style_class: 'codeburn-empty' }));
} else {
const maxCost = activities.reduce((m, a) => Math.max(m, Number(a.cost) || 0), 0) || 1;
for (const a of activities.slice(0, TOP_ACTIVITIES)) {
rows.add_child(this._buildActivityRow(a, maxCost));
}
}
this._contentArea.add_child(rows);
const models = Array.isArray(current.topModels) ? current.topModels : [];
if (models.length) {
this._contentArea.add_child(this._sectionTitle('Models'));
const modHeader = new St.BoxLayout({ style_class: 'codeburn-table-header' });
modHeader.add_child(new St.Label({ text: 'Model', style_class: 'codeburn-th', x_expand: true }));
modHeader.add_child(new St.Label({ text: 'Cost', style_class: 'codeburn-th codeburn-th-right codeburn-th-cost' }));
modHeader.add_child(new St.Label({ text: 'Calls', style_class: 'codeburn-th codeburn-th-right codeburn-th-calls' }));
this._contentArea.add_child(modHeader);
const mrows = new St.BoxLayout({ vertical: true, style_class: 'codeburn-models-rows' });
for (const m of models.slice(0, 3)) mrows.add_child(this._buildModelRow(m));
this._contentArea.add_child(mrows);
}
}
_renderTrendView() {
const daily = this._payload?.history?.daily ?? [];
if (!daily.length) {
this._contentArea.add_child(new St.Label({ text: 'Not enough history yet', style_class: 'codeburn-empty' }));
return;
}
for (const d of daily.slice(-7).reverse()) {
const row = new St.BoxLayout({ style_class: 'codeburn-trend-row' });
row.add_child(new St.Label({ text: d.date, style_class: 'codeburn-trend-date', x_expand: true }));
const costLabel = new St.Label({ text: this._fmt(d.cost), style_class: 'codeburn-trend-cost' });
costLabel.clutter_text.x_align = Clutter.ActorAlign.END;
row.add_child(costLabel);
const callsLabel = new St.Label({ text: `${Number(d.calls).toLocaleString()} calls`, style_class: 'codeburn-trend-calls' });
callsLabel.clutter_text.x_align = Clutter.ActorAlign.END;
row.add_child(callsLabel);
this._contentArea.add_child(row);
}
}
_renderForecastView() {
const daily = this._payload?.history?.daily ?? [];
if (daily.length < 3) {
this._contentArea.add_child(new St.Label({ text: 'Need at least 3 days of history', style_class: 'codeburn-empty' }));
return;
}
const last7 = daily.slice(-7);
const avg = last7.reduce((s, d) => s + Number(d.cost || 0), 0) / last7.length;
const yesterday = daily.at(-2);
const yestCost = Number(yesterday?.cost || 0);
const todCost = Number(daily.at(-1)?.cost || 0);
const dod = yestCost > 0 ? ((todCost - yestCost) / yestCost) * 100 : 0;
const now = new Date();
const dayOfMonth = now.getUTCDate();
const daysInMonth = new Date(now.getUTCFullYear(), now.getUTCMonth() + 1, 0).getUTCDate();
this._contentArea.add_child(this._kvRow('7-day avg', this._fmt(avg)));
this._contentArea.add_child(this._kvRow('Yesterday', yesterday ? this._fmt(yestCost) : '-'));
this._contentArea.add_child(this._kvRow('Day-over-day', `${dod > 0 ? '+' : ''}${dod.toFixed(1)}%`));
this._contentArea.add_child(this._kvRow('Month projection', this._fmt(avg * daysInMonth)));
this._contentArea.add_child(this._kvRow('Days elapsed', `${dayOfMonth} of ${daysInMonth}`));
}
_renderPulseView() {
const current = this._payload?.current ?? {};
const daily = this._payload?.history?.daily ?? [];
this._contentArea.add_child(this._sectionTitle('Pulse'));
const row = new St.BoxLayout({ style_class: 'codeburn-pulse-row' });
row.add_child(this._pulseTile(this._fmt(current.cost), 'cost'));
row.add_child(this._pulseTile(Number(current.calls || 0).toLocaleString(), 'calls'));
row.add_child(this._pulseTile(`${Number(current.cacheHitPercent || 0).toFixed(0)}%`, 'cache hit'));
this._contentArea.add_child(row);
if (daily.length) {
this._contentArea.add_child(this._sectionTitle('Last 7 days'));
const last7 = daily.slice(-7);
const sumCost = last7.reduce((s, d) => s + Number(d.cost || 0), 0);
const sumCalls = last7.reduce((s, d) => s + Number(d.calls || 0), 0);
const peakDay = last7.reduce((best, d) => Number(d.cost || 0) > Number(best.cost || 0) ? d : best, last7[0]);
this._contentArea.add_child(this._kvRow('Total spend', this._fmt(sumCost)));
this._contentArea.add_child(this._kvRow('Total calls', Number(sumCalls).toLocaleString()));
this._contentArea.add_child(this._kvRow('Peak day', `${peakDay?.date || '-'} ${this._fmt(peakDay?.cost)}`));
}
}
_renderStatsView() {
const current = this._payload?.current ?? {};
const daily = this._payload?.history?.daily ?? [];
this._contentArea.add_child(this._sectionTitle('Stats'));
const models = Array.isArray(current.topModels) ? current.topModels : [];
const favModel = models[0]?.name ?? '-';
const activeDays = daily.filter(d => Number(d.cost || 0) > 0).length;
const peakDay = daily.reduce((best, d) => Number(d.cost || 0) > Number((best || {}).cost || 0) ? d : best, null);
let streak = 0;
for (let i = daily.length - 1; i >= 0; i--) {
if (Number(daily[i].cost || 0) > 0) streak++;
else break;
}
this._contentArea.add_child(this._kvRow('Favorite model', favModel));
this._contentArea.add_child(this._kvRow('Active days', `${activeDays}`));
this._contentArea.add_child(this._kvRow('Current streak', `${streak} days`));
if (peakDay) this._contentArea.add_child(this._kvRow('Peak day', `${peakDay.date} ${this._fmt(peakDay.cost)}`));
}
_renderFindings(optimize) {
const count = Number(optimize?.findingCount ?? 0);
if (count === 0) {
this._findingsBtn.hide();
return;
}
const savings = Number(optimize?.savingsUSD ?? 0);
this._findingsCount.set_text(`${count} optimize findings`);
this._findingsSavings.set_text(`save ~${this._fmt(savings)}`);
this._findingsBtn.show();
}
_renderError(message) {
this._panelLabel.set_text('!');
if (message?.includes('not found') || message?.includes('No such file')) {
this._heroLabel.set_text('CodeBurn CLI not found');
this._heroMeta.set_text('Install: npm i -g codeburn');
} else {
this._heroLabel.set_text('Error loading data');
this._heroMeta.set_text(message?.substring(0, 80) || 'Unknown error');
}
this._heroAmount.set_text('');
this._findingsBtn.hide();
}
// -- Budget --
_updateBudget() {
const enabled = this._settings.get_boolean('budget-alert-enabled');
const threshold = this._settings.get_double('budget-threshold');
if (!enabled || threshold <= 0 || !this._payload?.current) {
this._budgetLabel.visible = false;
return;
}
const cost = Number(this._payload.current.cost ?? 0) * this._fxRate;
const thresholdConverted = threshold * this._fxRate;
if (cost >= thresholdConverted) {
this._budgetLabel.set_text(`Budget exceeded: ${this._fmt(cost)} / ${this._fmt(thresholdConverted)}`);
this._budgetLabel.visible = true;
} else {
this._budgetLabel.visible = false;
}
}
// -- Currency --
_loadCurrency() {
const configPath = GLib.build_filenamev([GLib.get_home_dir(), '.config', 'codeburn', 'config.json']);
try {
const [ok, contents] = GLib.file_get_contents(configPath);
if (ok) {
const config = JSON.parse(new TextDecoder().decode(contents));
if (config.currency?.code) {
const known = CURRENCIES.find(c => c.code === config.currency.code);
if (known) return known;
return { code: config.currency.code, symbol: config.currency.symbol || `${config.currency.code} ` };
}
}
} catch (_) { /* default */ }
return CURRENCIES[0];
}
_toggleCurrencyPicker() {
this._currencyPicker.visible = !this._currencyPicker.visible;
}
_setCurrency(code) {
try {
Gio.Subprocess.new(['codeburn', 'currency', code], Gio.SubprocessFlags.STDOUT_PIPE | Gio.SubprocessFlags.STDERR_PIPE);
} catch (_) { /* CLI missing */ }
const known = CURRENCIES.find(c => c.code === code);
this._currency = known || { code, symbol: `${code} ` };
this._currencyBtn.set_label(`${this._currency.code}`);
this._updateFxRate();
}
_updateFxRate() {
const code = this._currency?.code || 'USD';
if (this._fxCache[code] !== undefined) {
this._fxRate = this._fxCache[code];
if (this._payload) this._render(this._payload);
return;
}
const url = `https://api.frankfurter.app/latest?from=USD&to=${code}`;
const msg = Soup.Message.new('GET', url);
this._soupSession.send_and_read_async(msg, GLib.PRIORITY_DEFAULT, null, (session, result) => {
if (this._destroyed) return;
try {
const bytes = session.send_and_read_finish(result);
if (!bytes) return;
const json = JSON.parse(new TextDecoder().decode(bytes.get_data()));
const rate = json?.rates?.[code];
if (typeof rate === 'number' && rate > 0) {
this._fxCache[code] = rate;
this._fxRate = rate;
if (this._payload) this._render(this._payload);
}
} catch (_) { /* FX fetch failed */ }
});
}
_fmt(value) {
return formatCost(value, this._currency, this._fxRate, this._exactCosts);
}
// -- UI helpers --
_sectionTitle(text) {
return new St.Label({ text, style_class: 'codeburn-section-title' });
}
_kvRow(label, value) {
const row = new St.BoxLayout({ style_class: 'codeburn-kv-row' });
row.add_child(new St.Label({ text: label, style_class: 'codeburn-kv-label', x_expand: true }));
row.add_child(new St.Label({ text: String(value ?? '-'), style_class: 'codeburn-kv-value' }));
return row;
}
_pulseTile(value, label) {
const tile = new St.BoxLayout({ vertical: true, style_class: 'codeburn-pulse-tile', x_expand: true });
tile.add_child(new St.Label({ text: value, style_class: 'codeburn-pulse-value' }));
tile.add_child(new St.Label({ text: label, style_class: 'codeburn-pulse-label' }));
return tile;
}
_buildActivityRow(activity, maxCost) {
const row = new St.BoxLayout({ vertical: true, style_class: 'codeburn-activity-row' });
const topLine = new St.BoxLayout({ style_class: 'codeburn-activity-top' });
topLine.add_child(new St.Label({ text: activity.name, style_class: 'codeburn-activity-name', x_expand: true }));
const costLabel = new St.Label({ text: this._fmt(activity.cost), style_class: 'codeburn-activity-cost' });
costLabel.clutter_text.x_align = Clutter.ActorAlign.END;
topLine.add_child(costLabel);
const turnsLabel = new St.Label({ text: `${Number(activity.turns) || 0}`, style_class: 'codeburn-activity-turns' });
turnsLabel.clutter_text.x_align = Clutter.ActorAlign.END;
topLine.add_child(turnsLabel);
const osText = activity.oneShotRate != null ? `${Math.round(Number(activity.oneShotRate) * 100)}%` : '--';
const osLabel = new St.Label({ text: osText, style_class: 'codeburn-activity-oneshot' });
osLabel.clutter_text.x_align = Clutter.ActorAlign.END;
topLine.add_child(osLabel);
row.add_child(topLine);
const track = new St.BoxLayout({ style_class: 'codeburn-bar-track' });
const pct = Math.max(0.02, Math.min(1, Number(activity.cost) / maxCost));
const fill = new St.Widget({ style_class: 'codeburn-bar-fill' });
fill.set_width(Math.round(BAR_TRACK_WIDTH * pct));
track.add_child(fill);
row.add_child(track);
return row;
}
_buildModelRow(model) {
const row = new St.BoxLayout({ style_class: 'codeburn-model-row' });
row.add_child(new St.Label({ text: model.name, style_class: 'codeburn-model-name', x_expand: true }));
const mc = new St.Label({ text: this._fmt(model.cost), style_class: 'codeburn-model-cost' });
mc.clutter_text.x_align = Clutter.ActorAlign.END;
row.add_child(mc);
const mcalls = new St.Label({ text: `${Number(model.calls || 0).toLocaleString()}`, style_class: 'codeburn-model-calls' });
mcalls.clutter_text.x_align = Clutter.ActorAlign.END;
row.add_child(mcalls);
return row;
}
// -- Theme --
_applyThemeClass() {
const forceDark = this._settings.get_boolean('force-dark-mode');
const scheme = this._themeSettings.get_string('color-scheme');
const isDark = forceDark || scheme === 'prefer-dark';
if (isDark) {
this._root?.add_style_class_name('codeburn-dark');
this._root?.remove_style_class_name('codeburn-light');
} else {
this._root?.add_style_class_name('codeburn-light');
this._root?.remove_style_class_name('codeburn-dark');
}
}
// -- Terminal spawning --
_spawnTerminal(argv) {
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}`);
}
this.menu.close();
}
// -- Cleanup --
destroy() {
this._destroyed = true;
if (this._refreshSourceId) {
GLib.Source.remove(this._refreshSourceId);
this._refreshSourceId = 0;
}
if (this._themeSettings && this._themeSignal) {
this._themeSettings.disconnect(this._themeSignal);
this._themeSignal = null;
this._themeSettings = null;
}
for (const id of this._settingsChangedIds) this._settings.disconnect(id);
this._settingsChangedIds = [];
this._dataClient?.destroy();
if (this._soupSession) {
this._soupSession.abort();
this._soupSession = null;
}
super.destroy();
}
});