From 38efed6766c05935528699468a553096cae192ee Mon Sep 17 00:00:00 2001 From: Toni Hermoso Pulido Date: Mon, 24 Aug 2026 18:43:49 +0200 Subject: [PATCH 1/3] Render provider detail sections (OpenRouter usage.details) Adds a generic renderer for the codexbar CLI's usage.details array so OpenRouter's Credits/API key/Spend history breakdown shows up in the popup, gated behind a new show-provider-details setting. Any other provider that starts emitting details gets it for free; providers without it (Claude, Codex) are unaffected. Co-Authored-By: Claude Sonnet 5 --- extension.js | 82 +++++++++- prefs.js | 15 ++ ...nome.shell.extensions.codexbar.gschema.xml | 4 + stylesheet.css | 33 ++++ test_all_providers.js | 153 ++++++++++++++++-- usageApi.js | 43 +++++ 6 files changed, 320 insertions(+), 10 deletions(-) diff --git a/extension.js b/extension.js index a0a0b1c..fbc358c 100644 --- a/extension.js +++ b/extension.js @@ -4,13 +4,18 @@ import Gio from "gi://Gio"; import GLib from "gi://GLib"; import St from "gi://St"; import Clutter from "gi://Clutter"; +import Pango from "gi://Pango"; import { Extension, gettext as _, } from "resource:///org/gnome/shell/extensions/extension.js"; import * as PanelMenu from "resource:///org/gnome/shell/ui/panelMenu.js"; import * as Main from "resource:///org/gnome/shell/ui/main.js"; -import { calculateUsagePace, UsageApiClient } from "./usageApi.js"; +import { + calculateUsagePace, + normalizeDetailSections, + UsageApiClient, +} from "./usageApi.js"; import { loadToken, nullTokenSchema } from "./secret.js"; function logDev(msg) { @@ -121,6 +126,7 @@ export default class CodexBarExtension extends Extension { "changed::display-mode", () => this._updateUI(), "changed::show-logos", () => this._updateUI(), "changed::show-pacing-info", () => this._updateUI(), + "changed::show-provider-details", () => this._updateUI(), "changed::first-run", () => this._updateUI(), "changed::dev-custom-output-enabled", () => this._onSettingsChanged(), "changed::dev-custom-output-provider-name", () => this._onSettingsChanged(), @@ -742,6 +748,8 @@ export default class CodexBarExtension extends Extension { this._contentBox.add_child(accountBox); } + this._renderDetailSections(usage); + // Usage bars for each tier // Barras de uso para cada nivel const tiers = ["primary", "secondary", "tertiary", "quaternary"]; @@ -936,6 +944,78 @@ export default class CodexBarExtension extends Extension { } } + /** + * Render provider-supplied detail sections (codexbar `usage.details`). + * @param {object} usage + */ + _renderDetailSections(usage) { + if (!this._settings.get_boolean("show-provider-details")) return; + + const sections = normalizeDetailSections(usage?.details); + if (sections.length === 0) return; + + const detailsBox = new St.BoxLayout({ + vertical: true, + style_class: "codexbar-details-section", + x_expand: true, + }); + + sections.forEach((section) => { + const groupBox = new St.BoxLayout({ + vertical: true, + style_class: "codexbar-detail-group", + x_expand: true, + }); + + if (section.title) { + groupBox.add_child( + new St.Label({ + text: section.title, + style_class: "codexbar-detail-title", + }), + ); + } + + section.rows.forEach((row) => { + const rowBox = new St.BoxLayout({ vertical: false, x_expand: true }); + + const labelWidget = new St.Label({ + text: row.label, + style_class: "codexbar-detail-label", + }); + labelWidget.clutter_text.ellipsize = Pango.EllipsizeMode.END; + labelWidget.opacity = 200; + rowBox.add_child(labelWidget); + + const valueWidget = new St.Label({ + text: row.value, + style_class: "codexbar-detail-value", + x_align: Clutter.ActorAlign.END, + x_expand: true, + }); + valueWidget.clutter_text.ellipsize = Pango.EllipsizeMode.END; + rowBox.add_child(valueWidget); + + groupBox.add_child(rowBox); + + if (row.secondaryValue) { + const noteWidget = new St.Label({ + text: row.secondaryValue, + style_class: "codexbar-detail-note", + }); + noteWidget.clutter_text.line_wrap = true; + noteWidget.clutter_text.line_wrap_mode = Pango.WrapMode.WORD_CHAR; + noteWidget.opacity = 160; + groupBox.add_child(noteWidget); + } + }); + + detailsBox.add_child(groupBox); + }); + + this._contentBox.add_child(detailsBox); + } + /** * Get provider logo as an St.Icon. * Obtener el logo del proveedor como un St.Icon. diff --git a/prefs.js b/prefs.js index f361bb0..98efa08 100644 --- a/prefs.js +++ b/prefs.js @@ -434,6 +434,21 @@ const CodexBarPrefsPage = GObject.registerClass( ); group.add(showPacingInfoRow); + const showProviderDetailsRow = new Adw.SwitchRow({ + title: _("Show Provider Details"), + subtitle: _( + "Display the extra detail sections reported by the provider CLI (credits, spend history, rate limits)", + ), + active: this._settings.get_boolean("show-provider-details"), + }); + this._settings.bind( + "show-provider-details", + showProviderDetailsRow, + "active", + Gio.SettingsBindFlags.DEFAULT, + ); + group.add(showProviderDetailsRow); + return group; } diff --git a/schemas/org.gnome.shell.extensions.codexbar.gschema.xml b/schemas/org.gnome.shell.extensions.codexbar.gschema.xml index 02e6fe2..059355b 100644 --- a/schemas/org.gnome.shell.extensions.codexbar.gschema.xml +++ b/schemas/org.gnome.shell.extensions.codexbar.gschema.xml @@ -30,6 +30,10 @@ true Whether to show weekly usage pacing details + + true + Whether to show provider-supplied detail sections + false Enable developer custom output simulation diff --git a/stylesheet.css b/stylesheet.css index 67fa7e4..f94c2dd 100644 --- a/stylesheet.css +++ b/stylesheet.css @@ -91,6 +91,39 @@ margin-top: 2px; } +.codexbar-details-section { + margin-bottom: 8px; +} + +.codexbar-detail-group { + margin-bottom: 10px; +} + +.codexbar-detail-title { + font-size: 0.9em; + font-weight: bold; + margin-bottom: 4px; +} + +.codexbar-detail-label { + font-size: 0.8em; + margin-bottom: 1px; +} + +.codexbar-detail-value { + font-size: 0.8em; + font-weight: bold; + margin-bottom: 1px; + padding-left: 12px; +} + +.codexbar-detail-note { + font-size: 0.75em; + font-style: italic; + margin-left: 10px; + margin-bottom: 3px; +} + .codexbar-footer { padding: 10px; border-top: 1px solid rgba(255, 255, 255, 0.1); diff --git a/test_all_providers.js b/test_all_providers.js index b12b229..925217a 100644 --- a/test_all_providers.js +++ b/test_all_providers.js @@ -1,6 +1,7 @@ import { calculateUsagePace, formatResetDescription, + normalizeDetailSections, UsageApiClient, } from "./usageApi.js"; @@ -44,18 +45,53 @@ const codexSparkUsage = { ], }; +// OpenRouter CLI (v0.55.0): `codexbar --provider openrouter --source api --format json`. +// Hoisted to module scope so the testCases entry below and the top-level +// normalizeDetailSections assertions share the same fixture. +const openRouterDetailsPayload = { + loginMethod: "Balance: $0.82", + primary: { usedPercent: 0 }, + details: [ + { + title: "Credits", + rows: [ + { label: "Remaining", value: "$0.82" }, + { label: "Used", value: "$9.18" }, + { label: "Total added", value: "$10.00" }, + ], + }, + { + title: "API key", + rows: [ + { label: "API key budget", value: "$5.00" }, + { label: "API key remaining", value: "$5.00" }, + { label: "API key used", value: "$0.94" }, + { label: "Reset window", value: "weekly" }, + { label: "Today", value: "$0.12" }, + { label: "This week", value: "$0.45" }, + { label: "This month", value: "$0.94" }, + { label: "Rate limit", value: "-1 requests / 10s" }, + ], + chart: { kind: "bars", unit: "USD", points: [0.12, 0.45, 0.94] }, + }, + { + title: "Spend history", + rows: [ + { + label: "Last 30 days", + value: "Unavailable right now", + secondaryValue: "Management API key not configured", + }, + ], + }, + ], +}; + const testCases = [ { name: "OpenRouter (User reported)", - data: { - loginMethod: "Balance: $35.05", - openRouterUsage: { - balance: 35.05187273999999, - totalCredits: 160, - totalUsage: 124.94812726, - usedPercent: 78.0925795375, - }, - }, + data: openRouterDetailsPayload, + expectedUsedPercent: 0, }, { name: "OpenAI / Codex (Standard)", @@ -403,3 +439,102 @@ if (ollamaSummary.usage.loginMethod !== "Ollama Cloud Pro") { throw new Error(`Expected Ollama Cloud Pro login method, got ${ollamaSummary.usage.loginMethod}`); } console.log("✓ Ollama Cloud HTML parser extracts Session and Weekly usage"); + +// OpenRouter details: normalizeSummary must pass usage.details through untouched, +// and normalizeDetailSections must sanitize it into a flat, render-safe shape. +const normalizedOpenRouter = client.normalizeSummary(openRouterDetailsPayload, false); +if ( + !Array.isArray(normalizedOpenRouter.usage.details) || + normalizedOpenRouter.usage.details.length !== 3 +) { + throw new Error( + `Expected normalizeSummary to pass through 3 detail sections, got ${JSON.stringify(normalizedOpenRouter.usage.details)}`, + ); +} +console.log("✓ usage.details survives normalizeSummary untouched"); + +const openRouterSections = normalizeDetailSections(normalizedOpenRouter.usage.details); +if (openRouterSections.length !== 3) { + throw new Error(`Expected 3 sanitized detail sections, got ${openRouterSections.length}`); +} +const rowCounts = openRouterSections.map((s) => s.rows.length).join(","); +if (rowCounts !== "3,8,1") { + throw new Error(`Expected section row counts "3,8,1", got "${rowCounts}"`); +} +if ( + openRouterSections[0].rows[0].label !== "Remaining" || + openRouterSections[0].rows[0].value !== "$0.82" +) { + throw new Error( + `Expected first Credits row to be Remaining/$0.82, got ${JSON.stringify(openRouterSections[0].rows[0])}`, + ); +} +console.log("✓ normalizeDetailSections produces the correct sections and rows"); + +if (!openRouterSections[1].hasChart || "chart" in openRouterSections[1]) { + throw new Error( + "Expected the API key section to record hasChart:true without leaking the raw chart", + ); +} +console.log("✓ chart is detected via hasChart but not leaked into the sanitized shape"); + +if ( + openRouterSections[2].rows[0].secondaryValue !== + "Management API key not configured" +) { + throw new Error( + `Expected Spend history secondaryValue to be preserved, got "${openRouterSections[2].rows[0].secondaryValue}"`, + ); +} +console.log("✓ secondaryValue is preserved on sanitized rows"); + +// Defensive shapes: anything that isn't a usable details array normalizes to []. +[undefined, null, "nope", 42, {}, { rows: [] }].forEach((input) => { + const result = normalizeDetailSections(input); + if (result.length !== 0) { + throw new Error( + `Expected normalizeDetailSections(${JSON.stringify(input)}) to be [], got ${JSON.stringify(result)}`, + ); + } +}); +console.log("✓ normalizeDetailSections defends against non-array and malformed inputs"); + +// A section with only a chart (no rows) is dropped, not rendered as a bare title. +if (normalizeDetailSections([{ title: "Chart only", chart: { kind: "bars" } }]).length !== 0) { + throw new Error("Expected a chart-only section with no rows to be dropped"); +} +console.log("✓ chart-only sections with no rows are dropped"); + +// Value coercion: 0 must render as "0" (not "" via a naive `row.value || ""` guard), +// NaN/objects must coerce to "", and rows need at least a label or a value to survive. +const coercionRows = normalizeDetailSections([ + { + title: "Coercion", + rows: [ + { label: "num", value: 0 }, + { label: "nan", value: NaN }, + { label: "obj", value: {} }, + { label: "", value: "" }, + { label: "b", value: true }, + ], + }, +])[0].rows; +if (coercionRows.length !== 4) { + throw new Error(`Expected 4 surviving rows after coercion, got ${coercionRows.length}`); +} +if (coercionRows[0].value !== "0") { + throw new Error(`Expected numeric 0 to coerce to "0", got "${coercionRows[0].value}"`); +} +if (coercionRows[1].value !== "") { + throw new Error(`Expected NaN to coerce to "", got "${coercionRows[1].value}"`); +} +if (coercionRows[2].value !== "") { + throw new Error(`Expected an object value to coerce to "", got "${coercionRows[2].value}"`); +} +console.log("✓ row value coercion handles 0, NaN, objects, and empty rows correctly"); + +// Claude regression guard: providers with no `details` key must be unaffected. +if (normalizeDetailSections(normalizedDashboard.usage.details).length !== 0) { + throw new Error("Expected a provider with no details key to produce zero sections"); +} +console.log("✓ providers without usage.details are unaffected (Claude/Codex regression guard)"); diff --git a/usageApi.js b/usageApi.js index 7e59cc1..fb87bb4 100644 --- a/usageApi.js +++ b/usageApi.js @@ -146,6 +146,49 @@ export const formatResetDescription = (seconds, windowSeconds, now = new Date()) return `Resets at ${resetStr} (in ${hours}h)`; }; +/** + * Sanitize the provider-supplied `details` array from the codexbar CLI into a + * flat, render-safe shape. Charts are intentionally dropped (not rendered yet). + * @param {unknown} details + * @returns {Array<{title: string, rows: Array<{label: string, value: string, secondaryValue: string}>, hasChart: boolean}>} + */ +export function normalizeDetailSections(details) { + if (!Array.isArray(details)) return []; + + const toText = (value) => { + if (typeof value === 'string') return value.trim(); + if (typeof value === 'number') return Number.isFinite(value) ? String(value) : ''; + if (typeof value === 'boolean') return String(value); + return ''; // null, undefined, objects, arrays -> not renderable + }; + + const sections = []; + details.forEach((section) => { + if (!section || typeof section !== 'object') return; + + const rows = []; + if (Array.isArray(section.rows)) { + section.rows.forEach((row) => { + if (!row || typeof row !== 'object') return; + const label = toText(row.label); + const value = toText(row.value); + if (!label && !value) return; + rows.push({ label, value, secondaryValue: toText(row.secondaryValue) }); + }); + } + + if (rows.length === 0) return; // chart-only / empty -> drop section + + sections.push({ + title: toText(section.title), + rows, + hasChart: Boolean(section.chart && typeof section.chart === 'object'), + }); + }); + + return sections; +} + export const calculateUsagePace = (usageWindow) => { const usedPercent = Number(usageWindow?.usedPercent); const windowSeconds = Number(usageWindow?.windowSeconds); From 40ed36654c127d79273a3807110c36d1c764e436 Mon Sep 17 00:00:00 2001 From: Toni Hermoso Pulido Date: Mon, 24 Aug 2026 18:52:20 +0200 Subject: [PATCH 2/3] Handle degenerate usage tiers by deriving percent from Credits section When a provider reports a placeholder tier with usedPercent:0 and no windowSeconds (e.g. OpenRouter's balance tier), derive a meaningful usage percent from the Credits detail section instead. Add deriveCreditsPercent function to parse Used/Total or Remaining/Total from Credits rows, skip the tier entirely if no Credits section is available, and update the UI logic to use the derived percent or fall back to normalized usedPercent. --- extension.js | 37 +++++++++++++++++++++++++++------- test_all_providers.js | 18 +++++++++++++++++ usageApi.js | 47 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 95 insertions(+), 7 deletions(-) diff --git a/extension.js b/extension.js index fbc358c..8d382d9 100644 --- a/extension.js +++ b/extension.js @@ -13,6 +13,7 @@ import * as PanelMenu from "resource:///org/gnome/shell/ui/panelMenu.js"; import * as Main from "resource:///org/gnome/shell/ui/main.js"; import { calculateUsagePace, + deriveCreditsPercent, normalizeDetailSections, UsageApiClient, } from "./usageApi.js"; @@ -542,13 +543,25 @@ export default class CodexBarExtension extends Extension { let tierCount = 0; const activeData = this._providersData[this._activeProviderIndex]; + // A tier with usedPercent:0 and no windowSeconds isn't a real usage window + // (e.g. OpenRouter's balance placeholder) - derive a meaningful percent from + // the Credits detail section instead, or drop the tier if none is available. + const creditsPercent = deriveCreditsPercent( + normalizeDetailSections(activeData?.data?.usage?.details), + ); + const isDegenerateTier = (tierData) => + !!tierData && !tierData.windowSeconds && tierData.usedPercent === 0; + if (activeData && activeData.data && activeData.data.usage) { const usage = activeData.data.usage; const tiers = ["primary", "secondary", "tertiary", "quaternary"]; tiers.forEach((tier) => { if (usage[tier] && usage[tier].usedPercent !== undefined) { - let p = this._normalizePercent(usage[tier].usedPercent); + if (isDegenerateTier(usage[tier]) && creditsPercent === null) return; + let p = isDegenerateTier(usage[tier]) + ? creditsPercent + : this._normalizePercent(usage[tier].usedPercent); totalPercent += displayMode === "remaining" ? 100 - p : p; tierCount++; } @@ -756,13 +769,23 @@ export default class CodexBarExtension extends Extension { const discoveredLabels = activeData.labels || []; let hasTiers = false; - const usageEntries = tiers.map((tier, tierIdx) => ({ - data: usage[tier], - showPace: true, - title: + const usageEntries = tiers.map((tier, tierIdx) => { + let tierData = usage[tier]; + let title = discoveredLabels[tierIdx] || - tier.charAt(0).toUpperCase() + tier.slice(1), - })); + tier.charAt(0).toUpperCase() + tier.slice(1); + + if (isDegenerateTier(tierData)) { + if (creditsPercent === null) { + tierData = null; + } else { + tierData = { ...tierData, usedPercent: creditsPercent }; + title = _("Credits"); + } + } + + return { data: tierData, showPace: true, title }; + }); usageEntries.push({ data: usage.codeReview, showPace: false, diff --git a/test_all_providers.js b/test_all_providers.js index 925217a..5cb721e 100644 --- a/test_all_providers.js +++ b/test_all_providers.js @@ -1,5 +1,6 @@ import { calculateUsagePace, + deriveCreditsPercent, formatResetDescription, normalizeDetailSections, UsageApiClient, @@ -538,3 +539,20 @@ if (normalizeDetailSections(normalizedDashboard.usage.details).length !== 0) { throw new Error("Expected a provider with no details key to produce zero sections"); } console.log("✓ providers without usage.details are unaffected (Claude/Codex regression guard)"); + +// deriveCreditsPercent: OpenRouter's degenerate {usedPercent:0, windowSeconds:0} +// "Usage Window" tier is meaningless (it reads "100% left" no matter the real +// balance); derive a real percent from the Credits section's Used/Total added. +const creditsPercent = deriveCreditsPercent(openRouterSections); +if (creditsPercent === null || Math.abs(creditsPercent - 91.8) > 0.0001) { + throw new Error(`Expected Credits-derived percent of 91.8, got ${creditsPercent}`); +} +console.log("✓ deriveCreditsPercent computes used% from the Credits section's Used/Total added"); + +if (deriveCreditsPercent([]) !== null) { + throw new Error("Expected deriveCreditsPercent([]) to be null (no Credits section)"); +} +if (deriveCreditsPercent(normalizeDetailSections(normalizedDashboard.usage.details)) !== null) { + throw new Error("Expected deriveCreditsPercent to be null for a provider with no details"); +} +console.log("✓ deriveCreditsPercent is null when there's no parseable Credits section"); diff --git a/usageApi.js b/usageApi.js index fb87bb4..cd7b7e9 100644 --- a/usageApi.js +++ b/usageApi.js @@ -189,6 +189,53 @@ export function normalizeDetailSections(details) { return sections; } +/** + * Derive a used-percent from a sanitized "Credits" detail section (see + * normalizeDetailSections), for providers that report a balance instead of a + * time-bounded usage window (e.g. OpenRouter's `{usedPercent: 0, windowSeconds: 0}` + * placeholder tier). Returns null if no Credits section or no parseable + * Used/Total or Remaining/Total pair is found. + * @param {Array<{title: string, rows: Array<{label: string, value: string}>}>} sections + * @returns {number|null} + */ +export function deriveCreditsPercent(sections) { + if (!Array.isArray(sections)) return null; + + const parseAmount = (value) => { + if (typeof value !== 'string') return NaN; + const match = value.replace(/,/g, '').match(/-?\d+(\.\d+)?/); + return match ? parseFloat(match[0]) : NaN; + }; + + const creditsSection = sections.find( + (section) => section && typeof section.title === 'string' && + section.title.trim().toLowerCase() === 'credits' + ); + if (!creditsSection || !Array.isArray(creditsSection.rows)) return null; + + const findAmount = (label) => { + const row = creditsSection.rows.find( + (r) => r && typeof r.label === 'string' && r.label.trim().toLowerCase() === label + ); + return row ? parseAmount(row.value) : NaN; + }; + + const total = findAmount('total added'); + if (!Number.isFinite(total) || total <= 0) return null; + + const used = findAmount('used'); + if (Number.isFinite(used)) { + return Math.min(100, Math.max(0, (used / total) * 100)); + } + + const remaining = findAmount('remaining'); + if (Number.isFinite(remaining)) { + return Math.min(100, Math.max(0, ((total - remaining) / total) * 100)); + } + + return null; +} + export const calculateUsagePace = (usageWindow) => { const usedPercent = Number(usageWindow?.usedPercent); const windowSeconds = Number(usageWindow?.windowSeconds); From 9710c263298b42ab108cca74d1e055bd590e0bf3 Mon Sep 17 00:00:00 2001 From: Toni Hermoso Pulido Date: Mon, 24 Aug 2026 18:55:47 +0200 Subject: [PATCH 3/3] docs: Add API key setup guide for environment variables Add documentation explaining how to configure API keys (e.g. OPENROUTER_API_KEY) for the CodexBar CLI extension. Unlike shell dotfiles, environment variables must be set in systemd's user environment since GNOME Shell is started by the display manager rather than an interactive shell. Includes instructions for creating the environment.d configuration file, applying changes without full logout, and verifying the variable is accessible to GNOME Shell. --- README.md | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/README.md b/README.md index 95d4c25..b6cd9db 100644 --- a/README.md +++ b/README.md @@ -87,6 +87,32 @@ The extension will automatically attempt to locate the `codexbar` binary in comm Certain vendors have specific fields in Codexbar CLI, whose interpretation may not have been implemented so this is a great opportunity for you to implement support (if you want) and do a PR. +### API Keys (e.g. OpenRouter) + +Some providers (OpenRouter with `--source api`) authenticate the CodexBar CLI via an environment variable, e.g. `OPENROUTER_API_KEY`, instead of a token cached on disk. Setting that variable in `~/.zshrc`, `~/.bashrc`, or similar is **not enough**: GNOME Shell is started by your login/display manager, not by an interactive shell, so it never sources your shell's dotfiles, and any command the extension runs (as a child process of GNOME Shell) inherits GNOME Shell's environment, not your terminal's. + +To make the variable visible to the extension, add it to your systemd user environment instead: + +```bash +mkdir -p ~/.config/environment.d +echo 'OPENROUTER_API_KEY=sk-or-v1-...' > ~/.config/environment.d/codexbar.conf +chmod 600 ~/.config/environment.d/codexbar.conf +``` + +Then log out and log back in. `environment.d` files are only read once, when your `systemd --user` manager starts — if it's still running from before you added the file (e.g. lingering is enabled: `loginctl show-user $USER | grep Linger`), a normal logout/login won't pick it up. In that case, apply it to the running instance once, then log out/in as usual: + +```bash +systemctl --user import-environment OPENROUTER_API_KEY +``` + +You can confirm GNOME Shell actually has the variable with: + +```bash +tr '\0' '\n' < /proc/$(pgrep -x gnome-shell)/environ | grep OPENROUTER_API_KEY +``` + +Without it, `codexbar --provider openrouter --source api` still returns valid JSON, but with a top-level `error` field and no `usage.details` — so the OpenRouter tab shows only a bare, empty tier instead of the Credits / API key / Spend history breakdown. + ### Display Mode You can choose how metrics are displayed: