diff --git a/codexbar-meter/README.md b/codexbar-meter/README.md new file mode 100644 index 0000000..1c4f471 --- /dev/null +++ b/codexbar-meter/README.md @@ -0,0 +1,88 @@ +# CodexBar Meter + +CodexBar Meter is a native Noctalia v5 bar widget and attached panel for usage +limits reported by the local [CodexBar](https://github.com/steipete/CodexBar) +CLI. It discovers the providers enabled in CodexBar, keeps the bar compact, +and exposes every provider and quota window in a scrollable panel. + +## Plugin + +| Field | Value | +| --- | --- | +| ID | `salemsayed/codexbar-meter` | +| Entries | Bar widget: `bar`; panel entries: `panel-compact`, `panel`, `panel-tall` | + +## Requirements + +Install these commands on `PATH`: + +- `codexbar` 0.47 or newer, configured with at least one provider. +- `timeout` from GNU coreutils. + +CodexBar owns provider authentication, network access, and provider discovery. +The plugin does not read or store provider credentials. + +## Usage + +Add `salemsayed/codexbar-meter:bar` to a Noctalia bar. The bar shows up to two +provider meters by default, followed by a `+N` count when more providers are +enabled in CodexBar. The tooltip includes all returned providers. + +- Left-click opens the attached `CodexBar Meter` panel. The widget chooses a + compact, standard, or tall panel tier from the current provider payload; + each tier keeps scrolling as the safety net for unusually large responses. +- Right-click refreshes usage immediately. +- The panel refresh button requests a fresh CodexBar query. +- The panel close button dismisses the panel. + +To open the standard panel from a terminal: + +```sh +noctalia msg panel-toggle salemsayed/codexbar-meter:panel +``` + +The adaptive widget also uses these panel entries when opening from the bar: + +- `noctalia msg panel-toggle salemsayed/codexbar-meter:panel-compact` — 390 px +- `noctalia msg panel-toggle salemsayed/codexbar-meter:panel` — 560 px +- `noctalia msg panel-toggle salemsayed/codexbar-meter:panel-tall` — 720 px + +The panel renders all provider cards and scrolls when the response is taller +than the panel. It understands CodexBar's standard primary, secondary, and +tertiary windows, named `windows` and `extraRateWindows`, credits, pace +summaries, provider status, stale data, and per-provider errors. Unknown +providers receive a readable title, a neutral icon, and a theme-derived color. + +## Settings + +| Setting | Type | Default | Description | +| --- | --- | --- | --- | +| `codexbarPath` | `string` | `codexbar` | Command or absolute path used to query CodexBar. | +| `refreshIntervalSec` | `int` | `60` | Background refresh interval in seconds; allowed range is 30–3600. | +| `barProviderLimit` | `int` | `2` | Number of provider meters shown in the bar; allowed range is 1–4. The panel and tooltip always include all providers. | + +When no provider flag is supplied, CodexBar's configured enabled-provider list +is used. This lets the plugin work with any current or future CodexBar +provider without changing the Noctalia code. + +## IPC + +Refresh the widget and panel through the shared plugin state: + +```sh +noctalia msg plugin salemsayed/codexbar-meter:bar all refresh +``` + +## Notes + +- The plugin runs `timeout 30s usage --format json --json-only`. +- A provider-level error is kept as a card beside healthy providers. If a + refresh returns no usable JSON, the last successful response remains visible + with an explicit warning. +- The plugin is compositor-agnostic: it uses Noctalia's attached layer-shell + panels and theme roles rather than hard-coded light or dark colors, and calls + no compositor IPC of its own. Developed on Niri. +- The plugin writes no files and opens no network connections of its own. The + panel displays the account identity CodexBar reports for a provider, such as + the signed-in email address. CodexBar itself owns provider authentication, + credential storage, and all network access. diff --git a/codexbar-meter/bar_widget.luau b/codexbar-meter/bar_widget.luau new file mode 100644 index 0000000..0ab4f39 --- /dev/null +++ b/codexbar-meter/bar_widget.luau @@ -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() diff --git a/codexbar-meter/panel.luau b/codexbar-meter/panel.luau new file mode 100644 index 0000000..a60c104 --- /dev/null +++ b/codexbar-meter/panel.luau @@ -0,0 +1,448 @@ +--!nonstrict +-- CodexBar provider usage panel. +-- +-- The panel intentionally renders the provider array instead of naming a +-- fixed pair. CodexBar can add providers and provider-specific quota windows; +-- the standard usedPercent/reset envelope is enough for a useful generic card. + +local providers = {} +local errorMsg = "" +local panelOpen = false +local refreshIntervalSec = tonumber(noctalia.getConfig("refreshIntervalSec")) or 60 + +local PROVIDER_META = { + codex = { label = "Codex", glyph = "brand-openai", color = "primary", source = "OpenAI" }, + openai = { label = "OpenAI", glyph = "brand-openai", color = "primary", source = "OpenAI" }, + claude = { label = "Claude", glyph = "message-chatbot", color = "tertiary", source = "Anthropic" }, +} + +local FALLBACK_COLORS = { "secondary", "tertiary", "primary" } + +local function providerId(provider) + return tostring(provider and (provider.provider or provider.id) or "unknown") +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 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 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], + source = "CodexBar", + } +end + +local function providerLabel(provider, index) + local meta = metaFor(provider, index) + 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 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 localGuess = 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", localGuess)) + return localGuess + (localGuess - utcAsLocal) +end + +local function windowLabel(window, fallback) + if type(window) ~= "table" then return fallback or "Usage window" 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 "5-hour session" end + if minutes == 1440 then return "24-hour window" end + if minutes == 10080 then return "Weekly window" end + if minutes == 43200 then return "30-day window" end + if minutes >= 1440 then return string.format("%d-day window", math.floor(minutes / 1440)) end + if minutes >= 60 then return string.format("%d-hour window", math.floor(minutes / 60)) end + if minutes > 0 then return string.format("%d-minute window", minutes) end + return fallback or "Usage window" +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 percentText(value) + if value == nil then return "—" end + return string.format("%d%%", math.floor(value + 0.5)) +end + +local function relativeReset(window) + if type(window) ~= "table" then return "Reset unavailable" end + local epoch = parseIso(window.resetsAt) + if epoch == nil then return window.resetDescription or "Reset unavailable" end + + local seconds = epoch - os.time() + if seconds <= 0 then return "Resetting now" end + local days = math.floor(seconds / 86400) + local hours = math.floor((seconds % 86400) / 3600) + local minutes = math.floor((seconds % 3600) / 60) + if days > 0 then return string.format("in %dd %dh", days, hours) end + if hours > 0 then return string.format("in %dh %dm", hours, minutes) end + return string.format("in %dm", math.max(1, minutes)) +end + +local function resetAt(window) + if type(window) ~= "table" then return "—" end + local epoch = parseIso(window.resetsAt) + if epoch ~= nil then return noctalia.formatTime("%a %I:%M %p", epoch) end + return window.resetDescription or "—" +end + +local function updatedAge(provider) + local usage = type(provider) == "table" and provider.usage or nil + local credits = type(provider) == "table" and provider.credits or nil + local value = type(usage) == "table" and usage.updatedAt or nil + if value == nil and type(credits) == "table" then value = credits.updatedAt end + if value == nil and type(provider) == "table" then value = provider.updatedAt end + + local epoch = parseIso(value) + if epoch == nil then return "not yet" end + local seconds = math.max(0, os.time() - epoch) + if seconds < 10 then return "just now" end + if seconds < 60 then return string.format("%ds ago", seconds) end + if seconds < 3600 then return string.format("%dm ago", math.floor(seconds / 60)) end + return string.format("%dh ago", math.floor(seconds / 3600)) +end + +local function addWindow(windows, title, window, rank, color) + 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, + color = color, + } +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 + local color = metaFor(provider, 1).color + + addWindow(result, windowLabel(usage.primary, "Session"), usage.primary, 1, color) + addWindow(result, windowLabel(usage.secondary, "Weekly"), usage.secondary, 2, color) + addWindow(result, windowLabel(usage.tertiary, "Monthly"), usage.tertiary, 3, color) + + 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, "secondary") + 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, color) + end + end + end + + 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, color) + 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 paceFor(provider) + local pace = type(provider) == "table" and provider.pace or nil + if type(pace) ~= "table" then return nil end + for _, key in ipairs({ "primary", "secondary", "tertiary" }) do + if type(pace[key]) == "table" and pace[key].summary ~= nil then return pace[key] end + end + for _, value in pairs(pace) do + if type(value) == "table" and value.summary ~= nil then return value end + end + return nil +end + +local function paceColor(pace) + if pace == nil then return "on_surface_variant" end + local stage = string.lower(tostring(pace.stage or "")) + local summary = string.lower(tostring(pace.summary or "")) + if string.find(stage, "risk") or string.find(stage, "exhaust") or string.find(stage, "deficit") or string.find(summary, "deficit") then + return "error" + end + if string.find(stage, "ahead") or string.find(stage, "reserve") or string.find(summary, "reserve") then + return "secondary" + end + return "on_surface_variant" +end + +local function windowRow(item) + local window = item.window + local remaining = remainingPercent(window) or 0 + local used = tonumber(window.usedPercent) or 0 + local color = item.color or "secondary" + + return ui.column({ key = item.title, gap = 5 }, { + ui.row({ align = "center", justify = "space_between" }, { + ui.row({ align = "center", gap = 7 }, { + ui.glyph({ name = "chart-donut-3", size = 14, color = color }), + ui.label({ text = item.title, fontSize = 13, fontWeight = "semibold", color = "on_surface", maxLines = 1 }), + }), + ui.label({ text = percentText(remaining) .. " left", fontSize = 13, fontWeight = "bold", color = color }), + }), + ui.progress({ + progress = remaining / 100, + fill = color, + track = "on_surface/0.14", + radius = 8, + height = 8, + }), + ui.row({ align = "center", justify = "space_between" }, { + ui.label({ text = string.format("%d%% used", math.floor(used + 0.5)), fontSize = 11, color = "on_surface_variant" }), + ui.label({ text = "Resets " .. relativeReset(window), fontSize = 11, color = "on_surface_variant" }), + }), + }) +end + +local function providerCard(provider, index) + local meta = metaFor(provider, index) + local windows = windowsFor(provider) + local children = {} + local source = tostring(provider.source or meta.source or "CodexBar") + local account = providerAccount(provider) + if account ~= "" then source = source .. " · " .. account end + + children[#children + 1] = ui.row({ align = "center", justify = "space_between" }, { + ui.row({ align = "center", gap = 9 }, { + ui.row({ align = "center", justify = "center", width = 34, height = 34, fill = meta.color .. "/0.16", radius = 10 }, { + ui.glyph({ name = meta.glyph, size = 18, color = meta.color }), + }), + ui.column({ gap = 1 }, { + ui.label({ text = providerLabel(provider, index), fontSize = 15, fontWeight = "semibold", color = "on_surface", maxLines = 1 }), + ui.label({ text = source, fontSize = 11, color = "on_surface_variant", maxLines = 1 }), + }), + }), + ui.label({ text = updatedAge(provider), fontSize = 11, color = "on_surface_variant" }), + }) + + children[#children + 1] = ui.separator({ spacing = 2, color = "outline", opacity = 0.18 }) + + local issue = providerError(provider) + if issue ~= "" then + children[#children + 1] = ui.row({ fill = "error/0.10", radius = 9, padding = 10, align = "center", gap = 8 }, { + ui.glyph({ name = "alert-circle", size = 14, color = "error" }), + ui.label({ text = issue, fontSize = 12, color = "error", maxLines = 2 }), + }) + end + + if #windows == 0 then + children[#children + 1] = ui.row({ fill = "on_surface/0.05", radius = 9, padding = 10, align = "center", gap = 8 }, { + ui.glyph({ name = "info-circle", size = 14, color = "on_surface_variant" }), + ui.label({ text = "No active usage window reported", fontSize = 12, color = "on_surface_variant" }), + }) + else + for _, item in ipairs(windows) do children[#children + 1] = windowRow(item) end + end + + local pace = paceFor(provider) + if pace ~= nil and pace.summary ~= nil then + local paceAccent = paceColor(pace) + children[#children + 1] = ui.row({ fill = paceAccent .. "/0.10", radius = 8, paddingH = 9, paddingV = 6, align = "center", gap = 7 }, { + ui.glyph({ name = "activity", size = 13, color = paceAccent }), + ui.label({ text = tostring(pace.summary), fontSize = 11, color = paceAccent, maxLines = 2 }), + }) + end + + local credits = creditsFor(provider) + if credits ~= nil and credits.remaining ~= nil then + children[#children + 1] = ui.row({ align = "center", justify = "space_between" }, { + ui.row({ align = "center", gap = 7 }, { + ui.glyph({ name = "coins", size = 14, color = "on_surface_variant" }), + ui.label({ text = "Credits", fontSize = 12, color = "on_surface_variant" }), + }), + ui.label({ text = tostring(credits.remaining) .. " available", fontSize = 12, color = "on_surface" }), + }) + end + + local status = provider.status + if type(status) == "table" and status.description ~= nil and tostring(status.indicator or "none") ~= "none" then + children[#children + 1] = ui.row({ fill = "on_surface/0.05", radius = 8, padding = 8, align = "center", gap = 7 }, { + ui.glyph({ name = "info-circle", size = 13, color = "on_surface_variant" }), + ui.label({ text = tostring(status.description), fontSize = 11, color = "on_surface_variant", maxLines = 2 }), + }) + end + + if provider.stale == true then + children[#children + 1] = ui.row({ fill = "tertiary/0.10", radius = 8, padding = 8, align = "center", gap = 7 }, { + ui.glyph({ name = "clock", size = 13, color = "tertiary" }), + ui.label({ text = "Showing cached provider data", fontSize = 11, color = "tertiary", maxLines = 2 }), + }) + end + + return ui.column({ key = "provider-card-" .. tostring(index), fill = "surface_variant/0.58", radius = 14, padding = 11, gap = 8 }, children) +end + +local function panelStatus() + if errorMsg ~= "" then + return #providers > 0 and "STALE" or "OFFLINE", "alert-circle", "error" + end + for _, provider in ipairs(providers) do + if provider.stale == true then return "STALE", "clock", "tertiary" end + end + if #providers == 0 then return "WAITING", "loader-2", "on_surface_variant" end + return "LIVE", "circle-filled", "primary" +end + +local function render() + if not panelOpen then return end + + local statusLabel, statusGlyph, statusColor = panelStatus() + + local header = ui.row({ align = "center", justify = "space_between" }, { + ui.row({ align = "center", gap = 10 }, { + ui.row({ align = "center", justify = "center", width = 36, height = 36, fill = "primary/0.15", radius = 11 }, { + ui.glyph({ name = "chart-donut-3", size = 19, color = "primary" }), + }), + ui.column({ gap = 1 }, { + ui.label({ text = "CodexBar Meter", fontSize = 17, fontWeight = "bold", color = "on_surface" }), + ui.label({ text = "Enabled provider limits", fontSize = 11, color = "on_surface_variant" }), + }), + }), + ui.row({ align = "center", gap = 2 }, { + ui.button({ glyph = "refresh", variant = "ghost", tooltip = "Refresh now", onClick = "onRefresh" }), + ui.button({ glyph = "x", variant = "ghost", tooltip = "Close", onClick = "onCloseClicked" }), + }), + }) + + local children = { header } + children[#children + 1] = ui.row({ align = "center", justify = "space_between", fill = statusColor .. "/0.08", radius = 8, paddingH = 9, paddingV = 6 }, { + ui.row({ align = "center", gap = 6 }, { + ui.glyph({ name = statusGlyph, size = 8, color = statusColor }), + ui.label({ text = statusLabel, fontSize = 10, fontWeight = "bold", color = statusColor }), + }), + ui.label({ text = "Updates every " .. tostring(refreshIntervalSec) .. " seconds", fontSize = 11, color = "on_surface_variant" }), + }) + children[#children + 1] = ui.separator({ spacing = 2, color = "outline", opacity = 0.20 }) + + if errorMsg ~= "" and #providers > 0 then + children[#children + 1] = ui.row({ fill = "error/0.10", radius = 8, padding = 8, align = "center", gap = 7 }, { + ui.glyph({ name = "alert-circle", size = 13, color = "error" }), + ui.label({ text = errorMsg .. " · showing last known data", fontSize = 11, color = "error", maxLines = 2 }), + }) + end + + local content = {} + if #providers == 0 then + content[#content + 1] = ui.column({ align = "center", paddingV = 40, gap = 10 }, { + ui.glyph({ name = errorMsg ~= "" and "alert-circle" or "loader-2", size = 28, color = errorMsg ~= "" and "error" or "primary" }), + ui.label({ text = errorMsg ~= "" and errorMsg or "Waiting for CodexBar…", fontSize = 13, color = errorMsg ~= "" and "error" or "on_surface_variant" }), + }) + else + for index, provider in ipairs(providers) do + content[#content + 1] = providerCard(provider, index) + end + end + + -- The root column and this scroll child both need flexGrow. This bounds + -- arbitrary provider counts instead of allowing the panel to clip. + children[#children + 1] = ui.scroll({ key = "usage-providers", flexGrow = 1, gap = 10 }, content) + panel.render(ui.column({ flexGrow = 1, padding = 14, gap = 8 }, children)) +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 onOpen(_context) + panelOpen = true + 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 + render() +end + +function onClose() + panelOpen = false +end + +function onCloseClicked() + panel.close() +end + +function onRefresh() + noctalia.state.set("command", { action = "refresh" }) +end diff --git a/codexbar-meter/plugin.toml b/codexbar-meter/plugin.toml new file mode 100644 index 0000000..1b55e7b --- /dev/null +++ b/codexbar-meter/plugin.toml @@ -0,0 +1,69 @@ +id = "salemsayed/codexbar-meter" +name = "CodexBar Meter" +version = "1.0.0" +plugin_api = 8 +author = "salemsayed" +license = "MIT" +icon = "chart-donut-3" +description = "A native CodexBar-powered usage meter for enabled AI providers." +tags = ["bar", "panel", "ai", "indicator", "utility"] +dependencies = ["codexbar", "timeout"] + +[[setting]] +key = "codexbarPath" +type = "string" +label_key = "settings.codexbar_path.label" +description_key = "settings.codexbar_path.description" +default = "codexbar" + +[[setting]] +key = "refreshIntervalSec" +type = "int" +label_key = "settings.refresh_interval.label" +description_key = "settings.refresh_interval.description" +default = 60 +min = 30 +max = 3600 + +[[setting]] +key = "barProviderLimit" +type = "int" +label_key = "settings.bar_provider_limit.label" +description_key = "settings.bar_provider_limit.description" +default = 2 +min = 1 +max = 4 + +[[widget]] +id = "bar" +entry = "bar_widget.luau" + +[[panel]] +id = "panel-compact" +entry = "panel.luau" +width = 430 +height = 390 +placement = "attached" +position = "auto" +open_near_click = true +dismiss_on_outside_click = true + +[[panel]] +id = "panel" +entry = "panel.luau" +width = 430 +height = 560 +placement = "attached" +position = "auto" +open_near_click = true +dismiss_on_outside_click = true + +[[panel]] +id = "panel-tall" +entry = "panel.luau" +width = 430 +height = 720 +placement = "attached" +position = "auto" +open_near_click = true +dismiss_on_outside_click = true diff --git a/codexbar-meter/thumbnail.webp b/codexbar-meter/thumbnail.webp new file mode 100644 index 0000000..003b918 Binary files /dev/null and b/codexbar-meter/thumbnail.webp differ diff --git a/codexbar-meter/translations/en.json b/codexbar-meter/translations/en.json new file mode 100644 index 0000000..a9eac0f --- /dev/null +++ b/codexbar-meter/translations/en.json @@ -0,0 +1,17 @@ +{ + "plugin_name": "CodexBar Meter", + "settings": { + "codexbar_path": { + "label": "CodexBar command", + "description": "Command or absolute path used to query enabled CodexBar providers." + }, + "refresh_interval": { + "label": "Refresh interval", + "description": "Seconds between background usage refreshes." + }, + "bar_provider_limit": { + "label": "Bar provider limit", + "description": "Maximum number of provider meters shown in the bar capsule; the panel and tooltip still include all providers." + } + } +}