feat(ai-usagebar): rework the capsule and panel, share the helpers - #461
Draft
FelipeArtur wants to merge 32 commits into
Draft
feat(ai-usagebar): rework the capsule and panel, share the helpers#461FelipeArtur wants to merge 32 commits into
FelipeArtur wants to merge 32 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.
`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.
Contributor
ItsLemmy
marked this pull request as draft
August 24, 2026 23:41
The colon pattern matched any label containing key/token/secret/password, so 'Tokens: 45000 / 100000' rendered as 'Tokens: <redacted> / 100000'. The value is now captured, and a plain number keeps it.
The polling watchers run with the panel closed, so the poller's automatic cycle re-armed per-frame rendering every refresh_minutes for the rest of the session. Also orders the refresh tooltip like its glyph.
Every unreachable provider comes back under the same `credentials error:` prefix, including ones that are configured -- Antigravity reports "no local server found ... open Antigravity" that way -- so filtering on the prefix alone dropped the row that had something to say.
`tertiary` carried high severity, staleness and a pace running ahead. In the stock dark theme it measures 2.8:1 on a card -- below the 3.9:1 of `on_surface_variant`, the muted text it is supposed to outrank, and below the 3:1 an icon needs. The flagged reading was the least legible thing in the card. Those three states and the brand glyphs that landed on the same role now use `secondary`, which measures 8.5:1.
The CLI keeps them apart -- display_name is "Codex", plan is "ChatGPT Free" -- and the pane had them the wrong way round, so the header read "ChatGPT Free" over "Codex" while the row in the list read the other way.
Each provider carried a theme role picked for variety, so the OpenAI mark came out pink and the Claude one cyan for no reason a reader could act on. The glyph is identity; colour now belongs to severity and to the selected row alone, and the table collapses to id -> glyph.
The exemption that lets "Tokens: 45000" through was offered to every keyword, so "password: 1234" survived redaction too. It is now limited to `token` -- the only keyword that also names a metric -- and to nine characters, since a long run of digits is a credential whatever labels it.
The old one showed the detail pane naming the plan over the provider, the bug e258ea9 fixed, and the provider glyphs still tinted.
Twenty-three lines of `refreshPhase`, `onFrameTick` and an `open` flag drove one button's opacity at 20fps. The glyph already swaps to `loader-2` and the button already disables, and DESIGN.md's motion budget was "None" all along. This also removes b858348 by deletion: the closed panel cannot re-arm frame callbacks it no longer asks for.
metricIcon picked a decorative glyph by substring-matching English prose
("week", "credit", "balance") while the label it guessed from sat in text
right beside it. It would have died silently the day the CLI localised one.
The skeleton cards stood in for a read the CLI answers from its own cache in
about ten milliseconds. With them gone the pane is briefly empty instead,
which is what it actually is. Unwrapping the row that held blockCard's icon
took the last of it.
The style setting drew the same number four ways: pill, gauge, meter and label. pill stays and the branch, bars(), the manifest block, the six translation keys and the README table go with the other three. Major, not minor: the setting shipped in 1.1.0, so anyone who picked gauge or meter loses their choice and the saved value stops meaning anything. Also local-ises formatDuration and PROVIDER_GLYPHS, which nothing outside shared.luau read; drops the header chip that printed the raw word "error" next to the CLI's own error text; inlines separate() at its one remaining call site; and fixes the README, which still named tertiary as the high severity colour.
The style setting was four names for three independent choices. The core sysmon widget already solved this with orthogonal keys, so the capsule now borrows them verbatim: visualization (gauge, meter or none), show_value, show_glyph and glyph_position. A user who has configured the CPU reading beside this one already knows the vocabulary. gauge is the default and draws the quota bar over a thinner bar for how much of the window has gone, so a fill longer than the clock is spend running ahead. graph is left out: sysmon samples every few seconds and this reads every refresh_minutes, so the line would be flat between points.
Five segments could not tell 85% from 97%: percent > i * 20 fills all of them anywhere above 80. Beside the digits and the countdown they read as a barcode rather than a reading, so gauge and none are the whole set. The default is none, which is what the capsule looked like before this PR, so an upgrade changes nothing until someone picks the gauge. The capsule also stopped dropping to 55% opacity on every read. That was a blink every refresh_minutes with no transition behind it, and the plugin API gives bar widgets no frame tick to smooth it with. Settings copy rewritten while the keys were open. color_by_usage promised 'Primary, then amber, then red', which named a role at the user and was wrong twice over after the severity colours moved to secondary and error.
A capsule carrying two providers ran them together on a six-pixel gap, so the first one's countdown read as part of the second. A vertical rule now sits between them, and only between them. The provider mark also stopped taking the severity colour. It says which provider, never how full the plan is; the reading and the gauge already say that. Same split the panel settled on in 240bec2. The gauge option was labelled 'Quota bar over the window clock', which is a sentence in a dropdown. It is 'Quota bar'.
The assign pattern wanted the = flush against the name, so API_KEY = "..." survived. That spacing is not exotic: it is how the CLI's own TOML config spells api_key, and the CLI quotes that file back when it cannot read a credential.
Three things the capsule and the panel both did, each in its own copy: pace() existed twice with different grammars. The capsule matched (%d+)pts%s+(%a+) anywhere in the string; the panel split on the separator and looked at the tail. One shared.pace(detail) returns the tail, the points and the direction, and the two drawings stay where they belong. SEVERITY_RANK sat in bar.luau while severityRole sat in shared.luau. That split is what let 'medium' go on not matching a tier the CLI never emitted. entries() was written out twice, identical but for the report it closed over. paceNodes, countdownNode and textRow were one-caller functions and are now inlined at their single call site.
Three passages described something that no longer exists. The capsule paragraph put the reading behind the provider's mark and had that mark taking the severity colour, which stopped being true when the mark went neutral. The provider list was said to hide anything without a credential, when it now hides only what the CLI calls a missing API key, so a configured but unreachable provider keeps its row. And the refresh test was described as checking 'visual metadata', which was the name of a table that is now a glyph lookup. Also names the actor on the xdg-open sentence and hyphenates two-pane.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.

Plugin
felipeartur/ai-usagebarplugin.toml)What it does
AI Usage draws the quota that
ai-usagebar usage --jsonreports, in the bar and in a panel. The CLI owns the credentials, the vendor endpoints and the caching; this plugin runs it and renders what comes back.This update takes the plugin from
1.1.0to1.4.0. It reworks the capsule and the panel, moves the parsing both entries need into one file, and makes the refresh path answer every request instead of dropping the ones that arrive too close together.Refresh
[widget.actions]binding, so the settings editor lists it and a user can point it somewhere else.What it draws
shared.luauholds the ISO parsing, the severity tiers and the provider glyphs, so the capsule and the panel cannot disagree on screen. This is what needsplugin_api 22.highseverity, staleness and a pace running ahead usesecondary. They usedtertiary, which the stock dark theme renders at 2.8:1 on a card, below the 3.9:1 of the muted text it is supposed to outrank.Redaction
Every string the CLI prints is cleaned on the way in and capped at 200 characters. This PR widens what counts as a secret to the
name: valueshape, so a header or a JSON field loses its value, and adds URL userinfo and baresk-keys.The one exemption is numeric:
Tokens: 45000is a reading the plugin exists to draw. Onlytokengets it, because that is the one keyword that also names a metric, and only up to nine characters.password: 1234and any long run of digits are still redacted.tests/scrub_test.luacovers 15 secrets and 16 readings, and asserts the cost, because the whole report is scrubbed inside one async callback and a callback that overruns its CPU budget loses the read.External dependencies
ai-usagebaronPATH. The plugin spawnsai-usagebar usage --jsonand nothing else.xdg-openis optional and no longer declared. It is spawned only by the install-link button, only in the not-installed error state, and only whennoctalia.commandExists("xdg-open")says it is there.Testing
lua tests/scrub_test.luareports 15 secrets redacted, 16 readings untouched, the length capped, and 476gsubcalls over 17664 bytes per report.lua tests/refresh_test.luareports the refresh queue coalesced and the provider glyphs complete.noctalia plugins lint ai-usagebarreports 0 errors and 0 warnings.python3 .github/workflows/scripts/validate-plugins.pyvalidates 109 manifests and exits 0.Ran the panel against a live
ai-usagebar 1.4.0with five vendors: two ready, three in credential error, one of those configured but unreachable.Checked every glyph name against the Tabler set Noctalia ships (
/usr/share/noctalia/assets/fonts/tabler.json), so none falls back.Measured the theme roles off a panel screenshot to pick the severity colour, rather than guessing which one reads as brighter.
Pace and elapsed are still parsed out of the CLI's detail text. The CLI gives no structured field for either.
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
Ready-for-review requirement: Every box in this section must be checked. If any statement is not true, keep the
pull request as Draft. An explanation does not replace a required check.
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
Plugins run as trusted, unsandboxed Luau in the user's session. Confirm:
Ready-for-review requirement: Every attestation below must be checked.
licensedeclared inplugin.toml.