From dfcb7a8e02d0f72b04642bea061668e2693305ac Mon Sep 17 00:00:00 2001 From: Felipe Artur Date: Sat, 15 Aug 2026 04:19:02 -0300 Subject: [PATCH 1/2] 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 2/2] 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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