Skip to content

feat(ai-usagebar): rework the capsule and panel, share the helpers - #427

Open
FelipeArtur wants to merge 12 commits into
noctalia-dev:mainfrom
FelipeArtur:ai-usagebar-visual-refresh
Open

feat(ai-usagebar): rework the capsule and panel, share the helpers#427
FelipeArtur wants to merge 12 commits into
noctalia-dev:mainfrom
FelipeArtur:ai-usagebar-visual-refresh

Conversation

@FelipeArtur

@FelipeArtur FelipeArtur commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

Plugin

  • Id: felipeartur/ai-usagebar
  • New plugin
  • Update to an existing plugin (version bumped in plugin.toml)

What it does

AI Usage puts the quota of a paid coding plan in the bar: how much of the window
is spent, when it resets, and whether the spend is running ahead of the clock.
The numbers come from the ai-usagebar CLI, which owns the credentials and the
vendor endpoints; the plugin runs ai-usagebar usage --json and draws the
answer.

This is a rework of both surfaces, 1.1.0 to 1.3.0. No setting was added or
removed: the four capsule styles, provider_limit, extras, show_name and
color_by_usage all shipped in 1.1.0 and behave the same.

Both entries used to carry their own copy of the ISO parsing, the duration
formatting, the severity tiers, the clamp and the provider glyphs. Those now live
once in shared.luau, where they cannot drift between a capsule and a panel that
have to agree on screen. That is what moves plugin_api from 9 to 22, since
require() arrived at 22; the panel's new gear button needs openSettings(),
which arrived at 15. version is 1.3.0 rather than a patch because asking for
a newer API is a compatibility break, and Requirements says so.

The capsule no longer moves while it updates. Its percentage sits in a
fixed-width column, so it is the same size at 9% as at 100% instead of nudging
its neighbours every time a digit is added, and a read in flight dims the row
rather than appending a loader-2 glyph that shoves every widget to its right
once per cycle. The empty and failed state is one glyph coloured by the state,
where it used to be a brain glyph with an alert glyph beside it.

The panel became a master/detail view: every configured provider on the left with
its headline percentage, the selected one on the right as one card per window.
Each card carries a quota bar over a thinner "window elapsed" bar, so a fill that
outruns the clock bar is quota burning ahead of pace. A cold read draws muted
skeletons shaped like the content instead of a spinner, and severity arrives as a
word beside the colour rather than colour alone. The hand-rolled clickable rows
became ui.button, the one-pixel column that served as a divider became
ui.separator, and the install link no longer uses a raw URL as its caption. A
gear button opens the plugin's settings, and the refresh button becomes the
spinner while the CLI answers instead of a second glyph appearing beside it.

Everything the CLI prints reaches the screen, and a CLI that fails an HTTP
request tends to quote the request, so every string in the report is redacted on
its way in: keyword assignments, Bearer tokens, URL userinfo, and the sk- key
shape. It also caps runaway lines, which would otherwise push a capsule off the
bar. That redaction runs inside the poller's async callback, so this release
bounds what it costs as well: the patterns are built once instead of per string,
each keyword opens only its own two substitutions, and the length cap runs before
them rather than after.

External dependencies

  • ai-usagebar is the Rust CLI the readings come from
    (https://github.com/akitaonrails/ai-usagebar). A single headless service spawns
    it once per refresh interval as ai-usagebar usage --json, so N capsules on M
    monitors still cost one process per cycle. The plugin never talks to a
    provider, holds a token, or reads a credential file.
  • xdg-open is optional. One button in the panel uses it, the link to the CLI's
    project page offered when ai-usagebar is not on PATH. The URL is a literal.
    Where xdg-utils is absent, that button is not drawn.

The plugin makes no network calls and writes no files of its own.

Testing

Against a live ai-usagebar on Hyprland, with four providers configured, two of
them without credentials so the error paths were exercised.

  • The poller service on its interval and on demand, including
    noctalia msg plugin felipeartur/ai-usagebar:poller all refresh, with the
    panel opened and closed from the capsule and from
    noctalia msg panel-toggle felipeartur/ai-usagebar:panel.

  • A provider with no credential is not listed; one that is configured and failing
    keeps its row and shows the error.

  • noctalia plugins lint reports no errors or warnings, and the repository's own
    validate-plugins.py validates the manifest.

  • The redaction has an offline test, lua tests/scrub_test.lua, run from the
    plugin directory. It reads safeText and scrub out of service.luau rather
    than copying them, and covers eleven real credential shapes, twelve ordinary
    readings that must pass through untouched, the length cap, and the CPU budget
    the async callback is given.

  • Tested on Niri

  • Tested on Hyprland

  • Tested on Sway

  • Tested on another compositor:

  • Noctalia version tested against: 5.0.0-beta.9

  • Plugin API level: 22

Screenshots / Videos

aiusage-thumb-crop screenshot_20260821_110703-region

Checklist

  • The directory name matches the part of id after the / in plugin.toml exactly.
  • It ships plugin.toml, README.md, thumbnail.webp, and translations/en.json.
  • README.md follows the
    README template, documents
    every entry id and dependency, and includes exact panel IPC commands and launcher prefixes where applicable.
  • I created thumbnail.webp with the thumbnail generator.
  • version follows semver and is bumped in this PR; plugin_api is the oldest API level this plugin requires.
  • Every non-English translation in this PR uses a locale supported by Noctalia core, and I can read, write, and
    understand that language well enough to review and maintain it (no unreviewed machine/LLM translations).
  • I did not edit catalog.toml; CI generates it.
  • This PR touches exactly one plugin directory.

Code review attestation

  • The code is readable and not obfuscated, minified, or generated.
  • It does not download and execute remote code.
  • Every network call, filesystem write, and spawned process is something the description above accounts for.
  • I have the right to publish this code under the license declared in plugin.toml.

safeText() gated its redaction on a literal `=` or `Bearer `, so a secret
written any other way reached the screen. A CLI that fails an HTTP request
tends to quote the request, and 5 of 11 realistic shapes survived: an
`X-Api-Key:` header, a `{"api_key": "..."}` field, credentials in a URL's
userinfo half, and a bare provider key.

The gate is now the keyword. A separator is a bad one: `=` and `:` both
appear in ordinary readings, so the old check ran three backtracking
patterns over almost every string it saw. Measured over a realistic corpus
of 165 strings the two cost the same, and the new one runs nothing at all
for a plan name.

tests/scrub_test.lua reads the function out of service.luau instead of
copying it, so it cannot pass against a version that no longer exists. It
covers the eleven secrets, twelve readings that must survive untouched,
and the length cap.
The readings now line up. Every percentage is right-aligned in a fixed
column, so a stack of cards reads as one ruler and the bar capsule keeps
its width from 9% to 100% instead of nudging its neighbours on every read.

Capsule: a read in flight dims the row. It used to append a spinner, which
shoved every widget to its right once per cycle. A failure draws the plugin
glyph in the error colour; the old pair of glyphs read as two problems.

Panel:
- Selection is a tint. A filled `primary` row had to invert every colour
  inside it and shouted over the reading it was meant to mark.
- Severity ships a word next to the colour, so the tier is readable without
  separating two accents.
- The time story is one line: what is left of the window, when it lands,
  how much is gone, and whether the spend is running ahead.
- `ui.button` for the header refresh and the error actions, replacing rows
  hand-built to look like buttons. The refresh button becomes the spinner
  in place.
- Skeletons while the first read lands, and an empty state that names what
  is missing.
- Dropped the provider id and a "ready" status from the detail pane. The id
  is the row that was just clicked and a healthy read is the default.

Two layout bugs came out of testing it against the running shell. The root
row had no flexGrow, so neither pane was given a bounded height and their
ui.scroll children asked for their natural one, which clipped the cards. A
bare ui.column also takes a column's free space for itself, which parked
the detail title above a hundred pixels of nothing; the wrapping row that
prevents it is back, with a comment saying why it is there.

textRole and barRole differed only in their resting colour and existed in
both entries. They are one severityRole(x, calm). The two skeleton shapes
are one. 25 lines lighter.
The card was run through the official generator in 5d8b559, but the image
it was given was already a composed card with its own title and subtitle.
The generator nested that inside its frame, so the name and the description
appeared twice and the inner copy was too small to read.

Same frame and the same title, tag and accent it was given there. The
payload is now a plain screenshot of the panel, cropped to the geometry the
compositor reports for the panel layer.
Two entries kept their own copy of the ISO parsing, the duration and clock
formatting, the provider glyphs, the severity tiers and the clamp, because
require() needs plugin_api 22 and the manifest asked for 9. It asks for 22
now. That is the cost of this commit: the plugin stops installing on a shell
older than the one that shipped API 22.

shared.luau holds the copies that were identical. resetClock was not: the
capsule's version named only a weekday, so a reset three weeks out read as
"Sat 02:00" and named no particular Saturday. Both entries use the panel's
version, which falls back to a date once a weekday stops being enough, so
that is a fix to the capsule tooltip as well as a merge.

severityRole stays wrapped in bar.luau, where color_by_usage can still turn
the whole thing off, and delegates the thresholds.

The busy hold is gone with it: MIN_BUSY_MS, the pending-clear bookkeeping
and the 120 ms tick existed to keep `polling` true for 600 ms so a spinner
could be seen when the CLI answers from its cache in about ten. The capsule
dims now and the panel button swaps glyph in place, and a cold read takes
long enough to show either without help.

1055 lines to 1178 across four files, but 123 of those are the new module
and its header; the two entries lost 176 lines between them.
openSettings() needs plugin API 15, so the panel could not offer it while the
manifest asked for 9. The move to 22 makes it available, and the panel is
where someone is already looking at one provider and deciding the capsule
should follow another. The capsule still answers a middle click the same way.

The version is 1.3.0 rather than another 1.2.x because asking for API 22 is a
compatibility break: on a shell older than that the plugin no longer installs.
Requirements says so, since that is the page people read before installing.

Dropped the note about reloading the plugin to pick up an edited translation.
README.md is the plugin's page on noctalia.dev, written for someone installing
it; which files the shell's watcher follows is only of interest to whoever is
editing the plugin, and the note prescribed a full disable/enable when
touching any .luau entry is enough.
Since the panel rework the poller has been losing every read: the async
callback overran its CPU budget partway through scrubbing the report, so
`state.set("report", ...)` never ran and the capsule sat on nothing. The shell
named the line each time, always inside safeText.

Three things made it expensive, and the report itself is not big — 165 strings
for a two-vendor read.

The four keyword pattern pairs were concatenated on every call, so they were
rebuilt 165 times per report. They are constants; they are now built once, at
load.

The gate was one test for all four keywords, so a string carrying "key" — which
is most of what an AI usage CLI writes about, along with "tokens" — ran all
eight substitutions instead of the two belonging to its own keyword. Each
keyword now opens only its own pair.

The 200-character cap ran after the redaction rather than before it, which left
the patterns scanning a runaway line in full. Capping first bounds their work by
what the plugin was going to draw anyway; a secret past the cut is not
truncated into view, it is gone with the rest of the line.

Measured against a real `usage --json`: 0.676 ms down to 0.393 ms for the whole
report, and 6.44 ms down to 0.37 ms for a 4.4 KB line. No budget overruns in
eleven cycles on the running shell, against one on nearly every cycle before.
The test covered what the scrubber redacts and what it leaves alone, which is
why the rewrite that just landed could be checked at all. It did not cover what
the scrubber costs, which is the half that broke: the output was correct on
every string right up to the point the shell killed the callback for overrunning
its budget, and a correct answer nobody receives is not one.

So the test now scrubs a report shaped like a real `usage --json` — four
vendors, six metrics each, and the credential error the CLI writes for a
provider it has no key for, which is the string that opens the redaction
patterns on an otherwise healthy run — and asserts what that costs.

The meter is `string.gsub`, wrapped for the length of the call. Counting VM
instructions the way keymap's budget tests do reads nothing useful here: the
work happens inside the C matcher, where the count hook is blind, and the old
scrubber and the new one came out one block apart. What separates them is how
much text the patterns are handed: 37352 bytes for this report before, 17664
now. The ceiling sits between the two, near enough that either half of the
regression trips it on its own.

The slice the test loads was widened to take `scrub` along with `safeText`, so
the recursion over the report is measured rather than assumed, and README gained
the section that says how to run it, as keymap and udiskie do.
…ents

Three passes over the plugin.

The CLI tiers severity as low, mid, high and critical. The capsule's rank table
answered "medium", which nothing ever sends, so a mid provider sorted level with
a low one and "auto" could put the calmer plan on the bar. The table now keys on
what the CLI actually writes, and drops the two rows that were already the
default.

`elapsedPercent` was parsed the same way in both entries. It belongs with the
other shared readings, and the capsule's copy of `parseIso` was left over from
before the split. The panel's pace lookup had a branch that returned exactly
what the branch under it returns.

The rest is prose. The comments had grown into an argument for each decision
rather than a note about it, and the argument is what a reader has to skip to
reach the fact. What survives is what the code cannot say for itself: why the
patterns are built once, why the cap runs before them, why the title block is
wrapped in a row, why a row is keyed, why status 127 has to agree with its
message. The rest went, along with the em dashes; the ones left are the "no
reading" placeholder the panel and the capsule both draw.

No behaviour changed beyond the severity rank. Verified against the running
shell: eleven cycles, no errors, no budget overruns.
The card now shows the panel the way this release draws it: both providers in
the list, the two quota bars, and the severity word beside the weekly reading.
The previous one was assembled by hand, which the contribution checklist asks
against, and it was cropped loose enough that the percentages did not survive
being scaled into a catalog card.
@FelipeArtur
FelipeArtur marked this pull request as ready for review August 21, 2026 14:50
Copilot AI lite review requested due to automatic review settings August 21, 2026 14:50

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR updates the felipeartur/ai-usagebar plugin from 1.1.0 to 1.3.0, reworking both the bar capsule and panel UI while consolidating shared parsing/formatting logic into a single module (requiring Noctalia plugin API 22).

Changes:

  • Reworked capsule layout to avoid width-jitter during updates (fixed-width % column and dimming while polling).
  • Reworked panel into a master/detail layout with new actions (refresh + settings), skeleton loading, and improved severity presentation.
  • Centralized shared helpers in shared.luau, strengthened inbound text scrubbing/redaction, and added an offline redaction/perf test.

Reviewed changes

Copilot reviewed 8 out of 9 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
ai-usagebar/translations/en.json Adds new UI strings for install/refresh/settings and severity labels.
ai-usagebar/tests/scrub_test.lua Adds offline tests validating redaction correctness and callback-cost bounds.
ai-usagebar/shared.luau New shared helper module (ISO parsing, duration formatting, severity mapping, clamping).
ai-usagebar/service.luau Updates poller scrubbing/redaction logic and refresh/polling behavior.
ai-usagebar/README.md Updates documentation for API requirement, new panel behavior, and test instructions.
ai-usagebar/plugin.toml Bumps plugin version/API and keeps manifest aligned with the rework.
ai-usagebar/panel.luau Master/detail panel redesign, new actions (refresh/settings), skeletons, and shared helpers.
ai-usagebar/bar.luau Capsule rework (stable sizing, shared helpers, improved polling rendering).
Suppressed comments (1)

ai-usagebar/panel.luau:524

  • os.time() has 1-second resolution; if noctalia.state.watch("command", ...) coalesces repeated values, reopening the panel quickly (or opening + immediately hitting refresh) could fail to trigger a refresh. Prefer noctalia.nowMs() for a unique refresh nonce.
function onOpen(_context)
    -- Every open asks for fresh numbers. The CLI answers from its own cache when it
    -- has one, and the poller drops requests that arrive too close together.
    noctalia.state.set("command", { action = "refresh", at = os.time() })
    report = noctalia.state.get("report")

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread ai-usagebar/panel.luau Outdated
Comment on lines 276 to 278
local function requestRefresh()
noctalia.state.set("command", { action = "refresh", at = os.time() })
end

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Applied in ce38eaa and 31c0a9c.

I could not confirm the premise as stated. NoctaliaState.watch is declared as
watch: (key: string, callback: (value: any) -> ()) -> (), and the type
definitions say nothing about what the store does with a value equal to the one
already sitting there. Whether repeated payloads coalesce is not something the
contract answers. I made the change anyway, for a reason that holds either way.

The at field is never read. The poller's watcher looks at action and nothing
else:

noctalia.state.watch("command", function(value)
    if type(value) == "table" and value.action == "refresh" then refresh() end
end)

So at is not a timestamp anyone consumes. Its only job is to make this request
differ from the one before it, and a whole-second clock is a weak way to promise
that regardless of what the store does today. noctalia.nowMs() is documented as
the only sub-second clock the API offers, and the poller already measures
MIN_GAP_MS with it, so the plugin was reading two clocks to answer one
question.

There is a third site: onRightClick in bar.luau carried the same literal
payload. It is not in this diff, so it would have kept the coarse stamp.

One consequence worth naming, since it runs the other way from how this reads at
first. The rate limit is meant to live in the poller, where MIN_GAP_MS is
written down:

local MIN_GAP_MS = 2000
...
if now - lastStart < MIN_GAP_MS then return end

With a coarse stamp, a rapid second request could be dropped before it ever
reached that check, which makes the transport a second limiter that nobody
declared and nobody can find when they go looking for why a click did nothing.
With a distinct stamp, every click reaches refresh() and the declared limit is
the thing that decides. The behaviour a user sees is much the same; where it is
decided is not.

The payload now lives in shared.requestRefresh(), called from all three sites,
with the note about at written down where a reader will find it rather than
inferred from a literal three times over.

`at` is in that payload for one reason: to make each request distinct from the
one before it, so a watcher has something to tell them apart by. `os.time()` is
whole-second, which is a weak way to promise that, and the state store's contract
says nothing either way about what it does with a repeated value.

`noctalia.nowMs()` is the only sub-second clock the API offers, and it is already
what the poller measures MIN_GAP_MS with. Two clocks for one question was the
oversight. The rate limit belongs in the poller, where it is written down and can
be read; a coarse stamp in the transport is a second limit nobody declared.

Reported by Copilot on noctalia-dev#427, which reached the same line by a different route.
The payload was written out three times, in two files, in the release whose
point was to stop both entries from carrying their own copy of things. The
millisecond fix had to be made in all three, which is how the duplication
announced itself.

`shared.requestRefresh()` now owns it, and with it the note that `at` is never
read: the poller looks at `action` and nothing else, so the field is there to
keep two requests in a row from being the same value. Written down once, in the
place a reader will find it, rather than inferred three times from a literal.
Reported as a broken button. It was not broken: a probe on the callback and on
the poller's chain caught 39 requests from a burst of right clicks, every one of
them reaching the watcher. MIN_GAP_MS honoured five and dropped thirty-five, in
silence, and the five that ran came back from the CLI's cache fast enough that
the capsule's dim was over before it could be seen. On the `meter` style, which
draws ticks rather than digits, a fresh reading of the same number looks like
nothing happened at all. So the gesture worked and had no way to say so.

The half of that worth fixing is not the feedback. It is that the gesture was
invisible: `onRightClick` does not appear in the widget's settings, so there was
nothing to discover it by and no way to point it elsewhere. Every other plugin
in this repo that answers a gesture declares it, and the API notes say why, that
a declared action is listed where a Luau callback is not. This one now declares
it too, and the callback is gone rather than left to shadow it.

Left stays in the script. It sets `selected` before opening the panel so the
panel lands on the provider that capsule tracks, which `panel-toggle` on its own
cannot do, and the manifest says so next to the binding.

README claimed the click "refreshes immediately", which stops being true the
second time you press it. It now says a read is asked for, that one process
serves every capsule, and that the poller will not start another within two
seconds. Also that right is a binding, so it can be reassigned or turned off.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants