diff --git a/ai-usagebar/README.md b/ai-usagebar/README.md index 816126b4a..d70a62f17 100644 --- a/ai-usagebar/README.md +++ b/ai-usagebar/README.md @@ -1,14 +1,12 @@ # AI Usage -Your AI plan quota in the Noctalia bar: how much of the window is spent, when it -resets, and whether you are burning it faster than the clock. +Track AI plan usage, quota resets, and consumption pace in the Noctalia bar. The numbers come from [ai-usagebar](https://github.com/akitaonrails/ai-usagebar), a Rust CLI that reads Claude, Codex, Cursor, Antigravity, Kiro, Z.AI, Nous Research, OpenCode Go, Command Code, OpenRouter, DeepSeek, Kimi and Grok, among -others. This plugin never talks to a -provider, holds a token, or reads a credential file. It runs -`ai-usagebar usage --json` and draws the answer. +others. The plugin runs `ai-usagebar usage --json` and displays the report. +It does not call provider APIs or read credential files. ## Plugin @@ -19,53 +17,47 @@ provider, holds a token, or reads a credential file. It runs ## Requirements -Install `ai-usagebar` on `PATH`. The plugin runs it by name, so there is no path -setting to fill in. It ships as `ai-usagebar-bin` on the AUR, and as release +Install `ai-usagebar` on `PATH`. The plugin runs it by name and has no separate +path setting. It ships as `ai-usagebar-bin` on the AUR, and as release tarballs on the project's GitHub Releases page. Configure your providers once in -`~/.config/ai-usagebar/config.toml`; the CLI owns the credentials and the -endpoints, and this plugin never sees them. +`~/.config/ai-usagebar/config.toml`; the CLI manages credentials and provider +connections. -`xdg-open` is optional. The panel spawns it for one button, the link to the -CLI's project page it offers when `ai-usagebar` is not on `PATH`. Without -xdg-utils the panel leaves that button out and nothing else changes. +When the CLI is missing, the panel displays its project address for installation +instructions. No browser-opening command or additional dependency is needed. -The plugin asks for **plugin API 22**, which is where Noctalia gained -`require()`. On a shell older than that it will not install. Version 1.1.0 asked -for API 9 and still runs there. +The plugin requires plugin API 22 for `require()`. It will not install on an +older shell. Plugin version 1.1.0 remains available for API 9. ## Usage Add `felipeartur/ai-usagebar:bar` to a bar in Settings, Bar. The capsule shows -one provider's headline percentage next to that provider's mark. The reading -sits in the bar's own colour while there is room, picks up the theme's -`secondary` when the CLI calls the window high, and `error` when it calls it -critical. The mark itself never changes colour: it says which provider, not how -full the plan is. - -Left on `Automatic`, the capsule follows the busiest provider, so what sits in -the bar is the plan closest to running out. Raise `provider_limit` and it -carries the next busiest ones too, with a `+N` for whatever did not fit. Pin a -provider instead, or add the widget twice, when you want two fixed plans side by -side. +one provider's headline percentage beside its icon. Readings use the bar's text +color, the theme's `secondary` color for high usage, and `error` for critical +usage. Icons keep their normal color unless a read fails. + +`Automatic` selects by headline severity, then usage. Raise `provider_limit` to +show more providers; limits above one also show `+N` for providers that do not +fit. Pin a provider or add another widget instance to keep particular plans +visible. Named accounts use the label from the CLI config. Pick the provider and put the label in `account`: `vendor = "openai"` with `account = "work"` follows the `openai@work` report entry. Leave it empty for the provider's default account. -The capsule is put together the way the core `sysmon` widget is, with the same -key names, so the CPU reading beside it is configured with the same vocabulary. -`visualization` draws a `gauge`, a quota bar over a thinner bar for how much of -the window has gone, so a longer fill than clock is spend running ahead, or -`none`. `show_value`, `show_glyph` and `glyph_position` decide whether the -percentage and the icon are there and which side the icon sits on. +Settings use the same names as the core `sysmon` widget. Set `visualization` to +`gauge` for a usage bar above a thinner elapsed-time bar, or `none` to hide it. +Usage ahead of elapsed time means consumption is ahead of pace. `show_value`, +`show_glyph` and `glyph_position` control the percentage and icon placement, +including Antigravity's individual model readings. -`extras` puts the time left in the window (`3h 51m`), the pace against the -clock (`↑3` is three points ahead of where the window says you should be, `↓3` -is three under), both, or neither. +`extras` adds the reset countdown (`3h 51m`), consumption pace (`↑3` means three +percentage points ahead of elapsed time; `↓3` means three behind), both, or +neither. Countdowns show days and hours from 24 hours onward, and hours and +minutes below that. -If you add the widget by hand in `config.toml`, give it a name. A bar list entry -that is a raw widget id becomes an anonymous instance, and an anonymous instance -has no settings of its own, so the gear opens empty: +When editing `config.toml` by hand, create a named instance. A raw widget id in +the bar list creates an anonymous instance with no settings of its own: ```toml [widget.ai_usage] @@ -77,59 +69,55 @@ provider_limit = 2 start = [ "clock", "ai_usage" ] ``` -- **Hover** lists every window that provider reports: value, time left, and the - clock time the reset lands on. -- **Left click** opens the `AI Usage` panel for the provider that capsule +- Hover shows session and long-window values with a reset countdown per model. +- When a long window is exhausted, the capsule replaces the short-window value + with `100%` and counts down to the blocking window's reset. If several windows + are exhausted, it uses the latest reset. A critical warning below 100% does + not count as exhaustion. +- Left click opens the `AI Usage` panel for the provider that capsule tracks. -- **Right click** asks the poller for a read. One process serves every capsule, - and it coalesces repeated clicks into at most one pending read, so holding the - button down does not spawn a queue of processes. -- **Middle click** opens the widget's settings, as everywhere else in the shell. - -Left and middle are the script's; right is a gesture binding, so it is listed in -the widget's settings and can be pointed at any other action, or at `none`. - -The panel is a two-pane view. On the left is every provider you have set up, -with its headline percentage. On the right is the selected one in detail: one -card per reported metric, with a quota bar over a thinner "window elapsed" bar, -so a fill that outruns the clock bar means quota is burning ahead of pace. -Credit balances and free text rows the CLI reports get rendered as well. -Opening the panel asks the CLI for fresh numbers, and the detail pane says how -old the reading is. The refresh button in the header asks again; it turns into -a spinner while the CLI is answering. The gear beside it opens this plugin's -settings. There is no close button: the panel closes when you click away from -it or press the same widget again. - -The list follows the CLI. A provider the CLI reports no API key for never -appears, because it was never set up. One that is set up and unreachable keeps -its row, marked unavailable, and shows the CLI's own words, so Antigravity with -its local server down says to open Antigravity rather than vanishing. - -A provider's readings share one card, so its session and its week are read -together. Antigravity's plan spans two models and reports each of them once per -window, so its cards are keyed by model, titled with it, and each reading is -headed by the window it covers. Every other provider reports one subject, which -the pane's own header already names, so its readings head themselves and the -card goes untitled. - -The detail pane spells out what the CLI reports for that provider instead of -implying it: the plan and account name, when it was fetched, and a stale flag -when the reading is old. Each window gets its label, the percentage, the raw -value string when that says more than the percentage, how much of the window has -elapsed, the time left with the clock time (or date) its reset lands on, the -pace line, and the severity as a word whenever the CLI calls the window high or -critical. Credit blocks and free text rows appear as the CLI writes them. - -A provider that is down draws no gauges. A bar reads as a live reading, and -nothing is reading it: the warning takes their place, and the numbers it was -last seen with follow as dated text. If the plan changed since, they are dropped -instead -- a plan carries the limits every percentage is measured against, so a -reading taken under the old one says nothing about the new one. - -The plan a provider was last seen on is remembered only for as long as the panel -process lives, and only for providers the current report still carries. After a -shell reload there is no previous plan to compare against, so the first reading -that follows is shown whatever the plan says. +- Right click requests a refresh. One poller serves every capsule and keeps at + most one pending refresh when requests arrive during a read. +- Middle click opens the widget's settings, as everywhere else in the shell. + +The script handles left and middle clicks. Right click is a gesture binding in +the widget settings, where you can assign another action or choose `none`. + +The panel lists providers on the left and shows the selected provider's limits +on the right. Session and weekly limits share a card; Antigravity has a separate +card for each model. Each window shows usage above a thinner elapsed-time bar. +A longer usage bar means consumption is ahead of the window's pace. +The shared `Claude & GPT OSS` quota keeps the name supplied by the CLI. + +Exhausted quotas get a compact notice with the model name and reset countdown. +The notice uses text and theme colors. The panel displays quota readings, not +agent process health. + +Opening the panel requests fresh data. The header shows when the last reading +arrived, a refresh button, and plugin settings. Click outside or click the widget +again to close the panel. + +The list contains only providers with a usable reading. A provider the CLI +reports no API key for never appears, because it was never set up. A configured +provider with its own refresh failure also leaves the bar and panel, then +returns automatically after a healthy read. The panel sorts providers by headline +severity, then usage; equal readings keep the CLI's order. Automatic bar selection +uses the same priorities, then the configured primary provider and account id. + +Parser errors are an exception: the provider remains visible with missing usage +and the CLI error, because a response-format failure does not establish that the +account is unavailable. + +Details include the plan and account name, reading age, stale status, window +labels, percentages, elapsed time, reset countdowns and local reset times, pace, +and high or critical severity labels. Raw values appear when they add information +to the percentage. Credit blocks and free text are also shown; zero credit +balances are hidden. Codex reset credits show the reset type and expiry on +separate lines, with wrapping for longer descriptions. + +A whole-report failure is different: it cannot identify one broken provider, +so it keeps the last list and marks those readings as old instead of blanking +the panel during a network interruption. To open the panel from a terminal: @@ -156,9 +144,9 @@ Per widget instance, so two capsules can follow two providers: | `show_glyph` | `bool` | `true` | Show the provider's icon. | | `glyph_position` | `select` | `before` | `before` or `after` the reading. | | `provider_limit` | `int` | `1` | How many providers one capsule carries, busiest first, from 1 to 4. Only applies on `auto`. | -| `extras` | `select` | `countdown` | What rides beside the percentage: `countdown`, `pace`, `both` or `none`. | -| `show_name` | `bool` | `false` | Adds the product name, so two capsules do not look alike. | -| `color_by_usage` | `bool` | `true` | Off drops the high and critical tint, so the capsule never changes colour. | +| `extras` | `select` | `countdown` | Information beside the percentage: `countdown`, `pace`, `both` or `none`. | +| `show_name` | `bool` | `false` | Show the provider name beside the reading. | +| `color_by_usage` | `bool` | `true` | Color readings by quota severity. Turning it off does not hide error indicators. | ## IPC @@ -183,23 +171,20 @@ noctalia msg plugin felipeartur/ai-usagebar:poller all select anthropic process. - The plugin makes no network calls and writes no files of its own. Everything it knows arrives on that command's stdout. -- A provider that fails still comes back as an entry with `status = "error"`, so - one broken provider does not blank the others. A reading the CLI marks stale - keeps showing, flagged by an icon in the list and the panel's detail pane. -- A provider whose service is down leaves the bar on the first report that says - so, rather than sitting in the capsule with a number nothing is refreshing. It - is not counted behind the `+n`: that count is what the panel has more of, and - this one has nothing to show. It comes back the moment a report carries a - reading for it again. -- A failed read does not erase the last one. The failure is said once, as a - banner in the panel and a flag on the capsule, over readings that are simply - older than they should be; the panel only gives itself over to the failure - when there is no report behind it at all. +- A provider whose service is down leaves the bar and panel on the first report + that says so. It is not counted behind the `+n`, and returns when a report + carries a healthy reading for it again. +- A failed whole-report read keeps the previous report and marks it as stale. + Without a previous report, the panel shows the failure and its scrubbed + diagnostic. Raw HTTP details stay out of the reading cards. +- Report text is scrubbed for common credential patterns before publication to + plugin state. This is a precaution, not a guarantee that arbitrary CLI output + contains no sensitive data. Review account labels and diagnostics before + sharing screenshots. ## Tests -Everything the CLI prints is redacted on its way to the screen, and that is the -part worth a test. From the `ai-usagebar` directory: +Run the tests from the `ai-usagebar` directory: ```sh lua tests/scrub_test.lua diff --git a/ai-usagebar/bar.luau b/ai-usagebar/bar.luau index aaa4583fb..ec4bf3475 100644 --- a/ai-usagebar/bar.luau +++ b/ai-usagebar/bar.luau @@ -20,8 +20,9 @@ local report = nil local shared = require("./shared.luau") local providerGlyph, baseProvider = shared.providerGlyph, shared.baseProvider -local countdown, resetClock = shared.countdown, shared.resetClock +local fixedCountdown = shared.fixedCountdown local ratio, headline, elapsedPercent = shared.ratio, shared.headline, shared.elapsedPercent +local modelHeadlines, isCritical = shared.modelHeadlines, shared.isCritical local pace, SEVERITY_RANK = shared.pace, shared.SEVERITY_RANK local NO_FAILURE, asFailure = shared.NO_FAILURE, shared.asFailure local unavailable, warning = shared.unavailable, shared.warning @@ -54,7 +55,7 @@ local function shown() -- A pinned provider that stopped reporting leaves the capsule empty -- rather than holding a number nothing is refreshing. if entry.id == wanted then - if unavailable(entry) then return {}, 0 end + if unavailable(entry) and not shared.readFailure(entry) then return {}, 0 end return { entry }, 0 end end @@ -66,7 +67,7 @@ local function shown() -- A provider whose service is down drops out on the first report that -- says so, rather than sitting there with a frozen reading. Not counted -- as hidden either: hidden means the panel has more to show. - if entry.status ~= "error" and not unavailable(entry) then + if (entry.status ~= "error" and not unavailable(entry)) or shared.readFailure(entry) then ready[#ready + 1] = entry end end @@ -130,17 +131,25 @@ end -- running ahead of time. local function bars(percent, elapsed, tint, width) local stack = { - ui.progress({ progress = ratio(percent), fill = tint, track = "on_surface/0.16", - radius = 3, width = width, height = 4 }), + ui.progress({ progress = ratio(percent), fill = tint, track = "on_surface/0.12", + radius = 3, width = width, height = 5 }), } if elapsed ~= nil then stack[#stack + 1] = ui.progress({ progress = ratio(elapsed), fill = "on_surface/0.45", - track = "on_surface/0.10", radius = 1, width = width, height = 2 }) + track = "on_surface/0.10", radius = 2, width = width, height = 3 }) end return ui.column({ gap = 1, align = "center" }, stack) end local function chip(entry, flagged) + if shared.readFailure(entry) then + local nodes = { ui.label({ text = "—", fontSize = 11, color = "on_surface_variant" }) } + if showGlyph then + table.insert(nodes, glyphAfter and #nodes + 1 or 1, + ui.glyph({ name = providerGlyph(entry.id), size = 14, color = "error" })) + end + return ui.row({ gap = 5, align = "center" }, nodes) + end local metric = headline(entry) local tint = severityRole(metric, "on_surface") local percent = metric ~= nil and tonumber(metric.percent) or nil @@ -151,13 +160,71 @@ local function chip(entry, flagged) -- colours rather than appends: a glyph that exists only while the read fails -- would shove the neighbouring widget once per cycle. The tooltip still -- spells the failure out. - local glyph = showGlyph - and ui.glyph({ name = providerGlyph(entry.id), size = 13, + local submodels = modelHeadlines(entry) + local hasSubmodels = #submodels > 0 + + local glyph = (showGlyph and not hasSubmodels) + and ui.glyph({ name = providerGlyph(entry.id), size = 14, color = flagged and "error" or "on_surface" }) or nil - -- Fixed width, right-aligned: the capsule is the same size at 9% as at 100% - -- and stops nudging its neighbours once per read. - local pct = showValue and ui.label({ text = text, fontSize = 11, fontWeight = "semibold", - color = tint, maxLines = 1, width = 30, textAlign = "end" }) or nil + local pct = nil + if showValue or hasSubmodels then + if hasSubmodels then + local parts = {} + for _, item in ipairs(submodels) do + local shownMetric = item.blockingMetric or item.metric + local mTint = colorByUsage and isCritical(shownMetric) and "error" + or severityRole(shownMetric, "on_surface") + local mPct = tonumber(shownMetric.percent) or 0 + local rowChildren = {} + local modelGlyph = showGlyph and ui.glyph({ + name = item.glyph or providerGlyph(entry.id), + size = 13, + color = "on_surface", + }) or nil + if modelGlyph and not glyphAfter then rowChildren[#rowChildren + 1] = modelGlyph end + if showValue then + rowChildren[#rowChildren + 1] = ui.label({ + text = string.format("%d%%", mPct), + fontSize = 11, + fontWeight = "semibold", + color = mTint, + maxLines = 1, + }) + end + if extras == "countdown" or extras == "both" then + local left = fixedCountdown(shownMetric) + if left ~= "" then + rowChildren[#rowChildren + 1] = ui.label({ + text = left, + fontSize = 10, + color = "on_surface_variant", + maxLines = 1, + }) + end + end + if modelGlyph and glyphAfter then rowChildren[#rowChildren + 1] = modelGlyph end + if #parts > 0 and #rowChildren > 0 then + parts[#parts + 1] = ui.label({ + text = "·", + fontSize = 11, + color = "on_surface_variant", + }) + end + if #rowChildren > 0 then + parts[#parts + 1] = ui.row({ gap = 3, align = "center" }, rowChildren) + end + end + pct = ui.row({ gap = 7, align = "center" }, parts) + else + pct = ui.label({ + text = text, + fontSize = 11, + fontWeight = "semibold", + color = tint, + maxLines = 1, + }) + end + end local name = showName and ui.label({ text = shortName(entry), fontSize = 11, color = "on_surface_variant", maxLines = 1 }) or nil @@ -167,13 +234,13 @@ local function chip(entry, flagged) if not glyphAfter then add(glyph) end add(name) if percent ~= nil and visualization == "gauge" then - add(bars(percent, elapsedPercent(metric and metric.detail), fill, 26)) + add(bars(percent, elapsedPercent(metric and metric.detail), fill, 30)) end add(pct) if glyphAfter then add(glyph) end - if extras == "countdown" or extras == "both" then - local left = countdown(metric) + if (extras == "countdown" or extras == "both") and not hasSubmodels then + local left = fixedCountdown(metric) if left ~= "" then add(ui.label({ text = left, fontSize = 10, color = "on_surface_variant", maxLines = 1 })) end @@ -193,7 +260,36 @@ local function chip(entry, flagged) if entry.stale == true then add(ui.glyph({ name = "clock-exclamation", size = 11, color = "secondary" })) end - return ui.row({ gap = 4, align = "center" }, nodes) + return ui.row({ gap = 5, align = "center" }, nodes) +end + +local function formatDualMetric(m1, m2) + m1 = type(m1) == "table" and m1 or nil + m2 = type(m2) == "table" and m2 or nil + if m1 == nil and m2 == nil then return "" end + + local p1 = m1 and (m1.percent ~= nil and tonumber(m1.percent) or tostring(m1.value or "")) + local p2 = m2 and (m2.percent ~= nil and tonumber(m2.percent) or tostring(m2.value or "")) + + local str1 = type(p1) == "number" and string.format("%d%%", p1) or tostring(p1 or ""):gsub(" of ", " / ") + local str2 = type(p2) == "number" and string.format("%d%%", p2) or (p2 ~= nil and tostring(p2):gsub(" of ", " / ") or nil) + + -- If one window is exhausted, its reset is the real unlock time. Showing + -- the shorter session reset would promise availability while the weekly + -- limit still blocks the model. + local blocking = shared.blockingMetric(m1 and { m1, m2 } or { m2 }) + local left = blocking ~= nil and fixedCountdown(blocking) or "" + if left == "" and m1 ~= nil then left = fixedCountdown(m1) end + if left == "" and m2 ~= nil then left = fixedCountdown(m2) end + + local val = str1 + if str2 ~= nil and str2 ~= "" then + val = val .. " / " .. str2 + end + if left ~= "" then + val = val .. " · " .. left + end + return val end local function tooltip(picked, hidden) @@ -224,19 +320,22 @@ local function tooltip(picked, hidden) if entry.status == "error" then rows[#rows + 1] = { key = title, value = tostring(entry.error or noctalia.tr("ui.unavailable")) } else - if entry.plan ~= nil and tostring(entry.plan) ~= "" then - rows[#rows + 1] = { key = title, value = tostring(entry.plan) } - end - local metrics = type(entry.metrics) == "table" and entry.metrics or {} - for _, metric in ipairs(metrics) do - if type(metric) == "table" then - local value = tostring(metric.value or ""):gsub(" of ", " / ") - local left = countdown(metric) - if left ~= "" then - local clock = resetClock(metric) - value = value .. " · " .. left .. (clock ~= "" and (" (" .. clock .. ")") or "") + local submodels = modelHeadlines(entry) + if #submodels > 0 then + for _, item in ipairs(submodels) do + local mMetrics = type(item.metrics) == "table" and item.metrics or { item.metric } + local val = formatDualMetric(mMetrics[1], mMetrics[2]) + if val ~= "" then + rows[#rows + 1] = { key = item.displayName or item.model, value = val } end - rows[#rows + 1] = { key = tostring(metric.label or ""), value = value } + end + else + local metrics = type(entry.metrics) == "table" and entry.metrics or {} + local val = formatDualMetric(metrics[1], metrics[2]) + if val ~= "" then + rows[#rows + 1] = { key = title, value = val } + elseif entry.plan ~= nil and tostring(entry.plan) ~= "" then + rows[#rows + 1] = { key = title, value = tostring(entry.plan) } end end if entry.stale == true then @@ -276,7 +375,7 @@ local function render() -- One glyph, coloured by the state. A second icon beside it would read as -- a second problem. children[1] = ui.glyph({ - name = "brain", size = 13, + name = "brain", size = 14, color = failure.code ~= "" and "error" or "on_surface_variant", }) elseif hidden > 0 then @@ -284,7 +383,7 @@ local function render() color = "on_surface_variant", maxLines = 1 }) end - barWidget.render(ui.row({ gap = 6, align = "center" }, children)) + barWidget.render(ui.row({ gap = 7, align = "center" }, children)) barWidget.setTooltip(tooltip(picked, hidden)) end diff --git a/ai-usagebar/panel.luau b/ai-usagebar/panel.luau index 0a2c4d8b7..aba32fb16 100644 --- a/ai-usagebar/panel.luau +++ b/ai-usagebar/panel.luau @@ -10,10 +10,13 @@ local shared = require("./shared.luau") local providerGlyph, parseIso = shared.providerGlyph, shared.parseIso local countdown, resetClock = shared.countdown, shared.resetClock local ratio, headline, severityRole = shared.ratio, shared.headline, shared.severityRole +local modelHeadlines, isCritical = shared.modelHeadlines, shared.isCritical +local modelGlyph = shared.modelGlyph +local modelDisplayName = shared.modelDisplayName local elapsedPercent, pace = shared.elapsedPercent, shared.pace local requestRefresh = shared.requestRefresh local providerWarning, unavailable = shared.warning, shared.unavailable -local saysError = shared.saysError +local saysError, transportError = shared.saysError, shared.transportError local NO_FAILURE, asFailure = shared.NO_FAILURE, shared.asFailure local failure = NO_FAILURE @@ -23,10 +26,6 @@ local failure = NO_FAILURE -- nothing about the new one. local lastPlan = {} --- Read once: the panel would otherwise stat PATH on every second tick it spends --- in a failure. -local HAS_OPENER = noctalia.commandExists("xdg-open") - -- A vendor the user never set up is not listed. The CLI prefixes every such -- message with `credentials error:`, but that bucket also holds providers that -- are set up and merely unreachable, so the row is dropped only when the CLI @@ -102,7 +101,7 @@ local function metricRows(section, title, muted) } local word = severityWord(section) if word ~= nil then - header[#header + 1] = ui.label({ text = word, fontSize = 10, fontWeight = "semibold", color = tint }) + header[#header + 1] = ui.label({ text = word, fontSize = 11, fontWeight = "semibold", color = tint }) end if showValue then header[#header + 1] = ui.label({ text = value, fontSize = 11, color = "on_surface_variant", maxLines = 1 }) @@ -110,7 +109,7 @@ local function metricRows(section, title, muted) -- Fixed width, so a column of cards ends on one right edge. header[#header + 1] = ui.label({ text = string.format("%d%%", percent), - fontSize = 15, + fontSize = 17, fontWeight = "bold", color = tint, width = 46, @@ -119,7 +118,7 @@ local function metricRows(section, title, muted) local body = { ui.row({ gap = 6, align = "center" }, header), - ui.progress({ progress = ratio(percent), fill = fill, track = "on_surface/0.16", radius = 3, height = 6 }), + ui.progress({ progress = ratio(percent), fill = fill, track = "on_surface/0.10", radius = 3, height = 7 }), } local elapsed = elapsedPercent(section.detail) @@ -127,7 +126,7 @@ local function metricRows(section, title, muted) body[#body + 1] = ui.progress({ progress = ratio(elapsed), fill = "on_surface/0.45", - track = "on_surface/0.10", + track = "on_surface/0.08", radius = 2, height = 3, }) @@ -153,13 +152,13 @@ local function metricRows(section, title, muted) if elapsed ~= nil then context[#context + 1] = ui.label({ text = noctalia.tr("ui.elapsed", { percent = elapsed }), - fontSize = 10, color = "on_surface_variant", maxLines = 1, + fontSize = 11, color = "on_surface_variant", maxLines = 1, }) end if paceText ~= "" then context[#context + 1] = ui.spacer({ flexGrow = 1 }) context[#context + 1] = ui.label({ - text = paceText, fontSize = 10, fontWeight = "semibold", color = paceColor, maxLines = 1, + text = paceText, fontSize = 11, fontWeight = "semibold", color = paceColor, maxLines = 1, }) end if #timing > 0 then @@ -177,8 +176,8 @@ local function metricRows(section, title, muted) return body end -local CARD = { gap = 6, padding = 10, radius = 8, fill = "surface_variant/0.45", - border = "outline/0.12", borderWidth = 1 } +local CARD = { gap = 8, padding = 12, radius = 14, fill = "surface_variant/0.40", + border = "outline/0.08", borderWidth = 1 } -- A provider's readings share one card, so the session and the week are read -- together. `name` titles it only where the readings need telling apart by @@ -188,8 +187,11 @@ local function stackCard(name, readings) local titled = name ~= "" local body = {} if titled then - body[#body + 1] = ui.label({ text = name, fontSize = 12, fontWeight = "semibold", - color = "on_surface", maxLines = 1 }) + body[#body + 1] = ui.row({ gap = 8, align = "center" }, { + ui.glyph({ name = modelGlyph(name), size = 16, color = "on_surface" }), + ui.label({ text = modelDisplayName(name), fontSize = 13, fontWeight = "bold", + color = "on_surface", maxLines = 1 }), + }) end for index, reading in ipairs(readings) do -- Two readings in one card run together without a line between them, @@ -207,6 +209,7 @@ local function stackCard(name, readings) end local function blockCard(section) + local resetCredits = section.label == "Reset credits" local body = { ui.label({ text = tostring(section.label or ""), fontSize = 12, fontWeight = "semibold", color = "on_surface", maxLines = 1 }), @@ -216,15 +219,23 @@ local function blockCard(section) local text = noctalia.string.trim(tostring(line)) -- A bare "balance:" would read as a row that failed to render. if text:find(":$") then text = text .. " —" end - body[#body + 1] = ui.label({ - text = text ~= "" and text or "—", - fontSize = 11, - color = "on_surface_variant", - maxLines = 1, - }) + local kind, expiry = text:match("^(.-)%s+·%s+(.+)$") + if resetCredits and kind and expiry then + body[#body + 1] = ui.column({ gap = 4 }, { + ui.label({ text = kind, fontSize = 12, color = "on_surface", maxLines = 2 }), + ui.label({ text = expiry, fontSize = 11, color = "on_surface_variant", maxLines = 2 }), + }) + else + body[#body + 1] = ui.label({ + text = text ~= "" and text or "—", + fontSize = 11, + color = "on_surface_variant", + maxLines = resetCredits and 3 or 1, + }) + end end - return ui.column({ gap = 4, padding = 10, radius = 8, fill = "surface_variant/0.45", - border = "outline/0.18", borderWidth = 1 }, body) + return ui.column({ gap = 4, padding = 12, radius = 14, fill = "surface_variant/0.40", + border = "outline/0.08", borderWidth = 1 }, body) end -- A `text` section the CLI uses for a warning. The `credentials error:` prefix @@ -233,8 +244,8 @@ end local function noteCard(text) local clean = noctalia.string.trim(tostring(text or "")) clean = clean:gsub("^[Cc]redentials error:%s*", "") - return ui.row({ gap = 8, align = "start", padding = 10, radius = 8, - fill = "surface_variant/0.45", border = "outline/0.18", borderWidth = 1 }, { + return ui.row({ gap = 8, align = "start", padding = 12, radius = 14, + fill = "secondary/0.08", border = "outline/0.08", borderWidth = 1 }, { ui.glyph({ name = "alert-triangle", size = 14, color = "secondary" }), ui.label({ text = clean, fontSize = 11, color = "on_surface_variant", flexGrow = 1, maxLines = 3 }), @@ -297,7 +308,7 @@ local function recordCard(entry, items, windowed) local line = table.concat(parts, " · ") if name ~= "" then body[#body + 1] = ui.row({ gap = 6, align = "center" }, { - ui.label({ text = name, fontSize = 11, color = "on_surface_variant", maxLines = 1 }), + ui.label({ text = modelDisplayName(name), fontSize = 11, color = "on_surface_variant", maxLines = 1 }), ui.spacer({ flexGrow = 1 }), ui.label({ text = line, fontSize = 11, color = "on_surface", maxLines = 1 }), }) @@ -306,20 +317,57 @@ local function recordCard(entry, items, windowed) maxLines = 1 }) end end - return ui.column({ gap = 4, padding = 10, radius = 8, fill = "surface_variant/0.45", - border = "outline/0.18", borderWidth = 1 }, body) + return ui.column({ gap = 4, padding = 12, radius = 14, fill = "surface_variant/0.40", + border = "outline/0.08", borderWidth = 1 }, body) end -- Everything in a report that is not a reading: credit blocks, headings, -- warnings, free text. A provider that is down loses its gauges, not the rest of -- what the CLI said, so both passes draw these the same way. `warned` says the -- warning already stands at the top, so it is not said twice. +-- A credits block with zero balance and no usage is skipped: showing an empty +-- card for a user who does not purchase or use credits is noise. +local function isEmptyCredits(section) + if section.type ~= "block" then return false end + local label = tostring(section.label or ""):lower() + if not label:find("credit", 1, true) then return false end + + local lines = type(section.body) == "table" and section.body or {} + if #lines == 0 then return true end + + local hasPositive = false + for _, line in ipairs(lines) do + local text = tostring(line):lower() + local balanceVal = text:match("balance:%s*(.*)") + if balanceVal ~= nil then + local num = tonumber(balanceVal:match("[%d%.]+")) + if num ~= nil and num > 0 then + hasPositive = true + break + end + else + for numStr in text:gmatch("[%d%.]+") do + local n = tonumber(numStr) + if n ~= nil and n > 0 then + hasPositive = true + break + end + end + end + end + return not hasPositive +end + local function otherCard(section, warned) - if section.type == "block" then return blockCard(section) end + if section.type == "block" then + if isEmptyCredits(section) then return nil end + return blockCard(section) + end if section.type == "text" then local label = tostring(section.label or "") local value = tostring(section.value or "") + if transportError(label) or transportError(value) then return nil end if label:lower() == "warning" or saysError(value) then if warned then return nil end return noteCard(value) @@ -354,20 +402,143 @@ local function providerRow(entry, selected) -- what it cached: either way the row stops drawing a gauge for it. local broken = entry.status == "error" or unavailable(entry) local tint = severityRole(metric, "on_surface") + local submodels = modelHeadlines(entry) + local metrics = type(entry.metrics) == "table" and entry.metrics or {} -- The reading keeps its severity colour whether or not the row is selected: -- recolouring it hides the one thing the list exists to compare. local right + local gaugeNode = nil + if broken then right = ui.row({ width = 48, justify = "end" }, { ui.glyph({ name = "alert-circle", size = 14, color = "error" }), }) + elseif #submodels > 0 then + local colChildren = {} + local modelPairGroups = {} + for _, item in ipairs(submodels) do + local mMetrics = type(item.metrics) == "table" and item.metrics or { item.metric } + local m1 = mMetrics[1] or item.metric + local m2 = mMetrics[2] + local p1 = tonumber(m1.percent) or 0 + local p2 = m2 ~= nil and tonumber(m2.percent) or nil + local t1 = isCritical(m1) and "error" or severityRole(m1, "on_surface") + local t2 = m2 ~= nil and (isCritical(m2) and "error" or severityRole(m2, "on_surface_variant")) or nil + + local pctElements = { + ui.label({ + text = string.format("%d%%", p1), + fontSize = 11, + fontWeight = "bold", + color = t1, + textAlign = "end", + maxLines = 1, + }), + } + if p2 ~= nil then + pctElements[#pctElements + 1] = ui.label({ + text = string.format("/ %d%%", p2), + fontSize = 11, + fontWeight = "medium", + color = t2, + textAlign = "end", + maxLines = 1, + }) + end + + colChildren[#colChildren + 1] = ui.row({ gap = 3, align = "center", justify = "end" }, { + ui.glyph({ + name = item.glyph or "sparkles", + size = 11, + color = "on_surface_variant", + }), + ui.row({ gap = 2, align = "center", justify = "end" }, pctElements), + }) + + local modelBars = {} + for _, m in ipairs(mMetrics) do + local p = tonumber(m.percent) or 0 + local f = isCritical(m) and "error" or severityRole(m, "primary") + modelBars[#modelBars + 1] = ui.progress({ + progress = ratio(p), + fill = f, + track = "on_surface/0.10", + radius = 1, + height = 2, + }) + end + modelPairGroups[#modelPairGroups + 1] = ui.column({ gap = 2 }, modelBars) + end + gaugeNode = ui.column({ gap = 6 }, modelPairGroups) + local rightChildren = { + ui.column({ gap = 3, align = "end" }, colChildren), + } + if entry.stale == true then + rightChildren[#rightChildren + 1] = ui.glyph({ + name = "clock-exclamation", size = 12, color = "secondary", + }) + end + right = ui.row({ gap = 3, width = 76, justify = "end", align = "center" }, rightChildren) + elseif #metrics >= 2 then + local m1, m2 = metrics[1], metrics[2] + local p1 = tonumber(m1.percent) or 0 + local p2 = tonumber(m2.percent) or 0 + local t1 = severityRole(m1, "on_surface") + local t2 = severityRole(m2, "on_surface_variant") + local f1 = severityRole(m1, "primary") + local f2 = severityRole(m2, "primary") + + gaugeNode = ui.column({ gap = 2 }, { + ui.progress({ progress = ratio(p1), fill = f1, track = "on_surface/0.10", radius = 2, height = 3 }), + ui.progress({ progress = ratio(p2), fill = f2, track = "on_surface/0.10", radius = 2, height = 3 }), + }) + + local rightChildren = { + ui.column({ gap = 1, align = "end" }, { + ui.label({ + text = string.format("%d%%", p1), + fontSize = 11, + fontWeight = "bold", + color = t1, + textAlign = "end", + maxLines = 1, + }), + ui.label({ + text = string.format("%d%%", p2), + fontSize = 11, + fontWeight = "medium", + color = t2, + textAlign = "end", + maxLines = 1, + }), + }), + } + if entry.stale == true then + rightChildren[#rightChildren + 1] = ui.glyph({ + name = "clock-exclamation", size = 12, color = "secondary", + }) + end + right = ui.row({ gap = 3, width = 56, justify = "end", align = "center" }, rightChildren) else + local p1 = percent ~= nil and percent or 0 + if percent ~= nil then + gaugeNode = ui.progress({ + progress = ratio(p1), + fill = severityRole(metric, "primary"), + track = "on_surface/0.12", + radius = 2, + height = 4, + }) + end local rightChildren = { ui.label({ text = percent ~= nil and string.format("%d%%", percent) or "—", - fontSize = 13, fontWeight = "bold", color = tint, - width = 34, textAlign = "end", + fontSize = 13, + fontWeight = "bold", + color = tint, + width = 34, + textAlign = "end", }), } if entry.stale == true then @@ -385,24 +556,23 @@ local function providerRow(entry, selected) color = selected and "primary" or "on_surface", maxLines = 1, }), } - if percent ~= nil and not broken then - lines[#lines + 1] = ui.progress({ - progress = ratio(percent), - fill = severityRole(metric, "primary"), - track = "on_surface/0.16", - radius = 2, height = 3, - }) + if gaugeNode ~= nil and not broken then + lines[#lines + 1] = gaugeNode end lines[#lines + 1] = ui.label({ - text = broken and noctalia.tr("ui.unavailable") or tostring(entry.plan or entry.id or ""), - fontSize = 10, color = "on_surface_variant", maxLines = 1, + text = shared.readFailure(entry) and noctalia.tr("ui.error.failed") + or broken and noctalia.tr("ui.unavailable") or tostring(entry.plan or entry.id or ""), + fontSize = 11, color = "on_surface_variant", maxLines = 1, }) + if #submodels > 0 and not broken then + lines[#lines + 1] = ui.spacer({ height = 3 }) + end return ui.row({ -- Keyed, so the click handler survives the second tick the countdowns ride -- on instead of being rebuilt under the pointer. key = "provider-" .. tostring(entry.id), - gap = 8, align = "center", padding = 8, radius = 8, + gap = 8, align = "center", padding = 9, radius = 10, -- A tint, not a slab: a filled `primary` row inverts every colour in it. fill = selected and "primary/0.10" or "surface_variant/0.30", border = selected and "primary/0.55" or "outline/0.12", @@ -412,7 +582,7 @@ local function providerRow(entry, selected) render() end, }, { - ui.glyph({ name = providerGlyph(entry.id), size = 16, + ui.glyph({ name = providerGlyph(entry.id), size = selected and 18 or 16, color = selected and "primary" or "on_surface" }), ui.column({ gap = 3, flexGrow = 1 }, lines), right, @@ -426,7 +596,7 @@ local function errorBlock() local key = "ui.error." .. failure.code local children = { ui.row({ gap = 6, align = "center" }, { - ui.glyph({ name = "alert-circle", size = 14, color = "error" }), + ui.glyph({ name = "alert-circle", size = 18, color = "error" }), ui.label({ text = noctalia.tr(key), fontSize = 12, fontWeight = "bold", color = "error" }), }), ui.label({ text = noctalia.tr(key .. "_hint"), fontSize = 11, color = "on_surface_variant" }), @@ -434,7 +604,7 @@ local function errorBlock() if failure.detail ~= "" then children[#children + 1] = ui.label({ - text = failure.detail, fontSize = 10, color = "on_surface_variant", maxLines = 3, + text = failure.detail, fontSize = 11, color = "on_surface_variant", maxLines = 3, }) end @@ -447,22 +617,16 @@ local function errorBlock() }), } - -- Retrying is pointless until the CLI exists, so that failure gets the - -- install page too, offered only where something can open it. The address - -- lives in the tooltip: a raw URL is not a button caption. - if failure.code == "not_installed" and HAS_OPENER then - actions[#actions + 1] = ui.button({ - text = noctalia.tr("ui.install"), glyph = "external-link", - variant = "ghost", controlSize = "sm", - tooltip = "github.com/akitaonrails/ai-usagebar", - onClick = function() - noctalia.runAsync("xdg-open https://github.com/akitaonrails/ai-usagebar") - end, + if failure.code == "not_installed" then + children[#children + 1] = ui.label({ + text = "https://github.com/akitaonrails/ai-usagebar", + fontSize = 11, color = "on_surface", maxLines = 2, }) end children[#children + 1] = ui.row({ gap = 6, align = "center" }, actions) - return ui.column({ gap = 8 }, children) + return ui.column({ gap = 8, padding = 12, radius = 14, + fill = "error/0.06", border = "error/0.12", borderWidth = 1 }, children) end local function listPane(entry) @@ -472,7 +636,7 @@ local function listPane(entry) rows[#rows + 1] = providerRow(candidate, entry ~= nil and candidate.id == entry.id) end end - return ui.column({ gap = 10, padding = 14, width = 250 }, { + return ui.column({ gap = 10, padding = 14, width = 290 }, { ui.row({ gap = 8, align = "center" }, { ui.glyph({ name = "brain", size = 18, color = "primary" }), -- The accent belongs to the selection and the bars; a title taking @@ -516,15 +680,22 @@ local function detailPane(entry) -- With no entry the pane stays empty rather than repeating the list's title. local children = {} if entry ~= nil then - -- Wrapped in a row so the title block is as tall as its two labels: a - -- bare column claims the pane's free space and parks the title above it. - children[#children + 1] = ui.row({ gap = 8, align = "center" }, { - ui.glyph({ name = providerGlyph(entry.id), size = 18, color = "on_surface" }), - ui.column({ gap = 0, flexGrow = 1 }, { - ui.label({ text = title, fontSize = 15, fontWeight = "bold", - color = "on_surface", maxLines = 1 }), - ui.label({ text = subtitle, fontSize = 11, color = "on_surface_variant", - maxLines = 1, visible = subtitle ~= "" }), + local planBadge = subtitle ~= "" and ui.row({ + gap = 4, align = "center", padding = 4, radius = 6, + fill = "surface_variant/0.45", border = "outline/0.08", borderWidth = 1, + }, { + ui.label({ text = subtitle, fontSize = 11, fontWeight = "medium", + color = "on_surface_variant", maxLines = 1 }), + }) or nil + + children[#children + 1] = ui.row({ gap = 10, align = "center" }, { + ui.glyph({ name = providerGlyph(entry.id), size = 22, color = "on_surface" }), + ui.column({ gap = 2, flexGrow = 1 }, { + ui.row({ gap = 8, align = "center" }, { + ui.label({ text = title, fontSize = 16, fontWeight = "bold", + color = "on_surface", maxLines = 1 }), + planBadge, + }), }), }) end @@ -535,17 +706,17 @@ local function detailPane(entry) if entry ~= nil and not unavailable(entry) then local chips = {} if entry.stale == true then - chips[#chips + 1] = ui.label({ text = noctalia.tr("ui.stale"), fontSize = 10, color = "secondary" }) + chips[#chips + 1] = ui.label({ text = noctalia.tr("ui.stale"), fontSize = 11, color = "secondary" }) end local fetched = parseIso(entry.fetched_at) if fetched ~= nil then if #chips > 0 then - chips[#chips + 1] = ui.label({ text = "·", fontSize = 10, color = "on_surface_variant" }) + chips[#chips + 1] = ui.label({ text = "·", fontSize = 11, color = "on_surface_variant" }) end chips[#chips + 1] = ui.glyph({ name = "clock", size = 11, color = "on_surface_variant" }) chips[#chips + 1] = ui.label({ text = updatedText(entry) .. " · " .. noctalia.formatTime(noctalia.timeFormat(), fetched), - fontSize = 10, color = "on_surface_variant", + fontSize = 11, color = "on_surface_variant", }) end if #chips > 0 then @@ -553,13 +724,13 @@ local function detailPane(entry) end end - -- The read failed, but the numbers below it are only older than the panel - -- would like. Said once, at the top, rather than by dropping every card. + -- The read failed, but cached numbers remain the useful answer. Mark their + -- age quietly instead of making a transient network failure the headline. if failure.code ~= "" then children[#children + 1] = ui.row({ gap = 6, align = "center" }, { - ui.glyph({ name = "alert-circle", size = 13, color = "error" }), - ui.label({ text = noctalia.tr("ui.error." .. failure.code), fontSize = 11, - color = "error", flexGrow = 1, maxLines = 1 }), + ui.glyph({ name = "clock-exclamation", size = 13, color = "secondary" }), + ui.label({ text = noctalia.tr("ui.stale_hint"), fontSize = 11, + color = "on_surface_variant", flexGrow = 1, maxLines = 1 }), ui.button({ text = noctalia.tr("ui.retry"), glyph = "refresh", variant = "ghost", controlSize = "sm", @@ -569,6 +740,38 @@ local function detailPane(entry) }) end + -- Only exhausted quotas need a notice above the detailed windows. + if entry ~= nil and not unavailable(entry) then + local models = modelHeadlines(entry) + if #models == 0 then + models = { { displayName = title, metric = headline(entry) } } + end + local summary = {} + for _, model in ipairs(models) do + local metric = model.blockingMetric or model.metric + if (tonumber(metric and metric.percent) or 0) >= 100 then + local left = shared.fixedCountdown(metric) + local details = { + ui.label({ text = model.displayName .. " · " .. noctalia.tr("ui.quota_exhausted"), + fontSize = 11, color = "error", maxLines = 1 }), + } + if left ~= "" then + details[#details + 1] = ui.label({ + text = noctalia.tr("ui.quota_reset", { time = left }), + fontSize = 11, color = "on_surface_variant", maxLines = 1, + }) + end + summary[#summary + 1] = ui.row({ gap = 8, padding = 8, radius = 10, + fill = "surface_variant/0.40", border = "error/0.40", + borderWidth = 1, align = "center" }, { + ui.glyph({ name = "clock-exclamation", size = 16, color = "error" }), + ui.column({ gap = 2, flexGrow = 1 }, details), + }) + end + end + if #summary > 0 then children[#children + 1] = ui.column({ gap = 6 }, summary) end + end + local cards = {} local sections = entry ~= nil and type(entry.sections) == "table" and entry.sections or {} diff --git a/ai-usagebar/plugin.toml b/ai-usagebar/plugin.toml index f11d86df9..855890771 100644 --- a/ai-usagebar/plugin.toml +++ b/ai-usagebar/plugin.toml @@ -1,6 +1,6 @@ id = "felipeartur/ai-usagebar" name = "AI Usage" -version = "2.1.0" +version = "2.2.0" plugin_api = 22 author = "felipeartur" license = "MIT" @@ -165,8 +165,8 @@ default = true id = "panel" entry = "panel.luau" # Master/detail: the provider list on the left, its cards on the right. -width = 720 -height = 400 +width = 750 +height = 430 # The shell draws the Placement and Position rows for every plugin panel whether # or not they are declared; declaring them only picks the default the user gets. placement = "attached" diff --git a/ai-usagebar/service.luau b/ai-usagebar/service.luau index bc41c591f..6100f81ce 100644 --- a/ai-usagebar/service.luau +++ b/ai-usagebar/service.luau @@ -30,6 +30,7 @@ for _, word in ipairs({ "key", "token", "secret", "password" }) do -- Each keyword redacts on a pass of its own, so `api_key_tokens` has this -- exemption taken back by the `key` pass. keepsReadings = word == "token", + quoted = "(" .. name .. "[\"']?%s*[:=]%s*)[\"'].*", -- name=value: query string, shell assignment, or the CLI's own TOML. assign = "(" .. name .. "%s*=%s*[\"']?)" .. SECRET_VALUE, -- name: value: an HTTP header or a JSON field. Captured rather than @@ -37,8 +38,10 @@ for _, word in ipairs({ "key", "token", "secret", "password" }) do colon = "(" .. name .. "[\"']?%s*:%s*[\"']?)(" .. SECRET_VALUE .. ")", } end -local BASIC_AUTH_PATTERN = "(" .. anyCase("authorization") .. "[\"']?%s*:%s*[\"']?" - .. anyCase("basic") .. "%s+)" .. SECRET_VALUE +local HEADER_PATTERNS = {} +for _, word in ipairs({ "authorization", "cookie" }) do + HEADER_PATTERNS[word] = "(" .. anyCase(word) .. "[\"']?%s*[:=]%s*).*" +end -- A quota is not a credential, but the exemption stays narrow: short, digits -- only, and offered to labels that count tokens. The plural separates the two -- @@ -72,6 +75,11 @@ local function safeText(value) for _, secret in ipairs(SECRET_PATTERNS) do if lower:find(secret.word, 1, true) then + -- Fail closed on quoted diagnostics, including escaped quotes and + -- values cut by the length cap: omit the remainder of the line. + if text:find('"', 1, true) or text:find("'", 1, true) then + text = text:gsub(secret.quoted, "%1") + end text = text:gsub(secret.assign, "%1") text = text:gsub(secret.colon, secret.keepsReadings and redactColon or "%1") end @@ -81,8 +89,8 @@ local function safeText(value) text = text:gsub("([Bb][Ee][Aa][Rr][Ee][Rr]%s+)" .. SECRET_VALUE, "%1") end - if lower:find("authorization", 1, true) and lower:find("basic", 1, true) then - text = text:gsub(BASIC_AUTH_PATTERN, "%1") + for word, pattern in pairs(HEADER_PATTERNS) do + if lower:find(word, 1, true) then text = text:gsub(pattern, "%1") end end -- Credentials in the userinfo half of a URL the CLI echoed back. @@ -102,7 +110,21 @@ end local function scrub(value) if type(value) == "string" then return safeText(value) end if type(value) ~= "table" then return value end - for key, inner in pairs(value) do value[key] = scrub(inner) end + for key, inner in pairs(value) do + local sensitive = false + if type(key) == "string" then + local lower = key:lower() + sensitive = lower:find("authorization", 1, true) ~= nil + or lower:find("cookie", 1, true) ~= nil + for _, secret in ipairs(SECRET_PATTERNS) do + if lower:find(secret.word, 1, true) + and not (secret.keepsReadings and isReading(key .. ":", tostring(inner))) then + sensitive = true + end + end + end + if sensitive then value[key] = "" else value[key] = scrub(inner) end + end return value end diff --git a/ai-usagebar/shared.luau b/ai-usagebar/shared.luau index 549c6b488..913ce186d 100644 --- a/ai-usagebar/shared.luau +++ b/ai-usagebar/shared.luau @@ -53,6 +53,36 @@ function M.saysError(value) return tostring(value or ""):lower():find("error:") ~= nil end +function M.transportError(value) + return tostring(value or ""):lower():match("http%s*:?%s*%d%d%d") ~= nil +end + +local function terminalAuthText(value) + local text = tostring(value or ""):lower() + local status = text:match("http%s*:?%s*401") ~= nil + or text:match("http%s*:?%s*403") ~= nil + local rejected = text:find("reject", 1, true) ~= nil + or text:find("invalid", 1, true) ~= nil + or text:find("expired", 1, true) ~= nil + or text:find("forbidden", 1, true) ~= nil + or text:find("unauthorized", 1, true) ~= nil + return status and text:find("auth", 1, true) ~= nil and rejected +end + +function M.terminalAuth(entry) + if type(entry) ~= "table" then return false end + if terminalAuthText(entry.error) then return true end + local sections = type(entry.sections) == "table" and entry.sections or {} + for _, section in ipairs(sections) do + if type(section) == "table" and section.type == "text" + and terminalAuthText(tostring(section.label or "") .. " " + .. tostring(section.value or "")) then + return true + end + end + return false +end + -- A provider the CLI is no longer reading: the entry either fails outright, or -- keeps serving cached numbers with a warning saying why they stopped moving. -- Both mean the readings are a record, not a gauge. @@ -73,7 +103,23 @@ function M.warning(entry) end function M.unavailable(entry) - return M.warning(entry) ~= "" + if type(entry) ~= "table" then return true end + if entry.status == "error" or M.warning(entry) ~= "" then return true end + if entry.stale ~= true then return false end + local sections = type(entry.sections) == "table" and entry.sections or {} + for _, section in ipairs(sections) do + if type(section) == "table" and section.type == "text" + and (M.transportError(section.label) or M.transportError(section.value)) then + return true + end + end + return false +end + +-- A parser failure says nothing about the account's availability. +function M.readFailure(entry) + return type(entry) == "table" and entry.status == "error" + and tostring(entry.error or ""):find("^schema mismatch:") ~= nil end -- Anything else in the `error` slot means no failure. @@ -118,12 +164,28 @@ local function formatDuration(seconds) return string.format("%dm", rest) end +local function formatFixedDuration(seconds) + if seconds <= 0 then return noctalia.tr("ui.now") end + local minutes = math.floor(seconds / 60) + local days = math.floor(minutes / 1440) + local hours = math.floor((minutes % 1440) / 60) + local rest = minutes % 60 + if days > 0 then return string.format("%dd %dh", days, hours) end + return string.format("%dh %02dm", hours, rest) +end + function M.countdown(section) local at = M.parseIso(section and section.reset_at) if at == nil then return "" end return formatDuration(at - os.time()) end +function M.fixedCountdown(section) + local at = M.parseIso(section and section.reset_at) + if at == nil then return "" end + return formatFixedDuration(at - os.time()) +end + -- Where the countdown lands: "14:20" today, "Sat 14:20" past midnight, a date -- once a weekday stops naming one day. function M.resetClock(section) @@ -158,9 +220,142 @@ function M.pace(detail) return last, points, word end +-- Kept next to the role both entries colour with, so the tiers cannot be edited +-- in one file and forgotten in the other. `low` falls through to 0, like +-- anything the CLI adds later. +M.SEVERITY_RANK = { critical = 3, high = 2, mid = 1 } + +local function isCritical(metric) + if type(metric) ~= "table" then return false end + local sev = tostring(metric.severity or "") + local pct = tonumber(metric.percent) or 0 + return sev == "critical" or pct >= 100 +end +M.isCritical = isCritical + +-- Severity is a warning, not proof of exhaustion. Only a full quota blocks use. +function M.blockingMetric(metrics) + local blocking = nil + for _, metric in ipairs(metrics or {}) do + if type(metric) == "table" and (tonumber(metric.percent) or 0) >= 100 then + local reset = M.parseIso(metric.reset_at) or -1 + local previous = M.parseIso(blocking and blocking.reset_at) or -1 + if blocking == nil or reset > previous then blocking = metric end + end + end + return blocking +end + +function M.headline(entry) + if type(entry) ~= "table" or type(entry.metrics) ~= "table" then return nil end + local metrics = entry.metrics + if #metrics == 0 then return nil end + + local blocking = M.blockingMetric(metrics) + if blocking ~= nil then return blocking end + + -- 1. For Antigravity: always follow active session usage across models + if M.baseProvider(entry.id) == "antigravity" then + local seen = {} + local best = nil + local bestPct = -1 + for _, metric in ipairs(metrics) do + if type(metric) == "table" and metric.label ~= nil and metric.label ~= "" then + local model = tostring(metric.label) + if not seen[model] then + seen[model] = true + local pct = tonumber(metric.percent) or 0 + if pct > bestPct then + best = metric + bestPct = pct + end + end + end + end + if best ~= nil then return best end + end + + -- Otherwise keep the primary window selected by the CLI. + for _, metric in ipairs(metrics) do + if type(metric) == "table" then return metric end + end + return nil +end + +function M.modelGlyph(label) + local lower = tostring(label or ""):lower() + if lower:find("oss", 1, true) or lower:find("claude", 1, true) then return "robot" end + if lower:find("gemini", 1, true) then return "brand-google" end + if lower:find("gpt", 1, true) or lower:find("openai", 1, true) then return "brand-openai" end + return "sparkles" +end + +function M.modelDisplayName(label) + local lower = tostring(label or ""):lower() + if lower:find("oss", 1, true) or lower:find("claude", 1, true) then return tostring(label or "") end + if lower:find("gemini", 1, true) then return "Gemini" end + if lower:find("gpt", 1, true) or lower:find("openai", 1, true) then return "GPT" end + return tostring(label or "") +end + +function M.modelHeadlines(entry) + if type(entry) ~= "table" or type(entry.metrics) ~= "table" then return {} end + if M.baseProvider(entry.id) ~= "antigravity" then return {} end + + local order = {} + local byModel = {} + for _, metric in ipairs(entry.metrics) do + if type(metric) == "table" and metric.label ~= nil and metric.label ~= "" then + local model = tostring(metric.label) + if byModel[model] == nil then + byModel[model] = {} + order[#order + 1] = model + end + byModel[model][#byModel[model] + 1] = metric + end + end + + local result = {} + for _, model in ipairs(order) do + local modelMetrics = byModel[model] + local sessionMetric = modelMetrics[1] + local blockingMetric = M.blockingMetric(modelMetrics) + if sessionMetric ~= nil then + result[#result + 1] = { + model = model, + displayName = M.modelDisplayName(model), + glyph = M.modelGlyph(model), + metric = sessionMetric, + metrics = modelMetrics, + blockingMetric = blockingMetric, + } + end + end + return result +end + function M.entries(report) if type(report) ~= "table" or type(report.entries) ~= "table" then return {} end - return report.entries + local visible, positions = {}, {} + for _, entry in ipairs(report.entries) do + if type(entry) == "table" and not M.terminalAuth(entry) + and (not M.unavailable(entry) or M.readFailure(entry)) then + visible[#visible + 1] = entry + positions[entry] = #visible + end + end + table.sort(visible, function(a, b) + local aMetric = M.headline(a) + local bMetric = M.headline(b) + local aRank = M.SEVERITY_RANK[tostring(aMetric and aMetric.severity or "")] or 0 + local bRank = M.SEVERITY_RANK[tostring(bMetric and bMetric.severity or "")] or 0 + if aRank ~= bRank then return aRank > bRank end + local aPercent = type(aMetric) == "table" and tonumber(aMetric.percent) or -1 + local bPercent = type(bMetric) == "table" and tonumber(bMetric.percent) or -1 + if aPercent ~= bPercent then return aPercent > bPercent end + return positions[a] < positions[b] + end) + return visible end -- A provider can report more than it was given, so clamp before this becomes a @@ -172,17 +367,6 @@ function M.ratio(percent) return value end -function M.headline(entry) - if type(entry) ~= "table" or type(entry.metrics) ~= "table" then return nil end - local first = entry.metrics[1] - return type(first) == "table" and first or nil -end - --- Kept next to the role both entries colour with, so the tiers cannot be edited --- in one file and forgotten in the other. `low` falls through to 0, like --- anything the CLI adds later. -M.SEVERITY_RANK = { critical = 3, high = 2, mid = 1 } - -- The CLI tiers every percentage; copying its thresholds here would be a second -- source of truth. `calm` is for when it raised nothing. -- diff --git a/ai-usagebar/tests/bar_test.lua b/ai-usagebar/tests/bar_test.lua index 74347320c..7fec15f89 100644 --- a/ai-usagebar/tests/bar_test.lua +++ b/ai-usagebar/tests/bar_test.lua @@ -90,6 +90,15 @@ local function containsText(node, wanted) return false end +local function containsGlyph(node, wanted) + if type(node) ~= "table" then return false end + if type(node.props) == "table" and node.props.name == wanted then return true end + for _, child in ipairs(node.children or {}) do + if containsGlyph(child, wanted) then return true end + end + return false +end + local namedReport = { primary = "openai", entries = { @@ -189,22 +198,30 @@ for _, name in ipairs(names) do if name == "Antigravity" then listed = true end assert(not listed, "a provider that is down leaves the bar") -- Ranked first on severity, it would have led the capsule; the rest still show. -- Each provider contributes its name and then its readings. -assert(names[1] == "Codex" and names[3] == "Claude", "the rest keep their order") +assert(names[1] == "Codex" and names[2] == "Claude", "the rest keep their order") -- And it is not counted as hidden: hidden means there is more to see. for _, row in ipairs(dropped.tooltip()) do assert(row.key ~= "ui.hidden_label", "it is dropped, not hidden behind a +1") end +local rejected = entry("anthropic", "Claude", 90) +rejected.sections = { + { type = "text", label = "HTTP 403", value = "authentication rejected" }, +} +local withoutRejected = loadBar({ + vendor = "auto", account = "", extras = "none", visualization = "none", + provider_limit = 2, +}, { entries = { rejected, entry("openai", "Codex", 20) } }) +local rejectedRows = withoutRejected.tooltip() +assert(rejectedRows[1].key == "Codex" and #rejectedRows == 1, + "a terminal provider should leave the bar immediately") + local pinnedDown = loadBar({ vendor = "antigravity", account = "", extras = "none", visualization = "none", }, { entries = { downEntry("antigravity", "Antigravity", 90) } }) -assert(#pinnedDown.tooltip() > 0, "a pinned provider still explains itself in the tooltip") --- Configured but unreachable is not the same as never configured, and the --- tooltip may not call one the other. The CLI already worded the failure. local pinnedRow = pinnedDown.tooltip()[1] -assert(pinnedRow.key == "Antigravity", "a pinned provider that is down keeps its name") -assert(tostring(pinnedRow.value):find("no local server") ~= nil, - "the tooltip repeats the CLI's own words instead of calling it unconfigured") +assert(pinnedRow.value == "antigravity not configured", + "a pinned provider with its own failure should leave the bar") -- `[ui] primary` can come back as a full account id, and the tie-break has to -- recognise it in that form as well as the bare provider one. @@ -253,4 +270,153 @@ assert(glyphColor(broken.rendered(), "brand-openai") == "error", assert(glyphColor(healthy.rendered(), "brand-openai") == "on_surface", "a healthy read leaves the mark in its identity colour") -io.write("ok: account selection, malformed metrics, providers that are down, and a steady capsule\n") +local bottleneckBar = loadBar({ + vendor = "openai", account = "", extras = "none", visualization = "none", +}, { + entries = { + { + id = "openai", + display_name = "Codex", + plan = "ChatGPT Plus", + status = "ready", + metrics = { + { label = "Codex 5h", percent = 0, reset_at = os.date("!%Y-%m-%dT%H:%M:%SZ", os.time() + 7200), severity = "low", value = "0%" }, + { label = "Codex weekly", percent = 100, reset_at = os.date("!%Y-%m-%dT%H:%M:%SZ", os.time() + 590400), severity = "critical", value = "100%" }, + }, + }, + }, +}) +assert(containsText(bottleneckBar.rendered(), "100%"), + "capsule should show the 100% weekly bottleneck when session is 0%") +assert(not containsText(bottleneckBar.rendered(), "0%"), + "capsule should not show 0% when weekly limit is 100%") +local bottleneckRows = bottleneckBar.tooltip() +assert(#bottleneckRows == 1 and bottleneckRows[1].key == "Codex" and bottleneckRows[1].value == "0% / 100% · 6d 20h", + "bottleneckBar tooltip should show when the blocking weekly limit resets") + +local agyBar = loadBar({ + vendor = "antigravity", account = "", extras = "countdown", visualization = "none", +}, { + entries = { + { + id = "antigravity", + display_name = "Antigravity", + plan = "Google AI Pro", + status = "ready", + metrics = { + { label = "Gemini", percent = 11, reset_at = os.date("!%Y-%m-%dT%H:%M:%SZ", os.time() + 7200), severity = "low", value = "11%" }, + { label = "Claude & GPT OSS", percent = 0, reset_at = os.date("!%Y-%m-%dT%H:%M:%SZ", os.time() + 3600), severity = "low", value = "0%" }, + { label = "Gemini", percent = 24, severity = "low", value = "24%" }, + { label = "Claude & GPT OSS", percent = 100, reset_at = os.date("!%Y-%m-%dT%H:%M:%SZ", os.time() + 590400), severity = "critical", value = "100%" }, + }, + }, + }, +}) +assert(containsGlyph(agyBar.rendered(), "brand-google"), "capsule should show Gemini brand glyph") +assert(containsGlyph(agyBar.rendered(), "robot"), "capsule should show robot glyph for Claude & GPT OSS") +assert(glyphColor(agyBar.rendered(), "robot") == "on_surface", "model glyph should remain neutral on_surface") +assert(containsText(agyBar.rendered(), "11%"), "available model should keep its active session percentage") +assert(containsText(agyBar.rendered(), "100%"), "blocked model should show its exhausted long-window percentage") +assert(not containsText(agyBar.rendered(), "0%"), "blocked model should hide its irrelevant active session percentage") +assert(containsText(agyBar.rendered(), "6d 20h"), "blocked model should show its long-window unlock time") +local agyTooltip = agyBar.tooltip() +assert(#agyTooltip == 2, "antigravity tooltip should have 2 submodel rows") +assert(agyTooltip[1].key == "Gemini" and agyTooltip[1].value == "11% / 24% · 2h 00m", + "antigravity tooltip first row should be Gemini dual metrics") +assert(agyTooltip[2].key == "Claude & GPT OSS" and agyTooltip[2].value == "0% / 100% · 6d 20h", + "antigravity tooltip second row should be Claude & GPT OSS dual metrics") + +local normalAgyBar = loadBar({ + vendor = "antigravity", account = "", extras = "none", visualization = "none", +}, { + entries = { + { + id = "antigravity", + display_name = "Antigravity", + plan = "Google AI Pro", + status = "ready", + metrics = { + { label = "Gemini", percent = 11, severity = "low", value = "11%" }, + { label = "Claude & GPT OSS", percent = 0, severity = "low", value = "0%" }, + { label = "Gemini", percent = 43, severity = "low", value = "43%" }, + { label = "Claude & GPT OSS", percent = 78, severity = "high", value = "78%" }, + }, + }, + }, +}) +assert(containsGlyph(normalAgyBar.rendered(), "brand-google"), "capsule should show Gemini brand glyph") +assert(containsGlyph(normalAgyBar.rendered(), "robot"), "capsule should show robot glyph for Claude & GPT OSS") +assert(containsText(normalAgyBar.rendered(), "11%"), "capsule should show active session percentage (11%)") +assert(containsText(normalAgyBar.rendered(), "0%"), "capsule should show active session percentage (0%)") +assert(not containsText(normalAgyBar.rendered(), "78%"), "capsule should not stick to weekly percentage (78%)") + +local countdownBar = loadBar({ + vendor = "antigravity", account = "", extras = "countdown", visualization = "none", +}, { + entries = { + { + id = "antigravity", + display_name = "Antigravity", + plan = "Google AI Pro", + status = "ready", + metrics = { + { label = "Gemini", percent = 64, reset_at = os.date("!%Y-%m-%dT%H:%M:%SZ", os.time() + 3240), severity = "low", value = "64%" }, + { label = "Claude & GPT OSS", percent = 69, reset_at = os.date("!%Y-%m-%dT%H:%M:%SZ", os.time() + 83100), severity = "low", value = "69%" }, + }, + }, + }, +}) +assert(containsText(countdownBar.rendered(), "0h 54m"), "capsule should retain hours below one hour") +assert(containsText(countdownBar.rendered(), "23h 05m"), "capsule should show hours and minutes below 24 hours") +assert(containsText(countdownBar.rendered(), "·"), "capsule should show dot separator between submodels") +local countdownTooltip = countdownBar.tooltip() +assert(countdownTooltip[1].value == "64% · 0h 54m", "tooltip should show 0h 54m for minutes-only reset") +assert(countdownTooltip[2].value == "69% · 23h 05m", "tooltip should show fixed hours and minutes") + +io.write("ok: account selection, unavailable providers, and a steady capsule\n") +local parserFailure = loadBar({ vendor = "openai", extras = "countdown" }, { entries = { + { id = "openai", display_name = "Codex", status = "error", metrics = {}, sections = {}, + error = "schema mismatch: openai usage response: invalid type: null" }, +} }) +assert(containsGlyph(parserFailure.rendered(), "brand-openai"), "Codex must remain identifiable when its parser fails") +assert(containsText(parserFailure.rendered(), "—"), "a parser failure must show missing data, not zero usage") + +local resetSoon = os.date("!%Y-%m-%dT%H:%M:%SZ", os.time() + 7200) +local resetLater = os.date("!%Y-%m-%dT%H:%M:%SZ", os.time() + 590400) +local quotaEntry = entry("openai", "Codex", 100) +quotaEntry.metrics = { + { label = "Gemini", percent = 100, severity = "critical", reset_at = resetSoon }, + { label = "Gemini", percent = 100, severity = "critical", reset_at = resetLater }, +} +local bothBlocked = loadBar({ vendor = "openai", extras = "countdown" }, { entries = { quotaEntry } }) +assert(containsText(bothBlocked.rendered(), "6d 20h"), "two exhausted quotas must display the later reset") +assert(bothBlocked.tooltip()[1].value == "100% / 100% · 6d 20h", "tooltip must agree with the bar's blocking reset") +quotaEntry.id = "antigravity" +quotaEntry.metrics[1].percent = 0 +local singleModel = loadBar({ vendor = "antigravity" }, { entries = { quotaEntry } }) +assert(containsText(singleModel.rendered(), "100%"), "a single Antigravity model must show weekly exhaustion") +quotaEntry.metrics[#quotaEntry.metrics + 1] = { label = "Claude & GPT OSS", percent = 95, severity = "critical" } +local hiddenGlyphs = loadBar({ vendor = "antigravity", show_glyph = false }, { entries = { quotaEntry } }) +assert(not containsGlyph(hiddenGlyphs.rendered(), "brand-google") and not containsGlyph(hiddenGlyphs.rendered(), "robot"), + "show_glyph=false must hide every model glyph") +local function findNode(node, predicate) + if predicate(node) then return node end + for _, child in ipairs(node.children or {}) do + local found = findNode(child, predicate) + if found then return found end + end +end +local uncolored = loadBar({ vendor = "antigravity", color_by_usage = false }, { entries = { quotaEntry } }) +assert(findNode(uncolored.rendered(), function(n) return n.props.text == "100%" end).props.color == "on_surface", + "color_by_usage=false must suppress exhausted model tint") +local after = loadBar({ vendor = "antigravity", glyph_position = "after", extras = "none" }, { entries = { quotaEntry } }) +local modelRow = findNode(after.rendered(), function(n) + return n.kind == "row" and n.children[1] and n.children[1].props.text == "100%" +end) +assert(modelRow and modelRow.children[#modelRow.children].props.name == "brand-google", + "glyph_position=after must place the model glyph after its reading") +local iconOnly = loadBar({ vendor = "antigravity", show_value = false, extras = "none" }, { entries = { quotaEntry } }) +assert(containsGlyph(iconOnly.rendered(), "brand-google") and not containsText(iconOnly.rendered(), "100%"), + "show_value=false must preserve enabled model glyphs") +local quietFailure = loadBar({ vendor = "openai", show_glyph = false }, parserFailure.values.report) +assert(containsText(quietFailure.rendered(), "—"), "hidden glyph must not create a hole before the parser error label") diff --git a/ai-usagebar/tests/panel_test.lua b/ai-usagebar/tests/panel_test.lua index 27072999b..ab8f50d00 100644 --- a/ai-usagebar/tests/panel_test.lua +++ b/ai-usagebar/tests/panel_test.lua @@ -9,10 +9,13 @@ end local function loadPanel(entry, failure) local watchers = {} + local isReport = type(entry) == "table" and type(entry.entries) == "table" + local report = isReport and entry or entry ~= nil and { entries = { entry } } or nil + local first = report ~= nil and report.entries[1] or nil local values = { - report = { entries = { entry } }, + report = report, error = failure or { code = "", detail = "" }, - selected = entry.id, + selected = isReport and entry.selected or first ~= nil and first.id or nil, } local noctalia = { state = { @@ -23,7 +26,10 @@ local function loadPanel(entry, failure) commandExists = function() return false end, nowMs = function() return 1000 end, -- The host substitutes; the harness only needs the key back to assert on. - tr = function(key) return key end, + tr = function(key, args) + if key == "ui.quota_reset" and args then return key .. ": " .. args.time end + return key + end, string = { trim = function(value) return tostring(value):match("^%s*(.-)%s*$") end, }, @@ -119,6 +125,20 @@ local malformedBody = { local bodyOk = pcall(loadPanel, malformedBody) assert(bodyOk, "malformed block body should render as an empty block") +local rejectedProvider = { + id = "anthropic", + display_name = "Claude", + plan = "Claude Pro", + status = "ready", + metrics = {}, + sections = { + { type = "text", label = "HTTP 403", value = "authentication rejected" }, + }, +} +local rejectedLabels = labels(loadPanel(rejectedProvider)) +assert(not has(rejectedLabels, "Claude"), + "a terminal provider should leave the panel immediately") + -- Antigravity reports each model once per window, under "Session"/"Weekly" -- headings the CLI sends as text sections with no value. Claude and Codex send -- neither. @@ -161,7 +181,7 @@ end local function cards(node) local out = {} for _, column in ipairs(collect(node, "column")) do - if column.props.fill == "surface_variant/0.45" and #collect(column, "progress") > 0 then + if column.props.fill == "surface_variant/0.40" and #collect(column, "progress") > 0 then out[#out + 1] = column end end @@ -176,7 +196,7 @@ assert(#drawn == 2, "each model gets a card, its windows stacked inside") local first = labels(drawn[1]) assert(first[1] == "Gemini", "the card is titled with the model") assert(has(first, "Session") and has(first, "Weekly"), "both windows live in it") -assert(has(labels(drawn[2]), "Claude & GPT OSS"), "the second model follows below") +assert(has(labels(drawn[2]), "Claude & GPT OSS"), "the second model keeps its CLI name") local geminiTitles = 0 for _, card in ipairs(drawn) do if labels(card)[1] == "Gemini" then geminiTitles = geminiTitles + 1 end @@ -202,73 +222,15 @@ for _, card in ipairs(drawn) do end end --- Down: the CLI keeps serving what it cached and says why it stopped moving. +-- A provider with its own failure leaves the panel until it reports healthy. local DOWN = {} for index, section in ipairs(WINDOWS) do DOWN[index] = section end -DOWN[#DOWN + 1] = { type = "title", value = "Account" } -DOWN[#DOWN + 1] = { type = "text", label = "Balance", value = "$4.20" } -DOWN[#DOWN + 1] = { type = "block", label = "Credits", body = { "granted: 100" } } DOWN[#DOWN + 1] = { type = "text", label = "Warning", value = "credentials error: Antigravity: no local server found." } local downTree = loadPanel(withSections(DOWN)) -assert(#cards(downTree) == 0, "a provider that is down draws no gauges") -assert(#collect(downTree, "progress") == 0, "and no bars either") -local warned = false -for _, glyph in ipairs(collect(downTree, "glyph")) do - if glyph.props.name == "alert-triangle" then warned = true end -end -assert(warned, "the warning takes their place") local downLabels = labels(downTree) -assert(not has(downLabels, "credentials error: Antigravity: no local server found."), - "the CLI's log prefix is dropped") -assert(has(downLabels, "Antigravity: no local server found."), "the warning itself survives") --- The numbers survive as a record, in text. -assert(has(downLabels, "ui.last_reading_now") or has(downLabels, "ui.last_reading"), - "the last reading is kept") -assert(has(downLabels, "Session 3% · Weekly 8%"), "set as text, not as a gauge") --- Dropping the gauges is not dropping the report. Everything the CLI said --- besides the readings is still the CLI talking about this provider. -assert(has(downLabels, "Account"), "a heading the CLI sent survives") -assert(has(downLabels, "Balance") and has(downLabels, "$4.20"), "so does its free text") -assert(has(downLabels, "Credits") and has(downLabels, "granted: 100"), "and its blocks") -local warnings = 0 -for _, label in ipairs(downLabels) do - if label == "Antigravity: no local server found." then warnings = warnings + 1 end -end -assert(warnings == 1, "the warning itself is said once, at the top") - --- One fetch, one date. The record already says how old the numbers are, so the --- header must not date the same fetch again in the other tense. -for _, label in ipairs(downLabels) do - -- The header concatenates the age with a clock time, so match on the key. - assert(label:find("ui.updated", 1, true) == nil, - "a provider that is down is dated once, by its record") -end - --- A plan carries the limits every percentage is measured against, so readings --- taken under the previous one are dropped rather than shown under the new name. -local _, publish = loadPanel(withSections(WINDOWS, "Google AI Pro")) -local switched = labels(publish(withSections(DOWN, "Google AI Ultra"))) -assert(has(switched, "ui.plan_changed"), "the change is said out loud") -assert(not has(switched, "ui.last_reading_now") and not has(switched, "ui.last_reading"), - "and the old readings are not kept") - --- Same provider, same plan: the record stands. -local _, again = loadPanel(withSections(WINDOWS, "Google AI Pro")) -local kept = labels(again(withSections(DOWN, "Google AI Pro"))) -assert(has(kept, "ui.last_reading_now") or has(kept, "ui.last_reading"), - "an unchanged plan keeps its record") - --- A provider that falls over can stop naming its plan at all. Silence is not a --- new plan, and the reading it was last seen with is still worth keeping. -local _, silenced = loadPanel(withSections(WINDOWS, "Google AI Pro")) -local nameless = withSections(DOWN, "Google AI Pro") -nameless.plan = nil -local quiet = labels(silenced(nameless)) -assert(not has(quiet, "ui.plan_changed"), "an omitted plan is not a plan change") -assert(has(quiet, "ui.last_reading_now") or has(quiet, "ui.last_reading"), - "so the record it was last seen with stands") +assert(not has(downLabels, "Antigravity"), "an unavailable provider should leave the panel") -- A provider that sends no headings keeps one card, its readings heading -- themselves: the panel's own header already names Codex. @@ -285,29 +247,94 @@ local paced = { detail = "Resets in 6d 18h · 3% elapsed · 13pts ahead" }, }, } + +local function provider(id, name, percent) + local metric = { + type = "metric", label = "Usage", percent = percent, + value = tostring(percent) .. "%", detail = "", severity = "low", + } + return { + id = id, + display_name = name, + plan = "Plan", + status = "ready", + stale = false, + metrics = { metric }, + sections = { metric }, + } +end + +local claude = provider("anthropic", "Claude", 0) +claude.stale = true +claude.sections[#claude.sections + 1] = { + type = "text", label = "HTTP 429", value = "Rate limited", +} +local sorted = loadPanel({ + selected = "anthropic", + entries = { + claude, + provider("openai", "Codex", 32), + provider("antigravity", "Antigravity", 99), + }, +}) +local providerOrder = {} +for _, row in ipairs(collect(sorted, "row")) do + local key = tostring(row.props.key or "") + local id = key:match("^provider%-(.+)$") + if id ~= nil then providerOrder[#providerOrder + 1] = id end +end +assert(#providerOrder == 2, "providers with their own failures should leave the list") +assert(providerOrder[1] == "antigravity" and providerOrder[2] == "openai", + "the most-used working provider should lead the list") + local plain = cards(loadPanel(paced)) assert(#plain == 1, "the session and the week share one card here too") local plainLabels = labels(plain[1]) assert(plainLabels[1] == "Codex 5h", "the reading heads itself") assert(has(plainLabels, "Codex weekly"), "the week sits under the session") --- The panel outlives every report it draws, so what it remembers between them is --- only the providers the current one carries. A provider the user removed and --- added back is met like any other first reading, not accused of a change. -local _, sequence = loadPanel(withSections(WINDOWS, "Google AI Pro")) -sequence(paced) -local returned = labels(sequence(withSections(DOWN, "Google AI Ultra"))) -assert(not has(returned, "ui.plan_changed"), "a provider gone from the report is forgotten") -assert(has(returned, "ui.last_reading_now") or has(returned, "ui.last_reading"), - "and what it does report is kept") - -- A failed read with a report behind it is a banner over numbers that are merely -- older than the panel would like, not a reason to blank the panel. local failed = loadPanel(paced, { code = "timed_out", detail = "" }) local failedLabels = labels(failed) -assert(has(failedLabels, "ui.error.timed_out"), "the failure is named") +assert(has(failedLabels, "ui.stale_hint"), "cached data names its stale state") +assert(not has(failedLabels, "ui.error.timed_out"), + "a transient failure does not dominate cached data") assert(has(failedLabels, "Codex 5h"), "and the readings stay under it") assert(#cards(failed) == 1, "the cards are not dropped") +local retry = false +for _, button in ipairs(collect(failed, "button")) do + if button.props.text == "ui.retry" then retry = true end +end +assert(retry, "cached data keeps the retry action") +local staleGlyph = false +for _, glyph in ipairs(collect(failed, "glyph")) do + if glyph.props.name == "clock-exclamation" and glyph.props.color ~= "error" then + staleGlyph = true + end +end +assert(staleGlyph, "cached data uses a subdued stale glyph") + +local rateLimited = { + id = "openai", + display_name = "Codex", + plan = "ChatGPT Plus", + status = "ready", + metrics = paced.metrics, + sections = { + paced.sections[1], + { type = "text", label = "HTTP 429", value = "Rate limited" }, + }, +} +local rateLimitedTree = loadPanel(rateLimited) +local rateLimitedLabels = labels(rateLimitedTree) +assert(not has(rateLimitedLabels, "HTTP 429") and not has(rateLimitedLabels, "Rate limited"), + "raw transport details should not become loose content") +assert(#cards(rateLimitedTree) == 1, "transient provider failures keep cached readings") + +local emptyFailure = loadPanel(nil, { code = "timed_out", detail = "" }) +assert(has(labels(emptyFailure), "ui.error.timed_out_hint"), + "a failure without cached data keeps the full error state") -- Every row can be dropped -- a vendor with no key at all is not listed -- and a -- report is still a report. The failure is a banner over the panel it arrived @@ -326,4 +353,170 @@ assert(#collect(dropped, "separator") == 1, "the panel keeps its two panes") assert(not has(labels(dropped), "ui.error.timed_out_hint"), "and says the failure once, in the pane, not across the whole panel") -io.write("ok: panel degrades safely, groups by model, and stops gauging what is down\n") +local bottleneckPanelEntry = { + id = "openai", + display_name = "Codex", + plan = "ChatGPT Plus", + status = "ready", + metrics = { + { label = "Codex 5h", percent = 0, severity = "low", value = "0%" }, + { label = "Codex weekly", percent = 100, severity = "critical", value = "100%" }, + }, + sections = { + { label = "Codex 5h", percent = 0, severity = "low", type = "metric", value = "0%" }, + { label = "Codex weekly", percent = 100, severity = "critical", type = "metric", value = "100%" }, + }, +} +local bottleneckPanelTree = loadPanel(bottleneckPanelEntry) +assert(has(labels(bottleneckPanelTree), "100%"), + "panel sidebar should display the 100% bottleneck reading") + +local agyPanelEntry = { + id = "antigravity", + display_name = "Antigravity", + plan = "Google AI Pro", + status = "ready", + metrics = { + { label = "Gemini", percent = 0, severity = "low", value = "0%" }, + { label = "Claude & GPT OSS", percent = 0, severity = "low", value = "0%" }, + { label = "Gemini", percent = 24, severity = "low", value = "24%" }, + { label = "Claude & GPT OSS", percent = 100, severity = "critical", value = "100%" }, + }, + sections = { + { type = "text", label = "Session", value = "" }, + { label = "Gemini", percent = 0, severity = "low", type = "metric", value = "0%" }, + { label = "Claude & GPT OSS", percent = 0, severity = "low", type = "metric", value = "0%" }, + { type = "text", label = "Weekly", value = "" }, + { label = "Gemini", percent = 24, severity = "low", type = "metric", value = "24%" }, + { label = "Claude & GPT OSS", percent = 100, severity = "critical", type = "metric", value = "100%" }, + }, +} +local agyPanelTree = loadPanel(agyPanelEntry) +assert(has(labels(agyPanelTree), "Claude & GPT OSS · ui.quota_exhausted"), "notice must identify the exhausted model without relying on color") +assert(not has(labels(agyPanelTree), "ui.quota_remaining"), "healthy models must not duplicate the detailed quota reading") +local function hasGlyph(node, name) + for _, g in ipairs(collect(node, "glyph")) do + if g.props.name == name then return true end + end + return false +end +assert(hasGlyph(agyPanelTree, "brand-google"), "panel sidebar should display Gemini brand glyph") +assert(hasGlyph(agyPanelTree, "robot"), "panel sidebar should display robot glyph for Claude & GPT OSS") +assert(has(labels(agyPanelTree), "0%"), "panel sidebar should display active session 0%") +assert(has(labels(agyPanelTree), "/ 24%"), "panel sidebar should display Gemini weekly percentage / 24%") +assert(has(labels(agyPanelTree), "/ 100%"), "panel sidebar should display Claude weekly percentage / 100%") + +local googleGlyphs = {} +local robotGlyphs = {} +for _, g in ipairs(collect(agyPanelTree, "glyph")) do + if g.props.name == "brand-google" then googleGlyphs[#googleGlyphs + 1] = g end + if g.props.name == "robot" then robotGlyphs[#robotGlyphs + 1] = g end +end +assert(#googleGlyphs >= 2, "brand-google should appear in both sidebar and model detail card header") +assert(#robotGlyphs >= 2, "robot should appear in both sidebar and model detail card header") + +local agyRows = {} +for _, row in ipairs(collect(agyPanelTree, "row")) do + if row.props.key == "provider-antigravity" then agyRows[#agyRows + 1] = row end +end +assert(#agyRows == 1, "antigravity row should exist in panel") +local agyProgress = collect(agyRows[1], "progress") +assert(#agyProgress == 4, "antigravity providerRow should have 4 progress bars for Gemini and Claude windows") +local agySpacers = collect(agyRows[1], "spacer") +assert(#agySpacers >= 1, "antigravity providerRow should include balancing spacer for centered icon alignment") + +local codexDualEntry = { + id = "openai", + display_name = "Codex", + plan = "ChatGPT Plus", + status = "ready", + metrics = { + { label = "Codex 5h", percent = 100, severity = "critical", value = "100%" }, + { label = "Codex weekly", percent = 16, severity = "low", value = "16%" }, + }, + sections = {}, +} +local codexTree = loadPanel(codexDualEntry) +local codexRows = {} +for _, row in ipairs(collect(codexTree, "row")) do + if row.props.key == "provider-openai" then codexRows[#codexRows + 1] = row end +end +assert(#codexRows == 1, "codex row should exist in panel") +local codexProgress = collect(codexRows[1], "progress") +assert(#codexProgress == 2, "codex providerRow should have paired dual progress bars for session and weekly") + +local codexWithEmptyCredits = { + id = "openai", + display_name = "Codex", + plan = "ChatGPT Plus", + status = "ready", + metrics = { + { label = "Codex 5h", percent = 100, severity = "critical", value = "100%" }, + { label = "Codex weekly", percent = 16, severity = "low", value = "16%" }, + }, + sections = { + { type = "spacer" }, + { detail = "Resets in 1h 40m", label = "Codex 5h", percent = 100, severity = "critical", type = "metric", value = "100%" }, + { type = "spacer" }, + { detail = "Resets in 6d 20h", label = "Codex weekly", percent = 16, severity = "low", type = "metric", value = "16%" }, + { type = "spacer" }, + { body = { "balance: 0", "≈ 0-0 local messages", "≈ 0-0 cloud messages" }, label = "Credits", type = "block" }, + }, +} +local emptyCreditsTree = loadPanel(codexWithEmptyCredits) +assert(not has(labels(emptyCreditsTree), "Credits"), "empty credits block should be hidden") + +local codexWithActiveCredits = { + id = "openai", + display_name = "Codex", + plan = "ChatGPT Plus", + status = "ready", + metrics = { + { label = "Codex 5h", percent = 100, severity = "critical", value = "100%" }, + }, + sections = { + { body = { "balance: $12.50", "≈ 50 local messages" }, label = "Credits", type = "block" }, + }, +} +local activeCreditsTree = loadPanel(codexWithActiveCredits) +assert(has(labels(activeCreditsTree), "Credits"), "active credits block with positive balance should be displayed") + +io.write("ok: panel degrades safely, sorts usage, and removes unavailable providers\n") +local parserError = "schema mismatch: openai usage response: invalid type: null" +local parserPanel = loadPanel({ id = "openai", display_name = "Codex", status = "error", + error = parserError, metrics = {}, sections = {} }) +assert(has(labels(parserPanel), "Codex"), "parser failures must not remove Codex from the panel") +assert(has(labels(parserPanel), "ui.error.failed"), "parser failures should be described as read failures") +assert(has(labels(parserPanel), parserError), "the panel must retain the diagnostic explaining missing usage") + +local oneModelEntry = { id = "antigravity", display_name = "Antigravity", status = "ready", sections = {}, metrics = { + { label = "Gemini", percent = 0, severity = "low" }, + { label = "Gemini", percent = 100, severity = "critical", + reset_at = os.date("!%Y-%m-%dT%H:%M:%SZ", os.time() + 590400) }, +} } +local singleModelTree = loadPanel(oneModelEntry) +assert(has(labels(singleModelTree), "Gemini · ui.quota_exhausted"), "single-model notice must name the blocked model") +assert(has(labels(singleModelTree), "ui.quota_reset: 6d 20h"), "notice must show the weekly unlock time") +oneModelEntry.metrics[1].percent = 100 +oneModelEntry.metrics[1].severity = "critical" +oneModelEntry.metrics[1].reset_at = os.date("!%Y-%m-%dT%H:%M:%SZ", os.time() + 7200) +oneModelEntry.metrics[2].percent = 90 +assert(has(labels(loadPanel(oneModelEntry)), "ui.quota_reset: 2h 00m"), "weekly warning must not override actual session exhaustion") +local installTree = loadPanel(nil, { code = "not_installed", detail = "" }) +assert(has(labels(installTree), "https://github.com/akitaonrails/ai-usagebar"), + "installation instructions must be visible without a browser-opening dependency") + +local resetCreditTree = loadPanel({ id = "openai", display_name = "Codex", status = "ready", metrics = {}, sections = { + { type = "block", label = "Reset credits", body = { + "Full reset (Weekly + 5 hr) · expires Oct 4 19:45 (26d 8h)", + "Future credit format without a separator", + } }, +} }) +assert(has(labels(resetCreditTree), "Full reset (Weekly + 5 hr)"), "reset type must have its own line") +assert(has(labels(resetCreditTree), "expires Oct 4 19:45 (26d 8h)"), "credit expiry must remain visible on a separate line") +assert(has(labels(resetCreditTree), "Future credit format without a separator"), "unknown credit formats must remain readable") +for _, label in ipairs(collect(resetCreditTree, "label")) do + if label.props.text == "expires Oct 4 19:45 (26d 8h)" then + assert(label.props.maxLines >= 2, "expiry must wrap instead of truncating at one line") + end +end diff --git a/ai-usagebar/tests/scrub_test.lua b/ai-usagebar/tests/scrub_test.lua index 5059d3be1..c7a3a7eaf 100644 --- a/ai-usagebar/tests/scrub_test.lua +++ b/ai-usagebar/tests/scrub_test.lua @@ -46,6 +46,14 @@ local classify = loadSlice("local inFlight", "classify") -- Each case names the material that must not survive. local SECRETS = { + { 'password="synthetic first second"', "first second" }, + { "client_secret='synthetic first second'", "first second" }, + { '{"access_token": "synthetic first second"}', "first second" }, + { 'password="synthetic \\" second"', "second" }, + { 'password="synthetic ' .. string.rep('x', 250), "synthetic" }, + { 'Cookie: session=synthetic-session-value; other=private', "synthetic-session-value" }, + { 'Set-Cookie: session=synthetic-session-value; HttpOnly', "synthetic-session-value" }, + { 'Authorization: Digest username="synthetic-user", response="synthetic-response"', "synthetic-response" }, { "GET /v1/usage?api_key=sk-ant-abc123456 failed", "abc123456" }, { "request token=eyJhbGciOiJIUzI1NiJ9.SIGNATURE failed", "SIGNATURE" }, { "client_secret=hunter2 rejected", "hunter2" }, @@ -125,6 +133,23 @@ local function fail(message) io.write("FAIL ", message, "\n") end +local structured = scrub({ nested = { + access_token = "synthetic-structured-secret", + API_KEY = "synthetic-api-key", + password = { value = "synthetic-nested-password" }, + Authorization = "Digest synthetic-auth", + cookie = "session=synthetic-cookie", + input_tokens = 45000, + tokens_used = "1500", + api_key_tokens = 42, +} }) +for _, name in ipairs({ "access_token", "API_KEY", "password", "Authorization", "cookie", "api_key_tokens" }) do + if structured.nested[name] ~= "" then fail("sensitive field not redacted: " .. name) end +end +if structured.nested.input_tokens ~= 45000 or structured.nested.tokens_used ~= "1500" then + fail("structured token counts must retain their values and types") +end + -- A healthy usage entry is reported as `ok` by the CLI. Only `error` is a -- failed provider state. local healthy = classify({ exitCode = 0, stdout = "not json", stderr = "" }) diff --git a/ai-usagebar/tests/shared_test.lua b/ai-usagebar/tests/shared_test.lua index 1ee4931c6..acad01ca5 100644 --- a/ai-usagebar/tests/shared_test.lua +++ b/ai-usagebar/tests/shared_test.lua @@ -11,7 +11,11 @@ local function read(path) return source end -local noctalia = {} +local noctalia = { + string = { + trim = function(value) return tostring(value):match("^%s*(.-)%s*$") end, + }, +} local env = setmetatable({ noctalia = noctalia }, { __index = _G }) local shared = assert(load(read("shared.luau"), "shared", "t", env))() @@ -29,4 +33,148 @@ for _, case in ipairs(cases) do string.format("%s: expected %d for %s, got %s", name, expected, input, tostring(actual))) end -io.write("ok: ISO timestamps stay UTC across DST transitions\n") +assert(type(shared.terminalAuth) == "function", "terminal authentication classifier should exist") +assert(type(shared.transportError) == "function", "transport error classifier should exist") + +local retired = { + id = "anthropic", + error = "", + sections = { + { type = "text", label = "HTTP 403", value = "authentication rejected" }, + }, +} +assert(shared.terminalAuth(retired), "403 authentication rejection should be terminal") +assert(shared.terminalAuth({ error = "HTTP 401: authorization token expired" }), + "401 expired authorization should be terminal") +assert(not shared.terminalAuth({ error = "HTTP 429 authentication rate limited" }), + "rate limiting should stay transient") +assert(not shared.terminalAuth({ error = "HTTP 500 authentication service unavailable" }), + "server errors should stay transient") +assert(not shared.terminalAuth({ error = "authentication rejected by local configuration" }), + "authentication wording without HTTP 401 or 403 should stay visible") +assert(shared.transportError("HTTP 500: internal error"), "HTTP failures should be transport details") +assert(not shared.transportError("The weekly window resets soon"), "ordinary prose should remain content") + +local filtered = shared.entries({ entries = { + retired, + { id = "openai", sections = {} }, +} }) +assert(#filtered == 1 and filtered[1].id == "openai", + "terminal providers should be filtered from shared entries") + +local function usageEntry(id, percent) + return { + id = id, + status = "ready", + stale = false, + metrics = { { percent = percent } }, + sections = {}, + } +end + +local parserFailure = { id = "openai", status = "error", metrics = {}, sections = {}, + error = "schema mismatch: openai usage response: invalid type: null, expected a sequence" } +assert(#shared.entries({ entries = { parserFailure } }) == 1, + "a parser failure must remain visible because it does not prove the account is unavailable") +assert(shared.unavailable(parserFailure), "failed readings must not be drawn as live metrics") + +local unavailable = usageEntry("anthropic", 0) +unavailable.stale = true +unavailable.sections = { + { type = "text", label = "HTTP 429", value = "Rate limited" }, +} +local ordered = shared.entries({ entries = { + unavailable, + usageEntry("openai", 32), + usageEntry("antigravity", 99), +} }) +assert(#ordered == 2, "providers with their own refresh failure should be removed") +assert(ordered[1].id == "antigravity" and ordered[2].id == "openai", + "working providers should be ordered by highest usage") + +local bottleneckEntry = { + id = "openai", + status = "ready", + stale = false, + metrics = { + { label = "Codex 5h", percent = 0, severity = "low" }, + { label = "Codex weekly", percent = 100, severity = "critical" }, + }, + sections = {}, +} +local headlineMetric = shared.headline(bottleneckEntry) +assert(headlineMetric ~= nil and headlineMetric.label == "Codex weekly" and headlineMetric.percent == 100, + "headline should select the bottleneck/highest severity metric across windows") + +local tieSeverityEntry = { + id = "openai", + status = "ready", + stale = false, + metrics = { + { label = "Session", percent = 15, severity = "low" }, + { label = "Weekly", percent = 45, severity = "low" }, + }, + sections = {}, +} +local tieMetric = shared.headline(tieSeverityEntry) +assert(tieMetric ~= nil and tieMetric.label == "Session" and tieMetric.percent == 15, + "headline should prioritize active session when broader windows are not critical") + +local reordered = shared.entries({ entries = { + usageEntry("antigravity", 50), + bottleneckEntry, +} }) +assert(reordered[1].id == "openai" and reordered[2].id == "antigravity", + "a provider with critical 100% weekly limit should rank above a 50% low severity provider") + +local antigravityEntry = { + id = "antigravity", + status = "ready", + stale = false, + metrics = { + { label = "Gemini", percent = 0, severity = "low" }, + { label = "Claude & GPT OSS", percent = 0, severity = "low" }, + { label = "Gemini", percent = 24, severity = "low" }, + { label = "Claude & GPT OSS", percent = 100, severity = "critical" }, + }, + sections = {}, +} +assert(#shared.modelHeadlines(bottleneckEntry) == 0, + "single-model providers should have no sub-model headlines") +local agyModels = shared.modelHeadlines(antigravityEntry) +assert(#agyModels == 2, "antigravity should extract both model headlines") +assert(agyModels[1].model == "Gemini" and agyModels[1].glyph == "brand-google" and agyModels[1].metric.percent == 0, + "gemini should resolve to its active session") +assert(agyModels[2].model == "Claude & GPT OSS" and agyModels[2].glyph == "robot" and agyModels[2].displayName == "Claude & GPT OSS" and agyModels[2].metric.percent == 0 and agyModels[2].blockingMetric.percent == 100, + "claude/oss should preserve its identity, session and exhausted quota") + +local normalAgy = { + id = "antigravity", + status = "ready", + stale = false, + metrics = { + { label = "Gemini", percent = 11, severity = "low" }, + { label = "Claude & GPT OSS", percent = 0, severity = "low" }, + { label = "Gemini", percent = 43, severity = "low" }, + { label = "Claude & GPT OSS", percent = 78, severity = "high" }, + }, + sections = {}, +} +local normalHeadline = shared.headline(normalAgy) +assert(normalHeadline ~= nil and normalHeadline.percent == 11, + "antigravity headline should pick highest active session (11%) when no metric is critical") + +local session = { label = "Gemini", percent = 100, severity = "critical", reset_at = "2030-01-01T02:00:00Z" } +local weekly = { label = "Gemini", percent = 100, severity = "critical", reset_at = "2030-01-06T02:00:00Z" } +assert(shared.headline({ id = "openai", metrics = { session, weekly } }) == weekly, + "two exhausted windows must use the latest reset") +weekly.percent = 90 +local oneModel = { id = "antigravity", metrics = { session, weekly } } +assert(shared.headline(oneModel) == session, "critical but usable weekly quota must not block the session") +local oneHeadline = shared.modelHeadlines(oneModel) +assert(#oneHeadline == 1 and oneHeadline[1].blockingMetric == session, + "a single model must retain its actual exhausted window") +session.percent, weekly.percent = 0, 100 +assert(shared.headline(oneModel) == weekly, "one model must show its exhausted weekly quota") + +io.write("ok: shared timestamps, availability, and provider order\n") diff --git a/ai-usagebar/thumbnail.webp b/ai-usagebar/thumbnail.webp index f4060210e..97472433f 100644 Binary files a/ai-usagebar/thumbnail.webp and b/ai-usagebar/thumbnail.webp differ diff --git a/ai-usagebar/translations/en.json b/ai-usagebar/translations/en.json index aa1b26f94..9571e6049 100644 --- a/ai-usagebar/translations/en.json +++ b/ai-usagebar/translations/en.json @@ -5,21 +5,21 @@ "label": "Account label" }, "color_by_usage": { - "description": "Tints the reading as the quota gets tight. Off keeps the capsule in the bar's own colour.", + "description": "Color readings by quota severity. Error indicators remain visible when this is off.", "label": "Color by usage" }, "extras": { - "description": "The time left in the window, how far the spend is from the clock, both, or neither.", + "description": "Show the reset countdown, consumption pace, both, or neither.", "label": "Extra reading", "option": { "both": "Both", "countdown": "Time until reset", "none": "Neither", - "pace": "Pace against the clock" + "pace": "Consumption pace" } }, "glyph_position": { - "description": "Which side of the reading the icon sits on.", + "description": "Place the icon before or after the reading.", "label": "Icon position", "option": { "after": "After", @@ -31,19 +31,19 @@ "label": "Providers in the capsule" }, "refresh_minutes": { - "description": "How often the CLI is asked for fresh usage. Countdowns tick locally between calls.", + "description": "How often to request fresh usage from the CLI. Countdowns update locally between calls.", "label": "Refresh interval (minutes)" }, "show_glyph": { - "description": "The provider's icon. Off leaves the reading on its own.", + "description": "Show provider and model icons beside their readings.", "label": "Show icon" }, "show_name": { - "description": "Adds the product name next to the percentage, so two capsules do not look alike.", + "description": "Show the provider name beside the percentage.", "label": "Show provider name" }, "show_value": { - "description": "The percentage as text. Off leaves the gauge to say it.", + "description": "Show usage as a percentage. The gauge and icons have separate settings.", "label": "Show value" }, "style": { @@ -57,13 +57,13 @@ } }, "vendor": { - "description": "Which plan this capsule tracks. Add the widget twice to watch two.", + "description": "Choose a provider or select Automatic to follow usage priority.", "label": "Provider", "option": { "anthropic": "Claude", "anthropic_api": "Anthropic API", "antigravity": "Antigravity", - "auto": "Automatic (primary)", + "auto": "Automatic (usage priority)", "commandcode": "Command Code", "cursor": "Cursor", "deepseek": "DeepSeek", @@ -83,7 +83,7 @@ } }, "visualization": { - "description": "A gauge beside the reading, or nothing.", + "description": "Show a quota gauge beside the reading.", "label": "Visualization", "option": { "gauge": "Quota bar", @@ -102,9 +102,9 @@ "not_installed": "ai-usagebar is not installed", "not_installed_hint": "The CLI is not on PATH. Install it, then try again.", "spawn_failed": "Could not start ai-usagebar", - "spawn_failed_hint": "The process never started. Try again in a moment.", - "timed_out": "ai-usagebar did not answer", - "timed_out_hint": "The read took too long. A provider may be answering slowly." + "spawn_failed_hint": "The process could not start. Try again in a moment.", + "timed_out": "ai-usagebar timed out", + "timed_out_hint": "The usage request took too long. A provider may be slow to respond." }, "hidden_label": "Not shown", "hidden_value": "{count} more, click to open the panel", @@ -116,6 +116,8 @@ "not_configured": "`{vendor}` is not configured in ai-usagebar", "now": "now", "plan_changed": "The plan changed; readings taken under the previous one were dropped.", + "quota_exhausted": "Quota exhausted", + "quota_reset": "Renews in {time}", "refresh": "Refresh now", "refresh_queued": "Refresh queued", "refreshing": "Refreshing usage",