diff --git a/ai-usagebar/README.md b/ai-usagebar/README.md new file mode 100644 index 00000000..b1e5574f --- /dev/null +++ b/ai-usagebar/README.md @@ -0,0 +1,89 @@ +# 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. + +## Plugin + +| Field | Value | +| --- | --- | +| ID | `felipeartur/ai-usagebar` | +| Entries | Bar widget: `bar`; panel: `panel`; service: `poller` | + +## 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 +`~/.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, 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. | + +## IPC + +Force a refresh without waiting for the interval: + +```sh +noctalia msg plugin felipeartur/ai-usagebar:poller all refresh +``` + +## 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 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 new file mode 100644 index 00000000..6edb0099 --- /dev/null +++ b/ai-usagebar/bar.luau @@ -0,0 +1,232 @@ +--!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 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 + +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..2aed51f3 --- /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 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 +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..313cbb48 --- /dev/null +++ b/ai-usagebar/plugin.toml @@ -0,0 +1,104 @@ +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 +# 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/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/thumbnail.webp b/ai-usagebar/thumbnail.webp new file mode 100644 index 00000000..55da1253 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..0b96c204 --- /dev/null +++ b/ai-usagebar/translations/en.json @@ -0,0 +1,51 @@ +{ + "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." + }, + "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" + } + }, + "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." + } + } +}