diff --git a/ai-usagebar/README.md b/ai-usagebar/README.md index 840a493c..9bf6d8d0 100644 --- a/ai-usagebar/README.md +++ b/ai-usagebar/README.md @@ -24,17 +24,22 @@ 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. -`xdg-open` is optional. It is spawned by one row in the panel, the link to the -CLI's project page offered when `ai-usagebar` is not on `PATH`. Without -xdg-utils that row does nothing and the rest of the plugin is unaffected. +`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. + +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. ## Usage Add `felipeartur/ai-usagebar:bar` to a bar in Settings, Bar. The capsule shows -the headline percentage of a provider, behind that provider's icon. It reads in -the bar's own colour while there is room, picks up the theme's `tertiary` when -the CLI calls the window high, and `error` when it calls it critical. The accent -stays on the gauge fill, so a calm capsule looks like the widgets beside it. +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 @@ -42,18 +47,16 @@ 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. -Four styles, all with the same reading: - -| Style | Shape | -| --- | --- | -| `pill` | Icon and percentage. The compact one. | -| `gauge` | Icon, a small quota bar over a thinner "window elapsed" bar, percentage. | -| `meter` | Icon and five segments, filled in twenties, with no percentage. | -| `label` | Icon, provider name and percentage stacked over the bars. | +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. -Next to that, `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` 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. 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 @@ -62,7 +65,7 @@ has no settings of its own, so the gear opens empty: ```toml [widget.ai_usage] type = "felipeartur/ai-usagebar:bar" -style = "gauge" +visualization = "gauge" provider_limit = 2 [bar.default] @@ -73,29 +76,37 @@ start = [ "clock", "ai_usage" ] clock time the reset lands on. - **Left click** opens the `AI Usage` panel for the provider that capsule tracks. -- **Right click** refreshes immediately. +- **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. -The panel is a two pane view. On the left is every provider you have set up, +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 header says how old -the reading is. There is no refresh button and no close button: the read -happens on open, and the panel closes when you click away from it or press the -same widget again. - -The list follows the CLI. A provider that `ai-usagebar` has no credential for -never appears, while one that is set up and failing keeps its row and shows the -error. - -The detail pane spells out everything the CLI reports for that provider instead -of implying it: the plan and account name, the provider id, its status, a stale -flag when the reading is old, and when it was fetched. Each window gets its -label, the severity the CLI assigned it, 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, and the pace line. +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 and shows the CLI's own words, so Antigravity with its local server +down says to open Antigravity rather than vanishing. + +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, a stale flag when +the reading is old, and the status when it is anything other than a healthy +read. 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. To open the panel from a terminal: @@ -117,7 +128,10 @@ Per widget instance, so two capsules can follow two providers: | Setting | Type | Default | Description | | --- | --- | --- | --- | | `vendor` | `select` | `auto` | Which plan this capsule tracks. `auto` follows the busiest provider, with the CLI's own `[ui] primary` breaking ties. | -| `style` | `select` | `pill` | `pill`, `gauge`, `meter` or `label`, as described in the table above. | +| `visualization` | `select` | `none` | `gauge` or `none`, as described above. | +| `show_value` | `bool` | `true` | Show the percentage as text. | +| `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. | @@ -148,12 +162,22 @@ noctalia msg plugin felipeartur/ai-usagebar:poller all select anthropic 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 in the capsule and in the panel header. -- The file watcher follows the `.luau` entries only, so the files in - `translations/` are read once, when the plugin loads. Editing a string takes - a reload before the new text shows up: - - ```sh - noctalia msg plugins disable felipeartur/ai-usagebar - noctalia msg plugins enable felipeartur/ai-usagebar - ``` + keeps showing, flagged by an icon in the list, the capsule, and the panel's + detail pane. + +## 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: + +```sh +lua tests/scrub_test.lua +lua tests/refresh_test.lua +``` + +The first test reads `safeText` and `scrub` out of `service.luau` rather than +copying them, then checks that real credential shapes never survive, that ordinary +readings pass through unchanged, and that scrubbing a four-vendor report stays +inside the CPU budget the poller's async callback is given. The second exercises +the coalesced refresh state and checks that every provider it knows about has a +glyph of its own rather than the fallback. An overrun in the first test loses the whole reading, not just time. diff --git a/ai-usagebar/bar.luau b/ai-usagebar/bar.luau index eb8a992e..c39bfa43 100644 --- a/ai-usagebar/bar.luau +++ b/ai-usagebar/bar.luau @@ -1,12 +1,16 @@ --!nonstrict --- Bar capsule. Reads whatever the poller published and draws one provider, or --- the busiest few when `provider_limit` is raised. --- --- Per-instance settings, so two capsules can follow two different providers. +-- Bar capsule. Draws what the poller published: one provider, or the busiest few +-- when `provider_limit` is raised. Settings are per-instance, so a second capsule +-- can follow a second provider. local vendor = tostring(noctalia.getConfig("vendor") or "auto") -local style = tostring(noctalia.getConfig("style") or "pill") local extras = tostring(noctalia.getConfig("extras") or "countdown") +-- Named after the core `sysmon` widget's own keys, so a reading in this capsule +-- is configured the same way as the CPU one beside it. +local visualization = tostring(noctalia.getConfig("visualization") or "none") +local showValue = noctalia.getConfig("show_value") ~= false +local showGlyph = noctalia.getConfig("show_glyph") ~= false +local glyphAfter = tostring(noctalia.getConfig("glyph_position") or "before") == "after" local limit = math.max(1, math.min(4, tonumber(noctalia.getConfig("provider_limit")) or 1)) local showName = noctalia.getConfig("show_name") == true local colorByUsage = noctalia.getConfig("color_by_usage") ~= false @@ -14,112 +18,18 @@ local colorByUsage = noctalia.getConfig("color_by_usage") ~= false local report = nil local polling = false --- The poller names the failure, so the capsule only has a code to translate. --- Anything else in that state slot reads as no failure at all. -local NO_FAILURE = { code = "", detail = "" } - -local function asFailure(value) - return type(value) == "table" and value or NO_FAILURE -end +local shared = require("./shared.luau") +local providerGlyph = shared.providerGlyph +local countdown, resetClock = shared.countdown, shared.resetClock +local ratio, headline, elapsedPercent = shared.ratio, shared.headline, shared.elapsedPercent +local pace, SEVERITY_RANK = shared.pace, shared.SEVERITY_RANK +local NO_FAILURE, asFailure = shared.NO_FAILURE, shared.asFailure local failure = NO_FAILURE --- Tabler has no Anthropic mark, so providers without a brand glyph get a --- semantic one. Same approach the other CLI-backed meters in this repo take. -local GLYPHS = { - anthropic = "asterisk-simple", - anthropic_api = "asterisk-simple", - openai = "brand-openai", - zai = "bolt", - openrouter = "route", - deepseek = "fish", - kimi = "moon", - moonshot = "moon", - kilo = "robot", - novita = "cloud", - grok = "brand-x", - supergrok = "brand-x", - antigravity = "sparkles", - cursor = "cursor-text", - minimax = "wave-square", - kiro = "ghost", - copilot = "brand-github-copilot", - gemini = "brand-google", -} - -- ── Report helpers ──────────────────────────────────────────────────────────── --- "2026-08-15T11:29:59.872624Z" -> unix seconds. The stamps are UTC, so the --- naive os.time() reading (which assumes local time) is corrected by the local --- offset measured at that same instant. -local function parseIso(value) - if type(value) ~= "string" then return nil end - local y, mo, d, h, mi, s = value:match("^(%d+)%-(%d+)%-(%d+)T(%d+):(%d+):(%d+)") - if y == nil then return nil end - local asLocal = os.time({ - year = tonumber(y), month = tonumber(mo), day = tonumber(d), - hour = tonumber(h), min = tonumber(mi), sec = tonumber(s), - }) - local utcAsLocal = os.time(os.date("!*t", asLocal)) - return asLocal + (asLocal - utcAsLocal) -end - -local function formatDuration(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 - if hours > 0 then return string.format("%dh %dm", hours, rest) end - return string.format("%dm", rest) -end - -local function countdown(metric) - local at = parseIso(metric and metric.reset_at) - if at == nil then return "" end - return formatDuration(at - os.time()) -end - --- The clock time the countdown lands on: "14:20", or "Sat 14:20" past midnight. -local function resetClock(metric) - local at = parseIso(metric and metric.reset_at) - if at == nil then return "" end - local clock = noctalia.formatTime(noctalia.timeFormat(), at) - -- The weekday is prepended here rather than folded into the pattern: the - -- host's format grammar passes unknown text through verbatim, so a "ddd" - -- prefix would render as the literal word. - if os.date("%Y-%m-%d", at) ~= os.date("%Y-%m-%d") then - return os.date("%a", at) .. " " .. clock - end - return clock -end - --- "Resets in 4h 01m · 19% elapsed · 2pts ahead" says how much of the window is --- gone and how far the spend is from that line. -local function elapsedPercent(metric) - local value = tostring(metric and metric.detail or ""):match("(%d+)%%%s*elapsed") - return value ~= nil and tonumber(value) or nil -end - --- Returns points and direction: 2, "ahead" is burning faster than the clock. -local function pace(metric) - local points, word = tostring(metric and metric.detail or ""):match("(%d+)pts%s+(%a+)") - if points == nil then return nil, nil end - return tonumber(points), word -end - -local function entries() - if type(report) ~= "table" or type(report.entries) ~= "table" then return {} end - return report.entries -end - -local function headline(entry) - if type(entry) ~= "table" or type(entry.metrics) ~= "table" then return nil end - return entry.metrics[1] -end - -local SEVERITY_RANK = { critical = 3, high = 2, medium = 1, low = 0 } +local function entries() return shared.entries(report) end local function rank(entry) local metric = headline(entry) @@ -127,8 +37,9 @@ local function rank(entry) return SEVERITY_RANK[tostring(metric.severity or "")] or 0, tonumber(metric.percent) or 0 end --- A pinned vendor shows only itself. "auto" shows the busiest providers, so --- the one closest to running out is the one on the bar. `primary` breaks ties. +-- A pinned vendor shows only itself. "auto" ranks by severity then percentage, +-- so the provider closest to running out is the one on the bar. `primary` breaks +-- ties. local function shown() local all = entries() if vendor ~= "auto" then @@ -156,49 +67,26 @@ local function shown() local picked = {} for i = 1, math.min(limit, #ready) do picked[i] = ready[i] end if #picked == 0 then return {}, 0 end - -- Someone who asked for one provider does not need a count of the others, - -- so the "+N" only appears once the capsule carries more than one. if limit == 1 then return picked, 0 end return picked, #ready - #picked end --- The CLI already tiers every percentage, and copying its thresholds here --- would be a second source of truth. Text stays in the bar's own colour until --- the reading is high or critical, and the accent colour is used on the bar --- fill only. -local function textRole(metric) - if not colorByUsage then return "on_surface" end - local severity = metric ~= nil and tostring(metric.severity or "") or "" - if severity == "critical" then return "error" end - if severity == "high" then return "tertiary" end - return "on_surface" -end - -local function barRole(metric) - if not colorByUsage then return "primary" end - local severity = metric ~= nil and tostring(metric.severity or "") or "" - if severity == "critical" then return "error" end - if severity == "high" then return "tertiary" end - return "primary" +-- `calm` is the colour when the CLI has raised nothing, and every colour when +-- the tint is switched off. +local function severityRole(metric, calm) + if not colorByUsage then return calm end + return shared.severityRole(metric, calm) end local function shortName(entry) local name = tostring(entry.display_name or entry.name or entry.id or "") - -- "Claude · gmail" is the panel's business; the bar has room for the product. + -- "Claude · gmail" is the panel's business; the bar only has room for the + -- product name. return (name:gsub("%s*·.*$", "")) end -- ── Rendering ───────────────────────────────────────────────────────────────── --- A provider can report more than it was given, so the reading is clamped --- before it becomes a bar width. -local function ratio(percent) - local value = (tonumber(percent) or 0) / 100 - if value < 0 then return 0 end - if value > 1 then return 1 end - return value -end - -- Quota above, window elapsed below: a fill longer than the clock bar is spend -- running ahead of time. local function bars(percent, elapsed, tint, width) @@ -213,76 +101,54 @@ local function bars(percent, elapsed, tint, width) return ui.column({ gap = 1, align = "center" }, stack) end -local function paceNodes(metric) - if extras ~= "pace" and extras ~= "both" then return nil end - local points, word = pace(metric) - if points == nil then return nil end - local ahead = word == "ahead" - return ui.row({ gap = 0, align = "center" }, { - ui.glyph({ name = ahead and "arrow-up" or "arrow-down", size = 10, - color = ahead and "tertiary" or "on_surface_variant" }), - ui.label({ text = tostring(points), fontSize = 10, - color = ahead and "tertiary" or "on_surface_variant", maxLines = 1 }), - }) -end - -local function countdownNode(metric) - if extras ~= "countdown" and extras ~= "both" then return nil end - local left = countdown(metric) - if left == "" then return nil end - return ui.label({ text = left, fontSize = 10, color = "on_surface_variant", maxLines = 1 }) -end - --- One provider's chip. The style decides the shape, and the extras are --- appended to whatever it produced. local function chip(entry) local metric = headline(entry) - local tint = textRole(metric) - local fill = barRole(metric) + local tint = severityRole(metric, "on_surface") local percent = metric ~= nil and tonumber(metric.percent) or nil local text = percent ~= nil and string.format("%d%%", percent) or "—" - local glyph = ui.glyph({ name = GLYPHS[tostring(entry.id)] or "brain", size = 13, color = tint }) - local pct = ui.label({ text = text, fontSize = 11, fontWeight = "semibold", color = tint, maxLines = 1 }) + local fill = severityRole(metric, "primary") + -- The mark says which provider, never how full it is. Severity rides on the + -- reading and the gauge, the same split the panel uses. + local glyph = showGlyph + and ui.glyph({ name = providerGlyph(entry.id), size = 13, color = "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 name = showName and ui.label({ text = shortName(entry), fontSize = 11, color = "on_surface_variant", maxLines = 1 }) or nil local nodes = {} local function add(node) if node ~= nil then nodes[#nodes + 1] = node end end - if style == "meter" and percent ~= nil then - -- Five ticks instead of digits: the reading at a glance, no numbers. - local ticks = {} - for i = 0, 4 do - ticks[#ticks + 1] = ui.box({ - width = 3, height = 11, radius = 1, - fill = percent > i * 20 and fill or "on_surface/0.22", - }) - end - add(glyph); add(name) - add(ui.row({ gap = 2, align = "center" }, ticks)) - elseif style == "label" and percent ~= nil then - -- Name and number stacked over the bar, for a bar with room to spare. - add(glyph) - add(ui.column({ gap = 1, align = "center" }, { - ui.row({ gap = 3, align = "center" }, { - ui.label({ text = shortName(entry), fontSize = 10, color = "on_surface_variant", maxLines = 1 }), - pct, - }), - bars(percent, elapsedPercent(metric), fill, 44), - })) - elseif style == "gauge" and percent ~= nil then - add(glyph); add(name) - add(bars(percent, elapsedPercent(metric), fill, 26)) - add(pct) - else - add(glyph); add(name); add(pct) + 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)) end + add(pct) + if glyphAfter then add(glyph) end - add(countdownNode(metric)) - add(paceNodes(metric)) + if extras == "countdown" or extras == "both" then + local left = countdown(metric) + if left ~= "" then + add(ui.label({ text = left, fontSize = 10, color = "on_surface_variant", maxLines = 1 })) + end + end + if extras == "pace" or extras == "both" then + local _, points, word = pace(metric and metric.detail) + if points ~= nil then + local ahead = word == "ahead" + local role = ahead and "secondary" or "on_surface_variant" + add(ui.row({ gap = 0, align = "center" }, { + ui.glyph({ name = ahead and "arrow-up" or "arrow-down", size = 10, color = role }), + ui.label({ text = tostring(points), fontSize = 10, color = role, maxLines = 1 }), + })) + end + end if entry.stale == true then - add(ui.glyph({ name = "clock-exclamation", size = 11, color = "tertiary" })) + add(ui.glyph({ name = "clock-exclamation", size = 11, color = "secondary" })) end return ui.row({ gap = 4, align = "center" }, nodes) end @@ -336,31 +202,38 @@ end local function render() local picked, hidden = shown() - -- A failure drops the reading here too, so the bar cannot be read as a - -- live percentage while the panel behind it says the CLI is unreachable. - -- Empty is already the shape that draws the alert glyph. + -- A failure drops the reading, so the capsule cannot show a live percentage + -- while the panel behind it says the CLI is unreachable. Empty already draws + -- the alert glyph. if failure.code ~= "" then picked, hidden = {}, 0 end local children = {} for _, entry in ipairs(picked) do + -- Two providers in one capsule run together on a gap alone, and the + -- countdown of the first reads as part of the second. A dot, not a rule: + -- a vertical `ui.separator` in a row gets no height of its own and came + -- out two pixels wide by one tall. The panel divides its chips the same + -- way. + if #children > 0 then + children[#children + 1] = ui.label({ text = "·", fontSize = 11, + color = "on_surface_variant" }) + end children[#children + 1] = chip(entry) end - if polling then - children[#children + 1] = ui.glyph({ name = "loader-2", size = 11, color = "on_surface_variant" }) - end - if #children == 0 then - children[1] = ui.row({ gap = 4, align = "center" }, { - ui.glyph({ name = "brain", size = 13, color = "on_surface_variant" }), - ui.glyph({ name = "alert-circle", size = 12, color = "error" }), + -- 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, + color = failure.code ~= "" and "error" or "on_surface_variant", }) elseif hidden > 0 then children[#children + 1] = ui.label({ text = "+" .. tostring(hidden), fontSize = 10, color = "on_surface_variant", maxLines = 1 }) end - barWidget.render(ui.row({ gap = 7, align = "center" }, children)) + barWidget.render(ui.row({ gap = 6, align = "center" }, children)) barWidget.setTooltip(tooltip(picked, hidden)) end @@ -391,9 +264,6 @@ function onClick() noctalia.togglePanel("felipeartur/ai-usagebar:panel") end -function onRightClick() - noctalia.state.set("command", { action = "refresh", at = os.time() }) -end report = noctalia.state.get("report") failure = asFailure(noctalia.state.get("error")) diff --git a/ai-usagebar/panel.luau b/ai-usagebar/panel.luau index 0a94d839..9f759188 100644 --- a/ai-usagebar/panel.luau +++ b/ai-usagebar/panel.luau @@ -1,112 +1,40 @@ --!nonstrict --- Expanded panel for one provider. --- --- It renders `sections[]`, which is the CLI's lossless view, so credit blocks --- and free text that the shorter `metrics[]` view drops still show up. +-- Expanded panel for one provider. It renders `sections[]`, the CLI's lossless +-- view, so the credit blocks and free text that `metrics[]` drops still show up. local report = nil local polling = false +local refreshQueued = false --- The poller names the failure, so the panel only has a code to translate. --- Anything else in that state slot reads as no failure at all. -local NO_FAILURE = { code = "", detail = "" } - -local function asFailure(value) - return type(value) == "table" and value or NO_FAILURE -end +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 elapsedPercent, pace = shared.elapsedPercent, shared.pace +local requestRefresh = shared.requestRefresh +local NO_FAILURE, asFailure = shared.NO_FAILURE, shared.asFailure local failure = NO_FAILURE --- Read once: a session either has xdg-utils or it does not, and the panel --- would otherwise stat PATH on every second tick it spends in a failure. +-- Read once: a session either has xdg-utils or it does not, and the panel would +-- otherwise stat PATH on every second tick it spends in a failure. local HAS_OPENER = noctalia.commandExists("xdg-open") --- Same parsing the capsule does. There is no require() below API 22, so the --- four helpers below are copied instead of shared. -local function parseIso(value) - if type(value) ~= "string" then return nil end - local y, mo, d, h, mi, s = value:match("^(%d+)%-(%d+)%-(%d+)T(%d+):(%d+):(%d+)") - if y == nil then return nil end - local asLocal = os.time({ - year = tonumber(y), month = tonumber(mo), day = tonumber(d), - hour = tonumber(h), min = tonumber(mi), sec = tonumber(s), - }) - local utcAsLocal = os.time(os.date("!*t", asLocal)) - return asLocal + (asLocal - utcAsLocal) -end - -local function formatDuration(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 - if hours > 0 then return string.format("%dh %dm", hours, rest) end - return string.format("%dm", rest) -end - -local function countdown(section) - local at = parseIso(section and section.reset_at) - if at == nil then return "" end - return formatDuration(at - os.time()) -end - -local function resetClock(section) - local at = parseIso(section and section.reset_at) - if at == nil then return "" end - local clock = noctalia.formatTime(noctalia.timeFormat(), at) - if os.date("%Y-%m-%d", at) == os.date("%Y-%m-%d") then return clock end - -- A weekday alone is ambiguous once the window is more than a week out. - if at - os.time() > 6 * 86400 then return os.date("%d %b", at) .. " " .. clock end - return os.date("%a", at) .. " " .. clock -end - --- Text stays on the surface colour until the CLI calls the window high or --- critical. The accent colour is used on the bar fill only. -local function textRole(section) - local severity = tostring(section and section.severity or "") - if severity == "critical" then return "error" end - if severity == "high" then return "tertiary" end - return "on_surface" -end - -local function barRole(section) - local severity = tostring(section and section.severity or "") - if severity == "critical" then return "error" end - if severity == "high" then return "tertiary" end - return "primary" -end - --- The CLI reports a vendor it has no credential for as a `credentials error`. --- Those are not listed, because they were never set up. A configured provider --- that fails for any other reason keeps its row. +-- 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 right now -- "credentials error: +-- Antigravity: no local server found ... open Antigravity" -- so the row is only +-- dropped when the CLI says there is no key at all. Anything else keeps its row +-- and shows the CLI's own words, which say what to do about it. local function configured(entry) if entry.status ~= "error" then return true end - return not tostring(entry.error or ""):lower():find("credentials error") + local message = tostring(entry.error or ""):lower() + return not (message:find("^credentials error:") and message:find("no api key", 1, true)) end -- ── Detail line parsing ─────────────────────────────────────────────────────── -- "Resets in 1h 58m · 60% elapsed · 30pts ahead". The reset half is already in --- `reset_at`; what is left is the pace pair. - -local function elapsedPercent(detail) - local value = tostring(detail or ""):match("(%d+)%%%s*elapsed") - return value ~= nil and tonumber(value) or nil -end - -local function pace(detail) - local text = tostring(detail or "") - if not text:find("·") then return "", "on_surface_variant" end - local last = "" - for part in text:gmatch("[^·]+") do last = part end - last = noctalia.string.trim(last) - if last:find("elapsed") then return "", "on_surface_variant" end - -- Ahead of the clock is worth flagging. Under it means there is room left. - if last:find("ahead") then return last, "tertiary" end - if last:find("under") then return last, "on_surface_variant" end - return last, "on_surface_variant" -end +-- `reset_at`; what is left is the pace. -- Details that carry no reset at all, e.g. "62% of monthly limit consumed". local function plainDetail(detail) @@ -118,10 +46,7 @@ end -- ── Entry selection ─────────────────────────────────────────────────────────── -local function entries() - if type(report) ~= "table" or type(report.entries) ~= "table" then return {} end - return report.entries -end +local function entries() return shared.entries(report) end local function currentEntry() local wanted = noctalia.state.get("selected") @@ -143,57 +68,49 @@ local function updatedText(entry) return noctalia.tr("ui.updated_ago", { minutes = minutes }) end -local function ratio(percent) - local value = (tonumber(percent) or 0) / 100 - if value < 0 then return 0 end - if value > 1 then return 1 end - return value -end +-- ── Cards ───────────────────────────────────────────────────────────────────── -local function metricIcon(label) - local text = tostring(label or ""):lower() - if text:find("week") or text:find("month") then return "calendar" end - if text:find("credit") or text:find("balance") or text:find("extra") then return "shopping-cart" end - return "hourglass" +local function severityWord(section) + local severity = tostring(section and section.severity or "") + if severity ~= "high" and severity ~= "critical" then return nil end + return noctalia.tr("ui.severity." .. severity) end --- ── Cards ───────────────────────────────────────────────────────────────────── - local function metricCard(section) local percent = tonumber(section.percent) or 0 - local tint = textRole(section) - local fill = barRole(section) + local tint = severityRole(section, "on_surface") + local fill = severityRole(section, "primary") local value = tostring(section.value or ""):gsub(" of ", " / ") -- Only worth a column of its own when it says more than the percentage. local showValue = value ~= "" and value ~= string.format("%d%%", percent) local header = { - ui.glyph({ name = metricIcon(section.label), size = 14, color = tint }), - ui.label({ text = tostring(section.label or ""), fontSize = 11, color = "on_surface_variant" }), - ui.label({ - text = tostring(section.severity or ""), - fontSize = 9, fontWeight = "semibold", color = tint, - visible = tostring(section.severity or "") ~= "", - }), + ui.label({ text = tostring(section.label or ""), fontSize = 12, fontWeight = "semibold", + color = "on_surface", maxLines = 1 }), ui.spacer({ flexGrow = 1 }), } + local word = severityWord(section) + if word ~= nil then + header[#header + 1] = ui.label({ text = word, fontSize = 10, fontWeight = "semibold", color = tint }) + end if showValue then - header[#header + 1] = ui.label({ text = value, fontSize = 11, color = "on_surface_variant" }) + header[#header + 1] = ui.label({ text = value, fontSize = 11, color = "on_surface_variant", maxLines = 1 }) end + -- Fixed width, so a column of cards ends on one right edge. header[#header + 1] = ui.label({ text = string.format("%d%%", percent), fontSize = 15, fontWeight = "bold", color = tint, + width = 46, + textAlign = "end", }) local body = { ui.row({ gap = 6, align = "center" }, header), - ui.progress({ progress = ratio(percent), fill = fill, track = "on_surface/0.16", radius = 3, height = 5 }), + ui.progress({ progress = ratio(percent), fill = fill, track = "on_surface/0.16", radius = 3, height = 6 }), } - -- Two readings: quota spent above, window elapsed below. A shorter clock bar - -- than fill bar is quota burning ahead of time. local elapsed = elapsedPercent(section.detail) if elapsed ~= nil then body[#body + 1] = ui.progress({ @@ -201,31 +118,45 @@ local function metricCard(section) fill = "on_surface/0.45", track = "on_surface/0.10", radius = 2, - height = 2, - }) - body[#body + 1] = ui.label({ - text = noctalia.tr("ui.elapsed", { percent = elapsed }), - fontSize = 10, color = "on_surface_variant", + height = 3, }) end local left = countdown(section) local clock = resetClock(section) - local paceText, paceColor = pace(section.detail) - if left ~= "" or paceText ~= "" then - local footer = {} - if left ~= "" then - footer[#footer + 1] = ui.glyph({ name = "clock", size = 12, color = "on_surface_variant" }) - footer[#footer + 1] = ui.label({ text = left, fontSize = 11, color = "on_surface_variant" }) - if clock ~= "" then - footer[#footer + 1] = ui.label({ text = clock, fontSize = 11, fontWeight = "bold", color = "primary" }) - end - end - footer[#footer + 1] = ui.spacer({ flexGrow = 1 }) - if paceText ~= "" then - footer[#footer + 1] = ui.label({ text = paceText, fontSize = 11, fontWeight = "semibold", color = paceColor }) + -- Ahead of the clock is worth flagging; under it means there is room left. + local paceText, _, paceWord = pace(section.detail) + local paceColor = paceWord == "ahead" and "secondary" or "on_surface_variant" + local timing = {} + if left ~= "" then + timing[#timing + 1] = ui.glyph({ name = "clock", size = 12, color = "on_surface_variant" }) + timing[#timing + 1] = ui.label({ text = left, fontSize = 11, color = "on_surface_variant" }) + if clock ~= "" then + -- Parenthesised and muted: it is where the countdown beside it lands, + -- not a reading of its own. + timing[#timing + 1] = ui.label({ + text = "(" .. clock .. ")", fontSize = 11, color = "on_surface_variant", + }) end - body[#body + 1] = ui.row({ gap = 5, align = "center" }, footer) + end + local context = {} + if elapsed ~= nil then + context[#context + 1] = ui.label({ + text = noctalia.tr("ui.elapsed", { percent = elapsed }), + fontSize = 10, 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, + }) + end + if #timing > 0 then + body[#body + 1] = ui.row({ gap = 5, align = "center" }, timing) + end + if #context > 0 then + body[#body + 1] = ui.row({ gap = 5, align = "center" }, context) end local rest = plainDetail(section.detail) @@ -233,113 +164,98 @@ local function metricCard(section) body[#body + 1] = ui.label({ text = rest, fontSize = 11, color = "on_surface_variant" }) end - return ui.column({ gap = 6, padding = 10, radius = 8, fill = "surface_variant" }, body) + return ui.column({ gap = 6, padding = 10, radius = 8, fill = "surface_variant/0.45", + border = "outline/0.12", borderWidth = 1 }, body) end local function blockCard(section) local body = { - ui.row({ gap = 6, align = "center" }, { - ui.glyph({ name = metricIcon(section.label), size = 14, color = "primary" }), - ui.label({ text = tostring(section.label or ""), fontWeight = "bold", color = "on_surface" }), - }), + ui.label({ text = tostring(section.label or ""), fontSize = 12, fontWeight = "semibold", + color = "on_surface", maxLines = 1 }), } for _, line in ipairs(section.body or {}) do local text = noctalia.string.trim(tostring(line)) + -- A bare "balance:" from the CLI would read as a row that failed to + -- render, so nothing gets spelled the way it is everywhere else. + 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, }) end - return ui.column({ gap = 4, padding = 10, radius = 8, fill = "surface_variant" }, body) -end - -local function textRow(section) - return ui.row({ gap = 6, align = "center" }, { - ui.label({ text = tostring(section.label or ""), fontSize = 11, color = "on_surface_variant" }), - ui.spacer({ flexGrow = 1 }), - ui.label({ text = tostring(section.value or ""), fontSize = 11, color = "on_surface" }), - }) + return ui.column({ gap = 4, padding = 10, radius = 8, fill = "surface_variant/0.38", + border = "outline/0.18", borderWidth = 1 }, body) end -- ── Provider list ───────────────────────────────────────────────────────────── --- Same map the capsule uses; no require() below API 22, so it is duplicated. -local GLYPHS = { - anthropic = "asterisk-simple", - anthropic_api = "asterisk-simple", - openai = "brand-openai", - zai = "bolt", - openrouter = "route", - deepseek = "fish", - kimi = "moon", - moonshot = "moon", - kilo = "robot", - novita = "cloud", - grok = "brand-x", - supergrok = "brand-x", - antigravity = "sparkles", - cursor = "cursor-text", - minimax = "wave-square", - kiro = "ghost", - copilot = "brand-github-copilot", - gemini = "brand-google", -} - -local function headline(entry) - if type(entry) ~= "table" or type(entry.metrics) ~= "table" then return nil end - return entry.metrics[1] -end - local function providerRow(entry, selected) local metric = headline(entry) local percent = metric ~= nil and tonumber(metric.percent) or nil local broken = entry.status == "error" - local tint = selected and "on_primary" or textRole(metric) - local fill = selected and "on_primary" or barRole(metric) - local muted = selected and "on_primary" or "on_surface_variant" + local tint = severityRole(metric, "on_surface") + -- 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 if broken then - right = ui.glyph({ name = "alert-circle", size = 14, color = selected and "on_primary" or "error" }) - else - right = ui.label({ - text = percent ~= nil and string.format("%d%%", percent) or "—", - fontSize = 13, fontWeight = "bold", color = tint, + right = ui.row({ width = 48, justify = "end" }, { + ui.glyph({ name = "alert-circle", size = 14, color = "error" }), }) + else + local rightChildren = { + ui.label({ + text = percent ~= nil and string.format("%d%%", percent) or "—", + fontSize = 13, fontWeight = "bold", color = tint, + width = 34, textAlign = "end", + }), + } + if entry.stale == true then + rightChildren[#rightChildren + 1] = ui.glyph({ + name = "clock-exclamation", size = 12, color = "secondary", + }) + end + right = ui.row({ gap = 3, width = 48, justify = "end", align = "center" }, rightChildren) end local lines = { ui.label({ text = tostring(entry.display_name or entry.id), fontSize = 12, fontWeight = "semibold", - color = selected and "on_primary" or "on_surface", maxLines = 1, + 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 = fill, - track = selected and "on_primary/0.25" or "on_surface/0.16", + fill = severityRole(metric, "primary"), + track = "on_surface/0.16", radius = 2, height = 3, }) end lines[#lines + 1] = ui.label({ text = broken and noctalia.tr("ui.unavailable") or tostring(entry.plan or entry.id or ""), - fontSize = 10, color = muted, maxLines = 1, + fontSize = 10, color = "on_surface_variant", maxLines = 1, }) 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, - fill = selected and "primary" or "surface_variant", + -- 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", + borderWidth = 1, onClick = function() - -- currentEntry() reads this back, so the panel and the capsule that - -- opened it stay on the same provider. noctalia.state.set("selected", tostring(entry.id)) render() end, }, { - ui.glyph({ name = GLYPHS[tostring(entry.id)] or "brain", size = 16, color = tint }), + ui.glyph({ name = providerGlyph(entry.id), size = 16, + color = selected and "primary" or "on_surface" }), ui.column({ gap = 3, flexGrow = 1 }, lines), right, }) @@ -347,18 +263,7 @@ end -- ── Render ──────────────────────────────────────────────────────────────────── -local function actionRow(glyph, text, onClick) - return ui.row({ - gap = 5, align = "center", padding = 6, radius = 6, - fill = "surface_variant", onClick = onClick, - }, { - ui.glyph({ name = glyph, size = 12, color = "primary" }), - ui.label({ text = text, fontSize = 11, color = "primary" }), - }) -end - --- The failure and the suggested fix read first; the CLI's own words come last --- and smallest, where a bug report can still quote them. +-- The CLI's own words come last and smallest, where a bug report can quote them. local function errorBlock() local key = "ui.error." .. failure.code local children = { @@ -375,20 +280,32 @@ local function errorBlock() }) end - children[#children + 1] = actionRow("refresh", noctalia.tr("ui.retry"), function() - noctalia.state.set("command", { action = "refresh", at = os.time() }) - end) + local actions = { + ui.button({ + text = noctalia.tr("ui.retry"), glyph = "refresh", + variant = "outline", controlSize = "sm", + enabled = not polling, + onClick = requestRefresh, + }), + } - -- Retrying is pointless until the CLI exists, so that one failure gets the - -- install page as well. The URL is a literal, so there is nothing to quote, - -- and the row is only offered where something can open it. + -- Retrying is pointless until the CLI exists, so that failure gets the install + -- page too. The URL is a literal, and the button is only offered 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 - children[#children + 1] = actionRow("external-link", "github.com/akitaonrails/ai-usagebar", function() - noctalia.runAsync("xdg-open https://github.com/akitaonrails/ai-usagebar") - end) + 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, + }) end + children[#children + 1] = ui.row({ gap = 6, align = "center" }, actions) - return ui.column({ gap = 6 }, children) + return ui.column({ gap = 8 }, children) end local function listPane(entry) @@ -398,18 +315,31 @@ local function listPane(entry) rows[#rows + 1] = providerRow(candidate, entry ~= nil and candidate.id == entry.id) end end - if #rows == 0 then - rows[1] = ui.label({ text = noctalia.tr("ui.loading"), fontSize = 11, color = "on_surface_variant" }) - end - return ui.column({ gap = 10, padding = 14, width = 250 }, { ui.row({ gap = 8, align = "center" }, { ui.glyph({ name = "brain", size = 18, color = "primary" }), - ui.label({ text = noctalia.tr("ui.title"), fontSize = 15, fontWeight = "bold", color = "primary" }), + -- The accent belongs to the selection and the bars. A title that took + -- it too would leave the panel with no quiet level. + ui.label({ text = noctalia.tr("ui.title"), fontSize = 15, fontWeight = "bold", color = "on_surface" }), ui.spacer({ flexGrow = 1 }), - -- The panel refreshes when it opens, so the header only has to - -- show whether that read is still running. - ui.glyph({ name = "loader-2", size = 16, color = "primary", visible = polling }), + -- One slot for the read: the button becomes the spinner while the CLI + -- answers, instead of a second glyph pushing the header around. + ui.button({ + glyph = polling and "loader-2" or refreshQueued and "clock" or "refresh", + variant = "ghost", controlSize = "sm", + tooltip = noctalia.tr(polling and "ui.refreshing" + or refreshQueued and "ui.refresh_queued" or "ui.refresh"), + enabled = not polling, + onClick = requestRefresh, + }), + -- The capsule answers a middle click with this too, but the panel is + -- where someone decides the capsule should follow another provider. + ui.button({ + glyph = "settings", + variant = "ghost", controlSize = "sm", + tooltip = noctalia.tr("ui.settings"), + onClick = function() noctalia.openSettings() end, + }), }), ui.scroll({ gap = 6, flexGrow = 1 }, rows), }) @@ -419,49 +349,57 @@ local function detailPane(entry) local title = noctalia.tr("ui.title") local subtitle = "" if entry ~= nil then - title = tostring(entry.plan or entry.display_name or entry.id) - subtitle = tostring(entry.display_name or entry.id) + -- The provider names the pane, the plan qualifies it -- the same order the + -- row in the list uses. The CLI keeps them apart: `display_name` is + -- "Codex", `plan` is "ChatGPT Free". + title = tostring(entry.display_name or entry.id) + subtitle = tostring(entry.plan or "") if subtitle == title then subtitle = "" end end - local children = { - ui.row({ gap = 8, align = "center" }, { + -- With no entry the pane stays empty rather than repeating the list's title. + local children = {} + if entry ~= nil then + -- The row keeps the title block honest about its height: a bare ui.column + -- dropped into a column claims the pane's free space, parking the title at + -- the top of a hundred pixels of nothing. Wrapped, it is as tall as the two + -- labels in 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" }), - ui.label({ text = subtitle, fontSize = 11, color = "on_surface_variant", visible = subtitle ~= "" }), + 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 ~= "" }), }), - }), - } + }) + end - -- The entry's own fields, spelled out rather than implied by a colour. + -- The id and a "ready" status are skipped: the id is the row that was just + -- clicked, and a healthy read is the default. if entry ~= nil then - local chips = { - ui.label({ text = tostring(entry.id or ""), fontSize = 10, color = "on_surface_variant" }), - ui.label({ text = "·", fontSize = 10, color = "on_surface_variant" }), - ui.label({ - text = tostring(entry.status or ""), - fontSize = 10, - color = entry.status == "ready" and "on_surface_variant" or "error", - }), - } + local chips = {} if entry.stale == true then - chips[#chips + 1] = ui.label({ text = "·", fontSize = 10, color = "on_surface_variant" }) - chips[#chips + 1] = ui.label({ text = noctalia.tr("ui.stale"), fontSize = 10, color = "tertiary" }) + chips[#chips + 1] = ui.label({ text = noctalia.tr("ui.stale"), fontSize = 10, color = "secondary" }) end local fetched = parseIso(entry.fetched_at) if fetched ~= nil then - chips[#chips + 1] = ui.spacer({ flexGrow = 1 }) + if #chips > 0 then + chips[#chips + 1] = ui.label({ text = "·", fontSize = 10, 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", }) end - children[#children + 1] = ui.row({ gap = 4, align = "center" }, chips) + if #chips > 0 then + children[#children + 1] = ui.row({ gap = 4, align = "center" }, chips) + end end - local status = entry == nil and noctalia.tr("ui.loading") - or entry.status == "error" and tostring(entry.error or noctalia.tr("ui.unavailable")) + local status = entry ~= nil and entry.status == "error" + and tostring(entry.error or noctalia.tr("ui.unavailable")) or nil if status then children[#children + 1] = ui.label({ text = status, fontSize = 11, color = "on_surface_variant" }) end @@ -473,41 +411,68 @@ local function detailPane(entry) elseif section.type == "block" then cards[#cards + 1] = blockCard(section) elseif section.type == "text" then - cards[#cards + 1] = textRow(section) + cards[#cards + 1] = ui.row({ gap = 6, align = "center" }, { + ui.label({ text = tostring(section.label or ""), fontSize = 11, + color = "on_surface_variant" }), + ui.spacer({ flexGrow = 1 }), + ui.label({ text = tostring(section.value or ""), fontSize = 11, + color = "on_surface", maxLines = 1 }), + }) + elseif section.type == "spacer" then + cards[#cards + 1] = ui.spacer({ height = tonumber(section.height) or 8 }) + elseif section.type == "title" then + cards[#cards + 1] = ui.label({ + text = tostring(section.value or section.label or ""), + fontSize = 12, fontWeight = "semibold", color = "on_surface", + }) + else + -- Keep schema additions visible instead of silently losing report data. + cards[#cards + 1] = ui.label({ + text = tostring(section.value or section.label or section.type or "—"), + fontSize = 11, color = "on_surface_variant", + }) end end if #cards > 0 then children[#children + 1] = ui.scroll({ gap = 8, flexGrow = 1 }, cards) - else + elseif entry ~= nil then + children[#children + 1] = ui.row({ gap = 6, align = "center" }, { + ui.glyph({ name = "info-circle", size = 14, color = "on_surface_variant" }), + ui.label({ text = noctalia.tr("ui.no_usage"), fontSize = 11, color = "on_surface_variant" }), + }) children[#children + 1] = ui.spacer({ flexGrow = 1 }) end - return ui.column({ gap = 10, padding = 14, flexGrow = 1 }, children) + return ui.column({ gap = 8, padding = 14, flexGrow = 1 }, children) end function render() local entry = currentEntry() - -- A failure replaces the report rather than sitting above it. The numbers - -- are from a read that is no longer happening, and leaving them up puts a - -- provider list and a percentage next to an alert saying neither can be - -- trusted. + -- A failure replaces the report: those numbers came from a read that is no + -- longer happening. if failure.code ~= "" then - -- The panel keeps the fixed size the manifest gives it, so the block - -- is width-bounded rather than stretched across 720px of button. - panel.render(ui.column({ gap = 10, padding = 14, width = 320 }, { - ui.row({ gap = 8, align = "center" }, { - ui.glyph({ name = "brain", size = 18, color = "primary" }), - ui.label({ text = noctalia.tr("ui.title"), fontSize = 15, fontWeight = "bold", color = "primary" }), + -- The panel keeps the fixed size the manifest gives it, and a failure has + -- nowhere near 720x400 to say. Bounded to a readable width and centred, the + -- empty surround reads as composition rather than a half-drawn frame. + panel.render(ui.column({ flexGrow = 1, padding = 14, align = "center", justify = "center" }, { + ui.column({ gap = 10, width = 320 }, { + ui.row({ gap = 8, align = "center" }, { + ui.glyph({ name = "brain", size = 18, color = "primary" }), + ui.label({ text = noctalia.tr("ui.title"), fontSize = 15, + fontWeight = "bold", color = "on_surface" }), + }), + errorBlock(), }), - errorBlock(), })) return end - panel.render(ui.row({ gap = 0 }, { + -- Both panes have to be told to fill the panel, or their ui.scroll children ask + -- for their natural height: the cards overflow, and the free space goes to + -- whatever else in the column will take it. + panel.render(ui.row({ gap = 0, flexGrow = 1, align = "stretch" }, { listPane(entry), - -- ui.separator is horizontal only; a one-pixel column is the divider. - ui.column({ width = 1, fill = "on_surface/0.12" }, {}), + ui.separator({ orientation = "vertical", color = "outline", opacity = 0.28 }), detailPane(entry), })) end @@ -532,14 +497,19 @@ noctalia.state.watch("polling", function(value) render() end) +noctalia.state.watch("refresh_queued", function(value) + refreshQueued = value == true + render() +end) + function onOpen(_context) - -- Every open asks for fresh numbers. The CLI answers from its own cache - -- when it has one, and the poller drops requests that arrive too close - -- together, so reopening the panel repeatedly is cheap. - noctalia.state.set("command", { action = "refresh", at = os.time() }) + -- Every open asks for fresh numbers. The CLI answers from its own cache when it + -- has one, and the poller drops requests that arrive too close together. + requestRefresh() report = noctalia.state.get("report") failure = asFailure(noctalia.state.get("error")) polling = noctalia.state.get("polling") == true + refreshQueued = noctalia.state.get("refresh_queued") == true -- Countdowns tick locally; the CLI is only woken by the poller's interval. panel.setWantsSecondTicks(true) render() @@ -550,4 +520,9 @@ function update() render() end + +function onClose() + panel.setWantsSecondTicks(false) +end + render() diff --git a/ai-usagebar/plugin.toml b/ai-usagebar/plugin.toml index f4ae1437..b984790a 100644 --- a/ai-usagebar/plugin.toml +++ b/ai-usagebar/plugin.toml @@ -1,7 +1,7 @@ id = "felipeartur/ai-usagebar" name = "AI Usage" -version = "1.1.0" -plugin_api = 9 +version = "2.0.0" +plugin_api = 22 author = "felipeartur" license = "MIT" icon = "brain" @@ -9,7 +9,7 @@ description = "AI plan usage in the bar, powered by the ai-usagebar CLI." tags = ["bar", "panel", "ai", "indicator", "utility"] # The CLI owns credentials, vendor endpoints and caching. This plugin only runs # `ai-usagebar usage --json` and draws the result. -dependencies = ["ai-usagebar", "xdg-open"] +dependencies = ["ai-usagebar"] # ── Plugin-level settings (shared by the poller, every capsule and the panel) ── @@ -36,6 +36,16 @@ entry = "service.luau" id = "bar" entry = "bar.luau" +# Right click asks the poller for a read. Declared rather than handled in +# bar.luau because a binding is what the settings editor lists and what a user +# can point somewhere else; an onRightClick callback is neither. +# +# Left stays in the script: it sets `selected` before opening the panel, so the +# panel lands on the provider this capsule tracks, which `panel-toggle` alone +# cannot do. + [widget.actions] + right = "plugin felipeartur/ai-usagebar:poller all refresh" + # Per-instance, so a second capsule can track a second provider. [[widget.setting]] key = "vendor" @@ -63,17 +73,42 @@ options = [ { value = "kiro", label_key = "settings.vendor.option.kiro" }, ] +# Keys named after the core `sysmon` widget, so the CPU capsule and this one are +# configured with the same vocabulary. +[[widget.setting]] +key = "visualization" +type = "select" +label_key = "settings.visualization.label" +description_key = "settings.visualization.description" +default = "none" +options = [ + { value = "none", label_key = "settings.visualization.option.none" }, + { value = "gauge", label_key = "settings.visualization.option.gauge" }, +] + +[[widget.setting]] +key = "show_value" +type = "bool" +label_key = "settings.show_value.label" +description_key = "settings.show_value.description" +default = true + +[[widget.setting]] +key = "show_glyph" +type = "bool" +label_key = "settings.show_glyph.label" +description_key = "settings.show_glyph.description" +default = true + [[widget.setting]] -key = "style" +key = "glyph_position" type = "select" -label_key = "settings.style.label" -description_key = "settings.style.description" -default = "pill" +label_key = "settings.glyph_position.label" +description_key = "settings.glyph_position.description" +default = "before" options = [ - { value = "pill", label_key = "settings.style.option.pill" }, - { value = "gauge", label_key = "settings.style.option.gauge" }, - { value = "meter", label_key = "settings.style.option.meter" }, - { value = "label", label_key = "settings.style.option.label" }, + { value = "before", label_key = "settings.glyph_position.option.before" }, + { value = "after", label_key = "settings.glyph_position.option.after" }, ] # One capsule can carry more than one provider; "auto" fills it with the busiest. diff --git a/ai-usagebar/service.luau b/ai-usagebar/service.luau index 7e92a7e4..96359f1b 100644 --- a/ai-usagebar/service.luau +++ b/ai-usagebar/service.luau @@ -1,8 +1,7 @@ --!nonstrict --- Headless poller: the single owner of `ai-usagebar usage --json`. --- --- One call returns every configured vendor, so the capsules and the panel are --- pure subscribers of noctalia.state and never spawn a process of their own. +-- Headless poller: the single owner of `ai-usagebar usage --json`. One call +-- returns every configured vendor, so the capsules and the panel are subscribers +-- of noctalia.state and never spawn a process of their own. -- `ai-usagebar` is a declared dependency, so it is expected on PATH. local COMMAND = "ai-usagebar usage --json" @@ -13,31 +12,89 @@ local function intervalMs() return math.floor(minutes * 60 * 1000) end --- Everything the CLI produces ends up on screen, so all of it is cleaned once, --- here, where it enters the plugin: --- an error can quote the request that failed, and a request can carry a key in --- its query string. A runaway line would also push a bar capsule off screen. +-- Everything the CLI prints reaches the screen, so it is cleaned here, on the +-- way in: an error can quote the request that failed, and that request can carry +-- a key. A secret's value runs to the whitespace, quote or brace that closes it, +-- so a JSON field loses its value and keeps its punctuation. +local SECRET_VALUE = "[^%s\"',}]+" +-- Built once: safeText runs on every string in the report, about 165 of them for +-- a two-vendor read, and rebuilding these pairs each time cost more than matching +-- them. +local SECRET_PATTERNS = {} +for _, word in ipairs({ "key", "token", "secret", "password" }) do + local anyCase = (word:gsub("%a", function(c) return "[" .. c:upper() .. c .. "]" end)) + local name = "[%w_%-]*" .. anyCase .. "[%w_%-]*" + SECRET_PATTERNS[#SECRET_PATTERNS + 1] = { + word = word, + -- Only the keyword that collides with a metric label keeps its numbers. + keepsReadings = word == "token", + -- name=value: a query string, a shell assignment, or a line out of the + -- CLI's own TOML, where the separator is padded and the value quoted. + assign = "(" .. name .. "%s*=%s*\"?)" .. SECRET_VALUE, + -- name: value: an HTTP header or a JSON field. The value is captured + -- rather than swallowed, because this shape is also how the CLI labels a + -- reading -- "Tokens: 45000 / 100000" -- and a plugin that draws token + -- counts cannot redact them. + colon = "(" .. name .. "\"?%s*:%s*\"?)(" .. SECRET_VALUE .. ")", + } +end + +-- A quota or a percentage is not a credential. The exemption is deliberately +-- narrow: short, digits only, and offered to `token` alone. "Tokens: 45000" is a +-- reading this plugin exists to draw, while a bare number after `password` or +-- `secret` never is, and a long run of digits is a credential whatever labels it. +local function isReading(value) + return #value <= 9 and value:match("^%d[%d%.,]*%%?$") ~= nil +end + +local function redactColon(prefix, value) + if isReading(value) then return prefix .. value end + return prefix .. "" +end +-- Nine characters before the rest of a provider key, so a bare "sk-" in prose +-- is not mistaken for one. +local KEY_TAIL = string.rep("[%w_%-]", 9) + local function safeText(value) local text = noctalia.string.trim(tostring(value or "")) text = text:gsub("%s+", " ") - -- `scrub` runs this over ~165 strings per two-vendor read, and four - -- backtracking patterns on each one exhaust the callback's CPU budget, - -- which costs the whole report. All four need a literal `=` or `earer` to - -- match, so a plan name or a percentage skips them. - if text:find("=", 1, true) then - text = text:gsub("([%w_%-]*[Kk][Ee][Yy][%w_%-]*=)[^%s]+", "%1") - text = text:gsub("([Tt][Oo][Kk][Ee][Nn][%w_%-]*=)[^%s]+", "%1") - text = text:gsub("([Ss][Ee][Cc][Rr][Ee][Tt][%w_%-]*=)[^%s]+", "%1") + + -- Capped before the redaction, not after. The patterns are the expensive part + -- of the callback, and a callback that overruns its CPU budget loses the whole + -- report, so they only ever scan what the plugin would draw. + if #text > 200 then text = string.sub(text, 1, 200) .. "..." end + + -- A literal search gates each keyword's own two patterns. Gating on the + -- separator instead does not work: `=` and `:` turn up in ordinary readings, + -- in ratios, clock times and URLs. + local lower = text:lower() + + for _, secret in ipairs(SECRET_PATTERNS) do + if lower:find(secret.word, 1, true) then + text = text:gsub(secret.assign, "%1") + text = text:gsub(secret.colon, secret.keepsReadings and redactColon or "%1") + end + end + + if lower:find("bearer", 1, true) then + text = text:gsub("([Bb][Ee][Aa][Rr][Ee][Rr]%s+)" .. SECRET_VALUE, "%1") end - if text:find("earer", 1, true) then - text = text:gsub("([Bb]earer%s+)[^%s]+", "%1") + + -- Credentials in the userinfo half of a URL the CLI echoed back. + if lower:find("://", 1, true) then + text = text:gsub("(://)[^%s/@]+:[^%s/@]+(@)", "%1%2") end - if #text > 200 then text = string.sub(text, 1, 200) .. "..." end + + -- Anchored at a word start, so "desk-top" is not a key. + if lower:find("sk-", 1, true) then + text = text:gsub("%f[%w](sk%-)" .. KEY_TAIL .. "[%w_%-]*", "%1") + end + return text end --- Every string in the report, not just the error: a plan name, an account name --- or a metric detail is CLI text too, and any of them can arrive long. +-- Every string, not just the error: a plan name or a metric detail is CLI text +-- too, and any of them can arrive long. local function scrub(value) if type(value) == "string" then return safeText(value) end if type(value) ~= "table" then return value end @@ -45,35 +102,26 @@ local function scrub(value) return value end --- The one place a failure is named. Subscribers translate the code, and the --- CLI's own text travels with it as `detail`, redacted like any other string --- that reaches the screen. +-- The one place a failure is named. Subscribers translate the code; the CLI's +-- own words travel with it as `detail`. local function failure(code, detail) return { code = code, detail = safeText(detail) } end --- The run's outcome as one code. A missing binary is split out from the --- generic failure because the panel can offer an install link for that one, --- and shells report it as one of two messages. Both arrive with status 127, --- which a CLI that merely cannot open its own config file does not use, so the --- code has to agree with the message before the install link is offered. +-- The run's outcome as one code. A missing binary gets its own, because the +-- panel offers an install link for that one. Status 127 is the shell's own +-- "command not found"; the CLI never exits with it once it is on PATH. local function classify(result) if result == nil then return failure("spawn_failed") end if result.timedOut then return failure("timed_out") end - -- Matched raw. `failure` scrubs what it is given, and scrubbing first would - -- mean matching against text already capped at 200 characters, so a noisy - -- run could push the message that names the failure out of reach. local stderr = tostring(result.stderr or "") - local lower = stderr:lower() - if result.exitCode == 127 - and (lower:find("command not found", 1, true) - or lower:find("no such file or directory", 1, true)) then + if result.exitCode == 127 then return failure("not_installed", stderr) end if result.exitCode ~= 0 then - -- Whitespace-only stderr scrubs down to nothing, so the exit code has - -- to answer for it rather than a detail that arrives on screen empty. + -- Whitespace-only stderr scrubs down to nothing, so the exit code answers + -- for it instead. return failure("failed", stderr:find("%S") and stderr or ("ai-usagebar exited with code " .. tostring(result.exitCode))) end @@ -81,79 +129,71 @@ local function classify(result) end local inFlight = false +local pendingRefresh = false --- The CLI caches for a minute, so a manual refresh usually answers in about ten --- milliseconds, too fast for the loader to survive a frame. The busy state is --- held for a beat instead, timed by the service's own tick. -local MIN_BUSY_MS = 600 -local busyUntil = 0 -local clearPending = false - --- A floor between spawns. Opening the panel asks for a read, and a panel can be --- opened as fast as a pointer can click, so this bounds how often the plugin --- can start a process no matter how the request arrives. +-- A floor between spawns. Opening the panel asks for a read, and a panel opens as +-- fast as a pointer can click. local MIN_GAP_MS = 2000 local lastStart = 0 -local function stopPolling() - clearPending = false - noctalia.state.set("polling", false) - noctalia.setUpdateInterval(intervalMs()) +local function queueRefresh() + pendingRefresh = true + noctalia.state.set("refresh_queued", true) + noctalia.setUpdateInterval(250) end local function refresh() - if inFlight then return end + if inFlight then + queueRefresh() + return + end local now = noctalia.nowMs() - if now - lastStart < MIN_GAP_MS then return end + if MIN_GAP_MS - (now - lastStart) > 0 then + queueRefresh() + return + end lastStart = now + pendingRefresh = false + noctalia.setUpdateInterval(intervalMs()) inFlight = true - busyUntil = now + MIN_BUSY_MS - clearPending = false + noctalia.state.set("refresh_queued", false) noctalia.state.set("polling", true) - noctalia.setUpdateInterval(120) local started = noctalia.runAsync(COMMAND, function(result) inFlight = false - if noctalia.nowMs() >= busyUntil then - stopPolling() - else - clearPending = true - end + noctalia.state.set("polling", false) local decoded = result ~= nil and noctalia.json.decode(result.stdout or "") or nil if type(decoded) == "table" and type(decoded.entries) == "table" then - -- A vendor that failed still comes back as an entry with `status = - -- "error"`, so a non-zero exit is not a reason to drop the report. + -- A failed vendor still comes back as an entry with `status = "error"`, + -- so a non-zero exit is no reason to drop the report. noctalia.state.set("report", scrub(decoded)) noctalia.state.set("error", failure("")) - return + else + noctalia.state.set("error", classify(result)) end - noctalia.state.set("error", classify(result)) + if pendingRefresh then refresh() end end, 30000) - -- A refusal to spawn never calls back, and without this the poller would - -- sit in flight forever and stop asking. + -- A refusal to spawn never calls back, and the poller would sit in flight + -- forever. if not started then inFlight = false + pendingRefresh = false + noctalia.state.set("refresh_queued", false) + noctalia.state.set("polling", false) noctalia.state.set("error", failure("spawn_failed")) - stopPolling() end end --- Manual refresh from a capsule or the panel. noctalia.state.watch("command", function(value) if type(value) == "table" and value.action == "refresh" then refresh() end end) function update() - -- While a read is in flight the fast tick is the busy timer, not a poll. + -- A read in flight answers on its own callback; the tick only starts them. if inFlight then return end - if clearPending then - if noctalia.nowMs() >= busyUntil then stopPolling() end - return - end - noctalia.setUpdateInterval(intervalMs()) refresh() end diff --git a/ai-usagebar/shared.luau b/ai-usagebar/shared.luau new file mode 100644 index 00000000..3030aeaa --- /dev/null +++ b/ai-usagebar/shared.luau @@ -0,0 +1,159 @@ +--!nonstrict +-- What the capsule and the panel both need, in one copy, so the ISO parsing and +-- the severity tiers cannot drift between two entries that have to agree on +-- screen. Needs plugin_api 22, where require() arrived. + +local M = {} + +-- Tabler has no Anthropic mark, so providers without a brand glyph use a +-- semantic one. The glyph is identity and nothing else: it takes no colour of +-- its own, so every colour left in the panel means something -- severity on the +-- bars, and the selected row. +local PROVIDER_GLYPHS = { + anthropic = "asterisk-simple", + anthropic_api = "asterisk-simple", + openai = "brand-openai", + zai = "bolt", + openrouter = "route", + deepseek = "fish", + kimi = "moon", + moonshot = "moon", + kilo = "robot", + novita = "cloud", + grok = "brand-x", + supergrok = "brand-x", + antigravity = "sparkles", + cursor = "cursor-text", + minimax = "wave-square", + kiro = "ghost", + copilot = "brand-github-copilot", + gemini = "brand-google", +} + +function M.providerGlyph(id) + return PROVIDER_GLYPHS[tostring(id)] or "brain" +end + +-- Ask the poller for a read. It only looks at `action`; `at` is never read, and +-- is there so two requests in a row are not the same value. nowMs is the only +-- sub-second clock the API has, so os.time() would stamp two clicks in the same +-- second identically. +function M.requestRefresh() + noctalia.state.set("command", { action = "refresh", at = noctalia.nowMs() }) +end + +-- Anything else in the `error` slot means no failure. +M.NO_FAILURE = { code = "", detail = "" } + +function M.asFailure(value) + return type(value) == "table" and value or M.NO_FAILURE +end + +-- "2026-08-15T11:29:59.872624Z" -> unix seconds. The stamps are UTC, and +-- os.time() reads its table as local, so the offset is measured at that same +-- instant and added back. +function M.parseIso(value) + if type(value) ~= "string" then return nil end + local y, mo, d, h, mi, s = value:match("^(%d+)%-(%d+)%-(%d+)T(%d+):(%d+):(%d+)") + if y == nil then return nil end + local asLocal = os.time({ + year = tonumber(y), month = tonumber(mo), day = tonumber(d), + hour = tonumber(h), min = tonumber(mi), sec = tonumber(s), + }) + local utcAsLocal = os.time(os.date("!*t", asLocal)) + return asLocal + (asLocal - utcAsLocal) +end + +local function formatDuration(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 + if hours > 0 then return string.format("%dh %dm", hours, rest) end + return string.format("%dm", rest) +end + +-- How long the window this section describes has left. +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 + +-- Where the countdown lands: "14:20" today, "Sat 14:20" past midnight, and a date +-- once a weekday alone stops naming one day. +function M.resetClock(section) + local at = M.parseIso(section and section.reset_at) + if at == nil then return "" end + local clock = noctalia.formatTime(noctalia.timeFormat(), at) + if os.date("%Y-%m-%d", at) == os.date("%Y-%m-%d") then return clock end + -- Prepended rather than folded into the pattern: the host's format grammar + -- passes unknown text through verbatim, so "ddd" would render as the word. + if at - os.time() > 6 * 86400 then return os.date("%d %b", at) .. " " .. clock end + return os.date("%a", at) .. " " .. clock +end + +-- How much of the window is gone, out of "Resets in 1h 58m · 60% elapsed · 30pts +-- ahead". Both entries draw it under the quota bar. +function M.elapsedPercent(detail) + local value = tostring(detail or ""):match("(%d+)%%%s*elapsed") + return value ~= nil and tonumber(value) or nil +end + +-- The pace is the trailing segment of "Resets in 1h 58m · 60% elapsed · 30pts +-- ahead". `elapsed` sitting there means the reading carries no pace at all. +-- Returns that segment's text, then the points and the direction inside it. +function M.pace(detail) + local text = tostring(detail or "") + local points, word = text:match("(%d+)pts%s+(%a+)") + points = tonumber(points) + if not text:find("·") then return "", points, word end + local last = "" + for part in text:gmatch("[^·]+") do last = part end + last = noctalia.string.trim(last) + if last:find("elapsed") then return "", points, word end + return last, points, word +end + +function M.entries(report) + if type(report) ~= "table" or type(report.entries) ~= "table" then return {} end + return report.entries +end + +-- A provider can report more than it was given, so clamp before this becomes a +-- bar width. +function M.ratio(percent) + local value = (tonumber(percent) or 0) / 100 + if value < 0 then return 0 end + if value > 1 then return 1 end + return value +end + +function M.headline(entry) + if type(entry) ~= "table" or type(entry.metrics) ~= "table" then return nil end + return entry.metrics[1] +end + +-- The rank the capsule sorts by, next to the role both entries colour with, so +-- the tiers cannot be edited in one file and forgotten in the other. `low` is +-- absent on purpose: it falls through to 0 like anything the CLI adds later. +M.SEVERITY_RANK = { critical = 3, high = 2, mid = 1 } + +-- The CLI tiers every percentage, and copying its thresholds here would be a +-- second source of truth. `calm` is for when it raised nothing: text stays on the +-- surface colour, and the accent is kept for bar fills. +-- +-- `high` is deliberately not `tertiary`. Noctalia themes are free to make that +-- role a dark one -- in the stock dark theme it lands at 2.8:1 on a card, below +-- the 3.9:1 of `on_surface_variant`, the colour of the text it is supposed to +-- outrank -- so a flagged reading came out dimmer than the muted line beside it. +function M.severityRole(section, calm) + local severity = tostring(section and section.severity or "") + if severity == "critical" then return "error" end + if severity == "high" then return "secondary" end + return calm +end + +return M diff --git a/ai-usagebar/tests/refresh_test.lua b/ai-usagebar/tests/refresh_test.lua new file mode 100644 index 00000000..2cb121f8 --- /dev/null +++ b/ai-usagebar/tests/refresh_test.lua @@ -0,0 +1,65 @@ +-- Small host harness for the poller state machine and provider visual metadata. + +local function read(path) + local file = assert(io.open(path, "r")) + local source = file:read("*a") + file:close() + return source +end + +local values, watchers = {}, {} +local commands, callbacks = {}, {} +local now = 5000 +local intervals = {} + +local state = { + get = function(key) return values[key] end, + set = function(key, value) + values[key] = value + if watchers[key] then watchers[key](value) end + end, + watch = function(key, callback) watchers[key] = callback end, +} + +local noctalia = { + state = state, + nowMs = function() return now end, + getConfig = function(key) return key == "refresh_minutes" and 5 or nil end, + setUpdateInterval = function(ms) intervals[#intervals + 1] = ms end, + runAsync = function(command, callback) + commands[#commands + 1] = command + callbacks[#callbacks + 1] = callback + return true + end, + json = { decode = function() return { entries = {} } end }, + string = { trim = function(value) return value end }, +} + +local env = setmetatable({ noctalia = noctalia }, { __index = _G }) +local service = assert(load(read("service.luau"), "service", "t", env)) +service() + +assert(#callbacks == 1, "service should start one initial refresh") +env.onIpc("refresh") +assert(#callbacks == 1, "refresh while busy should be coalesced") +assert(values.refresh_queued == true, "coalesced refresh should be visible as queued") + +now = 7000 +callbacks[1]({ exitCode = 0, stdout = "{}", stderr = "" }) +assert(#callbacks == 2, "queued refresh should start after the first callback") +assert(values.refresh_queued == false, "queued state should clear when refresh starts") +assert(values.polling == true, "queued refresh should become the active poll") +assert(intervals[#intervals] == 5 * 60 * 1000, "active refresh should restore the configured interval") + +local sharedEnv = setmetatable({ noctalia = noctalia }, { __index = _G }) +local shared = assert(load(read("shared.luau"), "shared", "t", sharedEnv))() +for _, id in ipairs({ + "anthropic", "openai", "anthropic_api", "zai", "openrouter", "deepseek", "kimi", + "kilo", "novita", "moonshot", "grok", "supergrok", "antigravity", "cursor", + "minimax", "kiro", +}) do + -- "brain" is the fallback, so a provider still on it has no glyph of its own. + assert(shared.providerGlyph(id) ~= "brain", "missing glyph for " .. id) +end + +io.write("ok: refresh queue coalesced, provider visuals complete\n") diff --git a/ai-usagebar/tests/scrub_test.lua b/ai-usagebar/tests/scrub_test.lua new file mode 100644 index 00000000..3f0c126e --- /dev/null +++ b/ai-usagebar/tests/scrub_test.lua @@ -0,0 +1,231 @@ +-- Redaction test for service.luau's safeText() and scrub(). +-- +-- A CLI that fails an HTTP request tends to quote the request, and safeText is +-- the only thing between that and a rendered label. The functions are read out of +-- service.luau rather than copied, so a copy cannot keep passing after the real +-- one changes. +-- +-- lua tests/scrub_test.lua (or luajit) +-- +-- Run it from the plugin directory. Exits non-zero if anything fails. + +local SOURCE = "service.luau" + +local function loadSafeText() + local file = io.open(SOURCE, "r") + if file == nil then + error("run this from the plugin directory: " .. SOURCE .. " not found") + end + local source = file:read("*a") + file:close() + + -- The slice runs from the redaction constants through scrub, which is what + -- the poller's callback actually calls. + local chunk = source:match("(local SECRET_VALUE.-)\nlocal function failure") + if chunk == nil then + error("could not find safeText in " .. SOURCE .. "; update the markers here") + end + + -- The only host API the function touches. + local env = { + string = string, + ipairs = ipairs, + pairs = pairs, + type = type, + tostring = tostring, + noctalia = { string = { trim = function(s) return (s:gsub("^%s+", ""):gsub("%s+$", "")) end } }, + } + local loaded = load(chunk .. "\nreturn safeText, scrub", "scrubber", "t", env) + return loaded() +end + +local safeText, scrub = loadSafeText() + +local function loadClassify() + local file = io.open(SOURCE, "r") + local source = file:read("*a") + file:close() + local chunk = source:match("(local SECRET_VALUE.-)\nlocal inFlight") + if chunk == nil then error("could not find classify in " .. SOURCE) end + local env = { + string = string, + ipairs = ipairs, + pairs = pairs, + type = type, + tostring = tostring, + noctalia = { string = { trim = function(s) return (s:gsub("^%s+", ""):gsub("%s+$", "")) end } }, + } + local loaded = load(chunk .. "\nreturn classify", "classifier", "t", env) + return loaded() +end + +local classify = loadClassify() + +local panelFile = io.open("panel.luau", "r") +local panelSource = panelFile:read("*a") +panelFile:close() + +-- Each case names the material that must not survive. +local SECRETS = { + { "GET /v1/usage?api_key=sk-ant-abc123456 failed", "abc123456" }, + { "request token=eyJhbGciOiJIUzI1NiJ9.SIGNATURE failed", "SIGNATURE" }, + { "client_secret=hunter2 rejected", "hunter2" }, + { "Authorization: Bearer sk-ant-api03-REALKEY", "REALKEY" }, + { '{"api_key": "sk-ant-api03-REALKEY"}', "REALKEY" }, + { '{"token":"eyJhbGciOiJIUzI1NiJ9.PAYLOAD.SIG"}', "PAYLOAD" }, + { "-H 'X-Api-Key: sk-ant-api03-REALKEY'", "REALKEY" }, + { "curl https://user:hunter2@api.anthropic.com/v1/usage", "hunter2" }, + { "authorization: bearer sk-ant-api03-REALKEY", "REALKEY" }, + -- The numeric exemption that keeps "Tokens: 45000" readable is offered to + -- `token` alone, and never to a long run of digits. + { "password: 1234", "1234" }, + -- A padded separator is how the CLI's own config file spells it. + { 'api_key = "sk-ant-api03-REALKEY"', "REALKEY" }, + { "export ANTHROPIC_API_KEY = sk-ant-api03-REALKEY", "REALKEY" }, + { "secret: 99", "99" }, + { "api_key: 123456789012345", "123456789012345" }, + { "OPENAI_API_KEY sk-proj-REALKEYVALUE not accepted", "REALKEY" }, + { "password=hunter2", "hunter2" }, + -- The cap runs before the patterns, so a secret in a runaway line has to + -- survive the truncation. + { "api_key=sk-ant-REALKEY123 " .. string.rep("noise ", 60), "REALKEY123" }, +} + +-- Readings the plugin draws every minute. A scrubber that eats these is worse +-- than the leak it prevents. +local BENIGN = { + "Claude Pro", + "Session (5h)", + "Weekly (7d)", + "69% of the window elapsed", + "Resets in 4h 01m at 12:40", + "62% of monthly limit consumed", + "10pts under", + "https://github.com/akitaonrails/ai-usagebar", + "ai-usagebar exited with code 2", + "2026-08-20T11:29:59.872624Z", + "Desk-top mode", + "ChatGPT Free", + -- The colon form of the redaction is also how the CLI labels a reading. + "Tokens: 45000 / 100000", + "tokens_used: 1500", + "Session tokens: 98%", + "Prompt tokens: 1,024", +} + +local failures = 0 + +local function fail(message) + failures = failures + 1 + io.write("FAIL ", message, "\n") +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 = "" }) +if healthy.code ~= "no_data" then + fail("malformed successful output should be no_data") +end +local missing = classify({ exitCode = 127, stderr = "comando não encontrado" }) +if missing.code ~= "not_installed" then + fail("exit code 127 should mean not_installed regardless of shell language") +end +if panelSource:find('entry.status ~= "ready"', 1, true) + or not panelSource:find('entry.status == "error"', 1, true) then + fail("panel must treat only error entries as failed") +end + +for _, case in ipairs(SECRETS) do + local input, material = case[1], case[2] + local output = safeText(input) + if output:find(material, 1, true) then + fail(material .. " survived: " .. output) + end +end + +for _, input in ipairs(BENIGN) do + local output = safeText(input) + if output ~= input then + fail("mangled a normal reading: " .. input .. " -> " .. output) + end +end + +-- A runaway line would push a bar capsule off the screen. +local long = safeText(string.rep("x", 500)) +if #long > 210 then + fail("long text was not capped: " .. #long .. " characters") +end + +-- The poller scrubs the whole report inside one async callback, and the shell +-- kills a callback that overruns its CPU budget: the reading is lost, not just +-- late. So the cost is asserted, not only the output. +-- +-- The meter is `string.gsub`, which every pattern in safeText runs through. The +-- work itself happens inside the C matcher, where an instruction-count hook sees +-- nothing, so what gets counted is the calls and the bytes handed to them. +-- +-- The report below has the shape of a real `usage --json`: four vendors, six +-- metrics each, and the credential error the CLI writes for a provider it has no +-- key for, which is the string that opens the redaction patterns. +local function sampleReport() + local entries = {} + for _, vendor in ipairs({ "anthropic", "openai", "zai", "openrouter" }) do + local metrics = {} + for index = 1, 6 do + metrics[index] = { + label = "Session (5h)", + value = "62% of monthly limit consumed", + detail = "Resets in 4h 01m at 12:40", + reset_at = "2026-08-20T11:29:59.872624Z", + severity = "normal", + percent = 62, + } + end + entries[#entries + 1] = { + id = vendor, + name = vendor, + display_name = "Claude Pro", + plan = "Claude Pro", + status = "ok", + stale = false, + fetched_at = "2026-08-20T11:29:59.872624Z", + metrics = metrics, + sections = { { type = "session" }, { type = "weekly" } }, + error = "credentials error: " .. vendor .. ": no API key. Either set an API key in a" + .. " valid environment variable or set `api_key` under [" .. vendor .. "] in the" + .. " config file. " .. string.rep("Retry later. ", 40), + } + end + return { entries = entries } +end + +local calls, bytes = 0, 0 +local realGsub = string.gsub +string.gsub = function(subject, ...) + calls = calls + 1 + bytes = bytes + #subject + return realGsub(subject, ...) +end +scrub(sampleReport()) +string.gsub = realGsub + +-- Bytes, not calls: the count barely moves, since normalising whitespace is one +-- gsub per string either way. What moves is how much text the patterns are handed, +-- 37352 bytes for this report before the rewrite against 17664 after. The ceiling +-- sits between the two, near enough that widening the gate back to all four +-- keywords at once (22400) trips it as surely as moving the cap back after the +-- patterns (37352). +local MAX_BYTES = 20000 +if bytes > MAX_BYTES then + fail("the redaction patterns were handed " .. bytes .. " bytes of a four-vendor report" + .. " in " .. calls .. " gsub calls, past the " .. MAX_BYTES .. " bytes this callback" + .. " budgets for") +end + +if failures > 0 then + io.write(failures, " failure(s)\n") + os.exit(1) +end + +io.write("ok: ", #SECRETS, " secrets redacted, ", #BENIGN, " readings untouched, length capped, ", + calls, " gsub calls over ", bytes, " bytes per report\n") diff --git a/ai-usagebar/thumbnail.webp b/ai-usagebar/thumbnail.webp index 6a5ca136..2696ddbf 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 6127d2ce..a4cb059b 100644 --- a/ai-usagebar/translations/en.json +++ b/ai-usagebar/translations/en.json @@ -1,11 +1,11 @@ { "settings": { "color_by_usage": { - "description": "Primary while there is room, then amber, then red as the quota fills.", + "description": "Tints the reading as the quota gets tight. Off keeps the capsule in the bar's own colour.", "label": "Color by usage" }, "extras": { - "description": "What rides next to the percentage: the time left in the window, how far the spend is from the clock, or neither.", + "description": "The time left in the window, how far the spend is from the clock, both, or neither.", "label": "Extra reading", "option": { "both": "Both", @@ -14,6 +14,14 @@ "pace": "Pace against the clock" } }, + "glyph_position": { + "description": "Which side of the reading the icon sits on.", + "label": "Icon position", + "option": { + "before": "Before", + "after": "After" + } + }, "provider_limit": { "description": "How many providers one capsule shows, busiest first, with a +N for the rest. Only applies when the provider is Automatic.", "label": "Providers in the capsule" @@ -22,19 +30,17 @@ "description": "How often the CLI is asked for fresh usage. Countdowns tick locally between calls.", "label": "Refresh interval (minutes)" }, + "show_glyph": { + "description": "The provider's icon. Off leaves the reading on its own.", + "label": "Show icon" + }, "show_name": { "description": "Adds the product name next to the percentage, so two capsules do not look alike.", "label": "Show provider name" }, - "style": { - "description": "How this capsule looks in the bar.", - "label": "Style", - "option": { - "gauge": "Gauge and percentage", - "label": "Name, gauge and percentage", - "meter": "Segments", - "pill": "Percentage" - } + "show_value": { + "description": "The percentage as text. Off leaves the gauge to say it.", + "label": "Show value" }, "vendor": { "description": "Which plan this capsule tracks. Add the widget twice to watch two.", @@ -58,6 +64,14 @@ "supergrok": "SuperGrok", "zai": "Z.AI" } + }, + "visualization": { + "description": "A gauge beside the reading, or nothing.", + "label": "Visualization", + "option": { + "gauge": "Quota bar", + "none": "Nothing" + } } }, "ui": { @@ -77,11 +91,19 @@ }, "hidden_label": "Not shown", "hidden_value": "{count} more, click to open the panel", - "loading": "Loading…", + "install": "Install page", "no_usage": "No usage reported", "not_configured": "`{vendor}` is not configured in ai-usagebar", "now": "now", + "refresh": "Refresh now", + "refresh_queued": "Refresh queued", + "refreshing": "Refreshing usage", "retry": "Try again", + "settings": "Plugin settings", + "severity": { + "critical": "critical", + "high": "high" + }, "stale": "stale", "stale_hint": "showing last known data", "title": "AI Usage",