From dfcb7a8e02d0f72b04642bea061668e2693305ac Mon Sep 17 00:00:00 2001 From: Felipe Artur Date: Sat, 15 Aug 2026 04:19:02 -0300 Subject: [PATCH 01/21] ai-usagebar: AI plan usage in the bar via the ai-usagebar CLI A headless poller runs `ai-usagebar usage --json` on an interval and publishes the report to plugin state; the capsules and the panel are pure subscribers, so a second capsule or a second monitor costs no extra process. Severity and absolute reset stamps come from the CLI, so the plugin carries no vendor table and no countdown parsing. --- ai-usagebar/README.md | 56 ++++++ ai-usagebar/bar.luau | 229 +++++++++++++++++++++ ai-usagebar/panel.luau | 335 +++++++++++++++++++++++++++++++ ai-usagebar/plugin.toml | 103 ++++++++++ ai-usagebar/service.luau | 80 ++++++++ ai-usagebar/translations/en.json | 33 +++ 6 files changed, 836 insertions(+) create mode 100644 ai-usagebar/README.md create mode 100644 ai-usagebar/bar.luau create mode 100644 ai-usagebar/panel.luau create mode 100644 ai-usagebar/plugin.toml create mode 100644 ai-usagebar/service.luau create mode 100644 ai-usagebar/translations/en.json diff --git a/ai-usagebar/README.md b/ai-usagebar/README.md new file mode 100644 index 00000000..2d9919c7 --- /dev/null +++ b/ai-usagebar/README.md @@ -0,0 +1,56 @@ +# AI Usage + +Your AI plan quota in the Noctalia bar: how much of the window is spent, when it +resets, and whether you are burning it faster than the clock. + +The numbers come from [ai-usagebar](https://github.com/akitaonrails/ai-usagebar), +a Rust CLI that already knows how to read Claude, Codex, Cursor, Antigravity, +Kiro, Z.AI, OpenRouter, DeepSeek, Kimi, Grok and friends. This plugin never +talks to a provider, holds a token, or reads a credential file: it runs +`ai-usagebar usage --json` and draws the answer. + +## Requirements + +`ai-usagebar` on your `PATH` (`ai-usagebar-bin` on the AUR, or the release +tarballs). Configure your providers once in +`~/.config/ai-usagebar/config.toml` — the CLI owns credentials, this plugin +never sees them. + +## What you get + +- **A bar capsule** per provider, showing the headline percentage, colored by + the severity the CLI reports (calm, then amber past 75%, then red past 90%). + Add the widget twice to watch two plans at once. +- **A tooltip** with every window the provider reports: value, time left, and + the clock time the reset lands on. +- **A panel** on click, with one card per reported metric: a quota bar over a + thinner "window elapsed" bar, so a fill that outruns the clock bar is quota + burning ahead of pace. Credit balances and free-text rows the CLI reports are + rendered too, not dropped. +- **Right click** refreshes immediately. Middle click opens the widget settings, + as everywhere else in the shell. + +## Settings + +Plugin-level: + +| Setting | Default | What it does | +| --- | --- | --- | +| `binPath` | `ai-usagebar` | Command or absolute path to the binary. | +| `refreshMinutes` | `5` | How often the CLI is called. Countdowns tick locally in between. | + +Per widget instance: + +| Setting | Default | What it does | +| --- | --- | --- | +| `vendor` | Automatic | Which plan this capsule tracks. Automatic follows `[ui] primary` from the CLI's own config. | +| `style` | Percentage | Percentage only, or a small gauge next to it. | +| `showName` | off | Adds the product name, so two capsules do not look alike. | +| `colorByUsage` | on | Off keeps the capsule in the bar's own text color. | + +## What it runs + +One process, `ai-usagebar usage --json`, from a single headless service on the +configured interval (plus on demand from a right click or the panel's refresh +button). Capsules and the panel are subscribers, so a second monitor or a second +capsule costs no extra process. No network calls, no filesystem writes. diff --git a/ai-usagebar/bar.luau b/ai-usagebar/bar.luau new file mode 100644 index 00000000..d24c406c --- /dev/null +++ b/ai-usagebar/bar.luau @@ -0,0 +1,229 @@ +--!nonstrict +-- Bar capsule. Reads whatever the poller published and draws one provider. +-- +-- Per-instance settings, so adding the widget twice watches two providers. + +local vendor = tostring(noctalia.getConfig("vendor") or "auto") +local style = tostring(noctalia.getConfig("style") or "pill") +local showName = noctalia.getConfig("showName") == true +local colorByUsage = noctalia.getConfig("colorByUsage") ~= false + +local report = nil +local errorMsg = "" + +-- ── 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 "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 pattern = noctalia.timeFormat() or "HH:mm" + if os.date("%Y-%m-%d", at) ~= os.date("%Y-%m-%d") then + pattern = "ddd " .. pattern + end + return noctalia.formatTime(pattern, at) +end + +local function entries() + if type(report) ~= "table" or type(report.entries) ~= "table" then return {} end + return report.entries +end + +-- "auto" follows `[ui] primary` from the CLI's own config, then the first +-- provider that actually reported. +local function currentEntry() + local all = entries() + if vendor ~= "auto" then + for _, entry in ipairs(all) do + if entry.id == vendor then return entry end + end + return nil + end + + local primary = type(report) == "table" and report.primary or nil + if primary ~= nil then + for _, entry in ipairs(all) do + if entry.id == primary then return entry end + end + end + for _, entry in ipairs(all) do + if entry.status == "ready" then return entry end + end + return all[1] +end + +local function headline(entry) + if type(entry) ~= "table" or type(entry.metrics) ~= "table" then return nil end + return entry.metrics[1] +end + +-- The CLI already tiers every percentage; mirroring its thresholds here would +-- be a second source of truth. +local function severityRole(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 "secondary" end + return "primary" +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. + return (name:gsub("%s*·.*$", "")) +end + +-- ── Rendering ───────────────────────────────────────────────────────────────── + +local function tooltip(entry) + if errorMsg ~= "" then return { { key = "AI Usage", value = errorMsg } } end + if entry == nil then + local waiting = vendor == "auto" and "Waiting for usage data" + or ("`" .. vendor .. "` is not configured in ai-usagebar") + return { { key = "AI Usage", value = waiting } } + end + if entry.status == "error" then + return { { key = tostring(entry.display_name or entry.id), value = tostring(entry.error or "Unavailable") } } + end + + local rows = {} + if entry.plan ~= nil and tostring(entry.plan) ~= "" then + rows[#rows + 1] = { key = "Plan", value = tostring(entry.plan) } + end + for _, metric in ipairs(entry.metrics or {}) do + local value = tostring(metric.value or ""):gsub(" of ", " / ") + local left = countdown(metric) + if left ~= "" then + local clock = resetClock(metric) + value = value .. " · " .. left .. (clock ~= "" and (" (" .. clock .. ")") or "") + end + rows[#rows + 1] = { key = tostring(metric.label or ""), value = value } + end + if entry.stale == true then + rows[#rows + 1] = { key = "Updated", value = "showing last known data" } + end + if #rows == 0 then + rows[#rows + 1] = { key = tostring(entry.display_name or entry.id), value = "No usage reported" } + end + return rows +end + +local function render() + local entry = currentEntry() + local metric = headline(entry) + local tint = severityRole(metric) + local percent = metric ~= nil and tonumber(metric.percent) or nil + local text = percent ~= nil and (string.format("%d%%", percent)) or "—" + + local children = { ui.glyph({ name = "brain", size = 13, color = tint }) } + + if showName and entry ~= nil then + children[#children + 1] = ui.label({ + text = shortName(entry), + fontSize = 11, + color = "on_surface_variant", + maxLines = 1, + }) + end + + if style == "gauge" and percent ~= nil then + children[#children + 1] = ui.progress({ + progress = percent / 100, + fill = tint, + track = "on_surface/0.16", + radius = 6, + width = 26, + height = 5, + }) + end + + children[#children + 1] = ui.label({ + text = text, + fontSize = 11, + fontWeight = "semibold", + color = tint, + maxLines = 1, + }) + + local broken = errorMsg ~= "" or (entry ~= nil and entry.status == "error") or entry == nil + if broken then + children[#children + 1] = ui.glyph({ name = "alert-circle", size = 12, color = "error" }) + elseif entry.stale == true then + children[#children + 1] = ui.glyph({ name = "clock-exclamation", size = 12, color = "secondary" }) + end + + barWidget.render(ui.row({ gap = 5, align = "center" }, children)) + barWidget.setTooltip(tooltip(entry)) +end + +-- ── Wiring ──────────────────────────────────────────────────────────────────── + +noctalia.state.watch("report", function(value) + if type(value) == "table" then + report = value + errorMsg = "" + render() + end +end) + +noctalia.state.watch("error", function(value) + if type(value) == "string" then + errorMsg = value + render() + end +end) + +function onClick() + local entry = currentEntry() + -- One panel serves every capsule and is not told which one opened it. + noctalia.state.set("selected", entry ~= nil and entry.id or vendor) + noctalia.togglePanel("felipeartur/ai-usagebar:panel") +end + +function onRightClick() + noctalia.state.set("command", { action = "refresh", at = os.time() }) +end + +report = noctalia.state.get("report") +local existingError = noctalia.state.get("error") +if type(existingError) == "string" then errorMsg = existingError end + +-- Live countdowns in the tooltip without waking the CLI. +noctalia.setUpdateInterval(30000) +render() + +function update() + render() +end diff --git a/ai-usagebar/panel.luau b/ai-usagebar/panel.luau new file mode 100644 index 00000000..c4394fea --- /dev/null +++ b/ai-usagebar/panel.luau @@ -0,0 +1,335 @@ +--!nonstrict +-- Expanded panel for one provider. +-- +-- It renders `sections[]` — the CLI's lossless view — so credit blocks and free +-- text that the `metrics[]` convenience view drops still show up. + +local report = nil +local errorMsg = "" +local polling = false + +-- Same parsing the capsule does; plugin_api 8 has no module system, so the four +-- helpers below are duplicated rather than required. +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 "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 pattern = noctalia.timeFormat() or "HH:mm" + if os.date("%Y-%m-%d", at) ~= os.date("%Y-%m-%d") then + pattern = "ddd " .. pattern + end + return noctalia.formatTime(pattern, at) +end + +local function severityRole(section) + local severity = tostring(section and section.severity or "") + if severity == "critical" then return "error" end + if severity == "high" then return "secondary" end + return "primary" +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 the reading worth flagging; under is the roomy side. + if last:find("ahead") then return last, "secondary" end + if last:find("under") then return last, "tertiary" end + return last, "on_surface_variant" +end + +-- Details that carry no reset at all, e.g. "62% of monthly limit consumed". +local function plainDetail(detail) + local head = tostring(detail or ""):match("^[^·]*") or "" + head = noctalia.string.trim(head) + if head:lower():find("^resets?%s+in") then return "" end + return head +end + +-- ── Entry selection ─────────────────────────────────────────────────────────── + +local function entries() + if type(report) ~= "table" or type(report.entries) ~= "table" then return {} end + return report.entries +end + +local function currentEntry() + local wanted = noctalia.state.get("selected") + for _, entry in ipairs(entries()) do + if entry.id == wanted then return entry end + end + return entries()[1] +end + +local function updatedText(entry) + local at = parseIso(entry and entry.fetched_at) + if at == nil then return "" end + local minutes = math.floor((os.time() - at) / 60) + if minutes < 1 then return "Updated just now" end + return "Updated " .. tostring(minutes) .. " min ago" +end + +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" +end + +-- ── Cards ───────────────────────────────────────────────────────────────────── + +local function metricCard(section) + local percent = tonumber(section.percent) or 0 + local tint = severityRole(section) + 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.spacer({ flexGrow = 1 }), + } + if showValue then + header[#header + 1] = ui.label({ text = value, fontSize = 11, color = "on_surface_variant" }) + end + header[#header + 1] = ui.label({ + text = string.format("%d%%", percent), + fontSize = 15, + fontWeight = "bold", + color = tint, + }) + + local body = { + ui.row({ gap = 6, align = "center" }, header), + ui.progress({ progress = percent / 100, fill = tint, track = "on_surface/0.16", radius = 3, height = 5 }), + } + + -- 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({ + progress = elapsed / 100, + fill = "on_surface/0.45", + track = "on_surface/0.10", + radius = 2, + height = 2, + }) + 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 }) + end + body[#body + 1] = ui.row({ gap = 5, align = "center" }, footer) + end + + local rest = plainDetail(section.detail) + if rest ~= "" then + 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) +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" }), + }), + } + for _, line in ipairs(section.body or {}) do + local text = noctalia.string.trim(tostring(line)) + 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) +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" }), + }) +end + +-- ── Render ──────────────────────────────────────────────────────────────────── + +local function render() + local entry = currentEntry() + local title = "AI Usage" + 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) + if subtitle == title then subtitle = "" end + end + + local head = { + ui.glyph({ name = "brain", size = 18, color = "primary" }), + ui.column({ gap = 0, flexGrow = 1 }, { + ui.label({ text = title, fontSize = 14, fontWeight = "bold", color = "on_surface" }), + ui.label({ text = subtitle, fontSize = 11, color = "on_surface_variant", visible = subtitle ~= "" }), + }), + ui.button({ + glyph = "refresh", + variant = "ghost", + controlSize = "sm", + enabled = not polling, + tooltip = polling and "Reading…" or "Refresh now", + onClick = "doRefresh", + }), + } + + local children = { + ui.row({ gap = 8, align = "center" }, head), + ui.separator({}), + } + + local status = "" + if errorMsg ~= "" then + status = errorMsg + elseif entry == nil then + status = "Loading…" + elseif entry.status == "error" then + status = tostring(entry.error or "Unavailable") + end + if status ~= "" then + children[#children + 1] = ui.label({ + text = status, + fontSize = 11, + color = errorMsg ~= "" and "error" or "on_surface_variant", + }) + end + + local cards = {} + for _, section in ipairs((entry ~= nil and entry.sections) or {}) do + if section.type == "metric" then + cards[#cards + 1] = metricCard(section) + elseif section.type == "block" then + cards[#cards + 1] = blockCard(section) + elseif section.type == "text" then + cards[#cards + 1] = textRow(section) + end + end + if #cards > 0 then + children[#children + 1] = ui.scroll({ gap = 8, flexGrow = 1 }, cards) + else + children[#children + 1] = ui.spacer({ flexGrow = 1 }) + end + + if entry ~= nil then + local updated = updatedText(entry) + if entry.stale == true and updated ~= "" then updated = updated .. " · stale" end + children[#children + 1] = ui.separator({}) + children[#children + 1] = ui.row({ gap = 5, align = "center" }, { + ui.glyph({ name = "clock", size = 12, color = "on_surface_variant" }), + ui.label({ text = updated, fontSize = 11, color = entry.stale == true and "error" or "on_surface_variant" }), + ui.spacer({ flexGrow = 1 }), + ui.label({ text = tostring(entry.id or ""), fontSize = 11, color = "on_surface_variant" }), + }) + end + + panel.render(ui.column({ gap = 10, padding = 14 }, children)) +end + +-- ── Wiring ──────────────────────────────────────────────────────────────────── + +function doRefresh() + noctalia.state.set("command", { action = "refresh", at = os.time() }) +end + +noctalia.state.watch("report", function(value) + if type(value) == "table" then + report = value + errorMsg = "" + render() + end +end) + +noctalia.state.watch("error", function(value) + if type(value) == "string" then + errorMsg = value + render() + end +end) + +noctalia.state.watch("polling", function(value) + polling = value == true + render() +end) + +function onOpen(_context) + report = noctalia.state.get("report") + local existingError = noctalia.state.get("error") + errorMsg = type(existingError) == "string" and existingError or "" + polling = noctalia.state.get("polling") == true + -- Countdowns tick locally; the CLI is only woken by the poller's interval. + panel.setWantsSecondTicks(true) + render() +end + +-- Panel second tick. +function update() + render() +end + +render() diff --git a/ai-usagebar/plugin.toml b/ai-usagebar/plugin.toml new file mode 100644 index 00000000..0dab7eb4 --- /dev/null +++ b/ai-usagebar/plugin.toml @@ -0,0 +1,103 @@ +id = "felipeartur/ai-usagebar" +name = "AI Usage" +version = "1.0.0" +plugin_api = 8 +author = "felipeartur" +license = "MIT" +icon = "brain" +description = "AI plan usage in the bar, powered by the ai-usagebar CLI." +tags = ["bar", "panel", "ai", "utility"] +# The CLI owns credentials, vendor endpoints and caching. This plugin only runs +# `ai-usagebar usage --json` and draws the result. +dependencies = ["ai-usagebar"] + +# ── Plugin-level settings (shared by the poller, every capsule and the panel) ── + +[[setting]] +key = "binPath" +type = "string" +label_key = "settings.bin_path.label" +description_key = "settings.bin_path.description" +default = "ai-usagebar" + +[[setting]] +key = "refreshMinutes" +type = "int" +label_key = "settings.refresh_minutes.label" +description_key = "settings.refresh_minutes.description" +default = 5 +min = 1 +max = 120 + +# ── Entries ─────────────────────────────────────────────────────────────────── + +# One poller for the whole shell: a single `usage --json` call returns every +# vendor, so N capsules on M monitors still cost one process per cycle. +[[service]] +id = "poller" +entry = "service.luau" + +[[widget]] +id = "bar" +entry = "bar.luau" + +# Per-instance, so a second capsule can track a second provider. +[[widget.setting]] +key = "vendor" +type = "select" +label_key = "settings.vendor.label" +description_key = "settings.vendor.description" +default = "auto" +options = [ + { value = "auto", label_key = "settings.vendor.option.auto" }, + { value = "anthropic", label_key = "settings.vendor.option.anthropic" }, + { value = "openai", label_key = "settings.vendor.option.openai" }, + { value = "anthropic_api", label_key = "settings.vendor.option.anthropic_api" }, + { value = "zai", label_key = "settings.vendor.option.zai" }, + { value = "openrouter", label_key = "settings.vendor.option.openrouter" }, + { value = "deepseek", label_key = "settings.vendor.option.deepseek" }, + { value = "kimi", label_key = "settings.vendor.option.kimi" }, + { value = "kilo", label_key = "settings.vendor.option.kilo" }, + { value = "novita", label_key = "settings.vendor.option.novita" }, + { value = "moonshot", label_key = "settings.vendor.option.moonshot" }, + { value = "grok", label_key = "settings.vendor.option.grok" }, + { value = "supergrok", label_key = "settings.vendor.option.supergrok" }, + { value = "antigravity", label_key = "settings.vendor.option.antigravity" }, + { value = "cursor", label_key = "settings.vendor.option.cursor" }, + { value = "minimax", label_key = "settings.vendor.option.minimax" }, + { value = "kiro", label_key = "settings.vendor.option.kiro" }, +] + +[[widget.setting]] +key = "style" +type = "select" +label_key = "settings.style.label" +description_key = "settings.style.description" +default = "pill" +options = [ + { value = "pill", label_key = "settings.style.option.pill" }, + { value = "gauge", label_key = "settings.style.option.gauge" }, +] + +[[widget.setting]] +key = "showName" +type = "bool" +label_key = "settings.show_name.label" +description_key = "settings.show_name.description" +default = false + +[[widget.setting]] +key = "colorByUsage" +type = "bool" +label_key = "settings.color_by_usage.label" +description_key = "settings.color_by_usage.description" +default = true + +[[panel]] +id = "panel" +entry = "panel.luau" +width = 380 +height = 520 +placement = "attached" +position = "auto" +dismiss_on_outside_click = true diff --git a/ai-usagebar/service.luau b/ai-usagebar/service.luau new file mode 100644 index 00000000..78047bb2 --- /dev/null +++ b/ai-usagebar/service.luau @@ -0,0 +1,80 @@ +--!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. + +local function shellQuote(value) + return "'" .. string.gsub(tostring(value), "'", "'\\''") .. "'" +end + +local function command() + local configured = tostring(noctalia.getConfig("binPath") or "ai-usagebar") + if string.sub(configured, 1, 1) == "~" then + configured = noctalia.expandPath(configured) + end + return shellQuote(configured) .. " usage --json" +end + +local function intervalMs() + local minutes = tonumber(noctalia.getConfig("refreshMinutes")) or 5 + if minutes < 1 then minutes = 1 end + return math.floor(minutes * 60 * 1000) +end + +local inFlight = false + +local function refresh() + if inFlight then return end + inFlight = true + noctalia.state.set("polling", true) + + noctalia.runAsync(command(), function(result) + inFlight = false + 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. + noctalia.state.set("report", decoded) + noctalia.state.set("error", "") + noctalia.state.set("polledAt", os.time()) + return + end + + local message = "ai-usagebar returned no usage data" + if result == nil then + message = "could not run ai-usagebar" + elseif result.timedOut then + message = "ai-usagebar timed out" + elseif result.exitCode ~= 0 then + local stderr = noctalia.string.trim(result.stderr or "") + message = stderr ~= "" and stderr or ("ai-usagebar exited with code " .. tostring(result.exitCode)) + end + noctalia.state.set("error", message) + noctalia.state.set("polledAt", os.time()) + end, 30000) +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() + noctalia.setUpdateInterval(intervalMs()) + refresh() +end + +function onConfigChanged() + noctalia.setUpdateInterval(intervalMs()) + refresh() +end + +function onIpc(event, _payload) + if event == "refresh" then refresh() end +end + +noctalia.setUpdateInterval(intervalMs()) +refresh() diff --git a/ai-usagebar/translations/en.json b/ai-usagebar/translations/en.json new file mode 100644 index 00000000..633c2fd9 --- /dev/null +++ b/ai-usagebar/translations/en.json @@ -0,0 +1,33 @@ +{ + "settings.bin_path.label": "ai-usagebar path", + "settings.bin_path.description": "Command or absolute path to the ai-usagebar binary.", + "settings.refresh_minutes.label": "Refresh interval (minutes)", + "settings.refresh_minutes.description": "How often the CLI is asked for fresh usage. Countdowns tick locally between calls.", + "settings.vendor.label": "Provider", + "settings.vendor.description": "Which plan this capsule tracks. Add the widget twice to watch two.", + "settings.vendor.option.auto": "Automatic (primary)", + "settings.vendor.option.anthropic": "Claude", + "settings.vendor.option.openai": "Codex", + "settings.vendor.option.anthropic_api": "Anthropic API", + "settings.vendor.option.zai": "Z.AI", + "settings.vendor.option.openrouter": "OpenRouter", + "settings.vendor.option.deepseek": "DeepSeek", + "settings.vendor.option.kimi": "Kimi", + "settings.vendor.option.kilo": "Kilo", + "settings.vendor.option.novita": "Novita", + "settings.vendor.option.moonshot": "Moonshot", + "settings.vendor.option.grok": "Grok", + "settings.vendor.option.supergrok": "SuperGrok", + "settings.vendor.option.antigravity": "Antigravity", + "settings.vendor.option.cursor": "Cursor", + "settings.vendor.option.minimax": "MiniMax", + "settings.vendor.option.kiro": "Kiro", + "settings.style.label": "Style", + "settings.style.description": "How this capsule looks in the bar.", + "settings.style.option.pill": "Percentage", + "settings.style.option.gauge": "Gauge and percentage", + "settings.show_name.label": "Show provider name", + "settings.show_name.description": "Adds the product name next to the percentage, so two capsules do not look alike.", + "settings.color_by_usage.label": "Color by usage", + "settings.color_by_usage.description": "Primary while there is room, then amber, then red as the quota fills." +} From 2cf569307d3ef6aa7be2b9fdc155ba1d224e7aef Mon Sep 17 00:00:00 2001 From: Felipe Artur Date: Sat, 15 Aug 2026 13:34:15 -0300 Subject: [PATCH 02/21] Align the plugin with the community-plugins gate Nest translations/en.json, add the 960x540 thumbnail and rewrite the README on the template, so validate-plugins.py passes. Fix the reset weekday: formatTime passes unknown text through verbatim, so a "ddd" prefix rendered literally. Build it with os.date instead. Shrink the panel to 380px; the footer does not stretch to the bottom, so the taller box only added empty space. --- ai-usagebar/README.md | 103 ++++++++++++++++++++----------- ai-usagebar/bar.luau | 9 ++- ai-usagebar/panel.luau | 6 +- ai-usagebar/plugin.toml | 3 +- ai-usagebar/thumbnail.webp | Bin 0 -> 20392 bytes ai-usagebar/translations/en.json | 80 ++++++++++++++---------- 6 files changed, 128 insertions(+), 73 deletions(-) create mode 100644 ai-usagebar/thumbnail.webp diff --git a/ai-usagebar/README.md b/ai-usagebar/README.md index 2d9919c7..b1e5574f 100644 --- a/ai-usagebar/README.md +++ b/ai-usagebar/README.md @@ -9,48 +9,81 @@ Kiro, Z.AI, OpenRouter, DeepSeek, Kimi, Grok and friends. This plugin never talks to a provider, holds a token, or reads a credential file: it runs `ai-usagebar usage --json` and draws the answer. +## Plugin + +| Field | Value | +| --- | --- | +| ID | `felipeartur/ai-usagebar` | +| Entries | Bar widget: `bar`; panel: `panel`; service: `poller` | + ## Requirements -`ai-usagebar` on your `PATH` (`ai-usagebar-bin` on the AUR, or the release -tarballs). Configure your providers once in -`~/.config/ai-usagebar/config.toml` — the CLI owns credentials, this plugin -never sees them. - -## What you get - -- **A bar capsule** per provider, showing the headline percentage, colored by - the severity the CLI reports (calm, then amber past 75%, then red past 90%). - Add the widget twice to watch two plans at once. -- **A tooltip** with every window the provider reports: value, time left, and - the clock time the reset lands on. -- **A panel** on click, with one card per reported metric: a quota bar over a - thinner "window elapsed" bar, so a fill that outruns the clock bar is quota - burning ahead of pace. Credit balances and free-text rows the CLI reports are - rendered too, not dropped. -- **Right click** refreshes immediately. Middle click opens the widget settings, - as everywhere else in the shell. +Install `ai-usagebar` on `PATH` (`ai-usagebar-bin` on the AUR, or the release +tarballs from the project's GitHub Releases). Configure your providers once in +`~/.config/ai-usagebar/config.toml` — the CLI owns credentials and endpoints, +this plugin never sees them. + +## Usage + +Add `felipeartur/ai-usagebar:bar` to a bar in Settings → Bar. The capsule shows +the headline percentage of one provider, colored by the severity the CLI +reports: calm while there is room, amber past 75%, red past 90%. Add the widget +a second time and point it at another provider to watch two plans at once. + +- **Hover** lists every window that provider reports: value, time left, and the + clock time the reset lands on. +- **Left click** opens the `AI Usage` panel for the provider that capsule + tracks. +- **Right click** refreshes immediately. +- **Middle click** opens the widget's settings, as everywhere else in the shell. + +The panel shows one card per reported metric: a quota bar over a thinner +"window elapsed" bar, so a fill that outruns the clock bar is quota burning +ahead of pace. Credit balances and free-text rows the CLI reports are rendered +too, not dropped. Its refresh button asks the CLI for fresh numbers, and the +footer says how old the current reading is. + +To open the panel from a terminal: + +```sh +noctalia msg panel-toggle felipeartur/ai-usagebar:panel +``` ## Settings -Plugin-level: +Plugin-level, shared by the poller, every capsule and the panel: + +| Setting | Type | Default | Description | +| --- | --- | --- | --- | +| `binPath` | `string` | `ai-usagebar` | Command or absolute path to the binary. A leading `~` is expanded. | +| `refreshMinutes` | `int` | `5` | Minutes between CLI calls, 1–120. Countdowns tick locally in between. | + +Per widget instance, so two capsules can follow two providers: + +| Setting | Type | Default | Description | +| --- | --- | --- | --- | +| `vendor` | `select` | `auto` | Which plan this capsule tracks. `auto` follows `[ui] primary` from the CLI's own config, then the first provider that reported. | +| `style` | `select` | `pill` | `pill` is the percentage alone; `gauge` adds a small bar next to it. | +| `showName` | `bool` | `false` | Adds the product name, so two capsules do not look alike. | +| `colorByUsage` | `bool` | `true` | Off keeps the capsule in the bar's own text color instead of tinting by severity. | -| Setting | Default | What it does | -| --- | --- | --- | -| `binPath` | `ai-usagebar` | Command or absolute path to the binary. | -| `refreshMinutes` | `5` | How often the CLI is called. Countdowns tick locally in between. | +## IPC -Per widget instance: +Force a refresh without waiting for the interval: -| Setting | Default | What it does | -| --- | --- | --- | -| `vendor` | Automatic | Which plan this capsule tracks. Automatic follows `[ui] primary` from the CLI's own config. | -| `style` | Percentage | Percentage only, or a small gauge next to it. | -| `showName` | off | Adds the product name, so two capsules do not look alike. | -| `colorByUsage` | on | Off keeps the capsule in the bar's own text color. | +```sh +noctalia msg plugin felipeartur/ai-usagebar:poller all refresh +``` -## What it runs +## Notes -One process, `ai-usagebar usage --json`, from a single headless service on the -configured interval (plus on demand from a right click or the panel's refresh -button). Capsules and the panel are subscribers, so a second monitor or a second -capsule costs no extra process. No network calls, no filesystem writes. +- One process, `ai-usagebar usage --json`, spawned by a single headless service + on the configured interval, plus on demand from a right click, the panel's + refresh button, or the IPC event above. Capsules and the panel are + subscribers of plugin state, so a second monitor or a second capsule costs no + extra process. +- No network calls and no filesystem writes of its own. Everything the plugin + 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 footer. diff --git a/ai-usagebar/bar.luau b/ai-usagebar/bar.luau index d24c406c..6edb0099 100644 --- a/ai-usagebar/bar.luau +++ b/ai-usagebar/bar.luau @@ -49,11 +49,14 @@ end local function resetClock(metric) local at = parseIso(metric and metric.reset_at) if at == nil then return "" end - local pattern = noctalia.timeFormat() or "HH:mm" + 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 - pattern = "ddd " .. pattern + return os.date("%a", at) .. " " .. clock end - return noctalia.formatTime(pattern, at) + return clock end local function entries() diff --git a/ai-usagebar/panel.luau b/ai-usagebar/panel.luau index c4394fea..2aed51f3 100644 --- a/ai-usagebar/panel.luau +++ b/ai-usagebar/panel.luau @@ -42,11 +42,11 @@ end local function resetClock(section) local at = parseIso(section and section.reset_at) if at == nil then return "" end - local pattern = noctalia.timeFormat() or "HH:mm" + local clock = noctalia.formatTime(noctalia.timeFormat(), at) if os.date("%Y-%m-%d", at) ~= os.date("%Y-%m-%d") then - pattern = "ddd " .. pattern + return os.date("%a", at) .. " " .. clock end - return noctalia.formatTime(pattern, at) + return clock end local function severityRole(section) diff --git a/ai-usagebar/plugin.toml b/ai-usagebar/plugin.toml index 0dab7eb4..313cbb48 100644 --- a/ai-usagebar/plugin.toml +++ b/ai-usagebar/plugin.toml @@ -97,7 +97,8 @@ default = true id = "panel" entry = "panel.luau" width = 380 -height = 520 +# Two metric cards is the common case; richer providers scroll. +height = 380 placement = "attached" position = "auto" dismiss_on_outside_click = true diff --git a/ai-usagebar/thumbnail.webp b/ai-usagebar/thumbnail.webp new file mode 100644 index 0000000000000000000000000000000000000000..55da12530df49814e4111926d1a09007c537e564 GIT binary patch literal 20392 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 Date: Sat, 15 Aug 2026 13:51:13 -0300 Subject: [PATCH 03/21] Drop the binary path setting `ai-usagebar` is a declared dependency, so it is on PATH; the setting only added a row to a dialog the shell already fills with its own panel controls. Keep placement and position declared: the shell renders those rows for every plugin panel either way, and omitting them only defaults the panel to floating and adds a third row. --- ai-usagebar/README.md | 6 +++--- ai-usagebar/plugin.toml | 9 +++------ ai-usagebar/service.luau | 15 +++------------ ai-usagebar/translations/en.json | 4 ---- 4 files changed, 9 insertions(+), 25 deletions(-) diff --git a/ai-usagebar/README.md b/ai-usagebar/README.md index b1e5574f..04de7588 100644 --- a/ai-usagebar/README.md +++ b/ai-usagebar/README.md @@ -18,8 +18,9 @@ talks to a provider, holds a token, or reads a credential file: it runs ## Requirements -Install `ai-usagebar` on `PATH` (`ai-usagebar-bin` on the AUR, or the release -tarballs from the project's GitHub Releases). Configure your providers once in +Install `ai-usagebar` on `PATH` — the plugin runs it by name, with no path +setting to fill in (`ai-usagebar-bin` on the AUR, or the release tarballs from +the project's GitHub Releases). Configure your providers once in `~/.config/ai-usagebar/config.toml` — the CLI owns credentials and endpoints, this plugin never sees them. @@ -55,7 +56,6 @@ Plugin-level, shared by the poller, every capsule and the panel: | Setting | Type | Default | Description | | --- | --- | --- | --- | -| `binPath` | `string` | `ai-usagebar` | Command or absolute path to the binary. A leading `~` is expanded. | | `refreshMinutes` | `int` | `5` | Minutes between CLI calls, 1–120. Countdowns tick locally in between. | Per widget instance, so two capsules can follow two providers: diff --git a/ai-usagebar/plugin.toml b/ai-usagebar/plugin.toml index 313cbb48..fc76ee65 100644 --- a/ai-usagebar/plugin.toml +++ b/ai-usagebar/plugin.toml @@ -13,12 +13,7 @@ dependencies = ["ai-usagebar"] # ── Plugin-level settings (shared by the poller, every capsule and the panel) ── -[[setting]] -key = "binPath" -type = "string" -label_key = "settings.bin_path.label" -description_key = "settings.bin_path.description" -default = "ai-usagebar" +# No path setting: `ai-usagebar` is a declared dependency, so it is on PATH. [[setting]] key = "refreshMinutes" @@ -99,6 +94,8 @@ entry = "panel.luau" width = 380 # Two metric cards is the common case; richer providers scroll. height = 380 +# The shell draws the Placement and Position rows for every plugin panel whether +# or not they are declared; declaring them only picks the default the user gets. placement = "attached" position = "auto" dismiss_on_outside_click = true diff --git a/ai-usagebar/service.luau b/ai-usagebar/service.luau index 78047bb2..3050dfa6 100644 --- a/ai-usagebar/service.luau +++ b/ai-usagebar/service.luau @@ -4,17 +4,8 @@ -- 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. -local function shellQuote(value) - return "'" .. string.gsub(tostring(value), "'", "'\\''") .. "'" -end - -local function command() - local configured = tostring(noctalia.getConfig("binPath") or "ai-usagebar") - if string.sub(configured, 1, 1) == "~" then - configured = noctalia.expandPath(configured) - end - return shellQuote(configured) .. " usage --json" -end +-- `ai-usagebar` is a declared dependency, so it is expected on PATH. +local COMMAND = "ai-usagebar usage --json" local function intervalMs() local minutes = tonumber(noctalia.getConfig("refreshMinutes")) or 5 @@ -29,7 +20,7 @@ local function refresh() inFlight = true noctalia.state.set("polling", true) - noctalia.runAsync(command(), function(result) + noctalia.runAsync(COMMAND, function(result) inFlight = false noctalia.state.set("polling", false) diff --git a/ai-usagebar/translations/en.json b/ai-usagebar/translations/en.json index 0b96c204..5ae837fd 100644 --- a/ai-usagebar/translations/en.json +++ b/ai-usagebar/translations/en.json @@ -1,9 +1,5 @@ { "settings": { - "bin_path": { - "label": "ai-usagebar path", - "description": "Command or absolute path to the ai-usagebar binary." - }, "refresh_minutes": { "label": "Refresh interval (minutes)", "description": "How often the CLI is asked for fresh usage. Countdowns tick locally between calls." From 6d4e1b8d7e1ec6c1c85c4d6498c4a60ef8eee712 Mon Sep 17 00:00:00 2001 From: Felipe Artur Date: Sat, 15 Aug 2026 14:20:32 -0300 Subject: [PATCH 04/21] Carry more of the reading in the capsule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bring back the two styles the QML capsule had and the shell can draw: `meter`, five segments filled in twenties, and `label`, name and percentage stacked over the bars. The gauge gets the elapsed underlay the panel already draws, so a fill longer than the clock bar reads as spend running ahead. An `extras` setting puts the time until reset, the pace against the clock, or both beside the percentage — all of it from `reset_at` and `detail`, ticking locally between CLI calls. `provider_limit` lets one capsule carry up to four providers, busiest first, with a `+N` for the rest; `auto` now means the busiest provider rather than the first one that reported. Each gets its own icon: Tabler has no Anthropic mark, so providers without a brand glyph get a semantic one. Settings keys are snake_case to match the rest of the repo, and the tags gain `indicator`. --- ai-usagebar/README.md | 36 +++- ai-usagebar/bar.luau | 289 ++++++++++++++++++++++--------- ai-usagebar/plugin.toml | 33 +++- ai-usagebar/service.luau | 2 +- ai-usagebar/translations/en.json | 18 +- 5 files changed, 283 insertions(+), 95 deletions(-) diff --git a/ai-usagebar/README.md b/ai-usagebar/README.md index 04de7588..37598ac2 100644 --- a/ai-usagebar/README.md +++ b/ai-usagebar/README.md @@ -27,9 +27,27 @@ this plugin never sees them. ## Usage Add `felipeartur/ai-usagebar:bar` to a bar in Settings → Bar. The capsule shows -the headline percentage of one provider, colored by the severity the CLI -reports: calm while there is room, amber past 75%, red past 90%. Add the widget -a second time and point it at another provider to watch two plans at once. +the headline percentage of a provider, behind that provider's icon and colored +by the severity the CLI reports: calm while there is room, amber past 75%, red +past 90%. + +Left on `Automatic`, the capsule follows the **busiest** provider, so what sits +in the bar is the plan about to bite. Raise `provider_limit` and it carries the +next busiest ones too, with a `+N` for whatever did not fit. Pin a provider +instead, or add the widget twice, when you want two fixed plans side by side. + +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. No digits. | +| `label` | Icon, provider name and percentage stacked over the bars. | + +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. - **Hover** lists every window that provider reports: value, time left, and the clock time the reset lands on. @@ -56,16 +74,18 @@ Plugin-level, shared by the poller, every capsule and the panel: | Setting | Type | Default | Description | | --- | --- | --- | --- | -| `refreshMinutes` | `int` | `5` | Minutes between CLI calls, 1–120. Countdowns tick locally in between. | +| `refresh_minutes` | `int` | `5` | Minutes between CLI calls, 1–120. Countdowns tick locally in between. | Per widget instance, so two capsules can follow two providers: | Setting | Type | Default | Description | | --- | --- | --- | --- | -| `vendor` | `select` | `auto` | Which plan this capsule tracks. `auto` follows `[ui] primary` from the CLI's own config, then the first provider that reported. | -| `style` | `select` | `pill` | `pill` is the percentage alone; `gauge` adds a small bar next to it. | -| `showName` | `bool` | `false` | Adds the product name, so two capsules do not look alike. | -| `colorByUsage` | `bool` | `true` | Off keeps the capsule in the bar's own text color instead of tinting by severity. | +| `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` — see the table above. | +| `provider_limit` | `int` | `1` | How many providers one capsule carries, busiest first, 1–4. Only applies on `auto`. | +| `extras` | `select` | `countdown` | What rides beside the percentage: `countdown`, `pace`, `both` or `none`. | +| `show_name` | `bool` | `false` | Adds the product name, so two capsules do not look alike. | +| `color_by_usage` | `bool` | `true` | Off keeps the capsule in the bar's own text color instead of tinting by severity. | ## IPC diff --git a/ai-usagebar/bar.luau b/ai-usagebar/bar.luau index 6edb0099..b60399ed 100644 --- a/ai-usagebar/bar.luau +++ b/ai-usagebar/bar.luau @@ -1,16 +1,42 @@ --!nonstrict --- Bar capsule. Reads whatever the poller published and draws one provider. +-- Bar capsule. Reads whatever the poller published and draws one provider, or +-- the busiest few when `provider_limit` is raised. -- --- Per-instance settings, so adding the widget twice watches two providers. +-- Per-instance settings, so two capsules can follow two different providers. local vendor = tostring(noctalia.getConfig("vendor") or "auto") local style = tostring(noctalia.getConfig("style") or "pill") -local showName = noctalia.getConfig("showName") == true -local colorByUsage = noctalia.getConfig("colorByUsage") ~= false +local extras = tostring(noctalia.getConfig("extras") or "countdown") +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 local report = nil local errorMsg = "" +-- 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 = "message-chatbot", + anthropic_api = "message-chatbot", + 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 @@ -59,37 +85,71 @@ local function resetClock(metric) return clock end +-- "Resets in 4h 01m · 19% elapsed · 2pts ahead" — 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 --- "auto" follows `[ui] primary` from the CLI's own config, then the first --- provider that actually reported. -local function currentEntry() +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 rank(entry) + local metric = headline(entry) + if metric == nil then return -1, -1 end + 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 about to bite is the one on the bar, and `primary` breaks ties. +local function shown() local all = entries() if vendor ~= "auto" then for _, entry in ipairs(all) do - if entry.id == vendor then return entry end + if entry.id == vendor then return { entry }, 0 end end - return nil + return {}, 0 end - local primary = type(report) == "table" and report.primary or nil - if primary ~= nil then - for _, entry in ipairs(all) do - if entry.id == primary then return entry end - end - end + local ready = {} for _, entry in ipairs(all) do - if entry.status == "ready" then return entry end + if entry.status ~= "error" then ready[#ready + 1] = entry end end - return all[1] -end + local primary = type(report) == "table" and report.primary or nil + table.sort(ready, function(a, b) + local aRank, aPct = rank(a) + local bRank, bPct = rank(b) + if aRank ~= bRank then return aRank > bRank end + if aPct ~= bPct then return aPct > bPct end + if a.id == primary then return true end + if b.id == primary then return false end + return tostring(a.id) < tostring(b.id) + end) -local function headline(entry) - if type(entry) ~= "table" or type(entry.metrics) ~= "table" then return nil end - return entry.metrics[1] + local picked = {} + for i = 1, math.min(limit, #ready) do picked[i] = ready[i] end + if #picked == 0 then return {}, 0 end + -- A single-provider capsule is a deliberate choice, so it stays quiet about + -- the rest; the "+N" only appears once the capsule is meant to carry more. + if limit == 1 then return picked, 0 end + return picked, #ready - #picked end -- The CLI already tiers every percentage; mirroring its thresholds here would @@ -110,85 +170,152 @@ end -- ── Rendering ───────────────────────────────────────────────────────────────── -local function tooltip(entry) +-- 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) + local stack = { + ui.progress({ progress = percent / 100, fill = tint, track = "on_surface/0.16", + radius = 3, width = width, height = 4 }), + } + if elapsed ~= nil then + stack[#stack + 1] = ui.progress({ progress = elapsed / 100, fill = "on_surface/0.45", + track = "on_surface/0.10", radius = 1, width = width, height = 2 }) + end + return ui.column({ gap = 1, align = "center" }, stack) +end + +local function paceNodes(metric, tint) + 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 tint or "on_surface_variant" }), + ui.label({ text = tostring(points), fontSize = 10, + color = ahead and tint 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; the extras ride along. +local function chip(entry) + local metric = headline(entry) + local tint = severityRole(metric) + 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 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 tint 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), tint, 44), + })) + elseif style == "gauge" and percent ~= nil then + add(glyph); add(name) + add(bars(percent, elapsedPercent(metric), tint, 26)) + add(pct) + else + add(glyph); add(name); add(pct) + end + + add(countdownNode(metric)) + add(paceNodes(metric, tint)) + + if entry.stale == true then + add(ui.glyph({ name = "clock-exclamation", size = 11, color = "secondary" })) + end + return ui.row({ gap = 4, align = "center" }, nodes) +end + +local function tooltip(picked, hidden) if errorMsg ~= "" then return { { key = "AI Usage", value = errorMsg } } end - if entry == nil then + if #picked == 0 then local waiting = vendor == "auto" and "Waiting for usage data" or ("`" .. vendor .. "` is not configured in ai-usagebar") return { { key = "AI Usage", value = waiting } } end - if entry.status == "error" then - return { { key = tostring(entry.display_name or entry.id), value = tostring(entry.error or "Unavailable") } } - end local rows = {} - if entry.plan ~= nil and tostring(entry.plan) ~= "" then - rows[#rows + 1] = { key = "Plan", value = tostring(entry.plan) } - end - for _, metric in ipairs(entry.metrics or {}) do - local value = tostring(metric.value or ""):gsub(" of ", " / ") - local left = countdown(metric) - if left ~= "" then - local clock = resetClock(metric) - value = value .. " · " .. left .. (clock ~= "" and (" (" .. clock .. ")") or "") + for _, entry in ipairs(picked) do + local title = tostring(entry.display_name or entry.id) + if entry.status == "error" then + rows[#rows + 1] = { key = title, value = tostring(entry.error or "Unavailable") } + else + if entry.plan ~= nil and tostring(entry.plan) ~= "" then + rows[#rows + 1] = { key = title, value = tostring(entry.plan) } + end + for _, metric in ipairs(entry.metrics or {}) do + local value = tostring(metric.value or ""):gsub(" of ", " / ") + local left = countdown(metric) + if left ~= "" then + local clock = resetClock(metric) + value = value .. " · " .. left .. (clock ~= "" and (" (" .. clock .. ")") or "") + end + rows[#rows + 1] = { key = tostring(metric.label or ""), value = value } + end + if entry.stale == true then + rows[#rows + 1] = { key = "Updated", value = "showing last known data" } + end end - rows[#rows + 1] = { key = tostring(metric.label or ""), value = value } end - if entry.stale == true then - rows[#rows + 1] = { key = "Updated", value = "showing last known data" } + if hidden > 0 then + rows[#rows + 1] = { key = "Not shown", value = tostring(hidden) .. " more provider(s) — click to open the panel" } end if #rows == 0 then - rows[#rows + 1] = { key = tostring(entry.display_name or entry.id), value = "No usage reported" } + rows[#rows + 1] = { key = "AI Usage", value = "No usage reported" } end return rows end local function render() - local entry = currentEntry() - local metric = headline(entry) - local tint = severityRole(metric) - local percent = metric ~= nil and tonumber(metric.percent) or nil - local text = percent ~= nil and (string.format("%d%%", percent)) or "—" - - local children = { ui.glyph({ name = "brain", size = 13, color = tint }) } + local picked, hidden = shown() - if showName and entry ~= nil then - children[#children + 1] = ui.label({ - text = shortName(entry), - fontSize = 11, - color = "on_surface_variant", - maxLines = 1, - }) + local children = {} + for _, entry in ipairs(picked) do + children[#children + 1] = chip(entry) end - if style == "gauge" and percent ~= nil then - children[#children + 1] = ui.progress({ - progress = percent / 100, - fill = tint, - track = "on_surface/0.16", - radius = 6, - width = 26, - height = 5, + 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" }), }) + elseif hidden > 0 then + children[#children + 1] = ui.label({ text = "+" .. tostring(hidden), fontSize = 10, + color = "on_surface_variant", maxLines = 1 }) end - children[#children + 1] = ui.label({ - text = text, - fontSize = 11, - fontWeight = "semibold", - color = tint, - maxLines = 1, - }) - - local broken = errorMsg ~= "" or (entry ~= nil and entry.status == "error") or entry == nil - if broken then - children[#children + 1] = ui.glyph({ name = "alert-circle", size = 12, color = "error" }) - elseif entry.stale == true then - children[#children + 1] = ui.glyph({ name = "clock-exclamation", size = 12, color = "secondary" }) - end - - barWidget.render(ui.row({ gap = 5, align = "center" }, children)) - barWidget.setTooltip(tooltip(entry)) + barWidget.render(ui.row({ gap = 7, align = "center" }, children)) + barWidget.setTooltip(tooltip(picked, hidden)) end -- ── Wiring ──────────────────────────────────────────────────────────────────── @@ -209,9 +336,9 @@ noctalia.state.watch("error", function(value) end) function onClick() - local entry = currentEntry() + local picked = shown() -- One panel serves every capsule and is not told which one opened it. - noctalia.state.set("selected", entry ~= nil and entry.id or vendor) + noctalia.state.set("selected", picked[1] ~= nil and picked[1].id or vendor) noctalia.togglePanel("felipeartur/ai-usagebar:panel") end @@ -223,7 +350,7 @@ report = noctalia.state.get("report") local existingError = noctalia.state.get("error") if type(existingError) == "string" then errorMsg = existingError end --- Live countdowns in the tooltip without waking the CLI. +-- Live countdowns without waking the CLI. noctalia.setUpdateInterval(30000) render() diff --git a/ai-usagebar/plugin.toml b/ai-usagebar/plugin.toml index fc76ee65..8b39df9e 100644 --- a/ai-usagebar/plugin.toml +++ b/ai-usagebar/plugin.toml @@ -6,7 +6,7 @@ author = "felipeartur" license = "MIT" icon = "brain" description = "AI plan usage in the bar, powered by the ai-usagebar CLI." -tags = ["bar", "panel", "ai", "utility"] +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"] @@ -16,7 +16,7 @@ dependencies = ["ai-usagebar"] # No path setting: `ai-usagebar` is a declared dependency, so it is on PATH. [[setting]] -key = "refreshMinutes" +key = "refresh_minutes" type = "int" label_key = "settings.refresh_minutes.label" description_key = "settings.refresh_minutes.description" @@ -72,17 +72,42 @@ default = "pill" 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" }, ] +# One capsule can carry more than one provider; "auto" fills it with the busiest. [[widget.setting]] -key = "showName" +key = "provider_limit" +type = "int" +label_key = "settings.provider_limit.label" +description_key = "settings.provider_limit.description" +default = 1 +min = 1 +max = 4 + +[[widget.setting]] +key = "extras" +type = "select" +label_key = "settings.extras.label" +description_key = "settings.extras.description" +default = "countdown" +options = [ + { value = "countdown", label_key = "settings.extras.option.countdown" }, + { value = "pace", label_key = "settings.extras.option.pace" }, + { value = "both", label_key = "settings.extras.option.both" }, + { value = "none", label_key = "settings.extras.option.none" }, +] + +[[widget.setting]] +key = "show_name" type = "bool" label_key = "settings.show_name.label" description_key = "settings.show_name.description" default = false [[widget.setting]] -key = "colorByUsage" +key = "color_by_usage" type = "bool" label_key = "settings.color_by_usage.label" description_key = "settings.color_by_usage.description" diff --git a/ai-usagebar/service.luau b/ai-usagebar/service.luau index 3050dfa6..7edff5cd 100644 --- a/ai-usagebar/service.luau +++ b/ai-usagebar/service.luau @@ -8,7 +8,7 @@ local COMMAND = "ai-usagebar usage --json" local function intervalMs() - local minutes = tonumber(noctalia.getConfig("refreshMinutes")) or 5 + local minutes = tonumber(noctalia.getConfig("refresh_minutes")) or 5 if minutes < 1 then minutes = 1 end return math.floor(minutes * 60 * 1000) end diff --git a/ai-usagebar/translations/en.json b/ai-usagebar/translations/en.json index 5ae837fd..5950970f 100644 --- a/ai-usagebar/translations/en.json +++ b/ai-usagebar/translations/en.json @@ -32,7 +32,9 @@ "description": "How this capsule looks in the bar.", "option": { "pill": "Percentage", - "gauge": "Gauge and percentage" + "gauge": "Gauge and percentage", + "meter": "Segments", + "label": "Name, gauge and percentage" } }, "show_name": { @@ -42,6 +44,20 @@ "color_by_usage": { "label": "Color by usage", "description": "Primary while there is room, then amber, then red as the quota fills." + }, + "provider_limit": { + "label": "Providers in the capsule", + "description": "How many providers one capsule shows, busiest first, with a +N for the rest. Only applies when the provider is Automatic." + }, + "extras": { + "label": "Extra reading", + "description": "What rides next to the percentage: the time left in the window, how far the spend is from the clock, or neither.", + "option": { + "countdown": "Time until reset", + "pace": "Pace against the clock", + "both": "Both", + "none": "Neither" + } } } } From b97ced1fe82a6b94f1c0361c31a6723e23180359 Mon Sep 17 00:00:00 2001 From: Felipe Artur Date: Sat, 15 Aug 2026 14:54:39 -0300 Subject: [PATCH 05/21] Translate the strings the user reads Every runtime string now goes through noctalia.tr with keys in translations/en.json, the way most plugins in this repo do it, so the translation service has something to pick up. --- ai-usagebar/bar.luau | 18 +++++++++--------- ai-usagebar/panel.luau | 16 ++++++++-------- ai-usagebar/translations/en.json | 18 ++++++++++++++++++ 3 files changed, 35 insertions(+), 17 deletions(-) diff --git a/ai-usagebar/bar.luau b/ai-usagebar/bar.luau index b60399ed..6571d79e 100644 --- a/ai-usagebar/bar.luau +++ b/ai-usagebar/bar.luau @@ -55,7 +55,7 @@ local function parseIso(value) end local function formatDuration(seconds) - if seconds <= 0 then return "now" end + 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) @@ -257,18 +257,18 @@ local function chip(entry) end local function tooltip(picked, hidden) - if errorMsg ~= "" then return { { key = "AI Usage", value = errorMsg } } end + if errorMsg ~= "" then return { { key = noctalia.tr("ui.title"), value = errorMsg } } end if #picked == 0 then - local waiting = vendor == "auto" and "Waiting for usage data" - or ("`" .. vendor .. "` is not configured in ai-usagebar") - return { { key = "AI Usage", value = waiting } } + local waiting = vendor == "auto" and noctalia.tr("ui.waiting") + or noctalia.tr("ui.not_configured", { vendor = vendor }) + return { { key = noctalia.tr("ui.title"), value = waiting } } end local rows = {} for _, entry in ipairs(picked) do local title = tostring(entry.display_name or entry.id) if entry.status == "error" then - rows[#rows + 1] = { key = title, value = tostring(entry.error or "Unavailable") } + rows[#rows + 1] = { key = title, value = tostring(entry.error or noctalia.tr("ui.unavailable")) } else if entry.plan ~= nil and tostring(entry.plan) ~= "" then rows[#rows + 1] = { key = title, value = tostring(entry.plan) } @@ -283,15 +283,15 @@ local function tooltip(picked, hidden) rows[#rows + 1] = { key = tostring(metric.label or ""), value = value } end if entry.stale == true then - rows[#rows + 1] = { key = "Updated", value = "showing last known data" } + rows[#rows + 1] = { key = noctalia.tr("ui.updated"), value = noctalia.tr("ui.stale_hint") } end end end if hidden > 0 then - rows[#rows + 1] = { key = "Not shown", value = tostring(hidden) .. " more provider(s) — click to open the panel" } + rows[#rows + 1] = { key = noctalia.tr("ui.hidden_label"), value = noctalia.tr("ui.hidden_value", { count = hidden }) } end if #rows == 0 then - rows[#rows + 1] = { key = "AI Usage", value = "No usage reported" } + rows[#rows + 1] = { key = noctalia.tr("ui.title"), value = noctalia.tr("ui.no_usage") } end return rows end diff --git a/ai-usagebar/panel.luau b/ai-usagebar/panel.luau index 2aed51f3..d194415c 100644 --- a/ai-usagebar/panel.luau +++ b/ai-usagebar/panel.luau @@ -23,7 +23,7 @@ local function parseIso(value) end local function formatDuration(seconds) - if seconds <= 0 then return "now" end + 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) @@ -105,8 +105,8 @@ local function updatedText(entry) local at = parseIso(entry and entry.fetched_at) if at == nil then return "" end local minutes = math.floor((os.time() - at) / 60) - if minutes < 1 then return "Updated just now" end - return "Updated " .. tostring(minutes) .. " min ago" + if minutes < 1 then return noctalia.tr("ui.updated_now") end + return noctalia.tr("ui.updated_ago", { minutes = minutes }) end local function metricIcon(label) @@ -215,7 +215,7 @@ end local function render() local entry = currentEntry() - local title = "AI Usage" + local title = noctalia.tr("ui.title") local subtitle = "" if entry ~= nil then title = tostring(entry.plan or entry.display_name or entry.id) @@ -234,7 +234,7 @@ local function render() variant = "ghost", controlSize = "sm", enabled = not polling, - tooltip = polling and "Reading…" or "Refresh now", + tooltip = polling and noctalia.tr("ui.refreshing") or noctalia.tr("ui.refresh"), onClick = "doRefresh", }), } @@ -248,9 +248,9 @@ local function render() if errorMsg ~= "" then status = errorMsg elseif entry == nil then - status = "Loading…" + status = noctalia.tr("ui.loading") elseif entry.status == "error" then - status = tostring(entry.error or "Unavailable") + status = tostring(entry.error or noctalia.tr("ui.unavailable")) end if status ~= "" then children[#children + 1] = ui.label({ @@ -278,7 +278,7 @@ local function render() if entry ~= nil then local updated = updatedText(entry) - if entry.stale == true and updated ~= "" then updated = updated .. " · stale" end + if entry.stale == true and updated ~= "" then updated = updated .. " · " .. noctalia.tr("ui.stale") end children[#children + 1] = ui.separator({}) children[#children + 1] = ui.row({ gap = 5, align = "center" }, { ui.glyph({ name = "clock", size = 12, color = "on_surface_variant" }), diff --git a/ai-usagebar/translations/en.json b/ai-usagebar/translations/en.json index 5950970f..3bad332b 100644 --- a/ai-usagebar/translations/en.json +++ b/ai-usagebar/translations/en.json @@ -59,5 +59,23 @@ "none": "Neither" } } + }, + "ui": { + "title": "AI Usage", + "now": "now", + "waiting": "Waiting for usage data", + "not_configured": "`{vendor}` is not configured in ai-usagebar", + "unavailable": "Unavailable", + "no_usage": "No usage reported", + "updated": "Updated", + "stale_hint": "showing last known data", + "hidden_label": "Not shown", + "hidden_value": "{count} more — click to open the panel", + "loading": "Loading…", + "updated_now": "Updated just now", + "updated_ago": "Updated {minutes} min ago", + "stale": "stale", + "refresh": "Refresh now", + "refreshing": "Reading…" } } From 91af3ec95729d2c7be4dcc53dfe9e26ef85b3295 Mon Sep 17 00:00:00 2001 From: Felipe Artur Date: Sat, 15 Aug 2026 14:55:23 -0300 Subject: [PATCH 06/21] Document the named widget instance in the README A raw id in a bar list is an anonymous instance, and an anonymous instance has no per-widget settings: the gear opens empty. --- ai-usagebar/README.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/ai-usagebar/README.md b/ai-usagebar/README.md index 37598ac2..88eec5bc 100644 --- a/ai-usagebar/README.md +++ b/ai-usagebar/README.md @@ -49,6 +49,20 @@ 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. +If you add the widget by hand in `config.toml`, give it a name. A bar list entry +that is a raw widget id becomes an anonymous instance, and an anonymous instance +has no settings of its own — the gear opens empty: + +```toml +[widget.ai_usage] +type = "felipeartur/ai-usagebar:bar" +style = "gauge" +provider_limit = 2 + +[bar.default] +start = [ "clock", "ai_usage" ] +``` + - **Hover** lists every window that provider reports: value, time left, and the clock time the reset lands on. - **Left click** opens the `AI Usage` panel for the provider that capsule From 7516fc24dc775cd72149851417ad8545d1a5f9a2 Mon Sep 17 00:00:00 2001 From: Felipe Artur Date: Sat, 15 Aug 2026 15:02:52 -0300 Subject: [PATCH 07/21] Rebuild the panel as master and detail The provider list moves into the panel, in the shell's own two-pane shape: every configured provider on the left with its headline percentage, the selected one's cards on the right. Row selection needs a closure per row, which is plugin_api 9. --- ai-usagebar/panel.luau | 164 ++++++++++++++++++++++++++----- ai-usagebar/plugin.toml | 8 +- ai-usagebar/translations/en.json | 3 +- 3 files changed, 145 insertions(+), 30 deletions(-) diff --git a/ai-usagebar/panel.luau b/ai-usagebar/panel.luau index d194415c..ab0bc836 100644 --- a/ai-usagebar/panel.luau +++ b/ai-usagebar/panel.luau @@ -211,10 +211,121 @@ local function textRow(section) }) end +-- ── Provider list ───────────────────────────────────────────────────────────── + +-- Same map the capsule uses; no require() below API 22, so it is duplicated. +local GLYPHS = { + anthropic = "message-chatbot", + anthropic_api = "message-chatbot", + 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 severityRole(metric) + local muted = selected and "on_primary" or "on_surface_variant" + + 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, + }) + 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, + }), + } + if percent ~= nil and not broken then + lines[#lines + 1] = ui.progress({ + progress = percent / 100, + fill = selected and "on_primary" or tint, + track = selected and "on_primary/0.25" or "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, + }) + + return ui.row({ + gap = 8, align = "center", padding = 8, radius = 8, + fill = selected and "primary" or "surface_variant", + 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.column({ gap = 3, flexGrow = 1 }, lines), + right, + }) +end + -- ── Render ──────────────────────────────────────────────────────────────────── -local function render() - local entry = currentEntry() +local function listPane(entry) + local rows = {} + for _, candidate in ipairs(entries()) do + rows[#rows + 1] = providerRow(candidate, entry ~= nil and candidate.id == entry.id) + end + if #rows == 0 then + rows[1] = ui.label({ + text = errorMsg ~= "" and errorMsg or noctalia.tr("ui.loading"), + fontSize = 11, color = errorMsg ~= "" and "error" or "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" }), + ui.spacer({ flexGrow = 1 }), + ui.button({ + glyph = "refresh", + variant = "ghost", + controlSize = "sm", + enabled = not polling, + tooltip = polling and noctalia.tr("ui.refreshing") or noctalia.tr("ui.refresh"), + onClick = "doRefresh", + }), + }), + ui.scroll({ gap = 6, flexGrow = 1 }, rows), + }) +end + +local function detailPane(entry) local title = noctalia.tr("ui.title") local subtitle = "" if entry ~= nil then @@ -223,25 +334,15 @@ local function render() if subtitle == title then subtitle = "" end end - local head = { - ui.glyph({ name = "brain", size = 18, color = "primary" }), - ui.column({ gap = 0, flexGrow = 1 }, { - ui.label({ text = title, fontSize = 14, fontWeight = "bold", color = "on_surface" }), - ui.label({ text = subtitle, fontSize = 11, color = "on_surface_variant", visible = subtitle ~= "" }), - }), - ui.button({ - glyph = "refresh", - variant = "ghost", - controlSize = "sm", - enabled = not polling, - tooltip = polling and noctalia.tr("ui.refreshing") or noctalia.tr("ui.refresh"), - onClick = "doRefresh", - }), - } - local children = { - ui.row({ gap = 8, align = "center" }, head), - ui.separator({}), + 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.button({ glyph = "x", variant = "ghost", controlSize = "sm", + tooltip = noctalia.tr("ui.close"), onClick = "doClose" }), + }), } local status = "" @@ -254,8 +355,7 @@ local function render() end if status ~= "" then children[#children + 1] = ui.label({ - text = status, - fontSize = 11, + text = status, fontSize = 11, color = errorMsg ~= "" and "error" or "on_surface_variant", }) end @@ -279,16 +379,26 @@ local function render() if entry ~= nil then local updated = updatedText(entry) if entry.stale == true and updated ~= "" then updated = updated .. " · " .. noctalia.tr("ui.stale") end - children[#children + 1] = ui.separator({}) children[#children + 1] = ui.row({ gap = 5, align = "center" }, { ui.glyph({ name = "clock", size = 12, color = "on_surface_variant" }), - ui.label({ text = updated, fontSize = 11, color = entry.stale == true and "error" or "on_surface_variant" }), + ui.label({ text = updated, fontSize = 11, + color = entry.stale == true and "error" or "on_surface_variant" }), ui.spacer({ flexGrow = 1 }), ui.label({ text = tostring(entry.id or ""), fontSize = 11, color = "on_surface_variant" }), }) end - panel.render(ui.column({ gap = 10, padding = 14 }, children)) + return ui.column({ gap = 10, padding = 14, flexGrow = 1 }, children) +end + +function render() + local entry = currentEntry() + panel.render(ui.row({ gap = 0 }, { + listPane(entry), + -- ui.separator is horizontal only; a one-pixel column is the divider. + ui.column({ width = 1, fill = "on_surface/0.12" }, {}), + detailPane(entry), + })) end -- ── Wiring ──────────────────────────────────────────────────────────────────── @@ -297,6 +407,10 @@ function doRefresh() noctalia.state.set("command", { action = "refresh", at = os.time() }) end +function doClose() + panel.close() +end + noctalia.state.watch("report", function(value) if type(value) == "table" then report = value diff --git a/ai-usagebar/plugin.toml b/ai-usagebar/plugin.toml index 8b39df9e..e2231260 100644 --- a/ai-usagebar/plugin.toml +++ b/ai-usagebar/plugin.toml @@ -1,7 +1,7 @@ id = "felipeartur/ai-usagebar" name = "AI Usage" version = "1.0.0" -plugin_api = 8 +plugin_api = 9 author = "felipeartur" license = "MIT" icon = "brain" @@ -116,9 +116,9 @@ default = true [[panel]] id = "panel" entry = "panel.luau" -width = 380 -# Two metric cards is the common case; richer providers scroll. -height = 380 +# Master/detail: the provider list on the left, its cards on the right. +width = 720 +height = 520 # The shell draws the Placement and Position rows for every plugin panel whether # or not they are declared; declaring them only picks the default the user gets. placement = "attached" diff --git a/ai-usagebar/translations/en.json b/ai-usagebar/translations/en.json index 3bad332b..aabb8e0e 100644 --- a/ai-usagebar/translations/en.json +++ b/ai-usagebar/translations/en.json @@ -76,6 +76,7 @@ "updated_ago": "Updated {minutes} min ago", "stale": "stale", "refresh": "Refresh now", - "refreshing": "Reading…" + "refreshing": "Reading…", + "close": "Close" } } From ae6af4826fe54020a4a4e95cc4dc943d44743d91 Mon Sep 17 00:00:00 2001 From: Felipe Artur Date: Sat, 15 Aug 2026 15:12:41 -0300 Subject: [PATCH 08/21] Show only configured providers, and let the theme lead The panel listed every vendor the CLI knows about, including the ones it has no credential for. This is a front-end: a provider that was never set up is not a row. One that is set up and failing keeps its row and its error. Colours follow the shell instead of the severity: text stays on_surface until the reading earns warning or error, and the accent lives on the bar fill. The capsule now reads like the widgets next to it in the bar. --- ai-usagebar/README.md | 15 ++++++++++----- ai-usagebar/bar.luau | 25 ++++++++++++++++++------- ai-usagebar/panel.luau | 41 +++++++++++++++++++++++++++++++++-------- 3 files changed, 61 insertions(+), 20 deletions(-) diff --git a/ai-usagebar/README.md b/ai-usagebar/README.md index 88eec5bc..26514c3b 100644 --- a/ai-usagebar/README.md +++ b/ai-usagebar/README.md @@ -70,11 +70,16 @@ start = [ "clock", "ai_usage" ] - **Right click** refreshes immediately. - **Middle click** opens the widget's settings, as everywhere else in the shell. -The panel shows one card per reported metric: a quota bar over a thinner -"window elapsed" bar, so a fill that outruns the clock bar is quota burning -ahead of pace. Credit balances and free-text rows the CLI reports are rendered -too, not dropped. Its refresh button asks the CLI for fresh numbers, and the -footer says how old the current reading is. +The panel is a two-pane view: every provider you have set up on the left with +its headline percentage, and the selected one's detail on the right — one card +per reported metric, a quota bar over a thinner "window elapsed" bar, so a fill +that outruns the clock bar is quota burning ahead of pace. Credit balances and +free-text rows the CLI reports are rendered too, not dropped. Its refresh button +asks the CLI for fresh numbers, and the footer says how old the reading is. + +The list is a mirror of the CLI, not a catalogue: a provider `ai-usagebar` has +no credential for never appears, while one that is set up and failing keeps its +row and shows the error. To open the panel from a terminal: diff --git a/ai-usagebar/bar.luau b/ai-usagebar/bar.luau index 6571d79e..44bb05fa 100644 --- a/ai-usagebar/bar.luau +++ b/ai-usagebar/bar.luau @@ -153,12 +153,22 @@ local function shown() end -- The CLI already tiers every percentage; mirroring its thresholds here would --- be a second source of truth. -local function severityRole(metric) +-- be a second source of truth. Text stays in the bar's own colour until the +-- reading is worth interrupting for, which is the shell's own idiom: accent +-- lives on the bar fill, `warning` and `error` are earned. +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 "secondary" end + if severity == "high" then return "warning" 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 "warning" end return "primary" end @@ -207,7 +217,8 @@ end -- One provider's chip. The style decides the shape; the extras ride along. local function chip(entry) local metric = headline(entry) - local tint = severityRole(metric) + local tint = textRole(metric) + local fill = barRole(metric) 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 }) @@ -224,7 +235,7 @@ local function chip(entry) for i = 0, 4 do ticks[#ticks + 1] = ui.box({ width = 3, height = 11, radius = 1, - fill = percent > i * 20 and tint or "on_surface/0.22", + fill = percent > i * 20 and fill or "on_surface/0.22", }) end add(glyph); add(name) @@ -237,11 +248,11 @@ local function chip(entry) ui.label({ text = shortName(entry), fontSize = 10, color = "on_surface_variant", maxLines = 1 }), pct, }), - bars(percent, elapsedPercent(metric), tint, 44), + bars(percent, elapsedPercent(metric), fill, 44), })) elseif style == "gauge" and percent ~= nil then add(glyph); add(name) - add(bars(percent, elapsedPercent(metric), tint, 26)) + add(bars(percent, elapsedPercent(metric), fill, 26)) add(pct) else add(glyph); add(name); add(pct) diff --git a/ai-usagebar/panel.luau b/ai-usagebar/panel.luau index ab0bc836..c2441ea7 100644 --- a/ai-usagebar/panel.luau +++ b/ai-usagebar/panel.luau @@ -49,13 +49,30 @@ local function resetClock(section) return clock end -local function severityRole(section) +-- Text keeps the surface colour until the reading earns `warning` or `error`; +-- the accent lives on the bar fill, which is how the shell's own panels read. +local function textRole(section) local severity = tostring(section and section.severity or "") if severity == "critical" then return "error" end - if severity == "high" then return "secondary" end + if severity == "high" then return "warning" 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 "warning" end return "primary" end +-- The CLI reports a vendor it has no credential for as a `credentials error`. +-- This is a front-end: what was never set up is not a row, and a genuine +-- failure of a configured provider still is. +local function configured(entry) + if entry.status ~= "error" then return true end + return not tostring(entry.error or ""):lower():find("credentials error") +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. @@ -95,10 +112,14 @@ end local function currentEntry() local wanted = noctalia.state.get("selected") + local first = nil for _, entry in ipairs(entries()) do - if entry.id == wanted then return entry end + if configured(entry) then + if entry.id == wanted then return entry end + if first == nil then first = entry end + end end - return entries()[1] + return first end local function updatedText(entry) @@ -120,7 +141,8 @@ end local function metricCard(section) local percent = tonumber(section.percent) or 0 - local tint = severityRole(section) + local tint = textRole(section) + local fill = barRole(section) 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) @@ -142,7 +164,7 @@ local function metricCard(section) local body = { ui.row({ gap = 6, align = "center" }, header), - ui.progress({ progress = percent / 100, fill = tint, track = "on_surface/0.16", radius = 3, height = 5 }), + ui.progress({ progress = percent / 100, fill = fill, track = "on_surface/0.16", radius = 3, height = 5 }), } -- Two readings: quota spent above, window elapsed below. A shorter clock bar @@ -244,7 +266,8 @@ 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 severityRole(metric) + 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 right @@ -267,7 +290,7 @@ local function providerRow(entry, selected) if percent ~= nil and not broken then lines[#lines + 1] = ui.progress({ progress = percent / 100, - fill = selected and "on_primary" or tint, + fill = fill, track = selected and "on_primary/0.25" or "on_surface/0.16", radius = 2, height = 3, }) @@ -298,7 +321,9 @@ end local function listPane(entry) local rows = {} for _, candidate in ipairs(entries()) do + if configured(candidate) then rows[#rows + 1] = providerRow(candidate, entry ~= nil and candidate.id == entry.id) + end end if #rows == 0 then rows[1] = ui.label({ From 8a1423fd6c0e2d2f88ee40039f4f59ede2459f6c Mon Sep 17 00:00:00 2001 From: Felipe Artur Date: Sat, 15 Aug 2026 15:13:00 -0300 Subject: [PATCH 09/21] Match the README to how the capsule actually reads --- ai-usagebar/README.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/ai-usagebar/README.md b/ai-usagebar/README.md index 26514c3b..c1036710 100644 --- a/ai-usagebar/README.md +++ b/ai-usagebar/README.md @@ -27,9 +27,10 @@ this plugin never sees them. ## 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 and colored -by the severity the CLI reports: calm while there is room, amber past 75%, red -past 90%. +the headline percentage of a provider, behind that provider's icon. It reads in +the bar's own colour while there is room, turns `warning` 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. Left on `Automatic`, the capsule follows the **busiest** provider, so what sits in the bar is the plan about to bite. Raise `provider_limit` and it carries the @@ -104,7 +105,7 @@ Per widget instance, so two capsules can follow two providers: | `provider_limit` | `int` | `1` | How many providers one capsule carries, busiest first, 1–4. Only applies on `auto`. | | `extras` | `select` | `countdown` | What rides beside the percentage: `countdown`, `pace`, `both` or `none`. | | `show_name` | `bool` | `false` | Adds the product name, so two capsules do not look alike. | -| `color_by_usage` | `bool` | `true` | Off keeps the capsule in the bar's own text color instead of tinting by severity. | +| `color_by_usage` | `bool` | `true` | Off drops the `warning`/`error` tint, so the capsule never changes colour. | ## IPC From bc26d3ebf28a42fad27a80a2e896c65411960ee3 Mon Sep 17 00:00:00 2001 From: Felipe Artur Date: Sat, 15 Aug 2026 15:17:50 -0300 Subject: [PATCH 10/21] Spell out everything the CLI reports for a provider The detail pane was implying fields it had in hand: severity was only a colour, elapsed only a two-pixel bar, status and fetch time only a relative phrase. All of it is on screen now, and a reset more than a week out carries its date instead of a weekday that could mean either week. Adds a select IPC event so a script can point the panel at one provider. --- ai-usagebar/README.md | 14 ++++++++ ai-usagebar/panel.luau | 60 ++++++++++++++++++++++---------- ai-usagebar/service.luau | 6 +++- ai-usagebar/translations/en.json | 3 +- 4 files changed, 63 insertions(+), 20 deletions(-) diff --git a/ai-usagebar/README.md b/ai-usagebar/README.md index c1036710..ca476cb7 100644 --- a/ai-usagebar/README.md +++ b/ai-usagebar/README.md @@ -82,6 +82,14 @@ The list is a mirror of the CLI, not a catalogue: a provider `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, rather +than 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 and the severity the CLI assigned it, the percentage and the raw +value string when they differ, how much of the window has elapsed, the time +left with the exact clock time — or date — its reset lands on, and the pace +line. Credit blocks and free-text rows are rendered as the CLI writes them. + To open the panel from a terminal: ```sh @@ -115,6 +123,12 @@ Force a refresh without waiting for the interval: noctalia msg plugin felipeartur/ai-usagebar:poller all refresh ``` +Point the panel at a provider, by the id `ai-usagebar` uses for it: + +```sh +noctalia msg plugin felipeartur/ai-usagebar:poller all select anthropic +``` + ## Notes - One process, `ai-usagebar usage --json`, spawned by a single headless service diff --git a/ai-usagebar/panel.luau b/ai-usagebar/panel.luau index c2441ea7..90ed3e7a 100644 --- a/ai-usagebar/panel.luau +++ b/ai-usagebar/panel.luau @@ -43,10 +43,10 @@ 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 os.date("%a", at) .. " " .. clock - end - return clock + 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 keeps the surface colour until the reading earns `warning` or `error`; @@ -90,8 +90,8 @@ local function pace(detail) last = noctalia.string.trim(last) if last:find("elapsed") then return "", "on_surface_variant" end -- Ahead of the clock is the reading worth flagging; under is the roomy side. - if last:find("ahead") then return last, "secondary" end - if last:find("under") then return last, "tertiary" end + if last:find("ahead") then return last, "warning" end + if last:find("under") then return last, "success" end return last, "on_surface_variant" end @@ -150,6 +150,11 @@ local function metricCard(section) 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.spacer({ flexGrow = 1 }), } if showValue then @@ -178,6 +183,10 @@ local function metricCard(section) radius = 2, height = 2, }) + body[#body + 1] = ui.label({ + text = noctalia.tr("ui.elapsed", { percent = elapsed }), + fontSize = 10, color = "on_surface_variant", + }) end local left = countdown(section) @@ -370,6 +379,33 @@ local function detailPane(entry) }), } + -- The entry's own fields, spelled out rather than implied by a colour. + 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 "success" or "warning", + }), + } + 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 = "warning" }) + end + local fetched = parseIso(entry.fetched_at) + if fetched ~= nil then + chips[#chips + 1] = ui.spacer({ flexGrow = 1 }) + 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) + end + local status = "" if errorMsg ~= "" then status = errorMsg @@ -401,18 +437,6 @@ local function detailPane(entry) children[#children + 1] = ui.spacer({ flexGrow = 1 }) end - if entry ~= nil then - local updated = updatedText(entry) - if entry.stale == true and updated ~= "" then updated = updated .. " · " .. noctalia.tr("ui.stale") end - children[#children + 1] = ui.row({ gap = 5, align = "center" }, { - ui.glyph({ name = "clock", size = 12, color = "on_surface_variant" }), - ui.label({ text = updated, fontSize = 11, - color = entry.stale == true and "error" or "on_surface_variant" }), - ui.spacer({ flexGrow = 1 }), - ui.label({ text = tostring(entry.id or ""), fontSize = 11, color = "on_surface_variant" }), - }) - end - return ui.column({ gap = 10, padding = 14, flexGrow = 1 }, children) end diff --git a/ai-usagebar/service.luau b/ai-usagebar/service.luau index 7edff5cd..a88af16f 100644 --- a/ai-usagebar/service.luau +++ b/ai-usagebar/service.luau @@ -63,8 +63,12 @@ function onConfigChanged() refresh() end -function onIpc(event, _payload) +function onIpc(event, payload) if event == "refresh" then refresh() end + -- `select ` points the panel at one provider from a script. + if event == "select" and type(payload) == "string" and payload ~= "" then + noctalia.state.set("selected", payload) + end end noctalia.setUpdateInterval(intervalMs()) diff --git a/ai-usagebar/translations/en.json b/ai-usagebar/translations/en.json index aabb8e0e..8fafbf38 100644 --- a/ai-usagebar/translations/en.json +++ b/ai-usagebar/translations/en.json @@ -77,6 +77,7 @@ "stale": "stale", "refresh": "Refresh now", "refreshing": "Reading…", - "close": "Close" + "close": "Close", + "elapsed": "{percent}% of the window elapsed" } } From 090f1f25030738c84e2636d97c63d14166b1c327 Mon Sep 17 00:00:00 2001 From: Felipe Artur Date: Sat, 15 Aug 2026 15:19:53 -0300 Subject: [PATCH 11/21] Fit the panel to what it draws 520px left a third of the box empty; the cards and the provider list both end around 380. --- ai-usagebar/plugin.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ai-usagebar/plugin.toml b/ai-usagebar/plugin.toml index e2231260..04cc433d 100644 --- a/ai-usagebar/plugin.toml +++ b/ai-usagebar/plugin.toml @@ -118,7 +118,7 @@ id = "panel" entry = "panel.luau" # Master/detail: the provider list on the left, its cards on the right. width = 720 -height = 520 +height = 400 # The shell draws the Placement and Position rows for every plugin panel whether # or not they are declared; declaring them only picks the default the user gets. placement = "attached" From b8de5aa938babc806e82be918def64c0700df5d1 Mon Sep 17 00:00:00 2001 From: Felipe Artur Date: Sat, 15 Aug 2026 15:27:21 -0300 Subject: [PATCH 12/21] Clean the CLI text before it reaches the screen Error output is rendered as-is in a tooltip and in the panel, and an error can quote the request that failed, which can carry a key in its query string. It is now cleaned once, where it enters the plugin: whitespace collapsed, key, token, secret and bearer values redacted, and the line capped at 200 characters so it cannot push a bar capsule off screen. Also drops the polledAt state key, which nothing read. --- ai-usagebar/service.luau | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/ai-usagebar/service.luau b/ai-usagebar/service.luau index a88af16f..bdaeefb9 100644 --- a/ai-usagebar/service.luau +++ b/ai-usagebar/service.luau @@ -13,6 +13,21 @@ local function intervalMs() return math.floor(minutes * 60 * 1000) end +-- Everything below this line is text the CLI produced, and every bit of it ends +-- up on screen. 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. +local function safeText(value) + local text = noctalia.string.trim(tostring(value or "")) + text = text:gsub("%s+", " ") + 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") + text = text:gsub("([Bb]earer%s+)[%w%._%-]+", "%1") + if #text > 200 then text = string.sub(text, 1, 200) .. "..." end + return text +end + local inFlight = false local function refresh() @@ -28,9 +43,11 @@ local function refresh() 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. + for _, entry in ipairs(decoded.entries) do + if entry.error ~= nil then entry.error = safeText(entry.error) end + end noctalia.state.set("report", decoded) noctalia.state.set("error", "") - noctalia.state.set("polledAt", os.time()) return end @@ -40,11 +57,10 @@ local function refresh() elseif result.timedOut then message = "ai-usagebar timed out" elseif result.exitCode ~= 0 then - local stderr = noctalia.string.trim(result.stderr or "") + local stderr = safeText(result.stderr) message = stderr ~= "" and stderr or ("ai-usagebar exited with code " .. tostring(result.exitCode)) end noctalia.state.set("error", message) - noctalia.state.set("polledAt", os.time()) end, 30000) end From faef8b41d404d26fc38f614dd6607d8b87b4d0b0 Mon Sep 17 00:00:00 2001 From: Felipe Artur Date: Sat, 15 Aug 2026 15:27:21 -0300 Subject: [PATCH 13/21] Give Claude its own icon, and plain up the prose Tabler has no Anthropic mark, but it has asterisk-simple, which is the shape of the Claude symbol. Better than the chat bubble it borrowed before. The README and the comments lose the em dashes, the three-part lists and the lines that sounded like slogans. --- ai-usagebar/README.md | 94 ++++++++++++++++---------------- ai-usagebar/bar.luau | 27 ++++----- ai-usagebar/panel.luau | 22 ++++---- ai-usagebar/translations/en.json | 2 +- 4 files changed, 74 insertions(+), 71 deletions(-) diff --git a/ai-usagebar/README.md b/ai-usagebar/README.md index ca476cb7..68ceed0f 100644 --- a/ai-usagebar/README.md +++ b/ai-usagebar/README.md @@ -4,9 +4,9 @@ Your AI plan quota in the Noctalia bar: how much of the window is spent, when it resets, and whether you are burning it faster than the clock. The numbers come from [ai-usagebar](https://github.com/akitaonrails/ai-usagebar), -a Rust CLI that already knows how to read Claude, Codex, Cursor, Antigravity, -Kiro, Z.AI, OpenRouter, DeepSeek, Kimi, Grok and friends. This plugin never -talks to a provider, holds a token, or reads a credential file: it runs +a Rust CLI that reads Claude, Codex, Cursor, Antigravity, Kiro, Z.AI, +OpenRouter, DeepSeek, Kimi and Grok, among others. This plugin never talks to a +provider, holds a token, or reads a credential file. It runs `ai-usagebar usage --json` and draws the answer. ## Plugin @@ -18,24 +18,25 @@ talks to a provider, holds a token, or reads a credential file: it runs ## Requirements -Install `ai-usagebar` on `PATH` — the plugin runs it by name, with no path -setting to fill in (`ai-usagebar-bin` on the AUR, or the release tarballs from -the project's GitHub Releases). Configure your providers once in -`~/.config/ai-usagebar/config.toml` — the CLI owns credentials and endpoints, -this plugin never sees them. +Install `ai-usagebar` on `PATH`. The plugin runs it by name, so there is no path +setting to fill in. It ships as `ai-usagebar-bin` on the AUR, and as release +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. ## Usage -Add `felipeartur/ai-usagebar:bar` to a bar in Settings → Bar. The capsule shows +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, turns `warning` when the CLI calls the -window high, and `error` when it calls it critical — the accent stays on 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. -Left on `Automatic`, the capsule follows the **busiest** provider, so what sits -in the bar is the plan about to bite. Raise `provider_limit` and it carries the -next busiest ones too, with a `+N` for whatever did not fit. Pin a provider -instead, or add the widget twice, when you want two fixed plans side by side. +Left on `Automatic`, the capsule follows the busiest provider, so what sits in +the bar is the plan closest to running out. Raise `provider_limit` and it +carries the next busiest ones too, with a `+N` for whatever did not fit. Pin a +provider instead, or add the widget twice, when you want two fixed plans side by +side. Four styles, all with the same reading: @@ -43,7 +44,7 @@ Four styles, all with the same reading: | --- | --- | | `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. No digits. | +| `meter` | Icon and five segments, filled in twenties, with no percentage. | | `label` | Icon, provider name and percentage stacked over the bars. | Next to that, `extras` puts the time left in the window (`3h 51m`), the pace @@ -52,7 +53,7 @@ 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 -has no settings of its own — the gear opens empty: +has no settings of its own, so the gear opens empty: ```toml [widget.ai_usage] @@ -71,24 +72,25 @@ start = [ "clock", "ai_usage" ] - **Right click** refreshes immediately. - **Middle click** opens the widget's settings, as everywhere else in the shell. -The panel is a two-pane view: every provider you have set up on the left with -its headline percentage, and the selected one's detail on the right — one card -per reported metric, a quota bar over a thinner "window elapsed" bar, so a fill -that outruns the clock bar is quota burning ahead of pace. Credit balances and -free-text rows the CLI reports are rendered too, not dropped. Its refresh button -asks the CLI for fresh numbers, and the footer says how old the reading is. - -The list is a mirror of the CLI, not a catalogue: a provider `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, rather -than 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 and the severity the CLI assigned it, the percentage and the raw -value string when they differ, how much of the window has elapsed, the time -left with the exact clock time — or date — its reset lands on, and the pace -line. Credit blocks and free-text rows are rendered as the CLI writes them. +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. The +refresh button asks the CLI for fresh numbers, and the header says how old the +reading is. + +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. +Credit blocks and free text rows appear as the CLI writes them. To open the panel from a terminal: @@ -102,18 +104,18 @@ Plugin-level, shared by the poller, every capsule and the panel: | Setting | Type | Default | Description | | --- | --- | --- | --- | -| `refresh_minutes` | `int` | `5` | Minutes between CLI calls, 1–120. Countdowns tick locally in between. | +| `refresh_minutes` | `int` | `5` | Minutes between CLI calls, from 1 to 120. Countdowns tick locally in between. | 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` — see the table above. | -| `provider_limit` | `int` | `1` | How many providers one capsule carries, busiest first, 1–4. Only applies on `auto`. | +| `style` | `select` | `pill` | `pill`, `gauge`, `meter` or `label`, as described in the table above. | +| `provider_limit` | `int` | `1` | How many providers one capsule carries, busiest first, from 1 to 4. Only applies on `auto`. | | `extras` | `select` | `countdown` | What rides beside the percentage: `countdown`, `pace`, `both` or `none`. | | `show_name` | `bool` | `false` | Adds the product name, so two capsules do not look alike. | -| `color_by_usage` | `bool` | `true` | Off drops the `warning`/`error` tint, so the capsule never changes colour. | +| `color_by_usage` | `bool` | `true` | Off drops the `warning` and `error` tint, so the capsule never changes colour. | ## IPC @@ -133,11 +135,11 @@ noctalia msg plugin felipeartur/ai-usagebar:poller all select anthropic - One process, `ai-usagebar usage --json`, spawned by a single headless service on the configured interval, plus on demand from a right click, the panel's - refresh button, or the IPC event above. Capsules and the panel are - subscribers of plugin state, so a second monitor or a second capsule costs no - extra process. -- No network calls and no filesystem writes of its own. Everything the plugin - 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 footer. + refresh button, or the IPC event above. Capsules and the panel are subscribers + of plugin state, so a second monitor or a second capsule costs no extra + process. +- The plugin makes no network calls and writes no files of its own. Everything + it knows arrives on that command's stdout. +- A provider that fails still comes back as an entry with `status = "error"`, so + one broken provider does not blank the others. A reading the CLI marks stale + keeps showing, flagged in the capsule and in the panel header. diff --git a/ai-usagebar/bar.luau b/ai-usagebar/bar.luau index 44bb05fa..7ffe4dce 100644 --- a/ai-usagebar/bar.luau +++ b/ai-usagebar/bar.luau @@ -17,8 +17,8 @@ local errorMsg = "" -- 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 = "message-chatbot", - anthropic_api = "message-chatbot", + anthropic = "asterisk-simple", + anthropic_api = "asterisk-simple", openai = "brand-openai", zai = "bolt", openrouter = "route", @@ -85,8 +85,8 @@ local function resetClock(metric) return clock end --- "Resets in 4h 01m · 19% elapsed · 2pts ahead" — how much of the window is --- gone, and how far the spend is from that line. +-- "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 @@ -117,8 +117,8 @@ 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 about to bite is the one on the bar, and `primary` breaks ties. +-- 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. local function shown() local all = entries() if vendor ~= "auto" then @@ -146,16 +146,16 @@ 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 - -- A single-provider capsule is a deliberate choice, so it stays quiet about - -- the rest; the "+N" only appears once the capsule is meant to carry more. + -- 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; mirroring its thresholds here would --- be a second source of truth. Text stays in the bar's own colour until the --- reading is worth interrupting for, which is the shell's own idiom: accent --- lives on the bar fill, `warning` and `error` are earned. +-- 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 "" @@ -214,7 +214,8 @@ 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; the extras ride along. +-- 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) diff --git a/ai-usagebar/panel.luau b/ai-usagebar/panel.luau index 90ed3e7a..2e2d7aa0 100644 --- a/ai-usagebar/panel.luau +++ b/ai-usagebar/panel.luau @@ -1,15 +1,15 @@ --!nonstrict -- Expanded panel for one provider. -- --- It renders `sections[]` — the CLI's lossless view — so credit blocks and free --- text that the `metrics[]` convenience view drops still show up. +-- 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. local report = nil local errorMsg = "" local polling = false --- Same parsing the capsule does; plugin_api 8 has no module system, so the four --- helpers below are duplicated rather than required. +-- 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+)") @@ -49,8 +49,8 @@ local function resetClock(section) return os.date("%a", at) .. " " .. clock end --- Text keeps the surface colour until the reading earns `warning` or `error`; --- the accent lives on the bar fill, which is how the shell's own panels read. +-- 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 @@ -66,8 +66,8 @@ local function barRole(section) end -- The CLI reports a vendor it has no credential for as a `credentials error`. --- This is a front-end: what was never set up is not a row, and a genuine --- failure of a configured provider still is. +-- Those are not listed, because they were never set up. 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") @@ -89,7 +89,7 @@ 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 the reading worth flagging; under is the roomy side. + -- Ahead of the clock is worth flagging. Under it means there is room left. if last:find("ahead") then return last, "warning" end if last:find("under") then return last, "success" end return last, "on_surface_variant" @@ -246,8 +246,8 @@ end -- Same map the capsule uses; no require() below API 22, so it is duplicated. local GLYPHS = { - anthropic = "message-chatbot", - anthropic_api = "message-chatbot", + anthropic = "asterisk-simple", + anthropic_api = "asterisk-simple", openai = "brand-openai", zai = "bolt", openrouter = "route", diff --git a/ai-usagebar/translations/en.json b/ai-usagebar/translations/en.json index 8fafbf38..f7ecebb2 100644 --- a/ai-usagebar/translations/en.json +++ b/ai-usagebar/translations/en.json @@ -70,7 +70,7 @@ "updated": "Updated", "stale_hint": "showing last known data", "hidden_label": "Not shown", - "hidden_value": "{count} more — click to open the panel", + "hidden_value": "{count} more, click to open the panel", "loading": "Loading…", "updated_now": "Updated just now", "updated_ago": "Updated {minutes} min ago", From f5ea20bb712519694b037bb998b95db72be347aa Mon Sep 17 00:00:00 2001 From: Felipe Artur Date: Sat, 15 Aug 2026 16:15:46 -0300 Subject: [PATCH 14/21] Show the read while it happens A cold call to the CLI takes a second or two, and until now nothing on screen said so. The panel's refresh button gives way to a loader while the poller is in flight, and the capsule grows a small one beside the reading. Two arcs alternate on a 220ms tick, which reads as one turning loader; the tick goes back to its normal rate as soon as the read lands. The QML capsule spun its refresh button the same way. The shell has no rotation property on a ui node, so the turn is drawn as two frames instead. --- ai-usagebar/bar.luau | 19 +++++++++++++++++++ ai-usagebar/panel.luau | 34 +++++++++++++++++++++++++--------- 2 files changed, 44 insertions(+), 9 deletions(-) diff --git a/ai-usagebar/bar.luau b/ai-usagebar/bar.luau index 7ffe4dce..c5501523 100644 --- a/ai-usagebar/bar.luau +++ b/ai-usagebar/bar.luau @@ -13,6 +13,8 @@ local colorByUsage = noctalia.getConfig("color_by_usage") ~= false local report = nil local errorMsg = "" +local polling = false +local pulse = false -- 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. @@ -316,6 +318,15 @@ local function render() children[#children + 1] = chip(entry) end + if polling then + -- Two arcs alternating on the fast tick read as one turning loader. + children[#children + 1] = ui.glyph({ + name = pulse and "loader-2" or "loader-3", + 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" }), @@ -347,6 +358,13 @@ noctalia.state.watch("error", function(value) end end) +noctalia.state.watch("polling", function(value) + polling = value == true + -- A cold read takes a second or two; the capsule says so while it waits. + noctalia.setUpdateInterval(polling and 220 or 30000) + render() +end) + function onClick() local picked = shown() -- One panel serves every capsule and is not told which one opened it. @@ -367,5 +385,6 @@ noctalia.setUpdateInterval(30000) render() function update() + if polling then pulse = not pulse end render() end diff --git a/ai-usagebar/panel.luau b/ai-usagebar/panel.luau index 2e2d7aa0..acc961d8 100644 --- a/ai-usagebar/panel.luau +++ b/ai-usagebar/panel.luau @@ -7,6 +7,7 @@ local report = nil local errorMsg = "" local polling = false +local pulse = false -- Same parsing the capsule does. There is no require() below API 22, so the -- four helpers below are copied instead of shared. @@ -327,6 +328,26 @@ end -- ── Render ──────────────────────────────────────────────────────────────────── +-- A cold read takes a second or two, so the control says so while it waits. +-- The button gives way to two loader arcs that alternate on the faster update +-- interval `polling` turns on, which reads as one turning loader. +local function refreshControl() + if polling then + return ui.glyph({ + name = pulse and "loader-2" or "loader-3", + size = 16, + color = "primary", + }) + end + return ui.button({ + glyph = "refresh", + variant = "ghost", + controlSize = "sm", + tooltip = noctalia.tr("ui.refresh"), + onClick = "doRefresh", + }) +end + local function listPane(entry) local rows = {} for _, candidate in ipairs(entries()) do @@ -346,14 +367,7 @@ local function listPane(entry) ui.glyph({ name = "brain", size = 18, color = "primary" }), ui.label({ text = noctalia.tr("ui.title"), fontSize = 15, fontWeight = "bold", color = "primary" }), ui.spacer({ flexGrow = 1 }), - ui.button({ - glyph = "refresh", - variant = "ghost", - controlSize = "sm", - enabled = not polling, - tooltip = polling and noctalia.tr("ui.refreshing") or noctalia.tr("ui.refresh"), - onClick = "doRefresh", - }), + refreshControl(), }), ui.scroll({ gap = 6, flexGrow = 1 }, rows), }) @@ -477,6 +491,8 @@ end) noctalia.state.watch("polling", function(value) polling = value == true + -- Second ticks are enough for a countdown, not for a pulse. + noctalia.setUpdateInterval(polling and 220 or 1000) render() end) @@ -490,8 +506,8 @@ function onOpen(_context) render() end --- Panel second tick. function update() + if polling then pulse = not pulse end render() end From e033d33c56eb9cc24b2465f0f9fc24056a93f677 Mon Sep 17 00:00:00 2001 From: Felipe Artur Date: Sat, 15 Aug 2026 16:21:52 -0300 Subject: [PATCH 15/21] Keep the busy state on screen long enough to see The CLI caches for a minute, so a manual refresh usually answers in about ten milliseconds and the loader never survived a frame. The poller now holds the busy state for 600ms, timed by its own tick, and goes back to the normal interval as soon as it lets go. --- ai-usagebar/service.luau | 28 +++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/ai-usagebar/service.luau b/ai-usagebar/service.luau index bdaeefb9..ed5f61a8 100644 --- a/ai-usagebar/service.luau +++ b/ai-usagebar/service.luau @@ -30,14 +30,34 @@ end local inFlight = false +-- The CLI caches for a minute, so a manual refresh usually answers in about ten +-- milliseconds. Holding the busy state for a beat is what makes the loader in +-- the capsule and the panel visible at all; the service's own tick times it. +local MIN_BUSY_MS = 600 +local busyUntil = 0 +local clearPending = false + +local function stopPolling() + clearPending = false + noctalia.state.set("polling", false) + noctalia.setUpdateInterval(intervalMs()) +end + local function refresh() if inFlight then return end inFlight = true + busyUntil = noctalia.nowMs() + MIN_BUSY_MS + clearPending = false noctalia.state.set("polling", true) + noctalia.setUpdateInterval(120) noctalia.runAsync(COMMAND, function(result) inFlight = false - noctalia.state.set("polling", false) + if noctalia.nowMs() >= busyUntil then + stopPolling() + else + clearPending = true + end local decoded = result ~= nil and noctalia.json.decode(result.stdout or "") or nil if type(decoded) == "table" and type(decoded.entries) == "table" then @@ -70,6 +90,12 @@ noctalia.state.watch("command", function(value) end) function update() + -- While a read is in flight the fast tick is the busy timer, not a poll. + if inFlight then return end + if clearPending then + if noctalia.nowMs() >= busyUntil then stopPolling() end + return + end noctalia.setUpdateInterval(intervalMs()) refresh() end From 0b43499ad99d7e3ef8a409baa1d31ec84495584e Mon Sep 17 00:00:00 2001 From: Felipe Artur Date: Sat, 15 Aug 2026 16:27:03 -0300 Subject: [PATCH 16/21] Stop the loader from flickering Two glyph frames at 4.5fps read as a stutter, not a turn, and a smooth one would cost frame ticks and a newer plugin API for a 600ms event. The loader is now drawn once and held, so the capsule and the panel stop re-rendering four times a second while a read is in flight. --- ai-usagebar/bar.luau | 11 +---------- ai-usagebar/panel.luau | 16 ++++------------ 2 files changed, 5 insertions(+), 22 deletions(-) diff --git a/ai-usagebar/bar.luau b/ai-usagebar/bar.luau index c5501523..211ad463 100644 --- a/ai-usagebar/bar.luau +++ b/ai-usagebar/bar.luau @@ -14,7 +14,6 @@ local colorByUsage = noctalia.getConfig("color_by_usage") ~= false local report = nil local errorMsg = "" local polling = false -local pulse = false -- 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. @@ -319,12 +318,7 @@ local function render() end if polling then - -- Two arcs alternating on the fast tick read as one turning loader. - children[#children + 1] = ui.glyph({ - name = pulse and "loader-2" or "loader-3", - size = 11, - color = "on_surface_variant", - }) + children[#children + 1] = ui.glyph({ name = "loader-2", size = 11, color = "on_surface_variant" }) end if #children == 0 then @@ -360,8 +354,6 @@ end) noctalia.state.watch("polling", function(value) polling = value == true - -- A cold read takes a second or two; the capsule says so while it waits. - noctalia.setUpdateInterval(polling and 220 or 30000) render() end) @@ -385,6 +377,5 @@ noctalia.setUpdateInterval(30000) render() function update() - if polling then pulse = not pulse end render() end diff --git a/ai-usagebar/panel.luau b/ai-usagebar/panel.luau index acc961d8..06ce7faa 100644 --- a/ai-usagebar/panel.luau +++ b/ai-usagebar/panel.luau @@ -7,7 +7,6 @@ local report = nil local errorMsg = "" local polling = false -local pulse = false -- Same parsing the capsule does. There is no require() below API 22, so the -- four helpers below are copied instead of shared. @@ -328,16 +327,11 @@ end -- ── Render ──────────────────────────────────────────────────────────────────── --- A cold read takes a second or two, so the control says so while it waits. --- The button gives way to two loader arcs that alternate on the faster update --- interval `polling` turns on, which reads as one turning loader. +-- A cold read takes a second or two, so the button gives way to a loader while +-- the poller is in flight. local function refreshControl() if polling then - return ui.glyph({ - name = pulse and "loader-2" or "loader-3", - size = 16, - color = "primary", - }) + return ui.glyph({ name = "loader-2", size = 16, color = "primary" }) end return ui.button({ glyph = "refresh", @@ -491,8 +485,6 @@ end) noctalia.state.watch("polling", function(value) polling = value == true - -- Second ticks are enough for a countdown, not for a pulse. - noctalia.setUpdateInterval(polling and 220 or 1000) render() end) @@ -506,8 +498,8 @@ function onOpen(_context) render() end +-- Panel second tick, for the countdowns. function update() - if polling then pulse = not pulse end render() end From cf6d75c8d6124b57063397a4ae93f2859d715f4f Mon Sep 17 00:00:00 2001 From: Felipe Artur Date: Sat, 15 Aug 2026 16:31:08 -0300 Subject: [PATCH 17/21] Refresh when the panel opens, and drop both buttons Opening the panel is the refresh, so the button that asked for one is gone, and so is the close button, since clicking away already dismisses the panel. The header keeps a loader while the read is in flight. The CLI answers from its own cache on a quick reopen, so this costs nothing. --- ai-usagebar/README.md | 8 +++++--- ai-usagebar/panel.luau | 32 ++++++-------------------------- ai-usagebar/translations/en.json | 3 --- 3 files changed, 11 insertions(+), 32 deletions(-) diff --git a/ai-usagebar/README.md b/ai-usagebar/README.md index 68ceed0f..8bde5771 100644 --- a/ai-usagebar/README.md +++ b/ai-usagebar/README.md @@ -76,9 +76,11 @@ 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. The -refresh button asks the CLI for fresh numbers, and the header says how old the -reading is. +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 diff --git a/ai-usagebar/panel.luau b/ai-usagebar/panel.luau index 06ce7faa..4a54643c 100644 --- a/ai-usagebar/panel.luau +++ b/ai-usagebar/panel.luau @@ -327,21 +327,6 @@ end -- ── Render ──────────────────────────────────────────────────────────────────── --- A cold read takes a second or two, so the button gives way to a loader while --- the poller is in flight. -local function refreshControl() - if polling then - return ui.glyph({ name = "loader-2", size = 16, color = "primary" }) - end - return ui.button({ - glyph = "refresh", - variant = "ghost", - controlSize = "sm", - tooltip = noctalia.tr("ui.refresh"), - onClick = "doRefresh", - }) -end - local function listPane(entry) local rows = {} for _, candidate in ipairs(entries()) do @@ -361,7 +346,9 @@ local function listPane(entry) ui.glyph({ name = "brain", size = 18, color = "primary" }), ui.label({ text = noctalia.tr("ui.title"), fontSize = 15, fontWeight = "bold", color = "primary" }), ui.spacer({ flexGrow = 1 }), - refreshControl(), + -- Opening the panel is the refresh, so the only thing the header + -- shows is whether that read is still running. + ui.glyph({ name = "loader-2", size = 16, color = "primary", visible = polling }), }), ui.scroll({ gap = 6, flexGrow = 1 }, rows), }) @@ -382,8 +369,6 @@ local function detailPane(entry) ui.label({ text = title, fontSize = 15, fontWeight = "bold", color = "on_surface" }), ui.label({ text = subtitle, fontSize = 11, color = "on_surface_variant", visible = subtitle ~= "" }), }), - ui.button({ glyph = "x", variant = "ghost", controlSize = "sm", - tooltip = noctalia.tr("ui.close"), onClick = "doClose" }), }), } @@ -460,14 +445,6 @@ end -- ── Wiring ──────────────────────────────────────────────────────────────────── -function doRefresh() - noctalia.state.set("command", { action = "refresh", at = os.time() }) -end - -function doClose() - panel.close() -end - noctalia.state.watch("report", function(value) if type(value) == "table" then report = value @@ -489,6 +466,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, so this costs nothing on a quick reopen. + noctalia.state.set("command", { action = "refresh", at = os.time() }) report = noctalia.state.get("report") local existingError = noctalia.state.get("error") errorMsg = type(existingError) == "string" and existingError or "" diff --git a/ai-usagebar/translations/en.json b/ai-usagebar/translations/en.json index f7ecebb2..bfb5dcb0 100644 --- a/ai-usagebar/translations/en.json +++ b/ai-usagebar/translations/en.json @@ -75,9 +75,6 @@ "updated_now": "Updated just now", "updated_ago": "Updated {minutes} min ago", "stale": "stale", - "refresh": "Refresh now", - "refreshing": "Reading…", - "close": "Close", "elapsed": "{percent}% of the window elapsed" } } From 64902d8dade3b79035988971c408c840fb8a5bfc Mon Sep 17 00:00:00 2001 From: Felipe Artur Date: Sat, 15 Aug 2026 16:31:17 -0300 Subject: [PATCH 18/21] Drop the last mention of the refresh button --- ai-usagebar/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ai-usagebar/README.md b/ai-usagebar/README.md index 8bde5771..d384f74f 100644 --- a/ai-usagebar/README.md +++ b/ai-usagebar/README.md @@ -136,8 +136,8 @@ noctalia msg plugin felipeartur/ai-usagebar:poller all select anthropic ## Notes - One process, `ai-usagebar usage --json`, spawned by a single headless service - on the configured interval, plus on demand from a right click, the panel's - refresh button, or the IPC event above. Capsules and the panel are subscribers + on the configured interval, plus on demand from a right click, from opening + the panel, or from the IPC event above. Capsules and the panel are subscribers of plugin state, so a second monitor or a second capsule costs no extra process. - The plugin makes no network calls and writes no files of its own. Everything From a81b9bb2caaa1793d6227038e950fcc467b94263 Mon Sep 17 00:00:00 2001 From: Felipe Artur Date: Sat, 15 Aug 2026 16:38:53 -0300 Subject: [PATCH 19/21] Use colours the shell actually has, and refresh the thumbnail A probe of the four roles in a running panel showed warning and success falling back to plain text: this build renders tertiary and error. The high tier was therefore invisible. It now uses tertiary, which is what the QML capsule used for the same tier, and the pace, stale and status marks follow. The poller also refuses to start a process within two seconds of the last one, since opening the panel now asks for a read and a panel can be opened as fast as a pointer can click. Twenty requests in four seconds spawn one process. The thumbnail was still the single column panel from before the rewrite. --- ai-usagebar/README.md | 8 ++++---- ai-usagebar/bar.luau | 10 +++++----- ai-usagebar/panel.luau | 19 ++++++++++--------- ai-usagebar/service.luau | 15 ++++++++++++--- ai-usagebar/thumbnail.webp | Bin 20392 -> 19492 bytes 5 files changed, 31 insertions(+), 21 deletions(-) diff --git a/ai-usagebar/README.md b/ai-usagebar/README.md index d384f74f..6046d1f6 100644 --- a/ai-usagebar/README.md +++ b/ai-usagebar/README.md @@ -28,9 +28,9 @@ endpoints, and this plugin never sees them. 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, turns `warning` 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. +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. 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 @@ -117,7 +117,7 @@ Per widget instance, so two capsules can follow two providers: | `provider_limit` | `int` | `1` | How many providers one capsule carries, busiest first, from 1 to 4. Only applies on `auto`. | | `extras` | `select` | `countdown` | What rides beside the percentage: `countdown`, `pace`, `both` or `none`. | | `show_name` | `bool` | `false` | Adds the product name, so two capsules do not look alike. | -| `color_by_usage` | `bool` | `true` | Off drops the `warning` and `error` tint, so the capsule never changes colour. | +| `color_by_usage` | `bool` | `true` | Off drops the high and critical tint, so the capsule never changes colour. | ## IPC diff --git a/ai-usagebar/bar.luau b/ai-usagebar/bar.luau index 211ad463..a6c547f2 100644 --- a/ai-usagebar/bar.luau +++ b/ai-usagebar/bar.luau @@ -161,7 +161,7 @@ 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 "warning" end + if severity == "high" then return "tertiary" end return "on_surface" end @@ -169,7 +169,7 @@ 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 "warning" end + if severity == "high" then return "tertiary" end return "primary" end @@ -202,9 +202,9 @@ local function paceNodes(metric, tint) 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 tint or "on_surface_variant" }), + color = ahead and "tertiary" or "on_surface_variant" }), ui.label({ text = tostring(points), fontSize = 10, - color = ahead and tint or "on_surface_variant", maxLines = 1 }), + color = ahead and "tertiary" or "on_surface_variant", maxLines = 1 }), }) end @@ -264,7 +264,7 @@ local function chip(entry) add(paceNodes(metric, tint)) if entry.stale == true then - add(ui.glyph({ name = "clock-exclamation", size = 11, color = "secondary" })) + add(ui.glyph({ name = "clock-exclamation", size = 11, color = "tertiary" })) end return ui.row({ gap = 4, align = "center" }, nodes) end diff --git a/ai-usagebar/panel.luau b/ai-usagebar/panel.luau index 4a54643c..6615e885 100644 --- a/ai-usagebar/panel.luau +++ b/ai-usagebar/panel.luau @@ -54,14 +54,14 @@ end local function textRole(section) local severity = tostring(section and section.severity or "") if severity == "critical" then return "error" end - if severity == "high" then return "warning" 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 "warning" end + if severity == "high" then return "tertiary" end return "primary" end @@ -90,8 +90,8 @@ local function pace(detail) 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, "warning" end - if last:find("under") then return last, "success" end + 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 @@ -346,8 +346,8 @@ local function listPane(entry) ui.glyph({ name = "brain", size = 18, color = "primary" }), ui.label({ text = noctalia.tr("ui.title"), fontSize = 15, fontWeight = "bold", color = "primary" }), ui.spacer({ flexGrow = 1 }), - -- Opening the panel is the refresh, so the only thing the header - -- shows is whether that read is still running. + -- 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 }), }), ui.scroll({ gap = 6, flexGrow = 1 }, rows), @@ -380,12 +380,12 @@ local function detailPane(entry) ui.label({ text = tostring(entry.status or ""), fontSize = 10, - color = entry.status == "ready" and "success" or "warning", + color = entry.status == "ready" and "on_surface_variant" or "error", }), } 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 = "warning" }) + 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 @@ -467,7 +467,8 @@ end) function onOpen(_context) -- Every open asks for fresh numbers. The CLI answers from its own cache - -- when it has one, so this costs nothing on a quick reopen. + -- 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() }) report = noctalia.state.get("report") local existingError = noctalia.state.get("error") diff --git a/ai-usagebar/service.luau b/ai-usagebar/service.luau index ed5f61a8..1f60de12 100644 --- a/ai-usagebar/service.luau +++ b/ai-usagebar/service.luau @@ -31,12 +31,18 @@ end local inFlight = false -- The CLI caches for a minute, so a manual refresh usually answers in about ten --- milliseconds. Holding the busy state for a beat is what makes the loader in --- the capsule and the panel visible at all; the service's own tick times it. +-- 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. +local MIN_GAP_MS = 2000 +local lastStart = 0 + local function stopPolling() clearPending = false noctalia.state.set("polling", false) @@ -45,8 +51,11 @@ 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 = noctalia.nowMs() + MIN_BUSY_MS + busyUntil = now + MIN_BUSY_MS clearPending = false noctalia.state.set("polling", true) noctalia.setUpdateInterval(120) diff --git a/ai-usagebar/thumbnail.webp b/ai-usagebar/thumbnail.webp index 55da12530df49814e4111926d1a09007c537e564..9aa45eb84dbfa4b6c0cab7882ddd0cef95bafdca 100644 GIT binary patch literal 19492 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 O1kYdq000000002<5fF|5 literal 20392 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 Date: Sat, 15 Aug 2026 16:39:38 -0300 Subject: [PATCH 20/21] Recover when the process refuses to start runAsync answers whether it launched anything. When it says no, no callback ever arrives, so the poller stayed in flight and stopped asking for readings until the plugin was reloaded. --- ai-usagebar/service.luau | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/ai-usagebar/service.luau b/ai-usagebar/service.luau index 1f60de12..6fca6453 100644 --- a/ai-usagebar/service.luau +++ b/ai-usagebar/service.luau @@ -60,7 +60,7 @@ local function refresh() noctalia.state.set("polling", true) noctalia.setUpdateInterval(120) - noctalia.runAsync(COMMAND, function(result) + local started = noctalia.runAsync(COMMAND, function(result) inFlight = false if noctalia.nowMs() >= busyUntil then stopPolling() @@ -91,6 +91,14 @@ local function refresh() end noctalia.state.set("error", message) end, 30000) + + -- A refusal to spawn never calls back, and without this the poller would + -- sit in flight forever and stop asking. + if not started then + inFlight = false + noctalia.state.set("error", "could not run ai-usagebar") + stopPolling() + end end -- Manual refresh from a capsule or the panel. From 47a5602961b9e437b216adc66b21c0b1e0952a59 Mon Sep 17 00:00:00 2001 From: Felipe Artur Date: Sun, 16 Aug 2026 02:00:26 -0300 Subject: [PATCH 21/21] Answer the review on #376 Clamp every percentage before it becomes a bar width. A provider can report more than it was given, and a value outside [0,1] draws wrong. Clean every string in the report, not just the error. A plan name, an account name and a metric detail are CLI text too, and the comment above the routine claimed as much while only the error went through it. Widen the Bearer pattern to any run of non-space, since base64 tokens carry characters the old class missed. Drop the unused tint parameter from paceNodes, left over from the colour work. --- ai-usagebar/bar.luau | 17 +++++++++++++---- ai-usagebar/panel.luau | 13 ++++++++++--- ai-usagebar/service.luau | 20 +++++++++++++------- 3 files changed, 36 insertions(+), 14 deletions(-) diff --git a/ai-usagebar/bar.luau b/ai-usagebar/bar.luau index a6c547f2..dd73f20a 100644 --- a/ai-usagebar/bar.luau +++ b/ai-usagebar/bar.luau @@ -181,21 +181,30 @@ 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) local stack = { - ui.progress({ progress = percent / 100, fill = tint, track = "on_surface/0.16", + ui.progress({ progress = ratio(percent), fill = tint, track = "on_surface/0.16", radius = 3, width = width, height = 4 }), } if elapsed ~= nil then - stack[#stack + 1] = ui.progress({ progress = elapsed / 100, fill = "on_surface/0.45", + stack[#stack + 1] = ui.progress({ progress = ratio(elapsed), fill = "on_surface/0.45", track = "on_surface/0.10", radius = 1, width = width, height = 2 }) end return ui.column({ gap = 1, align = "center" }, stack) end -local function paceNodes(metric, tint) +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 @@ -261,7 +270,7 @@ local function chip(entry) end add(countdownNode(metric)) - add(paceNodes(metric, tint)) + add(paceNodes(metric)) if entry.stale == true then add(ui.glyph({ name = "clock-exclamation", size = 11, color = "tertiary" })) diff --git a/ai-usagebar/panel.luau b/ai-usagebar/panel.luau index 6615e885..80e376d0 100644 --- a/ai-usagebar/panel.luau +++ b/ai-usagebar/panel.luau @@ -130,6 +130,13 @@ 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 @@ -169,7 +176,7 @@ local function metricCard(section) local body = { ui.row({ gap = 6, align = "center" }, header), - ui.progress({ progress = percent / 100, 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 = 5 }), } -- Two readings: quota spent above, window elapsed below. A shorter clock bar @@ -177,7 +184,7 @@ local function metricCard(section) local elapsed = elapsedPercent(section.detail) if elapsed ~= nil then body[#body + 1] = ui.progress({ - progress = elapsed / 100, + progress = ratio(elapsed), fill = "on_surface/0.45", track = "on_surface/0.10", radius = 2, @@ -298,7 +305,7 @@ local function providerRow(entry, selected) } if percent ~= nil and not broken then lines[#lines + 1] = ui.progress({ - progress = percent / 100, + progress = ratio(percent), fill = fill, track = selected and "on_primary/0.25" or "on_surface/0.16", radius = 2, height = 3, diff --git a/ai-usagebar/service.luau b/ai-usagebar/service.luau index 6fca6453..1222bb2f 100644 --- a/ai-usagebar/service.luau +++ b/ai-usagebar/service.luau @@ -13,8 +13,8 @@ local function intervalMs() return math.floor(minutes * 60 * 1000) end --- Everything below this line is text the CLI produced, and every bit of it ends --- up on screen. It is cleaned once, here, where it enters the plugin: +-- 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. local function safeText(value) @@ -23,11 +23,20 @@ local function safeText(value) 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") - text = text:gsub("([Bb]earer%s+)[%w%._%-]+", "%1") + text = text:gsub("([Bb]earer%s+)[^%s]+", "%1") if #text > 200 then text = string.sub(text, 1, 200) .. "..." 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. +local function scrub(value) + if type(value) == "string" then return safeText(value) end + if type(value) ~= "table" then return value end + for key, inner in pairs(value) do value[key] = scrub(inner) end + return value +end + local inFlight = false -- The CLI caches for a minute, so a manual refresh usually answers in about ten @@ -72,10 +81,7 @@ local function refresh() 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. - for _, entry in ipairs(decoded.entries) do - if entry.error ~= nil then entry.error = safeText(entry.error) end - end - noctalia.state.set("report", decoded) + noctalia.state.set("report", scrub(decoded)) noctalia.state.set("error", "") return end