Add salemsayed/codexbar-meter (#243)

A bar widget and attached panel showing AI provider usage limits reported
by the local CodexBar CLI. The bar keeps a configurable number of provider
meters; the panel lists every provider, quota window, credit balance, and
pace summary, and scrolls when the response is taller than the panel.


Claude-Session: https://claude.ai/code/session_01BCNAY79YEoNxyubdt8Co9A

Co-authored-by: Salem Sayed Abdel Gawad <283208+salemsayed@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Salem Sayed Abdel Gawad
2026-08-05 17:57:32 -04:00
committed by GitHub
co-authored by Salem Sayed Abdel Gawad Claude Opus 5
parent bffb68c86f
commit 1a08a98c4e
6 changed files with 1115 additions and 0 deletions
+493
View File
@@ -0,0 +1,493 @@
--!nonstrict
-- CodexBar provider usage bar widget.
--
-- CodexBar owns provider discovery, authentication, and quota fetching. This
-- widget only normalizes the shared JSON envelope and keeps the bar bounded:
-- the first configured providers are shown in the capsule, while the tooltip
-- and attached panel expose the complete response.
local codexbarPath = noctalia.getConfig("codexbarPath") or "codexbar"
local refreshIntervalSec = tonumber(noctalia.getConfig("refreshIntervalSec")) or 60
local barProviderLimit = tonumber(noctalia.getConfig("barProviderLimit")) or 2
local providers = {}
local errorMsg = ""
local requestInFlight = false
-- Noctalia v5 panel surfaces have fixed manifest dimensions. Keep the
-- regular `panel` id as the compatibility/default entry, then choose a
-- nearby fixed tier when opening so the surface follows the current data
-- without relying on an unsupported runtime resize API.
local PANEL_IDS = {
compact = "salemsayed/codexbar-meter:panel-compact",
standard = "salemsayed/codexbar-meter:panel",
tall = "salemsayed/codexbar-meter:panel-tall",
}
local PROVIDER_META = {
codex = { label = "Codex", glyph = "brand-openai", color = "primary" },
openai = { label = "OpenAI", glyph = "brand-openai", color = "primary" },
azureopenai = { label = "Azure OpenAI", glyph = "brand-openai", color = "primary" },
claude = { label = "Claude", glyph = "message-chatbot", color = "tertiary" },
gemini = { label = "Gemini", glyph = "brand-google", color = "secondary" },
copilot = { label = "Copilot", glyph = "brand-github", color = "secondary" },
cursor = { label = "Cursor", glyph = "cursor-text", color = "secondary" },
opencode = { label = "OpenCode", glyph = "code", color = "secondary" },
opencodego = { label = "OpenCode Go", glyph = "code", color = "secondary" },
qwencloud = { label = "Qwen Cloud", glyph = "cloud", color = "secondary" },
alibaba = { label = "Alibaba", glyph = "cloud", color = "secondary" },
alibabatokenplan = { label = "Alibaba Token Plan", glyph = "cloud", color = "secondary" },
antigravity = { label = "Antigravity", glyph = "sparkles", color = "tertiary" },
kilo = { label = "Kilo", glyph = "robot", color = "secondary" },
ollama = { label = "Ollama", glyph = "server", color = "secondary" },
openrouter = { label = "OpenRouter", glyph = "route", color = "secondary" },
}
local FALLBACK_COLORS = { "secondary", "tertiary", "primary" }
local function shellQuote(value)
return "'" .. string.gsub(tostring(value), "'", "'\\''") .. "'"
end
local function commandPath()
local path = tostring(codexbarPath)
if string.sub(path, 1, 1) == "~" then
path = noctalia.expandPath(path)
end
return path
end
local function providerId(provider)
return tostring(provider and (provider.provider or provider.id) or "unknown")
end
local function providerAccount(provider)
if type(provider) ~= "table" then return "" end
if provider.account ~= nil and tostring(provider.account) ~= "" then
return tostring(provider.account)
end
local usage = provider.usage
local identity = type(usage) == "table" and usage.identity or nil
if type(identity) == "table" then
if identity.accountEmail ~= nil and tostring(identity.accountEmail) ~= "" then
return tostring(identity.accountEmail)
end
if identity.accountOrganization ~= nil and tostring(identity.accountOrganization) ~= "" then
return tostring(identity.accountOrganization)
end
end
return ""
end
local function titleFromId(value)
local text = string.gsub(tostring(value or "unknown"), "[_%-]+", " ")
text = string.gsub(text, "(%a)([%w]*)", function(first, rest)
return string.upper(first) .. string.lower(rest)
end)
return text
end
local function metaFor(provider, index)
local id = providerId(provider)
local known = PROVIDER_META[string.lower(id)]
if known ~= nil then return known end
local colorIndex = ((index - 1) % #FALLBACK_COLORS) + 1
return {
label = titleFromId(id),
glyph = "chart-donut-3",
color = FALLBACK_COLORS[colorIndex],
}
end
local function providerLabel(provider, index)
local meta = metaFor(provider, index)
local account = providerAccount(provider)
if account ~= "" then
return meta.label .. " · " .. account
end
return meta.label
end
local function providerError(provider)
if type(provider) ~= "table" or provider.error == nil then return "" end
if type(provider.error) == "string" then return provider.error end
if type(provider.error) == "table" then
return tostring(provider.error.message or provider.error.description or provider.error.kind or "Provider unavailable")
end
return "Provider unavailable"
end
local function windowLabel(window, fallback)
if type(window) ~= "table" then return fallback or "Limit" end
local named = window.title or window.name or window.label
if named ~= nil and tostring(named) ~= "" then return tostring(named) end
local minutes = tonumber(window.windowMinutes) or 0
if minutes == 300 then return "5h" end
if minutes == 1440 then return "24h" end
if minutes == 10080 then return "7d" end
if minutes == 43200 then return "30d" end
if minutes >= 1440 then return string.format("%dd", math.floor(minutes / 1440)) end
if minutes >= 60 then return string.format("%dh", math.floor(minutes / 60)) end
if minutes > 0 then return string.format("%dm", minutes) end
return fallback or "limit"
end
local function remainingPercent(window)
if type(window) ~= "table" or window.usedPercent == nil then return nil end
local used = tonumber(window.usedPercent)
if used == nil then return nil end
return math.max(0, math.min(100, 100 - used))
end
local function addWindow(windows, title, window, rank)
if type(window) ~= "table" or tonumber(window.usedPercent) == nil then return end
for _, item in ipairs(windows) do
if item.window == window then return end
end
windows[#windows + 1] = {
title = title or windowLabel(window),
window = window,
rank = rank or 99,
}
end
local function windowsFor(provider)
local result = {}
local usage = type(provider) == "table" and provider.usage or nil
if type(usage) ~= "table" then return result end
addWindow(result, windowLabel(usage.primary, "Session"), usage.primary, 1)
addWindow(result, windowLabel(usage.secondary, "Weekly"), usage.secondary, 2)
addWindow(result, windowLabel(usage.tertiary, "Monthly"), usage.tertiary, 3)
if type(usage.extraRateWindows) == "table" then
for index, extra in ipairs(usage.extraRateWindows) do
if type(extra) == "table" then
addWindow(result, extra.title or extra.name or "Additional window", extra.window or extra, 10 + index)
end
end
end
if type(usage.windows) == "table" then
for index, item in ipairs(usage.windows) do
if type(item) == "table" then
addWindow(result, item.title or item.name or "Usage window", item.window or item, 20 + index)
end
end
end
-- Future providers may add another named rate window. Pick up any table
-- that carries the standard usedPercent field without treating identity,
-- status, or provider-specific metadata as a quota row.
for key, value in pairs(usage) do
if type(value) == "table" and tonumber(value.usedPercent) ~= nil then
addWindow(result, windowLabel(value, titleFromId(key)), value, 40)
end
end
table.sort(result, function(a, b)
if a.rank ~= b.rank then return a.rank < b.rank end
return a.title < b.title
end)
return result
end
local function creditsFor(provider)
if type(provider) ~= "table" then return nil end
if type(provider.credits) == "table" then return provider.credits end
if type(provider.usage) == "table" and type(provider.usage.credits) == "table" then
return provider.usage.credits
end
return nil
end
local function summaryFor(provider)
local windows = windowsFor(provider)
if #windows > 0 then
local window = windows[1].window
return {
window = window,
label = windows[1].title .. " " .. string.format("%d%%", math.floor((remainingPercent(window) or 0) + 0.5)),
remaining = remainingPercent(window),
}
end
local credits = creditsFor(provider)
if credits ~= nil and credits.remaining ~= nil then
local creditPercent = tonumber(credits.remainingPercent)
return {
label = tostring(credits.remaining),
remaining = creditPercent,
credits = true,
}
end
return { label = "—", remaining = nil }
end
local function providerByIndex(index)
return providers[index]
end
local function selectedEntries()
local entries = {}
local healthy = {}
for index, provider in ipairs(providers) do
local entry = { provider = provider, index = index, summary = summaryFor(provider) }
entries[#entries + 1] = entry
if providerError(provider) == "" then healthy[#healthy + 1] = entry end
end
local pool = #healthy > 0 and healthy or entries
local limit = math.max(1, math.min(4, math.floor(barProviderLimit)))
local selected = {}
for index = 1, math.min(limit, #pool) do
selected[#selected + 1] = pool[index]
end
return selected
end
local function percentText(value)
if value == nil then return "—" end
return string.format("%d%%", math.floor(value + 0.5))
end
local function providerSegment(entry)
local provider = entry.provider
local meta = metaFor(provider, entry.index)
local summary = entry.summary
local children = {
ui.glyph({ name = meta.glyph, size = 13, color = meta.color }),
}
if summary.window ~= nil then
children[#children + 1] = ui.progress({
progress = (summary.remaining or 0) / 100,
fill = meta.color,
track = "on_surface/0.16",
radius = 6,
width = 26,
height = 5,
})
end
children[#children + 1] = ui.label({
text = summary.window ~= nil and (windowLabel(summary.window) .. " " .. percentText(summary.remaining)) or summary.label,
fontSize = 11,
fontWeight = "semibold",
color = "on_surface",
maxLines = 1,
})
return ui.row({ key = "provider-" .. tostring(entry.index), align = "center", gap = 5 }, children)
end
local function tooltipFor(provider, index)
local meta = metaFor(provider, index)
local issue = providerError(provider)
if issue ~= "" then
return { key = providerLabel(provider, index), value = "Unavailable · " .. issue }
end
local windows = windowsFor(provider)
local lines = {}
for _, item in ipairs(windows) do
lines[#lines + 1] = item.title .. ": " .. percentText(remainingPercent(item.window)) .. " left"
end
local credits = creditsFor(provider)
if credits ~= nil and credits.remaining ~= nil then
lines[#lines + 1] = "Credits: " .. tostring(credits.remaining)
end
if #lines == 0 then lines[#lines + 1] = "No usage window reported" end
if provider.stale == true then lines[#lines + 1] = "Showing last known data" end
return { key = meta.label, value = table.concat(lines, "\n") }
end
local function hasProviderErrors()
for _, provider in ipairs(providers) do
if providerError(provider) ~= "" then return true end
end
return false
end
local function hasProviderStatus(provider)
local status = type(provider) == "table" and provider.status or nil
return type(status) == "table"
and status.description ~= nil
and tostring(status.indicator or "none") ~= "none"
end
local function hasPaceSummary(provider)
local pace = type(provider) == "table" and provider.pace or nil
if type(pace) ~= "table" then return false end
for _, key in ipairs({ "primary", "secondary", "tertiary" }) do
if type(pace[key]) == "table" and pace[key].summary ~= nil then return true end
end
for _, value in pairs(pace) do
if type(value) == "table" and value.summary ~= nil then return true end
end
return false
end
local function estimatedProviderHeight(provider)
-- Approximate the panel card from semantic content, not provider names.
-- This deliberately errs upward; the scroll view remains the safety net
-- for providers with unusually verbose or future-specific payloads.
local height = 104
height = height + (#windowsFor(provider) * 82)
if providerError(provider) ~= "" then height = height + 44 end
if hasPaceSummary(provider) then height = height + 34 end
if provider.stale == true then height = height + 34 end
local credits = creditsFor(provider)
if credits ~= nil and credits.remaining ~= nil then height = height + 30 end
if hasProviderStatus(provider) then height = height + 34 end
return height
end
local function panelEntryIdForCurrentData()
if #providers == 0 then return PANEL_IDS.compact end
local estimate = 130
for index, provider in ipairs(providers) do
estimate = estimate + estimatedProviderHeight(provider)
if index > 1 then estimate = estimate + 10 end
end
if estimate <= 420 then return PANEL_IDS.compact end
if estimate <= 600 then return PANEL_IDS.standard end
return PANEL_IDS.tall
end
local function render()
local selected = selectedEntries()
local rows = {}
if #selected == 0 then
rows[#rows + 1] = ui.row({ align = "center", gap = 5 }, {
ui.glyph({ name = "chart-donut-3", size = 13, color = "primary" }),
ui.label({ text = "AI —", fontSize = 11, fontWeight = "semibold", color = "on_surface" }),
})
else
for index, entry in ipairs(selected) do
if index > 1 then
rows[#rows + 1] = ui.box({ width = 1, height = 15, fill = "outline/0.35" })
end
rows[#rows + 1] = providerSegment(entry)
end
if #providers > #selected then
rows[#rows + 1] = ui.label({
text = "+" .. tostring(#providers - #selected),
fontSize = 10,
color = "on_surface_variant",
})
end
end
if errorMsg ~= "" or hasProviderErrors() then
rows[#rows + 1] = ui.glyph({ name = "alert-circle", size = 12, color = "error" })
end
barWidget.render(ui.row({ gap = 7, align = "center" }, rows))
local tooltip = {}
for index, provider in ipairs(providers) do
tooltip[#tooltip + 1] = tooltipFor(provider, index)
end
if #tooltip == 0 then
tooltip[#tooltip + 1] = { key = "CodexBar", value = "Waiting for provider data" }
end
if errorMsg ~= "" then
tooltip[#tooltip + 1] = { key = "Status", value = errorMsg }
end
barWidget.setTooltip(tooltip)
end
local function decodeProviders(stdout)
local decoded = noctalia.json.decode(stdout or "")
if type(decoded) ~= "table" then return nil end
if type(decoded.providers) == "table" then return decoded.providers end
if decoded.provider ~= nil or decoded.error ~= nil then return { decoded } end
local result = {}
for _, provider in ipairs(decoded) do
if type(provider) == "table" then result[#result + 1] = provider end
end
return result
end
local function refresh()
if requestInFlight then return end
requestInFlight = true
local command = "timeout 30s " .. shellQuote(commandPath())
.. " usage --format json --json-only"
noctalia.runAsync(command, function(result)
requestInFlight = false
local decoded = result ~= nil and decodeProviders(result.stdout) or nil
-- CodexBar may return a non-zero exit code for a partial provider
-- response. Keep healthy rows and let each errored row explain itself.
if decoded ~= nil and #decoded > 0 then
providers = decoded
errorMsg = ""
noctalia.state.set("providers", providers)
noctalia.state.set("error", "")
noctalia.state.set("lastRefresh", os.time())
render()
return
end
errorMsg = result ~= nil and result.exitCode ~= 0
and "CodexBar could not refresh"
or "CodexBar returned no provider data"
noctalia.state.set("error", errorMsg)
render()
end, 30000)
end
noctalia.state.watch("command", function(value)
if type(value) == "table" and value.action == "refresh" then refresh() end
end)
noctalia.state.watch("providers", function(value)
if type(value) == "table" then
providers = value
render()
end
end)
noctalia.state.watch("error", function(value)
if type(value) == "string" then
errorMsg = value
render()
end
end)
function update()
noctalia.setUpdateInterval(refreshIntervalSec * 1000)
refresh()
end
function onClick()
noctalia.togglePanel(panelEntryIdForCurrentData())
end
function onRightClick()
refresh()
end
function onIpc(event, _payload)
if event == "refresh" then refresh() end
end
local existing = noctalia.state.get("providers")
if type(existing) == "table" then providers = existing end
local existingError = noctalia.state.get("error")
if type(existingError) == "string" then errorMsg = existingError end
noctalia.setUpdateInterval(refreshIntervalSec * 1000)
render()
refresh()