Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 41 additions & 23 deletions ai-usagebar/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,13 @@ tarballs on the project's GitHub Releases page. Configure your providers once in
`~/.config/ai-usagebar/config.toml`; the CLI owns the credentials and the
endpoints, and this plugin never sees them.

`xdg-open` is optional. It is spawned by one row in the panel, the link to the
CLI's project page offered when `ai-usagebar` is not on `PATH`. Without
xdg-utils that row does nothing and the rest of the plugin is unaffected.
`xdg-open` is optional. It is spawned by one button in the panel, the link to
the CLI's project page offered when `ai-usagebar` is not on `PATH`. Without
xdg-utils that button is not drawn and the rest of the plugin is unaffected.

The plugin asks for **plugin API 22**, which is where Noctalia gained
`require()`. On a shell older than that it will not install. Version 1.1.0 asked
for API 9 and still runs there.

## Usage

Expand Down Expand Up @@ -73,29 +77,36 @@ start = [ "clock", "ai_usage" ]
clock time the reset lands on.
- **Left click** opens the `AI Usage` panel for the provider that capsule
tracks.
- **Right click** refreshes immediately.
- **Right click** asks the poller for a read. One process serves every capsule,
and it will not start a second one within two seconds of the last, so holding
the button down does not spawn a queue of them.
- **Middle click** opens the widget's settings, as everywhere else in the shell.

Left and middle are the script's; right is a gesture binding, so it is listed in
the widget's settings and can be pointed at any other action, or at `none`.

The panel is a two pane view. On the left is every provider you have set up,
with its headline percentage. On the right is the selected one in detail: one
card per reported metric, with a quota bar over a thinner "window elapsed" bar,
so a fill that outruns the clock bar means quota is burning ahead of pace.
Credit balances and free text rows the CLI reports get rendered as well.
Opening the panel asks the CLI for fresh numbers, and the header says how old
the reading is. There is no refresh button and no close button: the read
happens on open, and the panel closes when you click away from it or press the
same widget again.
Opening the panel asks the CLI for fresh numbers, and the detail pane says how
old the reading is. The refresh button in the header asks again; it turns into
a spinner while the CLI is answering. The gear beside it opens this plugin's
settings. There is no close button: the panel closes when you click away from
it or press the same widget again.

The list follows the CLI. A provider that `ai-usagebar` has no credential for
never appears, while one that is set up and failing keeps its row and shows the
error.

The detail pane spells out everything the CLI reports for that provider instead
of implying it: the plan and account name, the provider id, its status, a stale
flag when the reading is old, and when it was fetched. Each window gets its
label, the severity the CLI assigned it, the percentage, the raw value string
when that says more than the percentage, how much of the window has elapsed, the
time left with the clock time (or date) its reset lands on, and the pace line.
The detail pane spells out what the CLI reports for that provider instead of
implying it: the plan and account name, when it was fetched, a stale flag when
the reading is old, and the status when it is anything other than a healthy
read. Each window gets its label, the percentage, the raw value string when
that says more than the percentage, how much of the window has elapsed, the
time left with the clock time (or date) its reset lands on, the pace line, and
the severity as a word whenever the CLI calls the window high or critical.
Credit blocks and free text rows appear as the CLI writes them.

To open the panel from a terminal:
Expand Down Expand Up @@ -148,12 +159,19 @@ noctalia msg plugin felipeartur/ai-usagebar:poller all select anthropic
it knows arrives on that command's stdout.
- A provider that fails still comes back as an entry with `status = "error"`, so
one broken provider does not blank the others. A reading the CLI marks stale
keeps showing, flagged in the capsule and in the panel header.
- The file watcher follows the `.luau` entries only, so the files in
`translations/` are read once, when the plugin loads. Editing a string takes
a reload before the new text shows up:

```sh
noctalia msg plugins disable felipeartur/ai-usagebar
noctalia msg plugins enable felipeartur/ai-usagebar
```
keeps showing, flagged in the capsule and in the panel's detail pane.

## Tests

Everything the CLI prints is redacted on its way to the screen, and that is the
part worth a test. From the `ai-usagebar` directory:

```sh
lua tests/scrub_test.lua
```

It reads `safeText` and `scrub` out of `service.luau` rather than copying them,
then checks that real credential shapes never survive, that ordinary readings
pass through unchanged, and that scrubbing a four-vendor report stays inside the
CPU budget the poller's async callback is given. An overrun there loses the whole
reading, not just time.
188 changes: 39 additions & 149 deletions ai-usagebar/bar.luau
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
--!nonstrict
-- Bar capsule. Reads whatever the poller published and draws one provider, or
-- the busiest few when `provider_limit` is raised.
--
-- Per-instance settings, so two capsules can follow two different providers.
-- Bar capsule. Draws what the poller published: one provider, or the busiest few
-- when `provider_limit` is raised. Settings are per-instance, so a second capsule
-- can follow a second provider.

local vendor = tostring(noctalia.getConfig("vendor") or "auto")
local style = tostring(noctalia.getConfig("style") or "pill")
Expand All @@ -14,94 +13,16 @@ local colorByUsage = noctalia.getConfig("color_by_usage") ~= false
local report = nil
local polling = false

-- The poller names the failure, so the capsule only has a code to translate.
-- Anything else in that state slot reads as no failure at all.
local NO_FAILURE = { code = "", detail = "" }

local function asFailure(value)
return type(value) == "table" and value or NO_FAILURE
end
local shared = require("./shared.luau")
local GLYPHS = shared.GLYPHS
local countdown, resetClock = shared.countdown, shared.resetClock
local ratio, headline, elapsedPercent = shared.ratio, shared.headline, shared.elapsedPercent
local NO_FAILURE, asFailure = shared.NO_FAILURE, shared.asFailure

local failure = NO_FAILURE

-- Tabler has no Anthropic mark, so providers without a brand glyph get a
-- semantic one. Same approach the other CLI-backed meters in this repo take.
local GLYPHS = {
anthropic = "asterisk-simple",
anthropic_api = "asterisk-simple",
openai = "brand-openai",
zai = "bolt",
openrouter = "route",
deepseek = "fish",
kimi = "moon",
moonshot = "moon",
kilo = "robot",
novita = "cloud",
grok = "brand-x",
supergrok = "brand-x",
antigravity = "sparkles",
cursor = "cursor-text",
minimax = "wave-square",
kiro = "ghost",
copilot = "brand-github-copilot",
gemini = "brand-google",
}

-- ── Report helpers ────────────────────────────────────────────────────────────

-- "2026-08-15T11:29:59.872624Z" -> unix seconds. The stamps are UTC, so the
-- naive os.time() reading (which assumes local time) is corrected by the local
-- offset measured at that same instant.
local function parseIso(value)
if type(value) ~= "string" then return nil end
local y, mo, d, h, mi, s = value:match("^(%d+)%-(%d+)%-(%d+)T(%d+):(%d+):(%d+)")
if y == nil then return nil end
local asLocal = os.time({
year = tonumber(y), month = tonumber(mo), day = tonumber(d),
hour = tonumber(h), min = tonumber(mi), sec = tonumber(s),
})
local utcAsLocal = os.time(os.date("!*t", asLocal))
return asLocal + (asLocal - utcAsLocal)
end

local function formatDuration(seconds)
if seconds <= 0 then return noctalia.tr("ui.now") end
local minutes = math.floor(seconds / 60)
local days = math.floor(minutes / 1440)
local hours = math.floor((minutes % 1440) / 60)
local rest = minutes % 60
if days > 0 then return string.format("%dd %dh", days, hours) end
if hours > 0 then return string.format("%dh %dm", hours, rest) end
return string.format("%dm", rest)
end

local function countdown(metric)
local at = parseIso(metric and metric.reset_at)
if at == nil then return "" end
return formatDuration(at - os.time())
end

-- The clock time the countdown lands on: "14:20", or "Sat 14:20" past midnight.
local function resetClock(metric)
local at = parseIso(metric and metric.reset_at)
if at == nil then return "" end
local clock = noctalia.formatTime(noctalia.timeFormat(), at)
-- The weekday is prepended here rather than folded into the pattern: the
-- host's format grammar passes unknown text through verbatim, so a "ddd"
-- prefix would render as the literal word.
if os.date("%Y-%m-%d", at) ~= os.date("%Y-%m-%d") then
return os.date("%a", at) .. " " .. clock
end
return clock
end

-- "Resets in 4h 01m · 19% elapsed · 2pts ahead" says how much of the window is
-- gone and how far the spend is from that line.
local function elapsedPercent(metric)
local value = tostring(metric and metric.detail or ""):match("(%d+)%%%s*elapsed")
return value ~= nil and tonumber(value) or nil
end

-- Returns points and direction: 2, "ahead" is burning faster than the clock.
local function pace(metric)
local points, word = tostring(metric and metric.detail or ""):match("(%d+)pts%s+(%a+)")
Expand All @@ -114,21 +35,17 @@ local function entries()
return report.entries
end

local function headline(entry)
if type(entry) ~= "table" or type(entry.metrics) ~= "table" then return nil end
return entry.metrics[1]
end

local SEVERITY_RANK = { critical = 3, high = 2, medium = 1, low = 0 }
local SEVERITY_RANK = { critical = 3, high = 2, mid = 1 }

local function rank(entry)
local metric = headline(entry)
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.
-- A pinned vendor shows only itself. "auto" ranks by severity then percentage,
-- so the provider closest to running out is the one on the bar. `primary` breaks
-- ties.
local function shown()
local all = entries()
if vendor ~= "auto" then
Expand Down Expand Up @@ -156,50 +73,27 @@ local function shown()
local picked = {}
for i = 1, math.min(limit, #ready) do picked[i] = ready[i] end
if #picked == 0 then return {}, 0 end
-- Someone who asked for one provider does not need a count of the others,
-- so the "+N" only appears once the capsule carries more than one.
if limit == 1 then return picked, 0 end
return picked, #ready - #picked
end

-- The CLI already tiers every percentage, and copying its thresholds here
-- would be a second source of truth. Text stays in the bar's own colour until
-- the reading is high or critical, and the accent colour is used on the bar
-- fill only.
local function textRole(metric)
if not colorByUsage then return "on_surface" end
local severity = metric ~= nil and tostring(metric.severity or "") or ""
if severity == "critical" then return "error" end
if severity == "high" then return "tertiary" end
return "on_surface"
end

local function barRole(metric)
if not colorByUsage then return "primary" end
local severity = metric ~= nil and tostring(metric.severity or "") or ""
if severity == "critical" then return "error" end
if severity == "high" then return "tertiary" end
return "primary"
-- `calm` is the colour when the CLI has raised nothing, and every colour when
-- the tint is switched off.
local function severityRole(metric, calm)
if not colorByUsage then return calm end
return shared.severityRole(metric, calm)
end

local function shortName(entry)
local name = tostring(entry.display_name or entry.name or entry.id or "")
-- "Claude · gmail" is the panel's business; the bar has room for the product.
-- "Claude · gmail" is the panel's business; the bar only has room for the
-- product name.
return (name:gsub("%s*·.*$", ""))
end

-- ── Rendering ─────────────────────────────────────────────────────────────────

-- A provider can report more than it was given, so the reading is clamped
-- before it becomes a bar width.
local function ratio(percent)
local value = (tonumber(percent) or 0) / 100
if value < 0 then return 0 end
if value > 1 then return 1 end
return value
end

-- Quota above, window elapsed below: a fill longer than the clock bar is spend
-- Quota above, window elapsed below: a longer fill than clock bar is spend
-- running ahead of time.
local function bars(percent, elapsed, tint, width)
local stack = {
Expand Down Expand Up @@ -233,24 +127,24 @@ local function countdownNode(metric)
return ui.label({ text = left, fontSize = 10, color = "on_surface_variant", maxLines = 1 })
end

-- One provider's chip. The style decides the shape, and the extras are
-- appended to whatever it produced.
local function chip(entry)
local metric = headline(entry)
local tint = textRole(metric)
local fill = barRole(metric)
local tint = severityRole(metric, "on_surface")
local fill = severityRole(metric, "primary")
local percent = metric ~= nil and tonumber(metric.percent) or nil
local text = percent ~= nil and string.format("%d%%", percent) or "—"
local glyph = ui.glyph({ name = GLYPHS[tostring(entry.id)] or "brain", size = 13, color = tint })
local pct = ui.label({ text = text, fontSize = 11, fontWeight = "semibold", color = tint, maxLines = 1 })
-- Fixed width, right-aligned: the capsule is the same size at 9% as at 100%
-- and stops nudging its neighbours once per read.
local pct = ui.label({ text = text, fontSize = 11, fontWeight = "semibold", color = tint,
maxLines = 1, width = 30, textAlign = "end" })
local name = showName and ui.label({ text = shortName(entry), fontSize = 11,
color = "on_surface_variant", maxLines = 1 }) or nil

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({
Expand All @@ -261,18 +155,17 @@ local function chip(entry)
add(glyph); add(name)
add(ui.row({ gap = 2, align = "center" }, ticks))
elseif style == "label" and percent ~= nil then
-- Name and number stacked over the bar, for a bar with room to spare.
add(glyph)
add(ui.column({ gap = 1, align = "center" }, {
ui.row({ gap = 3, align = "center" }, {
ui.label({ text = shortName(entry), fontSize = 10, color = "on_surface_variant", maxLines = 1 }),
pct,
}),
bars(percent, elapsedPercent(metric), fill, 44),
bars(percent, elapsedPercent(metric and metric.detail), fill, 44),
}))
elseif style == "gauge" and percent ~= nil then
add(glyph); add(name)
add(bars(percent, elapsedPercent(metric), fill, 26))
add(bars(percent, elapsedPercent(metric and metric.detail), fill, 26))
add(pct)
else
add(glyph); add(name); add(pct)
Expand Down Expand Up @@ -336,31 +229,31 @@ end

local function render()
local picked, hidden = shown()
-- A failure drops the reading here too, so the bar cannot be read as a
-- live percentage while the panel behind it says the CLI is unreachable.
-- Empty is already the shape that draws the alert glyph.
-- A failure drops the reading, so the capsule cannot show a live percentage
-- while the panel behind it says the CLI is unreachable. Empty already draws
-- the alert glyph.
if failure.code ~= "" then picked, hidden = {}, 0 end

local children = {}
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" }),
-- One glyph, coloured by the state. A second icon beside it would read as
-- a second problem.
children[1] = ui.glyph({
name = "brain", size = 13,
color = failure.code ~= "" and "error" or "on_surface_variant",
})
elseif hidden > 0 then
children[#children + 1] = ui.label({ text = "+" .. tostring(hidden), fontSize = 10,
color = "on_surface_variant", maxLines = 1 })
end

barWidget.render(ui.row({ gap = 7, align = "center" }, children))
-- A read in flight dims the capsule instead of appending a spinner: a node
-- that comes and goes every cycle shoves every widget to its right.
barWidget.render(ui.row({ gap = 6, align = "center", opacity = polling and 0.55 or 1 }, children))
barWidget.setTooltip(tooltip(picked, hidden))
end

Expand Down Expand Up @@ -391,9 +284,6 @@ function onClick()
noctalia.togglePanel("felipeartur/ai-usagebar:panel")
end

function onRightClick()
noctalia.state.set("command", { action = "refresh", at = os.time() })
end

report = noctalia.state.get("report")
failure = asFailure(noctalia.state.get("error"))
Expand Down
Loading