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>
494 lines
17 KiB
Luau
494 lines
17 KiB
Luau
--!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()
|