diff --git a/ai-usagebar/README.md b/ai-usagebar/README.md index 840a493c..77445014 100644 --- a/ai-usagebar/README.md +++ b/ai-usagebar/README.md @@ -24,9 +24,13 @@ 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. It is spawned by one button in the panel, the link to +the CLI's project page offered when `ai-usagebar` is not on `PATH`. Without +xdg-utils that button is not drawn and the rest of the plugin is unaffected. + +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 @@ -73,29 +77,36 @@ 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 will not start a second one within two seconds of the last, so holding + the button down does not spawn a queue of them. - **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 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. +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 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. +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: @@ -148,12 +159,19 @@ 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 in the capsule and in 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 +``` + +It 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. An overrun there loses the whole +reading, not just time. diff --git a/ai-usagebar/bar.luau b/ai-usagebar/bar.luau index eb8a992e..0d33091a 100644 --- a/ai-usagebar/bar.luau +++ b/ai-usagebar/bar.luau @@ -1,8 +1,7 @@ --!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") @@ -14,94 +13,16 @@ 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 GLYPHS = shared.GLYPHS +local countdown, resetClock = shared.countdown, shared.resetClock +local ratio, headline, elapsedPercent = shared.ratio, shared.headline, shared.elapsedPercent +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+)") @@ -114,12 +35,7 @@ local function entries() 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 SEVERITY_RANK = { critical = 3, high = 2, mid = 1 } local function rank(entry) local metric = headline(entry) @@ -127,8 +43,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,50 +73,27 @@ 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 +-- Quota above, window elapsed below: a longer fill than clock bar is spend -- running ahead of time. local function bars(percent, elapsed, tint, width) local stack = { @@ -233,16 +127,17 @@ local function countdownNode(metric) 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 fill = severityRole(metric, "primary") 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 }) + -- 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 = ui.label({ text = text, fontSize = 11, fontWeight = "semibold", color = tint, + maxLines = 1, width = 30, textAlign = "end" }) local name = showName and ui.label({ text = shortName(entry), fontSize = 11, color = "on_surface_variant", maxLines = 1 }) or nil @@ -250,7 +145,6 @@ local function chip(entry) 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({ @@ -261,18 +155,17 @@ local function chip(entry) 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), + bars(percent, elapsedPercent(metric and metric.detail), fill, 44), })) elseif style == "gauge" and percent ~= nil then add(glyph); add(name) - add(bars(percent, elapsedPercent(metric), fill, 26)) + add(bars(percent, elapsedPercent(metric and metric.detail), fill, 26)) add(pct) else add(glyph); add(name); add(pct) @@ -336,9 +229,9 @@ 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 = {} @@ -346,21 +239,21 @@ local function render() 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)) + -- A read in flight dims the capsule instead of appending a spinner: a node + -- that comes and goes every cycle shoves every widget to its right. + barWidget.render(ui.row({ gap = 6, align = "center", opacity = polling and 0.55 or 1 }, children)) barWidget.setTooltip(tooltip(picked, hidden)) end @@ -391,9 +284,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..82acf75c 100644 --- a/ai-usagebar/panel.luau +++ b/ai-usagebar/panel.luau @@ -1,86 +1,27 @@ --!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 --- 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 GLYPHS, parseIso = shared.GLYPHS, shared.parseIso +local countdown, resetClock = shared.countdown, shared.resetClock +local ratio, headline, severityRole = shared.ratio, shared.headline, shared.severityRole +local elapsedPercent = shared.elapsedPercent +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 with no credential comes back as a `credentials error`. It was never +-- set up, so it is not listed. A configured provider that fails for any other +-- reason keeps its row. local function configured(entry) if entry.status ~= "error" then return true end return not tostring(entry.error or ""):lower():find("credentials error") @@ -88,12 +29,7 @@ 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 +-- `reset_at`; what is left is the pace. local function pace(detail) local text = tostring(detail or "") @@ -102,9 +38,8 @@ local function pace(detail) 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. + -- 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 @@ -143,13 +78,6 @@ 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 - local function metricIcon(label) local text = tostring(label or ""):lower() if text:find("week") or text:find("month") then return "calendar" end @@ -159,41 +87,48 @@ end -- ── Cards ───────────────────────────────────────────────────────────────────── +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 + 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.glyph({ name = metricIcon(section.label), size = 14, color = "on_surface_variant" }), + 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,26 +136,35 @@ 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 + 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 + -- Parenthesised and muted: it is where the countdown beside it lands, + -- not a reading of its own. + footer[#footer + 1] = ui.label({ + text = "(" .. clock .. ")", fontSize = 11, color = "on_surface_variant", + }) end + end + if elapsed ~= nil then + if #footer > 0 then + footer[#footer + 1] = ui.label({ text = "·", fontSize = 11, color = "on_surface_variant" }) + end + footer[#footer + 1] = ui.label({ + text = noctalia.tr("ui.elapsed", { percent = elapsed }), + fontSize = 11, color = "on_surface_variant", maxLines = 1, + }) + end + if #footer > 0 or paceText ~= "" then footer[#footer + 1] = ui.spacer({ flexGrow = 1 }) if paceText ~= "" then footer[#footer + 1] = ui.label({ text = paceText, fontSize = 11, fontWeight = "semibold", color = paceColor }) @@ -239,19 +183,23 @@ 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.glyph({ name = metricIcon(section.label), size = 14, color = "on_surface_variant" }), + 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", }) end - return ui.column({ gap = 4, padding = 10, radius = 8, fill = "surface_variant" }, body) + return ui.column({ gap = 4, padding = 10, radius = 8, fill = "surface_variant/0.45" }, body) end local function textRow(section) @@ -264,48 +212,24 @@ 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" }) + right = ui.row({ width = 34, justify = "end" }, { + ui.glyph({ name = "alert-circle", size = 14, color = "error" }), + }) else right = ui.label({ text = percent ~= nil and string.format("%d%%", percent) or "—", fontSize = 13, fontWeight = "bold", color = tint, + width = 34, textAlign = "end", }) end @@ -313,33 +237,36 @@ local function providerRow(entry, selected) 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.14" or "surface_variant/0.45", 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 = GLYPHS[tostring(entry.id)] or "brain", size = 16, + color = selected and "primary" or "on_surface_variant" }), ui.column({ gap = 3, flexGrow = 1 }, lines), right, }) @@ -347,18 +274,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 +291,44 @@ 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 + +-- A muted stand-in shaped like what is coming, so a cold read is not a spinner +-- parked where the content will land. One shape serves both panes. +local function skeleton(key) + return ui.column({ + key = "skeleton-" .. key, + gap = 6, padding = 10, radius = 8, fill = "surface_variant/0.45", + }, { + ui.box({ width = 96, height = 10, radius = 3, fill = "on_surface/0.10" }), + ui.box({ height = 4, radius = 2, fill = "on_surface/0.06" }), + }) end local function listPane(entry) @@ -399,17 +339,33 @@ local function listPane(entry) end end if #rows == 0 then - rows[1] = ui.label({ text = noctalia.tr("ui.loading"), fontSize = 11, color = "on_surface_variant" }) + for index = 1, 3 do rows[index] = skeleton("row-" .. index) end 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 "refresh", + variant = "ghost", controlSize = "sm", + tooltip = noctalia.tr("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), }) @@ -424,44 +380,58 @@ local function detailPane(entry) if subtitle == title then subtitle = "" end end - local children = { - ui.row({ gap = 8, align = "center" }, { + -- With no entry the skeletons below are the whole pane, and a title here would + -- repeat the list pane's. + 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.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 = {} + local function separate() + if #chips > 0 then + chips[#chips + 1] = ui.label({ text = "·", fontSize = 10, color = "on_surface_variant" }) + end + end + if entry.status ~= "ready" then + chips[#chips + 1] = ui.label({ + text = tostring(entry.status or ""), fontSize = 10, color = "error", maxLines = 1, + }) + end if entry.stale == true then - chips[#chips + 1] = ui.label({ text = "·", fontSize = 10, color = "on_surface_variant" }) + separate() chips[#chips + 1] = ui.label({ text = noctalia.tr("ui.stale"), fontSize = 10, color = "tertiary" }) end local fetched = parseIso(entry.fetched_at) if fetched ~= nil then - chips[#chips + 1] = ui.spacer({ flexGrow = 1 }) + separate() 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 @@ -478,8 +448,16 @@ local function detailPane(entry) 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 }) + else + children[#children + 1] = ui.column({ gap = 8, flexGrow = 1 }, { + skeleton("card-1"), skeleton("card-2"), + }) end return ui.column({ gap = 10, padding = 14, flexGrow = 1 }, children) @@ -487,27 +465,31 @@ 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 @@ -533,10 +515,9 @@ noctalia.state.watch("polling", function(value) 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 diff --git a/ai-usagebar/plugin.toml b/ai-usagebar/plugin.toml index f4ae1437..969172e4 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 = "1.3.0" +plugin_api = 22 author = "felipeartur" license = "MIT" icon = "brain" @@ -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" diff --git a/ai-usagebar/service.luau b/ai-usagebar/service.luau index 7e92a7e4..bb87438d 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,70 @@ 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, + -- name=value: a query string or a shell assignment. + assign = "(" .. name .. "=)" .. SECRET_VALUE, + -- name: value: an HTTP header or a JSON field. + colon = "(" .. name .. "\"?%s*:%s*\"?)" .. SECRET_VALUE, + } +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, "%1") + end end - if text:find("earer", 1, true) then - text = text:gsub("([Bb]earer%s+)[^%s]+", "%1") + + if lower:find("bearer", 1, true) then + text = text:gsub("([Bb][Ee][Aa][Rr][Ee][Rr]%s+)" .. SECRET_VALUE, "%1") end - if #text > 200 then text = string.sub(text, 1, 200) .. "..." end + + -- 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 + + -- 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,25 +83,21 @@ 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. Shells report it as one of two +-- messages, both with status 127, so code and message have to agree. 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. + -- Matched raw: `failure` scrubs what it is given, and matching after the + -- 200-character cap would let a noisy run push the message out of reach. local stderr = tostring(result.stderr or "") local lower = stderr:lower() if result.exitCode == 127 @@ -72,8 +106,8 @@ local function classify(result) 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 @@ -82,48 +116,27 @@ end local inFlight = 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()) -end - local function refresh() if inFlight then return end local now = noctalia.nowMs() if now - lastStart < MIN_GAP_MS then return end lastStart = now inFlight = true - busyUntil = now + MIN_BUSY_MS - clearPending = 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 @@ -132,28 +145,22 @@ local function refresh() noctalia.state.set("error", classify(result)) 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 + 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..85439fb3 --- /dev/null +++ b/ai-usagebar/shared.luau @@ -0,0 +1,123 @@ +--!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 a provider without a brand glyph gets a +-- semantic one. +M.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", +} + +-- 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 + +function M.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 M.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 + +-- 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 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. +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 "tertiary" end + return calm +end + +return M diff --git a/ai-usagebar/tests/scrub_test.lua b/ai-usagebar/tests/scrub_test.lua new file mode 100644 index 00000000..0fca3171 --- /dev/null +++ b/ai-usagebar/tests/scrub_test.lua @@ -0,0 +1,179 @@ +-- 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() + +-- 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" }, + { "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", +} + +local failures = 0 + +local function fail(message) + failures = failures + 1 + io.write("FAIL ", message, "\n") +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..6398d3a6 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..f5a5074f 100644 --- a/ai-usagebar/translations/en.json +++ b/ai-usagebar/translations/en.json @@ -77,11 +77,17 @@ }, "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", "retry": "Try again", + "settings": "Plugin settings", + "severity": { + "critical": "critical", + "high": "high" + }, "stale": "stale", "stale_hint": "showing last known data", "title": "AI Usage",