Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions .github/workflows/xfce.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
name: Xfce Genmon

on:
push:
paths:
- 'xfce/**'
- 'tests/xfce*.mjs'
- 'codexAuth.js'
- 'usageApi.js'
- '.github/workflows/xfce.yml'
pull_request:
paths:
- 'xfce/**'
- 'tests/xfce*.mjs'
- 'codexAuth.js'
- 'usageApi.js'
- '.github/workflows/xfce.yml'

jobs:
genmon:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install GJS runtime
run: sudo apt-get update && sudo apt-get install -y gjs gir1.2-soup-3.0
- name: Test formatting and account handoff
run: node --test tests/xfceFormat.test.mjs tests/xfceRunner.test.mjs
- name: Prove CLI loads without GNOME Shell
run: |
output=$(gjs -m xfce/genmon.js --auth-file /tmp/codex-indicator-missing-auth.json)
test "$output" = '<txt>Codex ?</txt><tool>Codex CLI login required; check the auth file or run codex login.</tool>'
- name: Run shared parser checks
run: |
gjs -m tests/codexAuth.test.js
gjs -m tests/usageApi.test.js
gjs -m tests/usageApiHttp.test.js
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

GNOME Shell extension for GNOME Shell `45`–`51` that shows remaining Codex usage in the top bar and displays the current 5-hour and weekly windows in the popup.

**Xfce:** GNOME Shell extensions cannot run in the Xfce panel. This fork also includes a [Generic Monitor (Genmon) adapter](xfce/README.md) that uses the same Codex auth reader and usage API normalizer; it does not require GNOME Shell.

## Features

- Automatically reads the bearer token from the local Codex CLI auth file at `~/.codex/auth.json`
Expand Down
30 changes: 30 additions & 0 deletions tests/xfceFormat.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import assert from 'node:assert/strict';
import {test} from 'node:test';
import {renderGenmon, renderGenmonError} from '../xfce/format.mjs';

test('Genmon shows 5h/week usage, resets and credits', () => {
const output = renderGenmon({
primaryWindow: {id: 'primary', percent: 0.25, windowSeconds: 18000, resetAt: 2030000000},
weekWindow: {id: 'weekly', percent: 0.6, windowSeconds: 604800},
credits: {balance: '12.5'},
});
assert.match(output, /<txt>Codex 75% left<\/txt>/);
assert.match(output, /5h: 75% left/);
assert.match(output, /Week: 40% left/);
assert.match(output, /12.5 credits remaining/);
assert.match(output, /resets/);
});

test('used mode, weekly-only fallback and XML escaping are safe', () => {
const output = renderGenmon({weekWindow: {id: 'weekly', percent: 1, windowSeconds: 604800}},
{label: 'Work <&>', mode: 'used'});
assert.match(output, /<txt>Work &lt;&amp;&gt; 100% used<\/txt>/);
assert.match(output, /Week: 100% used/);
assert.doesNotMatch(output, /<txt>Work <&>/);
});

test('unknown usage and errors never leak raw markup', () => {
assert.match(renderGenmon({}), /Codex —/);
assert.equal(renderGenmonError('Bad <token>&', {label: 'A&B'}),
'<txt>A&amp;B ?</txt><tool>Bad &lt;token&gt;&amp;</tool>');
});
23 changes: 23 additions & 0 deletions tests/xfceRunner.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import assert from 'node:assert/strict';
import {test} from 'node:test';
import {fetchPanelSummary} from '../xfce/runner.mjs';

test('selected auth file, token and account ID reach the shared API client', async () => {
const calls = [];
const summary = {primaryWindow: {percent: 0.25}};
const result = await fetchPanelSummary({authFile: '/tmp/work-auth.json'},
async options => { calls.push(options); return {accessToken: 'test-token', accountId: 'test-account'}; },
() => ({
async fetchSummary(token, accountId) { calls.push([token, accountId]); return summary; },
destroy() { calls.push('destroy'); },
}));
assert.equal(result, summary);
assert.deepEqual(calls, [{path: '/tmp/work-auth.json'}, ['test-token', 'test-account'], 'destroy']);
});

test('API errors still close the client', async () => {
let closed = false;
await assert.rejects(fetchPanelSummary({}, async () => ({accessToken: 'test', accountId: null}),
() => ({fetchSummary: async () => { throw new Error('offline'); }, destroy() { closed = true; }})), /offline/);
assert.equal(closed, true);
});
43 changes: 43 additions & 0 deletions xfce/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# Xfce panel adapter

The root extension targets **GNOME Shell only**. On Xfce, use the official [Generic Monitor panel plugin](https://docs.xfce.org/panel-plugins/xfce4-genmon-plugin/start) to run `genmon.js` every few minutes. The script reuses this repository's `codexAuth.js` and `usageApi.js`; it neither imports GNOME Shell nor makes a copy of the API parser. It outputs Genmon's documented `<txt>` and `<tool>` tags for the panel label and tooltip.

## Install

On Debian/Ubuntu/Xubuntu:

```bash
sudo apt install xfce4-genmon-plugin gjs gir1.2-soup-3.0
git clone https://github.com/WaylonSmithersJr/codex-usage-indicator.git "$HOME/.local/share/codex-usage-indicator"
```

Sign in with Codex CLI using file-backed credentials (`~/.codex/auth.json`). If your Codex CLI uses another home, you can pass its file explicitly. Add **Generic Monitor** to the Xfce panel, open **Properties**, and set **Command** to the absolute path command below (replace `<your-user>`):

```text
gjs -m /home/<your-user>/.local/share/codex-usage-indicator/xfce/genmon.js
```

Set **Period** to `300` seconds. Do not use an interval shorter than 60 seconds; the script makes an authenticated usage request each refresh. The initial run can take several seconds. The panel shows the remaining 5-hour allowance, or the weekly allowance if that is the only window available. Hover to see both windows, reset times and credits. Errors appear as `Codex ?` with a non-sensitive explanation in the tooltip.

Options after the script path:

- `--used`: show used rather than remaining percentage.
- `--label NAME`: replace the `Codex` label (quote names with spaces in the Genmon command).
- `--auth-file /absolute/path/auth.json`: use a separate Codex CLI login, useful for a second Genmon instance/account. `~/...` is also supported by the shared auth reader, but an absolute path avoids shell/Genmon expansion differences.

For example, a work account panel item can use:

```text
gjs -m /home/<your-user>/.local/share/codex-usage-indicator/xfce/genmon.js --auth-file /home/<your-user>/.codex-work/auth.json --label Work
```

Xfce's Genmon plugin supplies the panel lifecycle and refresh timer; this is **not** a GNOME extension converted to a native Xfce C plugin. The GNOME preferences UI and its account selector do not appear on Xfce. Use separate Genmon instances for multiple accounts. The Codex CLI file must contain a valid access token; this adapter does not refresh credentials itself. It sends the token only to the same `chatgpt.com` usage endpoints as the GNOME extension, never prints it, and prints generic errors rather than raw API responses. The existing GPL-3.0 license applies.

## Checks

```bash
node --test tests/xfceFormat.test.mjs tests/xfceRunner.test.mjs
gjs -m xfce/genmon.js --auth-file /tmp/codex-indicator-missing-auth.json
```

The second command should show a Genmon `Codex ?` element and safe login guidance. The fork's CI installs GJS/Libsoup and runs these checks plus the shared auth/API parser tests. A live visual panel test still requires a machine with Xfce and `xfce4-genmon-plugin`; this headless repository check does not claim that visual sign-off.
56 changes: 56 additions & 0 deletions xfce/format.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
// Xfce Genmon accepts <txt> and <tool> on stdout. Keep this module free of
// GNOME/GI imports so its presentation and escaping can be tested anywhere.
export function escapeMarkup(value) {
return String(value).replaceAll('&', '&amp;').replaceAll('<', '&lt;')
.replaceAll('>', '&gt;').replaceAll('"', '&quot;').replaceAll("'", '&apos;');
}

function percent(value) {
return typeof value === 'number' && Number.isFinite(value)
? Math.round(Math.max(0, Math.min(1, value)) * 100)
: null;
}

function windowName(window) {
return window?.windowSeconds >= 5 * 86400 || /week|secondary/i.test(`${window?.id ?? ''} ${window?.label ?? ''}`)
? 'Week' : '5h';
}

function resetTime(window) {
const timestamp = window?.resetAt ?? (window?.resetAfterSeconds != null
? Date.now() / 1000 + window.resetAfterSeconds : null);
return typeof timestamp === 'number' && Number.isFinite(timestamp) && timestamp > 0
? new Date(timestamp * 1000).toLocaleString() : null;
}

function creditLabel(credits) {
if (!credits) return null;
if (credits.unlimited) return 'Unlimited credits';
const balance = Number(credits.balance);
if (credits.balance == null || !Number.isFinite(balance)) return 'Credits unavailable';
return `${new Intl.NumberFormat(undefined, {maximumFractionDigits: 2}).format(balance)} credits remaining`;
}

export function renderGenmon(summary, {label = 'Codex', mode = 'left'} = {}) {
const windows = [summary?.primaryWindow, summary?.weekWindow].filter(Boolean)
.filter((window, index, all) => all.findIndex(other => other.id === window.id) === index);
const active = summary?.primaryWindow ?? summary?.weekWindow ?? null;
const used = percent(active?.percent ?? summary?.percent);
const shown = used === null ? null : mode === 'used' ? used : 100 - used;
const caption = shown === null ? `${label} —` : `${label} ${shown}% ${mode === 'used' ? 'used' : 'left'}`;
const tooltip = windows.map(window => {
const usedValue = percent(window.percent);
const value = usedValue === null ? 'unavailable'
: `${mode === 'used' ? usedValue : 100 - usedValue}% ${mode === 'used' ? 'used' : 'left'}`;
const reset = resetTime(window);
return `${windowName(window)}: ${value}${reset ? ` · resets ${reset}` : ''}`;
});
const credits = creditLabel(summary?.credits);
if (credits) tooltip.push(credits);
if (tooltip.length === 0) tooltip.push('No usage windows available');
return `<txt>${escapeMarkup(caption)}</txt><tool>${escapeMarkup(tooltip.join('\n'))}</tool>`;
}

export function renderGenmonError(message, {label = 'Codex'} = {}) {
return `<txt>${escapeMarkup(label)} ?</txt><tool>${escapeMarkup(message)}</tool>`;
}
49 changes: 49 additions & 0 deletions xfce/genmon.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
#!/usr/bin/env -S gjs -m

// One-shot Xfce panel adapter. It shares the GNOME extension's auth reader and
// API normalizer, without importing GNOME Shell or reading browser cookies.
import {loadCodexCliAuth, CodexCliAuthError} from '../codexAuth.js';
import {UsageApiClient, UsageApiError} from '../usageApi.js';
import {renderGenmon, renderGenmonError} from './format.mjs';
import {fetchPanelSummary} from './runner.mjs';

function parseOptions(args) {
const options = {label: 'Codex', mode: 'left'};
for (let index = 0; index < args.length; index++) {
const arg = args[index];
if (arg === '--used') options.mode = 'used';
else if ((arg === '--auth-file' || arg === '--label') && args[index + 1]) {
options[arg === '--auth-file' ? 'authFile' : 'label'] = args[++index];
} else if (arg === '--help') return null;
else throw new Error(`Unknown or incomplete option: ${arg}`);
}
return options;
}

function safeError(error) {
if (error instanceof CodexCliAuthError) return 'Codex CLI login required; check the auth file or run codex login.';
if (error instanceof UsageApiError) {
if (error.isAuthError) return 'Codex login expired; run codex login again.';
if (error.statusCode === 429) return 'Usage API rate-limited; retry at the next refresh.';
return `Usage API unavailable (HTTP ${error.statusCode || '?'})`;
}
return 'Usage unavailable; check network and panel command.';
}

let options;
try {
options = parseOptions(ARGV);
} catch (error) {
print(renderGenmonError(error.message));
}
if (options === null) {
print('Usage: gjs -m xfce/genmon.js [--auth-file /path/to/auth.json] [--label NAME] [--used]');
} else if (options) {
try {
const summary = await fetchPanelSummary(options, loadCodexCliAuth, () => new UsageApiClient());
print(renderGenmon(summary, options));
} catch (error) {
// A panel display must never print the bearer token or raw API payload.
print(renderGenmonError(safeError(error), options));
}
}
11 changes: 11 additions & 0 deletions xfce/runner.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
// Dependencies are passed in by the GJS entrypoint; this keeps account/cleanup
// behavior testable without a real token or a live API request.
export async function fetchPanelSummary(options, loadAuth, createClient) {
const auth = await loadAuth(options.authFile ? {path: options.authFile} : {});
const client = createClient();
try {
return await client.fetchSummary(auth.accessToken, auth.accountId);
} finally {
client.destroy();
}
}
Loading