From fb370bc37e9a2b96e17d213869bda0b476604db7 Mon Sep 17 00:00:00 2001
From: kcnewman
Date: Sun, 26 Jul 2026 09:49:00 +0000
Subject: [PATCH 01/11] feat: add accessibility, system theme support, CI, and
tests
- Accessible names for screen readers on panel indicator, dots, and summaries
- CSS uses currentColor and opacity for better theme integration
- CONTRIBUTING.md with development setup and code style guidelines
- GitHub Actions CI: shellcheck, meson build, bats tests
- Bats test suite for shell script (JSON structure, required fields, cleanup)
---
.github/workflows/ci.yml | 35 +++++++++++++++++++++++
CHANGELOG.md | 13 +++++++++
CONTRIBUTING.md | 61 ++++++++++++++++++++++++++++++++++++++++
README.md | 6 ++++
indicator.js | 12 ++++----
meson.build | 2 +-
metadata.json | 2 +-
stylesheet.css | 13 ++++++---
tests/freeby.bats | 42 +++++++++++++++++++++++++++
9 files changed, 174 insertions(+), 12 deletions(-)
create mode 100644 .github/workflows/ci.yml
create mode 100644 CONTRIBUTING.md
create mode 100644 tests/freeby.bats
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..7c78902
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,35 @@
+name: CI
+
+on:
+ push:
+ branches: [main, dev]
+ pull_request:
+ branches: [main, dev]
+
+jobs:
+ shellcheck:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - name: Install shellcheck
+ run: sudo apt-get install -y shellcheck
+ - name: Lint shell scripts
+ run: shellcheck scripts/freeby.sh scripts/copilot-setup.sh
+
+ build:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - name: Install dependencies
+ run: sudo apt-get install -y meson ninja-build libglib2.0-dev-bin python3
+ - name: Build
+ run: meson setup build --prefix=$HOME/.local && meson install -C build
+
+ test:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - name: Install dependencies
+ run: sudo apt-get install -y bats python3 curl
+ - name: Run tests
+ run: bats tests/
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 67dda60..b0bf72a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,18 @@
# Changelog
+## v1.0.1
+
+### Added
+- Accessible names for screen readers on panel indicator, dots, and summaries
+- System theme support in CSS (uses `currentColor` and opacity for better theme integration)
+- CONTRIBUTING.md with development setup and code style guidelines
+- GitHub Actions CI: shellcheck, meson build, bats tests
+- Bats test suite for shell script (JSON structure, required fields, cleanup)
+
+### Changed
+- CSS colors use `currentColor` where possible for better theme compatibility
+- Status dot and summary text use opacity for dimmed states instead of hardcoded colors
+
## v1.0.0
### Fixed
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 0000000..d65d184
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,61 @@
+# Contributing
+
+Thanks for your interest in Freeby.
+
+## Development
+
+Clone and install locally:
+
+```bash
+git clone https://github.com/kcnewman/freeby.git && cd freeby
+git checkout dev
+meson setup build --prefix=$HOME/.local
+meson install -C build
+```
+
+Test your changes by restarting GNOME Shell (Alt+F2, type `r`, Enter) and enabling the extension:
+
+```bash
+gnome-extensions enable freeby@kelvin.local
+```
+
+## Code style
+
+- Keep it simple. No unnecessary abstraction.
+- Lowercase sentence case for user-facing text.
+- Follow existing patterns in the codebase.
+
+## Commits
+
+Use [conventional commits](https://www.conventionalcommits.org/):
+
+- `feat:` new feature
+- `fix:` bug fix
+- `docs:` documentation only
+- `chore:` maintenance, tests, CI
+
+## Testing
+
+Test the shell script directly:
+
+```bash
+bash ~/.local/bin/freeby.sh | python3 -m json.tool
+```
+
+Run shellcheck:
+
+```bash
+shellcheck scripts/freeby.sh scripts/copilot-setup.sh
+```
+
+## Branches
+
+- `main` — stable releases
+- `dev` — active development
+
+## Pull requests
+
+1. Fork and create a branch from `dev`
+2. Make your changes
+3. Test manually (the extension runs in your live desktop)
+4. Open a PR against `dev`
diff --git a/README.md b/README.md
index 43929f5..9898fd6 100644
--- a/README.md
+++ b/README.md
@@ -111,4 +111,10 @@ journalctl -f -o cat /usr/bin/gnome-shell
---
+## Contributing
+
+See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup and guidelines.
+
+---
+
[MIT](LICENSE)
diff --git a/indicator.js b/indicator.js
index f323d71..ac688b5 100644
--- a/indicator.js
+++ b/indicator.js
@@ -21,18 +21,18 @@ class FreebyIndicator extends PanelMenu.Button {
for (const k of PROVIDERS) this._prev[k] = null;
this._refreshSeq = 0;
- this._panelBox = new St.BoxLayout({ style_class: 'panel-status-menu-box freeby-panel' });
- this._label = new St.Label({ text: 'AI', y_align: Clutter.ActorAlign.CENTER, style_class: 'freeby-panel-label' });
+ this._panelBox = new St.BoxLayout({ style_class: 'panel-status-menu-box freeby-panel', accessible_name: 'Freeby usage indicator' });
+ this._label = new St.Label({ text: 'AI', y_align: Clutter.ActorAlign.CENTER, style_class: 'freeby-panel-label', accessible_name: 'Usage count' });
this._panelBox.add_child(this._label);
this.add_child(this._panelBox);
this.connect('button-press-event', () => { this._refresh(); return false; });
this._items = {};
for (const k of PROVIDERS) {
- const box = new St.BoxLayout({ style_class: 'freeby-item-box' });
- const dot = new St.Label({ text: '\u25CB', style_class: 'freeby-dot freeby-dot-off', y_align: Clutter.ActorAlign.CENTER });
+ const box = new St.BoxLayout({ style_class: 'freeby-item-box', accessible_name: `${LABELS[k]} usage` });
+ const dot = new St.Label({ text: '\u25CB', style_class: 'freeby-dot freeby-dot-off', y_align: Clutter.ActorAlign.CENTER, accessible_name: `${LABELS[k]} status` });
const name = new St.Label({ text: LABELS[k], style_class: 'freeby-item-name', y_align: Clutter.ActorAlign.CENTER });
- const summary = new St.Label({ text: '\u2014', style_class: 'freeby-item-summary', y_align: Clutter.ActorAlign.CENTER });
+ const summary = new St.Label({ text: '\u2014', style_class: 'freeby-item-summary', y_align: Clutter.ActorAlign.CENTER, accessible_name: `${LABELS[k]} summary` });
box.add_child(dot);
box.add_child(name);
box.add_child(summary);
@@ -43,7 +43,7 @@ class FreebyIndicator extends PanelMenu.Button {
}
this.menu.addMenuItem(new PopupMenu.PopupSeparatorMenuItem());
- this._statusItem = new PopupMenu.PopupMenuItem('\u21BB Last checked: never', { reactive: true, can_focus: false });
+ this._statusItem = new PopupMenu.PopupMenuItem('\u21BB Last checked: never', { reactive: true, can_focus: false, accessible_name: 'Refresh status' });
this._statusItem.label.add_style_class_name('freeby-status');
this._statusItem.connect('activate', () => { this._refresh(); this.menu.open(); });
this.menu.addMenuItem(this._statusItem);
diff --git a/meson.build b/meson.build
index 8e46ec0..22d6b06 100644
--- a/meson.build
+++ b/meson.build
@@ -1,4 +1,4 @@
-project('freeby', version: '1.0.0', license: 'MIT',
+project('freeby', version: '1.0.1', license: 'MIT',
meson_version: '>= 0.56.0',
default_options: ['warning_level=0'])
diff --git a/metadata.json b/metadata.json
index f8ccf99..5a8b29a 100644
--- a/metadata.json
+++ b/metadata.json
@@ -3,7 +3,7 @@
"name": "Freeby",
"description": "Minimal panel indicator for local AI coding tool usage (Codex, Cursor, Copilot)",
"shell-version": ["45", "46", "47", "48"],
- "version": 1,
+ "version": 2,
"settings-schema": "org.gnome.shell.extensions.freeby",
"url": "https://github.com/kcnewman/freeby"
}
diff --git a/stylesheet.css b/stylesheet.css
index 0fde099..ac2912b 100644
--- a/stylesheet.css
+++ b/stylesheet.css
@@ -6,6 +6,7 @@
font-weight: bold;
font-size: 10pt;
min-width: 16px;
+ color: currentColor;
}
.freeby-panel-green {
@@ -30,7 +31,8 @@
}
.freeby-dot-off {
- color: #888;
+ color: currentColor;
+ opacity: 0.4;
}
.freeby-dot-green {
@@ -49,15 +51,17 @@
font-weight: bold;
font-size: 9pt;
min-width: 52px;
+ color: currentColor;
}
.freeby-item-summary {
font-size: 9pt;
- color: #ccc;
+ color: currentColor;
+ opacity: 0.8;
}
.freeby-dim {
- color: #888;
+ opacity: 0.4;
}
.freeby-green {
@@ -74,5 +78,6 @@
.freeby-status {
font-size: 9pt;
- color: #bbb;
+ color: currentColor;
+ opacity: 0.7;
}
diff --git a/tests/freeby.bats b/tests/freeby.bats
new file mode 100644
index 0000000..b092960
--- /dev/null
+++ b/tests/freeby.bats
@@ -0,0 +1,42 @@
+#!/usr/bin/env bats
+
+SCRIPT="$BATS_TEST_DIRNAME/../scripts/freeby.sh"
+
+@test "outputs valid JSON" {
+ run bash "$SCRIPT"
+ [ "$status" -eq 0 ]
+ echo "$output" | python3 -c "import json,sys; json.load(sys.stdin)"
+}
+
+@test "JSON contains codex, cursor, copilot keys" {
+ run bash "$SCRIPT"
+ echo "$output" | python3 -c "
+import json,sys
+d = json.load(sys.stdin)
+assert 'codex' in d, 'missing codex'
+assert 'cursor' in d, 'missing cursor'
+assert 'copilot' in d, 'missing copilot'
+"
+}
+
+@test "each provider has required fields" {
+ run bash "$SCRIPT"
+ echo "$output" | python3 -c "
+import json,sys
+d = json.load(sys.stdin)
+for k in ('codex','cursor','copilot'):
+ p = d[k]
+ assert 'available' in p, f'{k} missing available'
+ assert 'has_remaining' in p, f'{k} missing has_remaining'
+ assert 'summary' in p, f'{k} missing summary'
+ assert isinstance(p['available'], bool), f'{k} available not bool'
+ assert isinstance(p['has_remaining'], bool), f'{k} has_remaining not bool'
+"
+}
+
+@test "script cleans up temp directory" {
+ before=$(ls /tmp | grep -c 'tmp\.' || true)
+ run bash "$SCRIPT"
+ after=$(ls /tmp | grep -c 'tmp\.' || true)
+ [ "$after" -le "$before" ]
+}
From caa0346212acaba9417959ab5211380df3d1e32f Mon Sep 17 00:00:00 2001
From: kcnewman
Date: Sun, 26 Jul 2026 09:52:27 +0000
Subject: [PATCH 02/11] fix: resolve shellcheck SC2002 (useless cat)
---
scripts/freeby.sh | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/scripts/freeby.sh b/scripts/freeby.sh
index 0a9e53a..4da906b 100755
--- a/scripts/freeby.sh
+++ b/scripts/freeby.sh
@@ -113,7 +113,7 @@ fetch_copilot() {
token="$(gh auth token 2>/dev/null | tr -d '[:space:]')"
fi
if [ -z "$token" ] && [ -f "$HOME/.config/freeby/copilot-token" ]; then
- token="$(cat "$HOME/.config/freeby/copilot-token" 2>/dev/null | tr -d '[:space:]')"
+ token="$(tr -d '[:space:]' < "$HOME/.config/freeby/copilot-token" 2>/dev/null)"
fi
if [ -z "$token" ]; then
if [ ! -f "$HOME/.config/freeby/copilot-token" ] && ! command -v gh >/dev/null 2>&1; then
From adfd377d567ba8283d8470caca37e39cba0f9f4c Mon Sep 17 00:00:00 2001
From: kcnewman
Date: Sun, 26 Jul 2026 09:55:00 +0000
Subject: [PATCH 03/11] docs: rewrite README and update CONTRIBUTING for v1.0.1
---
CONTRIBUTING.md | 42 ++++++++++++++++-----
README.md | 98 +++++++++++++++++++++++++++++--------------------
2 files changed, 90 insertions(+), 50 deletions(-)
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index d65d184..27606ca 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -4,8 +4,6 @@ Thanks for your interest in Freeby.
## Development
-Clone and install locally:
-
```bash
git clone https://github.com/kcnewman/freeby.git && cd freeby
git checkout dev
@@ -13,12 +11,35 @@ meson setup build --prefix=$HOME/.local
meson install -C build
```
-Test your changes by restarting GNOME Shell (Alt+F2, type `r`, Enter) and enabling the extension:
+Restart GNOME Shell (Alt+F2, type `r`, Enter) and enable:
```bash
gnome-extensions enable freeby@kelvin.local
```
+## Project structure
+
+```
+freeby/
+├── extension.js # Entry point (enable/disable)
+├── indicator.js # Panel UI, dropdown, refresh
+├── prefs.js # Settings UI in Extension Manager
+├── scripts/
+│ ├── freeby.sh # Data aggregator (parallel provider fetches)
+│ └── copilot-setup.sh # GitHub device-flow auth
+├── schemas/
+│ └── *.gschema.xml # GSettings schema
+├── build-aux/
+│ └── compile-schemas.sh
+├── tests/
+│ └── freeby.bats # Shell script tests
+├── .github/workflows/
+│ └── ci.yml # shellcheck, meson build, bats tests
+└── docs/
+ ├── s1.png
+ └── s2.png
+```
+
## Code style
- Keep it simple. No unnecessary abstraction.
@@ -36,18 +57,19 @@ Use [conventional commits](https://www.conventionalcommits.org/):
## Testing
-Test the shell script directly:
-
```bash
-bash ~/.local/bin/freeby.sh | python3 -m json.tool
-```
-
-Run shellcheck:
+# test the data script
+bash scripts/freeby.sh | python3 -m json.tool
-```bash
+# run shellcheck
shellcheck scripts/freeby.sh scripts/copilot-setup.sh
+
+# run bats tests
+bats tests/
```
+CI runs automatically on push to `main` or `dev`, and on all pull requests.
+
## Branches
- `main` — stable releases
diff --git a/README.md b/README.md
index 9898fd6..8457680 100644
--- a/README.md
+++ b/README.md
@@ -1,62 +1,75 @@
-# Freeby
-
-> GNOME Shell extension that tracks remaining usage across your free-tier AI coding tools.
-
-
-
-  |
-  |
-
-
+
+
Freeby
+ Track your free-tier AI coding tool usage at a glance.
+
+
+
+
+
+
+
+
+ Features ·
+ Providers ·
+ Install ·
+ Settings ·
+ Contributing ·
+ Releases
+
---
### Features
-- **Panel indicator** — colored `ai·N` shows available providers at a glance
-- **Dropdown** — per-provider usage, limits, and reset countdown
-- **Notifications** — desktop alert when a provider hits its limit
-- **Auto-refresh on wake** — refreshes immediately after sleep
-- **Fast** — all providers queried in parallel (~2s)
-- **Configurable** — adjust refresh interval and notifications in settings
+| | Feature | Description |
+|---|---|---|
+|  | **Panel indicator** | Colored `ai·N` shows available providers at a glance |
+| | **Dropdown** | Per-provider usage, limits, and reset countdown |
+| | **Notifications** | Desktop alert when a provider hits its limit |
+| | **Auto-refresh on wake** | Refreshes immediately after sleep |
+| | **Parallel fetches** | All providers queried in parallel (~2s) |
+| | **Configurable** | Adjust refresh interval and notifications in settings |
+| | **Accessible** | Screen reader support for all UI elements |
+| | **Theme-aware** | Works with light and dark GNOME themes |
---
-### Providers
+### Supported providers
-| | Provider | Source |
-|---|---|---|
-|  | **Codex** | `~/.codex/auth.json` → `codex-check` CLI |
-|  | **Cursor** | `~/.config/cursor/auth.json` → Cursor API |
-|  | **Copilot** | `gh` CLI → GitHub API |
+| | Provider | Auth source | Data source |
+|---|---|---|---|
+|  | **Codex** | `~/.codex/auth.json` | `codex-check` CLI |
+|  | **Cursor** | `~/.config/cursor/auth.json` | Cursor API |
+|  | **Copilot** | `gh` CLI or `~/.config/freeby/copilot-token` | GitHub API |
---
-### Prerequisites
+### Install
+
+**Prerequisites**
- GNOME Shell 45+ (Wayland or X11)
-- `python3`
-- `curl`
-- `meson` and `ninja-build` (for building)
-- `glib-compile-schemas` (usually pre-installed)
-- `npx` (for Codex usage tracking)
-- `gh` CLI (for Copilot usage tracking, optional)
+- `python3`, `curl`, `meson`, `ninja-build`
+- `npx` (for Codex)
+- `gh` CLI (for Copilot, optional)
-Install build dependencies on Fedora:
+
+Fedora
```bash
sudo dnf install meson ninja-build python3 curl glib2-devel
```
+
-On Ubuntu/Debian:
+
+Ubuntu / Debian
```bash
sudo apt install meson ninja-build python3 curl libglib2.0-dev-bin
```
+
----
-
-### Install
+**Build and install**
```bash
git clone https://github.com/kcnewman/freeby.git && cd freeby
@@ -64,7 +77,7 @@ meson setup build --prefix=$HOME/.local
meson install -C build
```
-Restart your session, then:
+Then restart your session and enable:
```bash
gnome-extensions enable freeby@kelvin.local
@@ -76,10 +89,10 @@ gnome-extensions enable freeby@kelvin.local
### Settings
-| Setting | Default | |
-|---|---|---|
-| Refresh interval | `120s` | min 30s |
-| Notifications | `on` | alerts on limit hit |
+| Setting | Default | Range | Description |
+|---|---|---|---|
+| Refresh interval | `120s` | 30–3600s | How often to check usage |
+| Notifications | `on` | — | Alert when a provider hits its limit |
Configure via Extension Manager or CLI:
@@ -105,16 +118,21 @@ rm -f ~/.local/share/glib-2.0/schemas/gschemas.compiled
### Debug
```bash
+# test the data script
bash ~/.local/bin/freeby.sh | python3 -m json.tool
+
+# watch extension logs
journalctl -f -o cat /usr/bin/gnome-shell
```
---
-## Contributing
+### Contributing
See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup and guidelines.
---
+### License
+
[MIT](LICENSE)
From 10f39f2ccbdcc4b8bd33ba6a82577043c04ec35d Mon Sep 17 00:00:00 2001
From: kcnewman
Date: Sun, 26 Jul 2026 09:56:23 +0000
Subject: [PATCH 04/11] fix: replace removed messageTray.get_source for GNOME
50
---
indicator.js | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/indicator.js b/indicator.js
index ac688b5..9f0eb5c 100644
--- a/indicator.js
+++ b/indicator.js
@@ -77,8 +77,8 @@ class FreebyIndicator extends PanelMenu.Button {
_notify(title, body) {
if (!this._settings.get_boolean('notifications-enabled')) return;
try {
- let src = Main.messageTray.get_source('Freeby');
- if (!src) { src = new Main.messageTray.Source('Freeby', 'dialog-information-symbolic'); Main.messageTray.add(src); }
+ const src = new Main.messageTray.Source('Freeby', 'dialog-information-symbolic');
+ Main.messageTray.add(src);
src.addNotification(new Main.messageTray.Notification({ source: src, title, body }));
} catch (e) { logError(e, 'freeby: notification failed'); }
}
From 820b28e8d50367805900eaec2827a6400383d51c Mon Sep 17 00:00:00 2001
From: kcnewman
Date: Sun, 26 Jul 2026 10:12:41 +0000
Subject: [PATCH 05/11] docs: fix broken image links with inline SVG data URIs
---
README.md | 8 ++++----
1 file changed, 4 insertions(+), 4 deletions(-)
diff --git a/README.md b/README.md
index 8457680..f57a5cf 100644
--- a/README.md
+++ b/README.md
@@ -23,7 +23,7 @@
| | Feature | Description |
|---|---|---|
-|  | **Panel indicator** | Colored `ai·N` shows available providers at a glance |
+|
| **Panel indicator** | Colored `ai·N` shows available providers at a glance |
| | **Dropdown** | Per-provider usage, limits, and reset countdown |
| | **Notifications** | Desktop alert when a provider hits its limit |
| | **Auto-refresh on wake** | Refreshes immediately after sleep |
@@ -38,9 +38,9 @@
| | Provider | Auth source | Data source |
|---|---|---|---|
-|  | **Codex** | `~/.codex/auth.json` | `codex-check` CLI |
-|  | **Cursor** | `~/.config/cursor/auth.json` | Cursor API |
-|  | **Copilot** | `gh` CLI or `~/.config/freeby/copilot-token` | GitHub API |
+|
| **Codex** | `~/.codex/auth.json` | `codex-check` CLI |
+|
| **Cursor** | `~/.config/cursor/auth.json` | Cursor API |
+|
| **Copilot** | `gh` CLI or `~/.config/freeby/copilot-token` | GitHub API |
---
From bad080694e122b54171267018a1785171ab92ec4 Mon Sep 17 00:00:00 2001
From: kcnewman
Date: Sun, 26 Jul 2026 10:14:27 +0000
Subject: [PATCH 06/11] docs: simplify tables, use emoji for provider colors
---
README.md | 28 +++++++++++++---------------
1 file changed, 13 insertions(+), 15 deletions(-)
diff --git a/README.md b/README.md
index f57a5cf..3e42e07 100644
--- a/README.md
+++ b/README.md
@@ -21,26 +21,24 @@
### Features
-| | Feature | Description |
-|---|---|---|
-|
| **Panel indicator** | Colored `ai·N` shows available providers at a glance |
-| | **Dropdown** | Per-provider usage, limits, and reset countdown |
-| | **Notifications** | Desktop alert when a provider hits its limit |
-| | **Auto-refresh on wake** | Refreshes immediately after sleep |
-| | **Parallel fetches** | All providers queried in parallel (~2s) |
-| | **Configurable** | Adjust refresh interval and notifications in settings |
-| | **Accessible** | Screen reader support for all UI elements |
-| | **Theme-aware** | Works with light and dark GNOME themes |
+- **Panel indicator** — colored `ai·N` shows available providers at a glance
+- **Dropdown** — per-provider usage, limits, and reset countdown
+- **Notifications** — desktop alert when a provider hits its limit
+- **Auto-refresh on wake** — refreshes immediately after sleep
+- **Parallel fetches** — all providers queried in parallel (~2s)
+- **Configurable** — adjust refresh interval and notifications in settings
+- **Accessible** — screen reader support for all UI elements
+- **Theme-aware** — works with light and dark GNOME themes
---
### Supported providers
-| | Provider | Auth source | Data source |
-|---|---|---|---|
-|
| **Codex** | `~/.codex/auth.json` | `codex-check` CLI |
-|
| **Cursor** | `~/.config/cursor/auth.json` | Cursor API |
-|
| **Copilot** | `gh` CLI or `~/.config/freeby/copilot-token` | GitHub API |
+| Provider | Auth source | Data source |
+|---|---|---|
+| 🟡 **Codex** | `~/.codex/auth.json` | `codex-check` CLI |
+| 🟢 **Cursor** | `~/.config/cursor/auth.json` | Cursor API |
+| 🟠 **Copilot** | `gh` CLI or `~/.config/freeby/copilot-token` | GitHub API |
---
From e85ee4feae1b3db19eafbc903c9addbe4e7ff254 Mon Sep 17 00:00:00 2001
From: kcnewman
Date: Sun, 26 Jul 2026 10:22:37 +0000
Subject: [PATCH 07/11] docs: remove dividers for seamless flow
---
README.md | 16 ----------------
1 file changed, 16 deletions(-)
diff --git a/README.md b/README.md
index 3e42e07..8ecdf50 100644
--- a/README.md
+++ b/README.md
@@ -17,8 +17,6 @@
Releases
----
-
### Features
- **Panel indicator** — colored `ai·N` shows available providers at a glance
@@ -30,8 +28,6 @@
- **Accessible** — screen reader support for all UI elements
- **Theme-aware** — works with light and dark GNOME themes
----
-
### Supported providers
| Provider | Auth source | Data source |
@@ -40,8 +36,6 @@
| 🟢 **Cursor** | `~/.config/cursor/auth.json` | Cursor API |
| 🟠 **Copilot** | `gh` CLI or `~/.config/freeby/copilot-token` | GitHub API |
----
-
### Install
**Prerequisites**
@@ -83,8 +77,6 @@ gnome-extensions enable freeby@kelvin.local
> **Copilot users:** If `gh` isn't installed, run `copilot-setup.sh` first.
----
-
### Settings
| Setting | Default | Range | Description |
@@ -99,8 +91,6 @@ gsettings --schemadir ~/.local/share/glib-2.0/schemas \
set org.gnome.shell.extensions.freeby refresh-interval 60
```
----
-
### Uninstall
```bash
@@ -111,8 +101,6 @@ rm -f ~/.local/share/glib-2.0/schemas/org.gnome.shell.extensions.freeby.gschema.
rm -f ~/.local/share/glib-2.0/schemas/gschemas.compiled
```
----
-
### Debug
```bash
@@ -123,14 +111,10 @@ bash ~/.local/bin/freeby.sh | python3 -m json.tool
journalctl -f -o cat /usr/bin/gnome-shell
```
----
-
### Contributing
See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup and guidelines.
----
-
### License
[MIT](LICENSE)
From c552a4373398630de2299cea3b2c5c5a1e9a413d Mon Sep 17 00:00:00 2001
From: kcnewman
Date: Sun, 26 Jul 2026 10:42:54 +0000
Subject: [PATCH 08/11] fix: add GNOME 50 support and remove deprecated version
field
---
metadata.json | 3 +--
1 file changed, 1 insertion(+), 2 deletions(-)
diff --git a/metadata.json b/metadata.json
index 5a8b29a..f131767 100644
--- a/metadata.json
+++ b/metadata.json
@@ -2,8 +2,7 @@
"uuid": "freeby@kelvin.local",
"name": "Freeby",
"description": "Minimal panel indicator for local AI coding tool usage (Codex, Cursor, Copilot)",
- "shell-version": ["45", "46", "47", "48"],
- "version": 2,
+ "shell-version": ["45", "46", "47", "48", "49", "50"],
"settings-schema": "org.gnome.shell.extensions.freeby",
"url": "https://github.com/kcnewman/freeby"
}
From 095efaf8d6e45103763aad46ff068a95f4f2271f Mon Sep 17 00:00:00 2001
From: kcnewman
Date: Sun, 26 Jul 2026 10:48:35 +0000
Subject: [PATCH 09/11] fix: remove invalid accessible_name from PopupMenuItem
---
indicator.js | 244 +++++++++++++++++++++++++--------------------------
1 file changed, 122 insertions(+), 122 deletions(-)
diff --git a/indicator.js b/indicator.js
index 9f0eb5c..3ad2559 100644
--- a/indicator.js
+++ b/indicator.js
@@ -13,128 +13,128 @@ const LABELS = { codex: 'Codex', cursor: 'Cursor', copilot: 'Copilot' };
const SCRIPT = GLib.build_filenamev([GLib.get_home_dir(), '.local', 'bin', 'freeby.sh']);
export const FreebyIndicator = GObject.registerClass(
-class FreebyIndicator extends PanelMenu.Button {
- _init(settings) {
- super._init(0.0, 'Freeby', false);
- this._settings = settings;
- this._prev = {};
- for (const k of PROVIDERS) this._prev[k] = null;
- this._refreshSeq = 0;
-
- this._panelBox = new St.BoxLayout({ style_class: 'panel-status-menu-box freeby-panel', accessible_name: 'Freeby usage indicator' });
- this._label = new St.Label({ text: 'AI', y_align: Clutter.ActorAlign.CENTER, style_class: 'freeby-panel-label', accessible_name: 'Usage count' });
- this._panelBox.add_child(this._label);
- this.add_child(this._panelBox);
- this.connect('button-press-event', () => { this._refresh(); return false; });
-
- this._items = {};
- for (const k of PROVIDERS) {
- const box = new St.BoxLayout({ style_class: 'freeby-item-box', accessible_name: `${LABELS[k]} usage` });
- const dot = new St.Label({ text: '\u25CB', style_class: 'freeby-dot freeby-dot-off', y_align: Clutter.ActorAlign.CENTER, accessible_name: `${LABELS[k]} status` });
- const name = new St.Label({ text: LABELS[k], style_class: 'freeby-item-name', y_align: Clutter.ActorAlign.CENTER });
- const summary = new St.Label({ text: '\u2014', style_class: 'freeby-item-summary', y_align: Clutter.ActorAlign.CENTER, accessible_name: `${LABELS[k]} summary` });
- box.add_child(dot);
- box.add_child(name);
- box.add_child(summary);
- const item = new PopupMenu.PopupMenuItem('', { reactive: false, can_focus: false });
- item.add_child(box);
- this.menu.addMenuItem(item);
- this._items[k] = { dot, summary };
+ class FreebyIndicator extends PanelMenu.Button {
+ _init(settings) {
+ super._init(0.0, 'Freeby', false);
+ this._settings = settings;
+ this._prev = {};
+ for (const k of PROVIDERS) this._prev[k] = null;
+ this._refreshSeq = 0;
+
+ this._panelBox = new St.BoxLayout({ style_class: 'panel-status-menu-box freeby-panel', accessible_name: 'Freeby usage indicator' });
+ this._label = new St.Label({ text: 'AI', y_align: Clutter.ActorAlign.CENTER, style_class: 'freeby-panel-label', accessible_name: 'Usage count' });
+ this._panelBox.add_child(this._label);
+ this.add_child(this._panelBox);
+ this.connect('button-press-event', () => { this._refresh(); return false; });
+
+ this._items = {};
+ for (const k of PROVIDERS) {
+ const box = new St.BoxLayout({ style_class: 'freeby-item-box', accessible_name: `${LABELS[k]} usage` });
+ const dot = new St.Label({ text: '\u25CB', style_class: 'freeby-dot freeby-dot-off', y_align: Clutter.ActorAlign.CENTER, accessible_name: `${LABELS[k]} status` });
+ const name = new St.Label({ text: LABELS[k], style_class: 'freeby-item-name', y_align: Clutter.ActorAlign.CENTER });
+ const summary = new St.Label({ text: '\u2014', style_class: 'freeby-item-summary', y_align: Clutter.ActorAlign.CENTER, accessible_name: `${LABELS[k]} summary` });
+ box.add_child(dot);
+ box.add_child(name);
+ box.add_child(summary);
+ const item = new PopupMenu.PopupMenuItem('', { reactive: false, can_focus: false });
+ item.add_child(box);
+ this.menu.addMenuItem(item);
+ this._items[k] = { dot, summary };
+ }
+
+ this.menu.addMenuItem(new PopupMenu.PopupSeparatorMenuItem());
+ this._statusItem = new PopupMenu.PopupMenuItem('\u21BB Last checked: never', { reactive: true, can_focus: false });
+ this._statusItem.label.add_style_class_name('freeby-status');
+ this._statusItem.connect('activate', () => { this._refresh(); this.menu.open(); });
+ this.menu.addMenuItem(this._statusItem);
+
+ this._timerId = null;
+ this._setupTwimer();
+ this._refresh();
+ this._monitorWake();
}
- this.menu.addMenuItem(new PopupMenu.PopupSeparatorMenuItem());
- this._statusItem = new PopupMenu.PopupMenuItem('\u21BB Last checked: never', { reactive: true, can_focus: false, accessible_name: 'Refresh status' });
- this._statusItem.label.add_style_class_name('freeby-status');
- this._statusItem.connect('activate', () => { this._refresh(); this.menu.open(); });
- this.menu.addMenuItem(this._statusItem);
-
- this._timerId = null;
- this._setupTimer();
- this._refresh();
- this._monitorWake();
- }
-
- _setupTimer() {
- if (this._timerId) GLib.source_remove(this._timerId);
- const sec = Math.max(30, this._settings.get_int('refresh-interval'));
- this._timerId = GLib.timeout_add_seconds(GLib.PRIORITY_DEFAULT, sec, () => { this._refresh(); return GLib.SOURCE_CONTINUE; });
- }
-
- _monitorWake() {
- try {
- this._sleepId = Gio.DBus.system.signal_subscribe(
- 'org.freedesktop.login1', 'org.freedesktop.login1.Manager',
- 'PrepareForSleep', '/org/freedesktop/login1', null, Gio.DBusSignalFlags.NONE,
- (_, __, ___, ____, _____, params) => {
- if (params.get_child_value(0).get_boolean()) {
- log('freeby: woke from sleep, refreshing');
- this._refresh();
- }
- });
- } catch (e) { logError(e, 'freeby: could not monitor sleep/wake'); }
- }
-
- _notify(title, body) {
- if (!this._settings.get_boolean('notifications-enabled')) return;
- try {
- const src = new Main.messageTray.Source('Freeby', 'dialog-information-symbolic');
- Main.messageTray.add(src);
- src.addNotification(new Main.messageTray.Notification({ source: src, title, body }));
- } catch (e) { logError(e, 'freeby: notification failed'); }
- }
-
- _setError() {
- this._label.text = 'ai';
- this._label.style_class = 'freeby-panel-label';
- }
-
- _refresh() {
- this._refreshSeq++;
- const seq = this._refreshSeq;
- let proc;
- try { proc = Gio.Subprocess.new(['/bin/bash', SCRIPT], Gio.SubprocessFlags.STDOUT_PIPE | Gio.SubprocessFlags.STDERR_PIPE); }
- catch (e) { this._setError(); logError(e, 'freeby: failed to spawn script'); return; }
-
- proc.communicate_utf8_async(null, null, (p, res) => {
- if (seq !== this._refreshSeq) return;
- let stdout, stderr;
- try { [, stdout, stderr] = p.communicate_utf8_finish(res); }
- catch (e) { this._setError(); logError(e, 'freeby: subprocess failed'); return; }
- if (!p.get_successful()) { this._setError(); log(`freeby: script error: ${stderr}`); return; }
- this._apply(stdout);
- });
- }
-
- _apply(stdout) {
- let data;
- try { data = JSON.parse(stdout); }
- catch (e) { this._setError(); logError(e, 'freeby: bad JSON'); return; }
- if (!data || typeof data !== 'object') { this._setError(); log('freeby: empty or invalid data'); return; }
-
- const active = PROVIDERS.filter(k => data[k]?.has_remaining).length;
- const total = PROVIDERS.filter(k => data[k]?.available).length;
- this._label.text = active > 0 ? `ai\u00B7${active}` : 'ai';
- this._label.style_class = 'freeby-panel-label' + (active === total && total > 0 ? ' freeby-panel-green' : active > 0 ? ' freeby-panel-yellow' : total > 0 ? ' freeby-panel-red' : '');
-
- const hit = PROVIDERS.filter(k => data[k]?.available && !data[k].has_remaining && this._prev[k]?.has_remaining !== false).map(k => LABELS[k]);
- this._prev = {};
- for (const k of PROVIDERS) this._prev[k] = data[k] ? { has_remaining: data[k].has_remaining } : null;
- if (hit.length) this._notify('Usage limit reached', `${hit.join(', ')} ${hit.length === 1 ? 'has' : 'have'} hit their limit`);
-
- for (const k of PROVIDERS) {
- const d = data[k], { dot, summary } = this._items[k];
- if (!d) { dot.text = '\u25CB'; dot.style_class = 'freeby-dot freeby-dot-off'; summary.text = 'no data'; summary.style_class = 'freeby-item-summary'; }
- else if (!d.available) { dot.text = '\u25CB'; dot.style_class = 'freeby-dot freeby-dot-off'; summary.text = d.summary || 'not available'; summary.style_class = 'freeby-item-summary freeby-dim'; }
- else if (d.summary?.includes('limit reached')) { dot.text = '\u25CF'; dot.style_class = 'freeby-dot freeby-dot-red'; summary.text = d.summary; summary.style_class = 'freeby-item-summary freeby-red'; }
- else if (d.summary?.includes('expired') || d.summary?.includes('parse error')) { dot.text = '\u25CF'; dot.style_class = 'freeby-dot freeby-dot-yellow'; summary.text = d.summary; summary.style_class = 'freeby-item-summary freeby-yellow'; }
- else { dot.text = '\u25CF'; dot.style_class = 'freeby-dot freeby-dot-green'; summary.text = d.summary; summary.style_class = 'freeby-item-summary freeby-green'; }
+ _setupTimer() {
+ if (this._timerId) GLib.source_remove(this._timerId);
+ const sec = Math.max(30, this._settings.get_int('refresh-interval'));
+ this._timerId = GLib.timeout_add_seconds(GLib.PRIORITY_DEFAULT, sec, () => { this._refresh(); return GLib.SOURCE_CONTINUE; });
}
- this._statusItem.label.text = `\u21BB Last checked: ${GLib.DateTime.new_now_local().format('%H:%M:%S')}`;
- }
-
- destroy() {
- if (this._timerId) { GLib.source_remove(this._timerId); this._timerId = null; }
- if (this._sleepId) { Gio.DBus.system.signal_unsubscribe(this._sleepId); this._sleepId = null; }
- super.destroy();
- }
-});
+
+ _monitorWake() {
+ try {
+ this._sleepId = Gio.DBus.system.signal_subscribe(
+ 'org.freedesktop.login1', 'org.freedesktop.login1.Manager',
+ 'PrepareForSleep', '/org/freedesktop/login1', null, Gio.DBusSignalFlags.NONE,
+ (_, __, ___, ____, _____, params) => {
+ if (params.get_child_value(0).get_boolean()) {
+ log('freeby: woke from sleep, refreshing');
+ this._refresh();
+ }
+ });
+ } catch (e) { logError(e, 'freeby: could not monitor sleep/wake'); }
+ }
+
+ _notify(title, body) {
+ if (!this._settings.get_boolean('notifications-enabled')) return;
+ try {
+ const src = new Main.messageTray.Source('Freeby', 'dialog-information-symbolic');
+ Main.messageTray.add(src);
+ src.addNotification(new Main.messageTray.Notification({ source: src, title, body }));
+ } catch (e) { logError(e, 'freeby: notification failed'); }
+ }
+
+ _setError() {
+ this._label.text = 'ai';
+ this._label.style_class = 'freeby-panel-label';
+ }
+
+ _refresh() {
+ this._refreshSeq++;
+ const seq = this._refreshSeq;
+ let proc;
+ try { proc = Gio.Subprocess.new(['/bin/bash', SCRIPT], Gio.SubprocessFlags.STDOUT_PIPE | Gio.SubprocessFlags.STDERR_PIPE); }
+ catch (e) { this._setError(); logError(e, 'freeby: failed to spawn script'); return; }
+
+ proc.communicate_utf8_async(null, null, (p, res) => {
+ if (seq !== this._refreshSeq) return;
+ let stdout, stderr;
+ try { [, stdout, stderr] = p.communicate_utf8_finish(res); }
+ catch (e) { this._setError(); logError(e, 'freeby: subprocess failed'); return; }
+ if (!p.get_successful()) { this._setError(); log(`freeby: script error: ${stderr}`); return; }
+ this._apply(stdout);
+ });
+ }
+
+ _apply(stdout) {
+ let data;
+ try { data = JSON.parse(stdout); }
+ catch (e) { this._setError(); logError(e, 'freeby: bad JSON'); return; }
+ if (!data || typeof data !== 'object') { this._setError(); log('freeby: empty or invalid data'); return; }
+
+ const active = PROVIDERS.filter(k => data[k]?.has_remaining).length;
+ const total = PROVIDERS.filter(k => data[k]?.available).length;
+ this._label.text = active > 0 ? `ai\u00B7${active}` : 'ai';
+ this._label.style_class = 'freeby-panel-label' + (active === total && total > 0 ? ' freeby-panel-green' : active > 0 ? ' freeby-panel-yellow' : total > 0 ? ' freeby-panel-red' : '');
+
+ const hit = PROVIDERS.filter(k => data[k]?.available && !data[k].has_remaining && this._prev[k]?.has_remaining !== false).map(k => LABELS[k]);
+ this._prev = {};
+ for (const k of PROVIDERS) this._prev[k] = data[k] ? { has_remaining: data[k].has_remaining } : null;
+ if (hit.length) this._notify('Usage limit reached', `${hit.join(', ')} ${hit.length === 1 ? 'has' : 'have'} hit their limit`);
+
+ for (const k of PROVIDERS) {
+ const d = data[k], { dot, summary } = this._items[k];
+ if (!d) { dot.text = '\u25CB'; dot.style_class = 'freeby-dot freeby-dot-off'; summary.text = 'no data'; summary.style_class = 'freeby-item-summary'; }
+ else if (!d.available) { dot.text = '\u25CB'; dot.style_class = 'freeby-dot freeby-dot-off'; summary.text = d.summary || 'not available'; summary.style_class = 'freeby-item-summary freeby-dim'; }
+ else if (d.summary?.includes('limit reached')) { dot.text = '\u25CF'; dot.style_class = 'freeby-dot freeby-dot-red'; summary.text = d.summary; summary.style_class = 'freeby-item-summary freeby-red'; }
+ else if (d.summary?.includes('expired') || d.summary?.includes('parse error')) { dot.text = '\u25CF'; dot.style_class = 'freeby-dot freeby-dot-yellow'; summary.text = d.summary; summary.style_class = 'freeby-item-summary freeby-yellow'; }
+ else { dot.text = '\u25CF'; dot.style_class = 'freeby-dot freeby-dot-green'; summary.text = d.summary; summary.style_class = 'freeby-item-summary freeby-green'; }
+ }
+ this._statusItem.label.text = `\u21BB Last checked: ${GLib.DateTime.new_now_local().format('%H:%M:%S')}`;
+ }
+
+ destroy() {
+ if (this._timerId) { GLib.source_remove(this._timerId); this._timerId = null; }
+ if (this._sleepId) { Gio.DBus.system.signal_unsubscribe(this._sleepId); this._sleepId = null; }
+ super.destroy();
+ }
+ });
From d2c2d8624d19dffa18ab54de03fa42dda5ef14f4 Mon Sep 17 00:00:00 2001
From: kcnewman
Date: Sun, 26 Jul 2026 10:54:18 +0000
Subject: [PATCH 10/11] fix: keep dropdown open when clicking refresh
---
indicator.js | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/indicator.js b/indicator.js
index 3ad2559..b2aaa8a 100644
--- a/indicator.js
+++ b/indicator.js
@@ -45,11 +45,11 @@ export const FreebyIndicator = GObject.registerClass(
this.menu.addMenuItem(new PopupMenu.PopupSeparatorMenuItem());
this._statusItem = new PopupMenu.PopupMenuItem('\u21BB Last checked: never', { reactive: true, can_focus: false });
this._statusItem.label.add_style_class_name('freeby-status');
- this._statusItem.connect('activate', () => { this._refresh(); this.menu.open(); });
+ this._statusItem.connect('activate', () => { this._refresh(); GLib.idle_add(GLib.PRIORITY_DEFAULT, () => { this.menu.open(); return GLib.SOURCE_REMOVE; }); });
this.menu.addMenuItem(this._statusItem);
this._timerId = null;
- this._setupTwimer();
+ this._setupTimer();
this._refresh();
this._monitorWake();
}
From 822d1636a7acc0c75a47ebfc663ed1428979bd4b Mon Sep 17 00:00:00 2001
From: kcnewman
Date: Sun, 26 Jul 2026 10:55:43 +0000
Subject: [PATCH 11/11] release: v1.0.2
---
CHANGELOG.md | 8 ++++++++
meson.build | 2 +-
2 files changed, 9 insertions(+), 1 deletion(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index b0bf72a..5c3f947 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,13 @@
# Changelog
+## v1.0.2
+
+### Fixed
+- Added GNOME 49 and 50 to shell-version compatibility
+- Removed deprecated `version` field from metadata.json
+- Dropdown now stays open when clicking refresh (deferred reopen)
+- Removed invalid `accessible_name` from PopupMenuItem (caused extension to fail loading)
+
## v1.0.1
### Added
diff --git a/meson.build b/meson.build
index 22d6b06..6af6f8f 100644
--- a/meson.build
+++ b/meson.build
@@ -1,4 +1,4 @@
-project('freeby', version: '1.0.1', license: 'MIT',
+project('freeby', version: '1.0.2', license: 'MIT',
meson_version: '>= 0.56.0',
default_options: ['warning_level=0'])