diff --git a/ai-usagebar/README.md b/ai-usagebar/README.md new file mode 100644 index 00000000..6046d1f6 --- /dev/null +++ b/ai-usagebar/README.md @@ -0,0 +1,147 @@ +# 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 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 + +| Field | Value | +| --- | --- | +| ID | `felipeartur/ai-usagebar` | +| Entries | Bar widget: `bar`; panel: `panel`; service: `poller` | + +## Requirements + +Install `ai-usagebar` on `PATH`. The plugin runs it by name, so there is no path +setting to fill in. It ships as `ai-usagebar-bin` on the AUR, and as release +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 +the headline percentage of a provider, behind that provider's icon. It reads in +the bar's own colour while there is room, picks up the theme's `tertiary` when +the CLI calls the window high, and `error` when it calls it critical. The accent +stays on the gauge fill, so a calm capsule looks like the widgets beside it. + +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: + +| Style | Shape | +| --- | --- | +| `pill` | Icon and percentage. The compact one. | +| `gauge` | Icon, a small quota bar over a thinner "window elapsed" bar, percentage. | +| `meter` | Icon and five segments, filled in twenties, with no percentage. | +| `label` | Icon, provider name and percentage stacked over the bars. | + +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, so 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 + tracks. +- **Right click** refreshes immediately. +- **Middle click** opens the widget's settings, as everywhere else in the shell. + +The panel is a two pane view. On the left is every provider you have set up, +with its headline percentage. On the right is the selected one in detail: one +card per reported metric, with a quota bar over a thinner "window elapsed" bar, +so a fill that outruns the clock bar means quota is burning ahead of pace. +Credit balances and free text rows the CLI reports get rendered as well. +Opening the panel asks the CLI for fresh numbers, and the header says how old +the reading is. There is no refresh button and no close button: the read +happens on open, and the panel closes when you click away from it or press the +same widget again. + +The list follows the CLI. A provider that `ai-usagebar` has no credential for +never appears, while one that is set up and failing keeps its row and shows the +error. + +The detail pane spells out everything the CLI reports for that provider instead +of implying it: the plan and account name, the provider id, its status, a stale +flag when the reading is old, and when it was fetched. Each window gets its +label, the severity the CLI assigned it, the percentage, the raw value string +when that says more than the percentage, how much of the window has elapsed, the +time left with the clock time (or date) its reset lands on, and the pace line. +Credit blocks and free text rows appear as the CLI writes them. + +To open the panel from a terminal: + +```sh +noctalia msg panel-toggle felipeartur/ai-usagebar:panel +``` + +## Settings + +Plugin-level, shared by the poller, every capsule and the panel: + +| Setting | Type | Default | Description | +| --- | --- | --- | --- | +| `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`, 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 high and critical tint, so the capsule never changes colour. | + +## IPC + +Force a refresh without waiting for the interval: + +```sh +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 + 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 + 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 new file mode 100644 index 00000000..dd73f20a --- /dev/null +++ b/ai-usagebar/bar.luau @@ -0,0 +1,390 @@ +--!nonstrict +-- Bar capsule. Reads whatever the poller published and draws one provider, or +-- the busiest few when `provider_limit` is raised. +-- +-- Per-instance settings, so two capsules can follow two different providers. + +local vendor = tostring(noctalia.getConfig("vendor") or "auto") +local style = tostring(noctalia.getConfig("style") or "pill") +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 = "" +local polling = 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. +local GLYPHS = { + anthropic = "asterisk-simple", + anthropic_api = "asterisk-simple", + openai = "brand-openai", + zai = "bolt", + openrouter = "route", + deepseek = "fish", + kimi = "moon", + moonshot = "moon", + kilo = "robot", + novita = "cloud", + grok = "brand-x", + supergrok = "brand-x", + antigravity = "sparkles", + cursor = "cursor-text", + minimax = "wave-square", + kiro = "ghost", + copilot = "brand-github-copilot", + gemini = "brand-google", +} + +-- ── Report helpers ──────────────────────────────────────────────────────────── + +-- "2026-08-15T11:29:59.872624Z" -> unix seconds. The stamps are UTC, so the +-- naive os.time() reading (which assumes local time) is corrected by the local +-- offset measured at that same instant. +local function parseIso(value) + if type(value) ~= "string" then return nil end + local y, mo, d, h, mi, s = value:match("^(%d+)%-(%d+)%-(%d+)T(%d+):(%d+):(%d+)") + if y == nil then return nil end + local asLocal = os.time({ + year = tonumber(y), month = tonumber(mo), day = tonumber(d), + hour = tonumber(h), min = tonumber(mi), sec = tonumber(s), + }) + local utcAsLocal = os.time(os.date("!*t", asLocal)) + return asLocal + (asLocal - utcAsLocal) +end + +local function formatDuration(seconds) + if seconds <= 0 then return noctalia.tr("ui.now") end + local minutes = math.floor(seconds / 60) + local days = math.floor(minutes / 1440) + local hours = math.floor((minutes % 1440) / 60) + local rest = minutes % 60 + if days > 0 then return string.format("%dd %dh", days, hours) end + if hours > 0 then return string.format("%dh %dm", hours, rest) end + return string.format("%dm", rest) +end + +local function countdown(metric) + local at = parseIso(metric and metric.reset_at) + if at == nil then return "" end + return formatDuration(at - os.time()) +end + +-- The clock time the countdown lands on: "14:20", or "Sat 14:20" past midnight. +local function resetClock(metric) + local at = parseIso(metric and metric.reset_at) + if at == nil then return "" end + local clock = noctalia.formatTime(noctalia.timeFormat(), at) + -- The weekday is prepended here rather than folded into the pattern: the + -- host's format grammar passes unknown text through verbatim, so a "ddd" + -- prefix would render as the literal word. + if os.date("%Y-%m-%d", at) ~= os.date("%Y-%m-%d") then + return os.date("%a", at) .. " " .. clock + end + return clock +end + +-- "Resets in 4h 01m · 19% elapsed · 2pts ahead" says how much of the window is +-- gone and how far the spend is from that line. +local function elapsedPercent(metric) + local value = tostring(metric and metric.detail or ""):match("(%d+)%%%s*elapsed") + return value ~= nil and tonumber(value) or nil +end + +-- Returns points and direction: 2, "ahead" is burning faster than the clock. +local function pace(metric) + local points, word = tostring(metric and metric.detail or ""):match("(%d+)pts%s+(%a+)") + if points == nil then return nil, nil end + return tonumber(points), word +end + +local function entries() + if type(report) ~= "table" or type(report.entries) ~= "table" then return {} end + return report.entries +end + +local function headline(entry) + if type(entry) ~= "table" or type(entry.metrics) ~= "table" then return nil end + return entry.metrics[1] +end + +local SEVERITY_RANK = { critical = 3, high = 2, medium = 1, low = 0 } + +local function 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 closest to running out is the one on the bar. `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 }, 0 end + end + return {}, 0 + end + + local ready = {} + for _, entry in ipairs(all) do + if entry.status ~= "error" then ready[#ready + 1] = entry end + 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 picked = {} + for i = 1, math.min(limit, #ready) do picked[i] = ready[i] end + if #picked == 0 then return {}, 0 end + -- Someone who asked for one provider does not need a count of the others, + -- so the "+N" only appears once the capsule carries more than one. + if limit == 1 then return picked, 0 end + return picked, #ready - #picked +end + +-- The CLI already tiers every percentage, and copying its thresholds here +-- would be a second source of truth. Text stays in the bar's own colour until +-- the reading is high or critical, and the accent colour is used on the bar +-- fill only. +local function textRole(metric) + if not colorByUsage then return "on_surface" end + local severity = metric ~= nil and tostring(metric.severity or "") or "" + if severity == "critical" then return "error" end + if severity == "high" then return "tertiary" end + return "on_surface" +end + +local function barRole(metric) + if not colorByUsage then return "primary" end + local severity = metric ~= nil and tostring(metric.severity or "") or "" + if severity == "critical" then return "error" end + if severity == "high" then return "tertiary" end + return "primary" +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 ───────────────────────────────────────────────────────────────── + +-- 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 = 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 = 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) + if extras ~= "pace" and extras ~= "both" then return nil end + local points, word = pace(metric) + if points == nil then return nil end + local ahead = word == "ahead" + return ui.row({ gap = 0, align = "center" }, { + ui.glyph({ name = ahead and "arrow-up" or "arrow-down", size = 10, + color = ahead and "tertiary" or "on_surface_variant" }), + ui.label({ text = tostring(points), fontSize = 10, + color = ahead and "tertiary" or "on_surface_variant", maxLines = 1 }), + }) +end + +local function countdownNode(metric) + if extras ~= "countdown" and extras ~= "both" then return nil end + local left = countdown(metric) + if left == "" then return nil end + return ui.label({ text = left, fontSize = 10, color = "on_surface_variant", maxLines = 1 }) +end + +-- One provider's chip. The style decides the shape, and the extras are +-- appended to whatever it produced. +local function chip(entry) + local metric = headline(entry) + local tint = textRole(metric) + local fill = barRole(metric) + local 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 fill or "on_surface/0.22", + }) + end + add(glyph); add(name) + add(ui.row({ gap = 2, align = "center" }, ticks)) + elseif style == "label" and percent ~= nil then + -- Name and number stacked over the bar, for a bar with room to spare. + add(glyph) + add(ui.column({ gap = 1, align = "center" }, { + ui.row({ gap = 3, align = "center" }, { + ui.label({ text = shortName(entry), fontSize = 10, color = "on_surface_variant", maxLines = 1 }), + pct, + }), + bars(percent, elapsedPercent(metric), fill, 44), + })) + elseif style == "gauge" and percent ~= nil then + add(glyph); add(name) + add(bars(percent, elapsedPercent(metric), fill, 26)) + add(pct) + else + add(glyph); add(name); add(pct) + end + + add(countdownNode(metric)) + add(paceNodes(metric)) + + if entry.stale == true then + add(ui.glyph({ name = "clock-exclamation", size = 11, color = "tertiary" })) + end + return ui.row({ gap = 4, align = "center" }, nodes) +end + +local function tooltip(picked, hidden) + if errorMsg ~= "" then return { { key = noctalia.tr("ui.title"), value = errorMsg } } end + if #picked == 0 then + 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 noctalia.tr("ui.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 = noctalia.tr("ui.updated"), value = noctalia.tr("ui.stale_hint") } + end + end + end + if hidden > 0 then + 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 = noctalia.tr("ui.title"), value = noctalia.tr("ui.no_usage") } + end + return rows +end + +local function render() + local picked, hidden = shown() + + local children = {} + for _, entry in ipairs(picked) do + children[#children + 1] = chip(entry) + end + + if polling then + children[#children + 1] = ui.glyph({ name = "loader-2", size = 11, color = "on_surface_variant" }) + end + + if #children == 0 then + children[1] = ui.row({ gap = 4, align = "center" }, { + ui.glyph({ name = "brain", size = 13, color = "on_surface_variant" }), + ui.glyph({ name = "alert-circle", size = 12, color = "error" }), + }) + elseif hidden > 0 then + children[#children + 1] = ui.label({ text = "+" .. tostring(hidden), fontSize = 10, + color = "on_surface_variant", maxLines = 1 }) + end + + barWidget.render(ui.row({ gap = 7, align = "center" }, children)) + barWidget.setTooltip(tooltip(picked, hidden)) +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) + +noctalia.state.watch("polling", function(value) + polling = value == true + render() +end) + +function onClick() + local picked = shown() + -- One panel serves every capsule and is not told which one opened it. + noctalia.state.set("selected", picked[1] ~= nil and picked[1].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 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..80e376d0 --- /dev/null +++ b/ai-usagebar/panel.luau @@ -0,0 +1,494 @@ +--!nonstrict +-- Expanded panel for one provider. +-- +-- It renders `sections[]`, which is the CLI's lossless view, so credit blocks +-- and free text that the shorter `metrics[]` view drops still show up. + +local report = nil +local errorMsg = "" +local polling = false + +-- Same parsing the capsule does. There is no require() below API 22, so the +-- four helpers below are copied instead of shared. +local function parseIso(value) + if type(value) ~= "string" then return nil end + local y, mo, d, h, mi, s = value:match("^(%d+)%-(%d+)%-(%d+)T(%d+):(%d+):(%d+)") + if y == nil then return nil end + local asLocal = os.time({ + year = tonumber(y), month = tonumber(mo), day = tonumber(d), + hour = tonumber(h), min = tonumber(mi), sec = tonumber(s), + }) + local utcAsLocal = os.time(os.date("!*t", asLocal)) + return asLocal + (asLocal - utcAsLocal) +end + +local function formatDuration(seconds) + if seconds <= 0 then return noctalia.tr("ui.now") end + local minutes = math.floor(seconds / 60) + local days = math.floor(minutes / 1440) + local hours = math.floor((minutes % 1440) / 60) + local rest = minutes % 60 + if days > 0 then return string.format("%dd %dh", days, hours) end + if hours > 0 then return string.format("%dh %dm", hours, rest) end + return string.format("%dm", rest) +end + +local function countdown(section) + local at = parseIso(section and section.reset_at) + if at == nil then return "" end + return formatDuration(at - os.time()) +end + +local function resetClock(section) + local at = parseIso(section and section.reset_at) + if at == nil then return "" end + local clock = noctalia.formatTime(noctalia.timeFormat(), at) + if os.date("%Y-%m-%d", at) == os.date("%Y-%m-%d") then return clock end + -- A weekday alone is ambiguous once the window is more than a week out. + if at - os.time() > 6 * 86400 then return os.date("%d %b", at) .. " " .. clock end + return os.date("%a", at) .. " " .. clock +end + +-- Text stays on the surface colour until the CLI calls the window high or +-- critical. The accent colour is used on the bar fill only. +local function textRole(section) + local severity = tostring(section and section.severity or "") + if severity == "critical" then return "error" end + if severity == "high" then return "tertiary" end + return "on_surface" +end + +local function barRole(section) + local severity = tostring(section and section.severity or "") + if severity == "critical" then return "error" end + if severity == "high" then return "tertiary" end + return "primary" +end + +-- The CLI reports a vendor it has no credential for as a `credentials error`. +-- Those are not listed, because they were never set up. A configured provider +-- that fails for any other reason keeps its row. +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. + +local function elapsedPercent(detail) + local value = tostring(detail or ""):match("(%d+)%%%s*elapsed") + return value ~= nil and tonumber(value) or nil +end + +local function pace(detail) + local text = tostring(detail or "") + if not text:find("·") then return "", "on_surface_variant" end + local last = "" + for part in text:gmatch("[^·]+") do last = part end + last = noctalia.string.trim(last) + if last:find("elapsed") then return "", "on_surface_variant" end + -- Ahead of the clock is worth flagging. Under it means there is room left. + if last:find("ahead") then return last, "tertiary" end + if last:find("under") then return last, "on_surface_variant" end + return last, "on_surface_variant" +end + +-- 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") + local first = nil + for _, entry in ipairs(entries()) do + if configured(entry) then + if entry.id == wanted then return entry end + if first == nil then first = entry end + end + end + return first +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 noctalia.tr("ui.updated_now") end + 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 + 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 = 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) + + 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 + 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 = 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 + -- than fill bar is quota burning ahead of time. + local elapsed = elapsedPercent(section.detail) + if elapsed ~= nil then + body[#body + 1] = ui.progress({ + progress = ratio(elapsed), + fill = "on_surface/0.45", + track = "on_surface/0.10", + radius = 2, + height = 2, + }) + body[#body + 1] = ui.label({ + text = noctalia.tr("ui.elapsed", { percent = elapsed }), + fontSize = 10, color = "on_surface_variant", + }) + 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 + +-- ── Provider list ───────────────────────────────────────────────────────────── + +-- Same map the capsule uses; no require() below API 22, so it is duplicated. +local GLYPHS = { + anthropic = "asterisk-simple", + anthropic_api = "asterisk-simple", + openai = "brand-openai", + zai = "bolt", + openrouter = "route", + deepseek = "fish", + kimi = "moon", + moonshot = "moon", + kilo = "robot", + novita = "cloud", + grok = "brand-x", + supergrok = "brand-x", + antigravity = "sparkles", + cursor = "cursor-text", + minimax = "wave-square", + kiro = "ghost", + copilot = "brand-github-copilot", + gemini = "brand-google", +} + +local function headline(entry) + if type(entry) ~= "table" or type(entry.metrics) ~= "table" then return nil end + return entry.metrics[1] +end + +local function providerRow(entry, selected) + local metric = headline(entry) + local percent = metric ~= nil and tonumber(metric.percent) or nil + local broken = entry.status == "error" + local tint = selected and "on_primary" or textRole(metric) + local fill = selected and "on_primary" or barRole(metric) + local muted = selected and "on_primary" or "on_surface_variant" + + local 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 = ratio(percent), + fill = fill, + 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 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({ + 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 }), + -- 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), + }) +end + +local function detailPane(entry) + local title = noctalia.tr("ui.title") + local subtitle = "" + if entry ~= nil then + title = tostring(entry.plan or entry.display_name or entry.id) + subtitle = tostring(entry.display_name or entry.id) + if subtitle == title then subtitle = "" end + end + + local children = { + 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 ~= "" }), + }), + }), + } + + -- 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 "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 = "tertiary" }) + 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 + elseif entry == nil then + status = noctalia.tr("ui.loading") + elseif entry.status == "error" then + status = tostring(entry.error or noctalia.tr("ui.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 + + 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 ──────────────────────────────────────────────────────────────────── + +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) + -- Every open asks for fresh numbers. The CLI answers from its own cache + -- when it has one, and the poller drops requests that arrive too close + -- together, so reopening the panel repeatedly is cheap. + noctalia.state.set("command", { action = "refresh", at = os.time() }) + 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, for the countdowns. +function update() + render() +end + +render() diff --git a/ai-usagebar/plugin.toml b/ai-usagebar/plugin.toml new file mode 100644 index 00000000..04cc433d --- /dev/null +++ b/ai-usagebar/plugin.toml @@ -0,0 +1,126 @@ +id = "felipeartur/ai-usagebar" +name = "AI Usage" +version = "1.0.0" +plugin_api = 9 +author = "felipeartur" +license = "MIT" +icon = "brain" +description = "AI plan usage in the bar, powered by the ai-usagebar CLI." +tags = ["bar", "panel", "ai", "indicator", "utility"] +# The CLI owns credentials, vendor endpoints and caching. This plugin only runs +# `ai-usagebar usage --json` and draws the result. +dependencies = ["ai-usagebar"] + +# ── Plugin-level settings (shared by the poller, every capsule and the panel) ── + +# No path setting: `ai-usagebar` is a declared dependency, so it is on PATH. + +[[setting]] +key = "refresh_minutes" +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" }, + { 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 = "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 = "color_by_usage" +type = "bool" +label_key = "settings.color_by_usage.label" +description_key = "settings.color_by_usage.description" +default = true + +[[panel]] +id = "panel" +entry = "panel.luau" +# Master/detail: the provider list on the left, its cards on the right. +width = 720 +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" +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..1222bb2f --- /dev/null +++ b/ai-usagebar/service.luau @@ -0,0 +1,140 @@ +--!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. + +-- `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("refresh_minutes")) or 5 + if minutes < 1 then minutes = 1 end + return math.floor(minutes * 60 * 1000) +end + +-- Everything the CLI produces ends up on screen, so all of it is cleaned once, +-- here, where it enters the plugin: +-- an error can quote the request that failed, and a request can carry a key in +-- its query string. A runaway line would also push a bar capsule off screen. +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+)[^%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 +-- 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) + noctalia.setUpdateInterval(intervalMs()) +end + +local function refresh() + if inFlight then return end + local now = noctalia.nowMs() + if now - lastStart < MIN_GAP_MS then return end + lastStart = now + inFlight = true + busyUntil = now + MIN_BUSY_MS + clearPending = false + noctalia.state.set("polling", true) + noctalia.setUpdateInterval(120) + + local started = noctalia.runAsync(COMMAND, function(result) + inFlight = false + if noctalia.nowMs() >= busyUntil then + stopPolling() + else + clearPending = true + end + + 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", scrub(decoded)) + noctalia.state.set("error", "") + 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 = safeText(result.stderr) + message = stderr ~= "" and stderr or ("ai-usagebar exited with code " .. tostring(result.exitCode)) + 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. +noctalia.state.watch("command", function(value) + if type(value) == "table" and value.action == "refresh" then refresh() end +end) + +function update() + -- While a read is in flight the fast tick is the busy timer, not a poll. + if inFlight then return end + if clearPending then + if noctalia.nowMs() >= busyUntil then stopPolling() end + return + end + noctalia.setUpdateInterval(intervalMs()) + refresh() +end + +function onConfigChanged() + noctalia.setUpdateInterval(intervalMs()) + refresh() +end + +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()) +refresh() diff --git a/ai-usagebar/thumbnail.webp b/ai-usagebar/thumbnail.webp new file mode 100644 index 00000000..9aa45eb8 Binary files /dev/null and b/ai-usagebar/thumbnail.webp differ diff --git a/ai-usagebar/translations/en.json b/ai-usagebar/translations/en.json new file mode 100644 index 00000000..bfb5dcb0 --- /dev/null +++ b/ai-usagebar/translations/en.json @@ -0,0 +1,80 @@ +{ + "settings": { + "refresh_minutes": { + "label": "Refresh interval (minutes)", + "description": "How often the CLI is asked for fresh usage. Countdowns tick locally between calls." + }, + "vendor": { + "label": "Provider", + "description": "Which plan this capsule tracks. Add the widget twice to watch two.", + "option": { + "auto": "Automatic (primary)", + "anthropic": "Claude", + "openai": "Codex", + "anthropic_api": "Anthropic API", + "zai": "Z.AI", + "openrouter": "OpenRouter", + "deepseek": "DeepSeek", + "kimi": "Kimi", + "kilo": "Kilo", + "novita": "Novita", + "moonshot": "Moonshot", + "grok": "Grok", + "supergrok": "SuperGrok", + "antigravity": "Antigravity", + "cursor": "Cursor", + "minimax": "MiniMax", + "kiro": "Kiro" + } + }, + "style": { + "label": "Style", + "description": "How this capsule looks in the bar.", + "option": { + "pill": "Percentage", + "gauge": "Gauge and percentage", + "meter": "Segments", + "label": "Name, gauge and percentage" + } + }, + "show_name": { + "label": "Show provider name", + "description": "Adds the product name next to the percentage, so two capsules do not look alike." + }, + "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" + } + } + }, + "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", + "elapsed": "{percent}% of the window elapsed" + } +}