feat(extensions): insight pills + token histogram chart on GNOME

Port Wave 1 of the Mac popover to the GNOME extension:

* Insight pills row (Activity, Trend, Forecast, Pulse, Stats, Plan) below
  period tabs. Clicking a pill swaps the content area between views.
* 19-day token histogram chart between period tabs and content area.
  Each bar scales to the top-token day, rendered as a St.Widget with a
  brand-orange linear gradient.
* Six view implementations:
  - Activity (default): current activity rows + top 3 models
  - Trend: last 7 days, per-day cost and call count
  - Forecast: 7-day avg, yesterday, day-over-day %, month projection
  - Pulse: three KPI tiles (cost, calls, cache hit) + last-7-day summary
  - Stats: favorite model, active days, current streak, peak day
  - Plan: placeholder (Claude OAuth is macOS-only until Wave 3)

Shared helpers:
* _sectionTitle, _kvRow, _pulseTile for consistent row layouts
* formatTokensCompact for 1.2k / 4.5M / 2.3B token totals
This commit is contained in:
AgentSeal 2026-04-18 05:44:10 -07:00
parent 6358100d3d
commit e246aa9ece
2 changed files with 395 additions and 16 deletions

View file

@ -35,6 +35,17 @@ const PERIODS = [
{id: 'all', label: 'All'},
];
// Secondary view pills (below period tabs, matches Mac popover). Each pill swaps
// the content area between Activity+Models and a dedicated insight view.
const INSIGHTS = [
{id: 'activity', label: 'Activity'},
{id: 'trend', label: 'Trend' },
{id: 'forecast', label: 'Forecast'},
{id: 'pulse', label: 'Pulse' },
{id: 'stats', label: 'Stats' },
{id: 'plan', label: 'Plan' },
];
const PROVIDERS = [
{id: 'all', label: 'All'},
{id: 'claude', label: 'Claude'},
@ -71,6 +82,7 @@ class CodeburnIndicator extends PanelMenu.Button {
super._init(0.0, 'CodeBurn');
this._period = 'today';
this._insight = 'activity';
this._availableProviders = this._detectAvailableProviders();
// If only one provider is installed, use it directly so the popup doesn't
// pretend to be filtering when there's nothing to filter. Otherwise start
@ -125,7 +137,9 @@ class CodeburnIndicator extends PanelMenu.Button {
this._buildAgentTabs();
this._buildHero();
this._buildPeriodTabs();
this._buildActivitySection();
this._buildInsightPills();
this._buildTokenChart();
this._buildContentArea();
this._buildFindingsSection();
this._buildFooter();
@ -261,13 +275,58 @@ class CodeburnIndicator extends PanelMenu.Button {
}
}
_buildActivitySection() {
const section = new St.BoxLayout({vertical: true, style_class: 'codeburn-activity'});
const title = new St.Label({text: 'Activity', style_class: 'codeburn-section-title'});
section.add_child(title);
_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._root.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');
}
}
/// 19-day token histogram, matches the Mac Trend chart that sits below the
/// hero. Each bar scales to the top-token day; colors come from the brand
/// palette. Populated from payload.history.daily.
_buildTokenChart() {
const chart = 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);
chart.add_child(header);
this._chartBars = new St.BoxLayout({style_class: 'codeburn-chart-bars'});
chart.add_child(this._chartBars);
this._root.add_child(chart);
}
/// Swappable content area: Activity (default), Trend, Forecast, Pulse,
/// Stats, or Plan view — driven by this._insight.
_buildContentArea() {
this._contentArea = new St.BoxLayout({vertical: true, style_class: 'codeburn-content'});
this._activityRows = new St.BoxLayout({vertical: true, style_class: 'codeburn-activity-rows'});
section.add_child(this._activityRows);
this._root.add_child(section);
this._modelsRows = new St.BoxLayout({vertical: true, style_class: 'codeburn-models-rows'});
this._root.add_child(this._contentArea);
}
_buildFindingsSection() {
@ -441,26 +500,197 @@ class CodeburnIndicator extends PanelMenu.Button {
const sessions = Number(current.sessions ?? 0);
this._heroMeta.set_text(`${calls.toLocaleString()} calls ${sessions} sessions`);
this._renderActivity(Array.isArray(current.topActivities) ? current.topActivities : []);
this._renderChart(payload?.history?.daily ?? []);
this._renderContent();
this._renderFindings(payload?.optimize ?? {});
const updated = payload?.generated ? formatTime(new Date(payload.generated)) : '';
this._updatedLabel.set_text(updated ? `Updated ${updated}` : '');
}
_renderActivity(activities) {
this._activityRows.destroy_all_children();
if (!activities.length) {
const empty = new St.Label({text: 'No activity for this period', style_class: 'codeburn-empty'});
this._activityRows.add_child(empty);
_renderChart(daily) {
this._chartBars.destroy_all_children();
if (!daily.length) {
this._chartTotal.set_text('');
return;
}
const maxCost = activities.reduce((m, a) => Math.max(m, Number(a.cost) || 0), 0) || 1;
for (const a of activities.slice(0, TOP_ACTIVITIES)) {
this._activityRows.add_child(this._buildActivityRow(a, maxCost));
const window = daily.slice(-19); // last 19 days, matches Mac Trend chart
const totals = window.map(d => Number(d.inputTokens || 0) + Number(d.outputTokens || 0) + Number(d.cacheReadTokens || 0) + Number(d.cacheWriteTokens || 0));
const maxTotal = Math.max(...totals, 1);
const totalAll = totals.reduce((a, b) => a + b, 0);
this._chartTotal.set_text(`${formatTokensCompact(totalAll)} tokens`);
const CHART_HEIGHT = 52;
const BAR_WIDTH = 12;
for (let i = 0; i < window.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'});
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(BAR_WIDTH);
bar.set_height(h);
col.add_child(spacer);
col.add_child(bar);
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();
case 'plan': return this._renderPlanView();
default: return this._renderActivityView();
}
}
_renderActivityView() {
const current = this._payload?.current ?? {};
this._contentArea.add_child(this._sectionTitle('Activity'));
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 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 ?? [];
this._contentArea.add_child(this._sectionTitle('Trend'));
if (!daily.length) {
this._contentArea.add_child(new St.Label({text: 'Not enough history yet', style_class: 'codeburn-empty'}));
return;
}
const recent = daily.slice(-7).reverse();
for (const d of recent) {
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}));
row.add_child(new St.Label({text: formatCost(d.cost, this._currency, this._fxRate), style_class: 'codeburn-trend-cost'}));
row.add_child(new St.Label({text: `${Number(d.calls).toLocaleString()} calls`, style_class: 'codeburn-trend-calls'}));
this._contentArea.add_child(row);
}
}
_renderForecastView() {
const daily = this._payload?.history?.daily ?? [];
this._contentArea.add_child(this._sectionTitle('Forecast'));
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 today = daily.at(-1);
const yesterday = daily.at(-2);
const wowDelta = yesterday ? ((Number(today.cost || 0) - Number(yesterday.cost || 0)) / Math.max(Number(yesterday.cost || 0), 1)) * 100 : 0;
const now = new Date();
const dayOfMonth = now.getUTCDate();
const daysInMonth = new Date(now.getUTCFullYear(), now.getUTCMonth() + 1, 0).getUTCDate();
const monthProjection = avg * daysInMonth;
this._contentArea.add_child(this._kvRow('7-day avg', formatCost(avg, this._currency, this._fxRate)));
this._contentArea.add_child(this._kvRow('Yesterday', yesterday ? formatCost(yesterday.cost, this._currency, this._fxRate) : '-'));
this._contentArea.add_child(this._kvRow('Day-over-day', `${wowDelta > 0 ? '+' : ''}${wowDelta.toFixed(1)}%`));
this._contentArea.add_child(this._kvRow('Month projection', formatCost(monthProjection, this._currency, this._fxRate)));
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(formatCost(current.cost, this._currency, this._fxRate), '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);
// Last 7 days mini
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', formatCost(sumCost, this._currency, this._fxRate)));
this._contentArea.add_child(this._kvRow('Total calls', Number(sumCalls).toLocaleString()));
this._contentArea.add_child(this._kvRow('Peak day', `${peakDay?.date || '-'} ${formatCost(peakDay?.cost, this._currency, this._fxRate)}`));
}
}
_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);
// Simple streak: consecutive recent non-zero days
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} ${formatCost(peakDay.cost, this._currency, this._fxRate)}`));
}
}
_renderPlanView() {
this._contentArea.add_child(this._sectionTitle('Plan'));
this._contentArea.add_child(new St.Label({
text: 'Claude OAuth subscription tracking is macOS-only for now. Coming to Linux in a future release.',
style_class: 'codeburn-empty',
}));
}
_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;
}
_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}));
row.add_child(new St.Label({text: formatCost(model.cost, this._currency, this._fxRate), style_class: 'codeburn-model-cost'}));
row.add_child(new St.Label({text: `${Number(model.calls || 0).toLocaleString()}`, style_class: 'codeburn-model-calls'}));
return row;
}
_buildActivityRow(activity, maxCost) {
const row = new St.BoxLayout({vertical: true, style_class: 'codeburn-activity-row'});
@ -568,6 +798,14 @@ function formatCost(value, currency, rate = 1) {
return `${symbol}${n.toFixed(2)}`;
}
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();

View file

@ -256,6 +256,147 @@
padding: 0 16px 10px 16px;
}
/* ---- insight pills row ---- */
.codeburn-insight-row {
padding: 4px 10px 8px 10px;
spacing: 4px;
}
.codeburn-insight-pill {
padding: 4px 4px;
border-radius: 6px;
font-size: 10.5px;
font-weight: 500;
background: transparent;
border: none;
opacity: 0.65;
transition-duration: 80ms;
}
.codeburn-insight-pill:hover {
background: rgba(255, 140, 66, 0.08);
opacity: 1;
}
.codeburn-insight-pill-active {
background: rgba(255, 140, 66, 0.18);
color: #ff8c42;
opacity: 1;
font-weight: 600;
}
/* ---- token histogram chart ---- */
.codeburn-chart {
padding: 0 16px 10px 16px;
spacing: 4px;
}
.codeburn-chart-header {
spacing: 6px;
}
.codeburn-chart-label {
font-weight: 600;
font-size: 11px;
opacity: 0.6;
}
.codeburn-chart-total {
font-family: monospace;
font-size: 11px;
opacity: 0.7;
color: #ff8c42;
}
.codeburn-chart-bars {
spacing: 2px;
height: 52px;
}
.codeburn-chart-col {
width: 12px;
height: 52px;
}
.codeburn-chart-spacer {
background: transparent;
}
.codeburn-chart-bar {
background: linear-gradient(to top, #c9521d, #ff8c42);
border-radius: 2px 2px 0 0;
}
/* ---- trend, pulse, stats, kv rows ---- */
.codeburn-content {
padding: 6px 16px 10px 16px;
spacing: 6px;
}
.codeburn-trend-row,
.codeburn-kv-row {
padding: 4px 0;
spacing: 8px;
}
.codeburn-trend-date,
.codeburn-kv-label {
font-size: 11.5px;
font-weight: 500;
}
.codeburn-trend-cost,
.codeburn-kv-value {
font-family: monospace;
font-size: 11.5px;
font-weight: 600;
color: #ffd700;
}
.codeburn-trend-calls {
font-size: 10.5px;
opacity: 0.6;
min-width: 62px;
text-align: right;
}
/* ---- pulse tiles ---- */
.codeburn-pulse-row {
spacing: 6px;
padding: 4px 0;
}
.codeburn-pulse-tile {
padding: 10px 8px;
border-radius: 8px;
background: rgba(255, 140, 66, 0.08);
spacing: 2px;
}
.codeburn-pulse-value {
font-size: 16px;
font-weight: 700;
color: #ff8c42;
font-family: monospace;
}
.codeburn-pulse-label {
font-size: 10px;
opacity: 0.6;
text-transform: uppercase;
letter-spacing: 0.04em;
}
/* ---- models rows ---- */
.codeburn-models-rows {
spacing: 3px;
padding-top: 4px;
}
.codeburn-model-row {
spacing: 8px;
padding: 2px 0;
}
.codeburn-model-name {
font-size: 11.5px;
}
.codeburn-model-cost {
font-family: monospace;
font-size: 11.5px;
color: #ffd700;
min-width: 60px;
text-align: right;
}
.codeburn-model-calls {
font-family: monospace;
font-size: 10.5px;
opacity: 0.6;
min-width: 60px;
text-align: right;
}
/* ---- dark / light theme hooks ---- */
.codeburn-light .codeburn-bar-track {
background-color: rgba(0, 0, 0, 0.08);