feat(ai-usagebar): rework the capsule and panel, share the helpers - #427
feat(ai-usagebar): rework the capsule and panel, share the helpers#427FelipeArtur wants to merge 12 commits into
Conversation
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.
There was a problem hiding this comment.
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; ifnoctalia.state.watch("command", ...)coalesces repeated values, reopening the panel quickly (or opening + immediately hitting refresh) could fail to trigger a refresh. Prefernoctalia.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.
| local function requestRefresh() | ||
| noctalia.state.set("command", { action = "refresh", at = os.time() }) | ||
| end |
There was a problem hiding this comment.
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 endWith 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.
Plugin
felipeartur/ai-usagebarplugin.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-usagebarCLI, which owns the credentials and thevendor endpoints; the plugin runs
ai-usagebar usage --jsonand draws theanswer.
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_nameandcolor_by_usageall 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 thathave to agree on screen. That is what moves
plugin_apifrom 9 to 22, sincerequire()arrived at 22; the panel's new gear button needsopenSettings(),which arrived at 15.
versionis1.3.0rather than a patch because asking fora 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-2glyph that shoves every widget to its rightonce 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 becameui.separator, and the install link no longer uses a raw URL as its caption. Agear 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,
Bearertokens, URL userinfo, and thesk-keyshape. 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-usagebaris 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 Mmonitors still cost one process per cycle. The plugin never talks to a
provider, holds a token, or reads a credential file.
xdg-openis optional. One button in the panel uses it, the link to the CLI'sproject page offered when
ai-usagebaris not onPATH. 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-usagebaron Hyprland, with four providers configured, two ofthem without credentials so the error paths were exercised.
The
pollerservice on its interval and on demand, includingnoctalia msg plugin felipeartur/ai-usagebar:poller all refresh, with thepanel 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 lintreports no errors or warnings, and the repository's ownvalidate-plugins.pyvalidates the manifest.The redaction has an offline test,
lua tests/scrub_test.lua, run from theplugin directory. It reads
safeTextandscrubout ofservice.luauratherthan 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
Checklist
idafter the/inplugin.tomlexactly.plugin.toml,README.md,thumbnail.webp, andtranslations/en.json.README.mdfollows theREADME template, documents
every entry id and dependency, and includes exact panel IPC commands and launcher prefixes where applicable.
thumbnail.webpwith the thumbnail generator.versionfollows semver and is bumped in this PR;plugin_apiis the oldest API level this plugin requires.understand that language well enough to review and maintain it (no unreviewed machine/LLM translations).
catalog.toml; CI generates it.Code review attestation
licensedeclared inplugin.toml.