diff --git a/shell/plugins/README.md b/shell/plugins/README.md index 7b5e4db8..1f158ed3 100644 --- a/shell/plugins/README.md +++ b/shell/plugins/README.md @@ -27,7 +27,7 @@ User-installed plugins live alongside these conceptually but on disk under | Network | `omarchy.network` | `bar-widget` | `panels/network/Panel.qml` | | Power | `omarchy.power` | `bar-widget` | `panels/power/Panel.qml` | | Tailscale | `omarchy.tailscale` | `bar-widget` | `panels/tailscale/Panel.qml` | -| Model usage | `omarchy.model-usage` | `bar-widget` | `model-usage/Widget.qml` | +| Model usage | `omarchy.model-usage` | `bar-widget` | `model-usage/Panel.qml` | | Weather | `omarchy.weather` | `bar-widget` | `panels/weather/BarWidget.qml` | | Media | `omarchy.media` | `service`, `bar-widget` | `services/media/Service.qml`, `services/media/BarWidget.qml` | | Battery | `omarchy.battery` | `service` | `services/battery/Service.qml` | diff --git a/shell/plugins/bar/README.md b/shell/plugins/bar/README.md index a4399320..48ae486b 100644 --- a/shell/plugins/bar/README.md +++ b/shell/plugins/bar/README.md @@ -67,7 +67,7 @@ Example `shell.json` (bar subtree only shown): | `omarchy.audio` | Volume icon + popup with master slider, output-device picker, per-app mixer | left = popup · right = mute · middle = popup · scroll = volume | | `omarchy.network` | Wi-Fi/Ethernet icon + popup with Wi-Fi scan, signal, connect, DNS provider selection | left = popup · right = nmtui | | `omarchy.tailscale` | Tailscale status, connection switcher, machine browser, and copy actions | left = popup · right = toggle · middle = refresh | -| `omarchy.model-usage` | Claude Code and Codex usage, limits, synced usage aggregation, and settings | left = popup · right = settings · middle = refresh | +| `omarchy.model-usage` | Claude Code and Codex limits with pace, today, last week, and all-time model breakdown | left = panel · right = refresh · middle = next subscription | | `omarchy.power` | Battery/AC icon + popup with battery stats, power profiles, and system info | left = popup | | `omarchy.bluetooth` | Bluetooth icon + popup with device list, connect/disconnect, battery | left = popup · right = toggle radio · middle = bluetoothctl TUI | | `omarchy.monitor` | Brightness and laptop display controls | left = popup | diff --git a/shell/plugins/model-usage/Main.qml b/shell/plugins/model-usage/Main.qml index 2374592c..625759ba 100644 --- a/shell/plugins/model-usage/Main.qml +++ b/shell/plugins/model-usage/Main.qml @@ -26,23 +26,22 @@ Item { } property var providers: [claudeProvider, codexProvider] + + // A subscription earns a place in the panel by being both switched on in + // settings and actually present on this machine — nobody wants a Codex tab + // full of zeroes on a box that has never run Codex. property var enabledProviders: { var rev = syncRevision var running = syncRunning var result = [] - if (claudeProvider.enabled) result.push(displayProvider(claudeProvider)) - if (codexProvider.enabled) result.push(displayProvider(codexProvider)) + if (claudeProvider.enabled && claudeProvider.installed) result.push(displayProvider(claudeProvider)) + if (codexProvider.enabled && codexProvider.installed) result.push(displayProvider(codexProvider)) return result } - property int activeIndex: 0 - property var activeProvider: enabledProviders.length > 0 ? enabledProviders[Math.min(activeIndex, enabledProviders.length - 1)] : null property bool refreshing: claudeProvider.refreshing || codexProvider.refreshing || syncRunning property double aggregateUpdatedAtMs: aggregateData && aggregateData.updatedAtMs ? Number(aggregateData.updatedAtMs) : 0 property double lastRefreshedAtMs: Math.max(aggregateUpdatedAtMs, claudeProvider.lastRefreshedAtMs || 0, codexProvider.lastRefreshedAtMs || 0) - property string barDisplayMode: setting("barDisplayMode", "active") - property int barCycleIntervalSec: Math.max(1, Number(setting("barCycleIntervalSec", 5))) - property string barMetric: setting("barMetric", "prompts") property int refreshIntervalSec: Math.max(30, Number(setting("refreshIntervalSec", 900))) property var syncModeSetting: setting("syncMode", setting("syncEnabled", false)) @@ -75,13 +74,6 @@ Item { return value === undefined || value === null ? fallback : value } - Timer { - interval: root.barCycleIntervalSec * 1000 - running: root.barDisplayMode === "cycle" && root.enabledProviders.length > 1 - repeat: true - onTriggered: root.activeIndex = (root.activeIndex + 1) % root.enabledProviders.length - } - Timer { interval: root.refreshIntervalSec * 1000 running: true @@ -147,10 +139,6 @@ Item { onLoaded: root.detectedHostname = String(text() || "").trim() } - onEnabledProvidersChanged: { - if (enabledProviders.length === 0 || activeIndex >= enabledProviders.length) activeIndex = 0 - } - function providerEnabled(id) { if (!settings || !settings.providers || !settings.providers[id]) return id === "claude" || id === "codex" return settings.providers[id].enabled !== false @@ -355,6 +343,8 @@ Item { recentByDay: recentByDay, totalPrompts: 0, totalSessions: 0, + activeDays: 0, + activeDates: ({}), modelUsage: ({}), devices: ({}) } @@ -378,6 +368,12 @@ Item { acc.todayTotalTokens += numberValue(stats.todayTotalTokens) acc.totalPrompts += numberValue(stats.totalPrompts) acc.totalSessions += numberValue(stats.totalSessions) + // Active days overlap between machines, so union the dates rather than + // summing counts. Snapshots written before activeDates existed only + // carry a count; the widest one stands in for them. + var activeDates = Array.isArray(stats.activeDates) ? stats.activeDates : [] + for (var ad = 0; ad < activeDates.length; ad++) acc.activeDates[String(activeDates[ad])] = true + acc.activeDays = Math.max(acc.activeDays, numberValue(stats.activeDays)) addObjectNumbers(acc.todayTokensByModel, stats.todayTokensByModel || {}) var recent = Array.isArray(stats.recentDays) ? stats.recentDays : [] @@ -418,6 +414,7 @@ Item { recentDays: recentDays, totalPrompts: acc.totalPrompts, totalSessions: acc.totalSessions, + activeDays: Math.max(acc.activeDays, Object.keys(acc.activeDates).length), modelUsage: acc.modelUsage, deviceCount: providerDevices.length, devices: providerDevices @@ -447,6 +444,8 @@ Item { recentDays: cloneValue(provider.recentDays, []), totalPrompts: numberValue(provider.totalPrompts), totalSessions: numberValue(provider.totalSessions), + activeDays: numberValue(provider.activeDays), + activeDates: cloneValue(provider.activeDates, []), modelUsage: cloneValue(provider.modelUsage, ({})) } } @@ -502,6 +501,7 @@ Item { recentDays: synced ? (stats.recentDays || []) : provider.recentDays, totalPrompts: synced ? numberValue(stats.totalPrompts) : provider.totalPrompts, totalSessions: synced ? numberValue(stats.totalSessions) : provider.totalSessions, + activeDays: synced ? numberValue(stats.activeDays) : provider.activeDays, modelUsage: synced ? (stats.modelUsage || ({})) : provider.modelUsage, hasLocalStats: synced ? (stats.hasLocalStats !== false) : provider.hasLocalStats, @@ -531,12 +531,34 @@ Item { return String(n) } + function modelWordCase(word) { + if (word === "gpt") return "GPT" + return word.charAt(0).toUpperCase() + word.slice(1) + } + + // Model ids arrive hyphenated with the version split across segments + // (`claude-opus-4-8`, `gpt-5.6-sol`). Rejoin the numeric run into one + // version and title-case the words around it. function friendlyModelName(id) { if (!id) return "Unknown" var name = String(id).replace(/^claude-/, "").replace(/-\d{8}$/, "") var parts = name.split("-") - if (parts.length >= 3) return parts[0].charAt(0).toUpperCase() + parts[0].slice(1) + " " + parts[1] + "." + parts[2] - if (parts.length === 2) return parts[0].charAt(0).toUpperCase() + parts[0].slice(1) + " " + parts[1] - return name.charAt(0).toUpperCase() + name.slice(1) + var words = [] + var version = [] + for (var i = 0; i < parts.length; i++) { + var part = parts[i] + if (part === "") continue + if (/^\d/.test(part)) { + version.push(part) + continue + } + if (version.length > 0) { + words.push(version.join(".")) + version = [] + } + words.push(modelWordCase(part)) + } + if (version.length > 0) words.push(version.join(".")) + return words.length > 0 ? words.join(" ") : "Unknown" } } diff --git a/shell/plugins/model-usage/Panel.qml b/shell/plugins/model-usage/Panel.qml new file mode 100644 index 00000000..1458cd17 --- /dev/null +++ b/shell/plugins/model-usage/Panel.qml @@ -0,0 +1,941 @@ +import QtQuick +import QtQuick.Controls +import Quickshell +import Quickshell.Io +import qs.Commons +import qs.Ui + +Panel { + id: root + moduleName: "omarchy.model-usage" + ipcTarget: "omarchy.model-usage" + manageIpc: false + + readonly property color foreground: bar ? bar.foreground : Color.foreground + readonly property color urgent: bar ? bar.urgent : Color.urgent + readonly property color dim: Qt.darker(foreground, 1.55) + readonly property color surface: Color.popups.background + readonly property color track: Style.selectedFillFor(foreground, Color.accent) + readonly property string fontFamily: bar ? bar.fontFamily : Style.font.family + + readonly property var providers: usage.enabledProviders + property int providerIndex: 0 + readonly property var provider: providers.length > 0 + ? providers[Math.max(0, Math.min(providerIndex, providers.length - 1))] + : null + + property bool cursorActive: false + + // Countdowns, pace, and "updated" all read this instead of Date.now() so the + // panel keeps telling the truth while it sits open. + property double nowMs: Date.now() + + readonly property var limits: limitWindows(provider) + readonly property var models: modelRows(provider) + readonly property var headline: bindingWindow(provider) + readonly property bool alarming: !!headline && headline.percent >= 0.9 + + function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)) } + function alpha(c, a) { return Qt.rgba(c.r, c.g, c.b, a) } + + function selectProvider(index) { + if (providers.length === 0) { + providerIndex = 0 + return + } + providerIndex = ((index % providers.length) + providers.length) % providers.length + } + + function refreshNow() { + usage.refreshAll(true) + } + + // ---------------------------------------------------------------- limits + // + // Both providers report the same two shapes: a short rolling session window + // and a long weekly one. Everything below normalizes them into one record so + // the meters, the pace math, and the hero all speak a single language. + + // Claude spells its windows out ("Session (5-hour)"), Codex abbreviates + // them ("5h window", "30m window"). Both have to land on the same record. + function windowIsLong(text) { + return text.indexOf("week") >= 0 || text.indexOf("7-day") >= 0 || text.indexOf("seven") >= 0 + || text.indexOf("month") >= 0 || text.indexOf("30-day") >= 0 + } + + function windowSpanMs(label) { + var text = String(label || "").toLowerCase() + if (text.indexOf("month") >= 0 || text.indexOf("30-day") >= 0) return 30 * 24 * 3600 * 1000 + if (windowIsLong(text)) return 7 * 24 * 3600 * 1000 + var hours = text.match(/(\d+)\s*-?\s*h(?:our)?\b/) + if (hours) return Number(hours[1]) * 3600 * 1000 + var minutes = text.match(/(\d+)\s*-?\s*m(?:in(?:ute)?s?)?\b/) + if (minutes) return Number(minutes[1]) * 60 * 1000 + return 0 + } + + function windowTitle(label) { + var text = String(label || "").toLowerCase() + if (text.indexOf("month") >= 0) return "Monthly" + if (windowIsLong(text)) return "Weekly" + if (text.indexOf("session") >= 0 || windowSpanMs(label) > 0) return "Session" + var plain = String(label || "").replace(/\s*\(.*\)\s*/, "").trim() + return plain === "" ? "Limit" : plain + } + + function windowSpanLabel(spanMs) { + if (spanMs <= 0) return "" + if (spanMs >= 24 * 3600 * 1000) return Math.round(spanMs / (24 * 3600 * 1000)) + "d" + if (spanMs >= 3600 * 1000) return Math.round(spanMs / (3600 * 1000)) + "h" + return Math.round(spanMs / 60000) + "m" + } + + function limitWindow(label, percent, resetAt) { + var spanMs = windowSpanMs(label) + return { + title: windowTitle(label), + span: windowSpanLabel(spanMs), + spanMs: spanMs, + percent: Number(percent), + resetAt: String(resetAt || "") + } + } + + function limitWindows(p) { + if (!p) return [] + var out = [] + if (p.rateLimitPercent >= 0) out.push(limitWindow(p.rateLimitLabel, p.rateLimitPercent, p.rateLimitResetAt)) + if (p.secondaryRateLimitPercent >= 0) out.push(limitWindow(p.secondaryRateLimitLabel, p.secondaryRateLimitPercent, p.secondaryRateLimitResetAt)) + return out + } + + // The window that decides how much room is left — the fullest one, since + // that is what stops the next prompt. + function bindingWindow(p) { + var windows = limitWindows(p) + var best = null + for (var i = 0; i < windows.length; i++) { + if (!best || windows[i].percent > best.percent) best = windows[i] + } + return best + } + + function resetMsFor(w) { + if (!w || w.resetAt === "") return -1 + var ms = new Date(w.resetAt).getTime() + return isFinite(ms) ? ms - root.nowMs : -1 + } + + // Where the clock says you should be, versus where you actually are. + function paceFor(w) { + if (!w || w.percent < 0 || w.spanMs <= 0) return null + var remaining = resetMsFor(w) + if (remaining <= 0 || remaining > w.spanMs) return null + + var elapsed = w.spanMs - remaining + var expected = clamp(elapsed / w.spanMs, 0, 1) + var used = clamp(w.percent, 0, 1) + var projected = expected > 0 ? used / expected : -1 + var runsOutMs = (used > 0 && projected > 1) ? (elapsed / used) * (1 - used) : -1 + return { + expected: expected, + diff: used - expected, + projected: projected, + runsOutMs: runsOutMs < remaining ? runsOutMs : -1, + remaining: remaining + } + } + + function paceText(w) { + var pace = paceFor(w) + if (!pace) return "" + if (pace.runsOutMs >= 0) return "Runs out in " + formatDuration(pace.runsOutMs) + if (Math.abs(pace.diff) <= 0.02) return "On pace" + if (pace.diff > 0) return Math.round(pace.diff * 100) + "% ahead of pace" + return Math.round(-pace.diff * 100) + "% in reserve" + } + + function paceIsUrgent(w) { + var pace = paceFor(w) + return !!pace && pace.runsOutMs >= 0 + } + + function paceDetail(w) { + var pace = paceFor(w) + if (!pace) return "" + var lines = "Expected " + Math.round(pace.expected * 100) + "% used by now" + if (pace.projected >= 0) lines += " · tracking to " + Math.round(pace.projected * 100) + "% by reset" + return lines + } + + function formatDuration(ms) { + if (!(ms > 0)) return "now" + var minutes = Math.floor(ms / 60000) + var hours = Math.floor(minutes / 60) + var days = Math.floor(hours / 24) + if (days > 0) return days + "d " + (hours % 24) + "h" + if (hours > 0) return hours + "h " + (minutes % 60) + "m" + return Math.max(1, minutes) + "m" + } + + function percentText(value) { + return value < 0 ? "—" : Math.round(value * 100) + "%" + } + + // ---------------------------------------------------------------- content + + // The plan you pay for, under the name of the tool it pays for. Limits and + // pace live in their own section; the hero just says what this is. + function heroMeta(p) { + if (!p) return "" + if (String(p.usageStatusText || "") !== "") return p.usageStatusText + var tier = String(p.tierLabel || "") + if (tier === "") return "Subscription" + return tier.charAt(0).toUpperCase() + tier.slice(1) + } + + // Local calendar date, recomputed from nowMs so a panel left open across + // midnight moves the "Today" row with the clock. + function todayDate() { + var now = new Date(root.nowMs) + return now.getFullYear() + + "-" + String(now.getMonth() + 1).padStart(2, "0") + + "-" + String(now.getDate()).padStart(2, "0") + } + + function dayName(date) { + var parsed = new Date(String(date || "") + "T00:00:00") + if (isNaN(parsed.getTime())) return String(date || "") + return ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"][parsed.getDay()] + } + + function dayLabel(date, today) { + if (today) return "Today" + return dayName(date) + } + + function dayTooltip(day, today) { + if (!day) return "" + var parsed = new Date(String(day.date) + "T00:00:00") + var label = isNaN(parsed.getTime()) + ? String(day.date) + : dayName(day.date) + " " + (parsed.getMonth() + 1) + "/" + parsed.getDate() + var text = label + " · " + usage.formatTokenCount(Number(day.messageCount || 0)) + " tokens" + // Prompt and session counts only exist for today, so they ride along here + // instead of taking a section of their own. + if (today && provider) + text += " · " + Number(provider.todayPrompts || 0) + " prompts · " + + Number(provider.todaySessions || 0) + " sessions" + return text + } + + function weekTotal(p) { + var days = p ? (p.recentDays || []) : [] + var total = 0 + for (var i = 0; i < days.length; i++) total += Number(days[i].messageCount || 0) + return total + } + + function weekPeak(p) { + var days = p ? (p.recentDays || []) : [] + var peak = 0 + for (var i = 0; i < days.length; i++) peak = Math.max(peak, Number(days[i].messageCount || 0)) + return peak + } + + function modelRows(p) { + var usageByModel = p ? (p.modelUsage || {}) : {} + var rows = [] + for (var id in usageByModel) { + var bucket = usageByModel[id] || {} + var input = Number(bucket.inputTokens || 0) + var output = Number(bucket.outputTokens || 0) + var cacheRead = Number(bucket.cacheReadInputTokens || 0) + var cacheWrite = Number(bucket.cacheCreationInputTokens || 0) + rows.push({ + name: usage.friendlyModelName(id), + total: input + output + cacheRead + cacheWrite, + input: input, + output: output, + cacheRead: cacheRead, + cacheWrite: cacheWrite + }) + } + rows.sort(function(a, b) { return b.total - a.total }) + return rows.slice(0, 4) + } + + // Every model, not just the rows that fit — the section header sums the lot. + function modelTotalTokens(p) { + var usageByModel = p ? (p.modelUsage || {}) : {} + var total = 0 + for (var id in usageByModel) { + var bucket = usageByModel[id] || {} + total += Number(bucket.inputTokens || 0) + Number(bucket.outputTokens || 0) + + Number(bucket.cacheReadInputTokens || 0) + Number(bucket.cacheCreationInputTokens || 0) + } + return total + } + + function modelTooltip(row) { + if (!row) return "" + return "In " + usage.formatTokenCount(row.input) + + " · out " + usage.formatTokenCount(row.output) + + " · cache read " + usage.formatTokenCount(row.cacheRead) + + " · cache write " + usage.formatTokenCount(row.cacheWrite) + } + + // Only speaks up when the numbers cover more than this machine. + function footerText() { + if (usage.syncStatusText !== "") return usage.syncStatusText + if (provider && provider.syncEnabled && provider.syncDeviceCount > 0) + return "Merged from " + provider.syncDeviceCount + " device" + (provider.syncDeviceCount === 1 ? "" : "s") + return "" + } + + // Codex ships as a white mark; swap to the dark one when the surface behind + // it is light. The Claude mark is brand-orange and works on both. + function colorChannelLuminance(value) { + var channel = Number(value) + if (!isFinite(channel)) return 0 + return channel <= 0.03928 ? channel / 12.92 : Math.pow((channel + 0.055) / 1.055, 2.4) + } + + function colorLuminance(color) { + return 0.2126 * colorChannelLuminance(color.r) + + 0.7152 * colorChannelLuminance(color.g) + + 0.0722 * colorChannelLuminance(color.b) + } + + function iconSourceForProvider(p, surfaceColor) { + if (!p) return "" + if (p.providerId === "claude") return Qt.resolvedUrl("assets/claude.svg") + if (p.providerId === "codex") + return colorLuminance(surfaceColor || Color.background) >= 0.5 + ? Qt.resolvedUrl("assets/codex-light.svg") + : Qt.resolvedUrl("assets/codex.svg") + return "" + } + + implicitWidth: button.implicitWidth + implicitHeight: button.implicitHeight + + onProvidersChanged: if (providerIndex >= providers.length) selectProvider(0) + onProviderIndexChanged: if (panelFlick) panelFlick.contentY = 0 + onOpenedChanged: if (opened) { + cursorActive = false + nowMs = Date.now() + if (panelFlick) panelFlick.contentY = 0 + usage.refreshAll() + Qt.callLater(function() { keyCatcher.forceActiveFocus() }) + } + + Main { + id: usage + settings: root.settings + } + + // Cheap enough to keep running: it only re-evaluates text bindings, and a + // stale "resets in 2h" on a panel that is open is worse than a timer. + Timer { + interval: 30000 + running: root.opened + repeat: true + onTriggered: root.nowMs = Date.now() + } + + IpcHandler { + target: root.ipcTarget + function open(): void { root.open() } + function close(): void { root.close() } + function show(): void { root.open() } + function hide(): void { root.close() } + function toggle(): void { root.toggle() } + function refresh(): string { root.refreshNow(); return "ok" } + function next(): string { root.selectProvider(root.providerIndex + 1); return "ok" } + } + + BarIconButton { + id: button + anchors.fill: parent + bar: root.bar + text: "󱚣" + active: root.alarming + dimmed: root.providers.length === 0 + onPressed: function(buttonCode) { + if (buttonCode === Qt.RightButton) root.refreshNow() + else if (buttonCode === Qt.MiddleButton) root.selectProvider(root.providerIndex + 1) + else root.toggle() + } + } + + KeyboardPanel { + id: panel + anchorItem: button + owner: root + bar: root.bar + open: root.opened + focusTarget: keyCatcher + contentWidth: panel.fittedContentWidth(Style.space(380)) + // Taller than the control panels on purpose: this one is a dashboard, and + // the whole point is reading limits, pace, and history without scrolling. + contentHeight: panel.fittedContentHeight(column.implicitHeight, Style.space(640)) + + PanelKeyCatcher { + id: keyCatcher + anchors.fill: parent + + onMoveRequested: function(dx, dy) { + if (dx !== 0) { + root.cursorActive = true + root.selectProvider(root.providerIndex + dx) + } + if (dy !== 0) + panelFlick.contentY = root.clamp(panelFlick.contentY + dy * Style.space(56), 0, + Math.max(0, panelFlick.contentHeight - panelFlick.height)) + } + onActivateRequested: root.refreshNow() + onCloseRequested: root.close() + onTabRequested: function(direction) { root.switchPanel(direction) } + onTextKey: function(t) { if (t === "r" || t === "R") root.refreshNow() } + + Flickable { + id: panelFlick + anchors.fill: parent + contentWidth: width + contentHeight: column.implicitHeight + clip: true + boundsBehavior: Flickable.StopAtBounds + flickableDirection: Flickable.VerticalFlick + interactive: contentHeight > height + ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded } + + Column { + id: column + width: panelFlick.width + spacing: Style.space(12) + + // ---------- Hero: provider mark · name · plan ---------- + PanelHero { + id: hero + visible: !!root.provider + width: parent.width + title: root.provider ? root.provider.providerName : "" + meta: root.heroMeta(root.provider) + foreground: root.foreground + fontFamily: root.fontFamily + + iconComponent: Component { + Image { + source: root.iconSourceForProvider(root.provider, root.surface) + width: Style.font.display + height: Style.font.display + sourceSize.width: Style.font.display * 2 + sourceSize.height: Style.font.display * 2 + fillMode: Image.PreserveAspectFit + } + } + } + + Text { + visible: root.providers.length === 0 + width: parent.width + topPadding: Style.space(24) + text: "No AI coding subscriptions found.\nClaude Code and Codex show up here once installed." + color: root.dim + font.family: root.fontFamily + font.pixelSize: Style.font.body + horizontalAlignment: Text.AlignHCenter + wrapMode: Text.WordWrap + } + + // ---------- Provider switch ---------- + Row { + id: providerSwitch + visible: root.providers.length > 1 + width: parent.width + spacing: Style.spacing.md + + readonly property real cellWidth: root.providers.length > 0 + ? (width - spacing * (root.providers.length - 1)) / root.providers.length + : 0 + + Repeater { + model: root.providers + + Button { + required property var modelData + required property int index + + width: providerSwitch.cellWidth + text: modelData.providerName + selected: index === root.providerIndex + hasCursor: root.cursorActive && index === root.providerIndex + bordered: true + foreground: root.foreground + fontFamily: root.fontFamily + fontSize: Style.font.bodySmall + verticalPadding: Style.spacing.controlPaddingY + onClicked: { + root.cursorActive = true + root.selectProvider(index) + } + onHovered: function(isHovered) { if (isHovered) root.cursorActive = true } + } + } + } + + // ---------- Status ---------- + BorderSurface { + visible: !!root.provider && String(root.provider.usageStatusText || "") !== "" + width: parent.width + implicitHeight: statusText.implicitHeight + Style.spacing.xl * 2 + color: root.alpha(root.urgent, 0.10) + borderSpec: Border.flat(root.alpha(root.urgent, 0.35), 1) + radius: Style.cornerRadius + + Text { + id: statusText + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.leftMargin: Style.space(12) + anchors.rightMargin: Style.space(12) + text: root.provider ? String(root.provider.authHelpText || "") : "" + color: root.dim + font.family: root.fontFamily + font.pixelSize: Style.font.caption + wrapMode: Text.WordWrap + } + } + + // ---------- Limits ---------- + PanelSeparator { + visible: limitsSection.visible + foreground: root.foreground + } + + Column { + id: limitsSection + visible: root.limits.length > 0 + width: parent.width + spacing: Style.space(10) + + PanelSectionHeader { + text: "LIMITS" + foreground: root.foreground + fontFamily: root.fontFamily + } + + Repeater { + model: root.limits + + LimitRow { + required property var modelData + width: limitsSection.width + window: modelData + } + } + } + + // ---------- Usage ---------- + PanelSeparator { + visible: usageSection.visible + foreground: root.foreground + } + + Column { + id: usageSection + visible: !!root.provider && root.provider.recentDays && root.provider.recentDays.length > 0 + width: parent.width + spacing: Style.spacing.md + + readonly property var days: root.provider ? (root.provider.recentDays || []) : [] + readonly property real peak: Math.max(1, root.weekPeak(root.provider)) + + Item { + width: parent.width + implicitHeight: Math.max(usageHeader.implicitHeight, usageTotalText.implicitHeight) + + PanelSectionHeader { + id: usageHeader + text: "USAGE THIS WEEK" + foreground: root.foreground + fontFamily: root.fontFamily + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + } + + Text { + id: usageTotalText + text: usage.formatTokenCount(root.weekTotal(root.provider)) + " tokens · 7 days" + color: root.dim + font.family: root.fontFamily + font.pixelSize: Style.font.caption + font.bold: true + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + } + } + + Repeater { + model: usageSection.days + + DayRow { + required property var modelData + required property int index + + width: usageSection.width + day: modelData + ratio: Number(modelData.messageCount || 0) / usageSection.peak + // By date, not by position: the Claude stats-cache fallback can + // hand us a window that stops short of today. + today: String(modelData.date || "") === root.todayDate() + } + } + } + + // ---------- Models ---------- + PanelSeparator { + visible: modelSection.visible + foreground: root.foreground + } + + Column { + id: modelSection + visible: root.models.length > 0 + width: parent.width + spacing: Style.spacing.md + + Item { + width: parent.width + implicitHeight: Math.max(modelHeader.implicitHeight, modelTotals.implicitHeight) + + PanelSectionHeader { + id: modelHeader + text: "USAGE BY MODEL" + foreground: root.foreground + fontFamily: root.fontFamily + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + } + + Text { + id: modelTotals + text: { + if (!root.provider) return "" + var days = Number(root.provider.activeDays || 0) + return usage.formatTokenCount(root.modelTotalTokens(root.provider)) + " tokens" + + (days > 0 ? " · " + days + " day" + (days === 1 ? "" : "s") : "") + } + color: root.dim + font.family: root.fontFamily + font.pixelSize: Style.font.caption + font.bold: true + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + } + } + + Repeater { + model: root.models + + ModelRow { + required property var modelData + width: modelSection.width + row: modelData + share: modelData.total / Math.max(1, root.models[0].total) + } + } + } + + Text { + visible: text !== "" + width: parent.width + topPadding: Style.space(2) + text: root.footerText() + color: root.dim + font.family: root.fontFamily + font.pixelSize: Style.font.caption + horizontalAlignment: Text.AlignHCenter + elide: Text.ElideRight + } + } + } + } + } + + // A limit window: label, percent, meter with the pace notch, reset and pace. + component LimitRow: Column { + id: limitRow + property var window: null + + readonly property var pace: root.paceFor(window) + readonly property bool alarming: window && window.percent >= 0.9 + + spacing: Style.space(6) + + Item { + width: parent.width + implicitHeight: Math.max(limitLabel.implicitHeight, limitValue.implicitHeight) + + Text { + id: limitLabel + text: limitRow.window + ? limitRow.window.title + (limitRow.window.span !== "" ? " · " + limitRow.window.span : "") + : "" + color: root.foreground + font.family: root.fontFamily + font.pixelSize: Style.font.body + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + } + + Text { + id: limitValue + text: root.percentText(limitRow.window ? limitRow.window.percent : -1) + color: limitRow.alarming ? root.urgent : root.foreground + font.family: root.fontFamily + font.pixelSize: Style.font.subtitle + font.bold: true + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + } + } + + Meter { + width: parent.width + value: limitRow.window ? limitRow.window.percent : -1 + marker: limitRow.pace ? limitRow.pace.expected : -1 + alarming: limitRow.alarming + tooltipText: root.paceDetail(limitRow.window) + } + + Item { + width: parent.width + implicitHeight: Math.max(resetText.implicitHeight, paceText.implicitHeight) + + Text { + id: resetText + text: { + var remaining = root.resetMsFor(limitRow.window) + return remaining > 0 ? "Resets in " + root.formatDuration(remaining) : "" + } + color: root.dim + font.family: root.fontFamily + font.pixelSize: Style.font.caption + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + } + + Text { + id: paceText + text: root.paceText(limitRow.window) + color: root.paceIsUrgent(limitRow.window) ? root.urgent : root.dim + font.family: root.fontFamily + font.pixelSize: Style.font.caption + font.bold: true + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + } + } + } + + // Rounded track with a fill, plus an optional notch marking where an evenly + // paced week would have you right now. + component Meter: Item { + id: meter + property real value: -1 + property real marker: -1 + property bool alarming: false + property string tooltipText: "" + property real thickness: Math.max(Style.space(4), Math.round(Style.spacing.controlHeight * 0.14)) + + implicitHeight: thickness + + Rectangle { + id: meterTrack + anchors.fill: parent + radius: height / 2 + color: root.track + } + + Rectangle { + anchors.left: meterTrack.left + anchors.verticalCenter: meterTrack.verticalCenter + height: meterTrack.height + radius: meterTrack.radius + width: meterTrack.width * root.clamp(meter.value, 0, 1) + color: meter.alarming ? root.urgent : root.foreground + + Behavior on width { + NumberAnimation { duration: 160; easing.type: Easing.OutCubic } + } + } + + Rectangle { + visible: meter.marker >= 0 && meter.marker <= 1 + width: Math.max(1, Style.space(2)) + height: meterTrack.height + Style.space(4) + radius: width / 2 + anchors.verticalCenter: meterTrack.verticalCenter + x: root.clamp(meterTrack.width * meter.marker - width / 2, 0, Math.max(0, meterTrack.width - width)) + color: meter.marker <= meter.value ? root.surface : root.alpha(root.foreground, 0.5) + } + + MouseArea { + id: meterHover + anchors.fill: parent + hoverEnabled: true + acceptedButtons: Qt.NoButton + } + + PanelToolTip { + visible: meter.tooltipText !== "" && meterHover.containsMouse + text: meter.tooltipText + fontFamily: root.fontFamily + } + } + + // One row per day: label, bar, tokens. Today is picked out in full + // foreground so the week reads as a run-up to right now. + component DayRow: Item { + id: dayRow + property var day: null + property real ratio: 0 + property bool today: false + + implicitHeight: Math.max(dayLabel.implicitHeight, dayValue.implicitHeight) + Style.spacing.sm + + Text { + id: dayLabel + text: root.dayLabel(dayRow.day ? dayRow.day.date : "", dayRow.today) + color: dayRow.today ? root.foreground : root.dim + font.family: root.fontFamily + font.pixelSize: Style.font.caption + font.bold: dayRow.today + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + width: Style.space(52) + } + + Rectangle { + id: dayTrack + anchors.left: dayLabel.right + anchors.right: dayValue.left + anchors.leftMargin: Style.space(8) + anchors.rightMargin: Style.space(10) + anchors.verticalCenter: parent.verticalCenter + height: Math.max(Style.space(4), Math.round(Style.spacing.controlHeight * 0.14)) + radius: height / 2 + color: root.track + + Rectangle { + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + height: parent.height + radius: parent.radius + width: parent.width * root.clamp(dayRow.ratio, 0, 1) + color: dayRow.today ? root.foreground : root.alpha(root.foreground, 0.55) + + Behavior on width { + NumberAnimation { duration: 160; easing.type: Easing.OutCubic } + } + } + } + + Text { + id: dayValue + text: usage.formatTokenCount(dayRow.day ? Number(dayRow.day.messageCount || 0) : 0) + color: dayRow.today ? root.foreground : root.dim + font.family: root.fontFamily + font.pixelSize: Style.font.caption + font.bold: true + horizontalAlignment: Text.AlignRight + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + width: Style.space(52) + } + + MouseArea { + id: dayHover + anchors.fill: parent + hoverEnabled: true + acceptedButtons: Qt.NoButton + } + + PanelToolTip { + visible: dayHover.containsMouse + text: root.dayTooltip(dayRow.day, dayRow.today) + fontFamily: root.fontFamily + } + } + + // Model rows read as a table: the share bar fills the row behind the label + // instead of stacking under it, which keeps the whole dashboard on one screen. + component ModelRow: Item { + id: modelRow + property var row: null + property real share: 0 + + implicitHeight: modelName.implicitHeight + Style.spacing.lg + + Rectangle { + anchors.fill: parent + radius: Style.cornerRadius + color: root.alpha(root.foreground, 0.05) + } + + Rectangle { + anchors.left: parent.left + anchors.top: parent.top + anchors.bottom: parent.bottom + width: parent.width * root.clamp(modelRow.share, 0, 1) + radius: Style.cornerRadius + color: root.alpha(root.foreground, 0.14) + + Behavior on width { + NumberAnimation { duration: 160; easing.type: Easing.OutCubic } + } + } + + Text { + id: modelName + text: modelRow.row ? modelRow.row.name : "" + color: root.foreground + font.family: root.fontFamily + font.pixelSize: Style.font.bodySmall + elide: Text.ElideRight + anchors.left: parent.left + anchors.leftMargin: Style.space(8) + anchors.right: modelTokens.left + anchors.rightMargin: Style.space(8) + anchors.verticalCenter: parent.verticalCenter + } + + Text { + id: modelTokens + text: modelRow.row ? usage.formatTokenCount(modelRow.row.total) : "" + color: root.dim + font.family: root.fontFamily + font.pixelSize: Style.font.bodySmall + font.bold: true + anchors.right: parent.right + anchors.rightMargin: Style.space(8) + anchors.verticalCenter: parent.verticalCenter + } + + MouseArea { + id: modelHover + anchors.fill: parent + hoverEnabled: true + acceptedButtons: Qt.NoButton + } + + PanelToolTip { + visible: modelHover.containsMouse + text: root.modelTooltip(modelRow.row) + fontFamily: root.fontFamily + } + } +} diff --git a/shell/plugins/model-usage/README.md b/shell/plugins/model-usage/README.md new file mode 100644 index 00000000..e4d0401c --- /dev/null +++ b/shell/plugins/model-usage/README.md @@ -0,0 +1,93 @@ +# Model usage + +One bar icon and one panel for every AI coding subscription on the machine. +`Panel.qml` owns the bar button and the popup; `Main.qml` owns provider +fan-out and the optional cross-device aggregation; `providers/` holds one +adapter per subscription. + +## Panel + +- **Hero** — the mark, the tool, and the plan it runs on ("Max 20x", "Pro"). + Auth and endpoint problems replace the plan line and repeat in a card. +- **Subscription switch** — one chip per enabled provider (`h`/`l` or click). + It appears only when more than one provider is enabled. +- **Limits** — a meter per window (session, weekly). The notch on the meter + marks where an evenly paced window would have you right now, so the gap + between the fill and the notch is the whole story: fill behind the notch + means budget in reserve, fill past it means you are burning faster than + the clock and the row says when it runs out. +- **Usage this week** — one row per day for the last week: day, bar, tokens, with today + bolded at the bottom. Hover today for its prompt and session count. +- **Usage by model** — total tokens and active days in the header, then + tokens per model with the bar behind each row showing its share of the + heaviest one. Hover for the input / output / cache split. + +A subscription appears only when it is both enabled in settings and actually +present on this machine (state directory or CLI). With one installed there is +no switch row at all; with none, the panel says so and the bar icon dims. + +## Providers + +| Provider | Limits | Local stats | +|---|---|---| +| `claude` | Anthropic's OAuth usage endpoint (5-hour session + 7-day weekly) | `~/.claude/projects` scanned by `scripts/claude_usage_scanner.py`, plus `stats-cache.json` and `history.jsonl` | +| `codex` | `scripts/codex_usage_scanner.py` reading the Codex CLI state | the same scanner | + +Claude limits need a signed-in CLI; without credentials the panel says so and +falls back to local stats only. + +## Interactions + +- Bar icon: left = panel, right = refresh, middle = next subscription. +- Panel: `h`/`l` switch subscription, `j`/`k` scroll, `r` or Enter refresh, + Tab moves to the neighboring bar panel, Esc closes. +- IPC: `omarchy-shell omarchy.model-usage `. + +## Settings + +Settings live in the widget's entry in `~/.config/omarchy/shell.json`. The +top-level keys can be set with +`omarchy bar plugin set omarchy.model-usage `: + +| Key | Default | What it does | +|---|---|---| +| `refreshIntervalSec` | `900` | How often local scans and snapshots refresh | +| `syncMode` | `"Off"` | `"On"` writes this machine's snapshot and merges the others | +| `syncDir` | `""` | A folder synced by Syncthing, Dropbox, rsync, … | +| `syncFileName` | `.json` | This machine's snapshot file | +| `syncDeviceId` | hostname | Stable device name inside the snapshot | + +Numbers need `--json`, or they land in `shell.json` as strings: + +```bash +omarchy bar plugin set omarchy.model-usage refreshIntervalSec 300 --json +omarchy bar plugin set omarchy.model-usage syncDir '~/Sync/model-usage' +``` + +Per-provider settings are nested, and `set` writes its key literally rather +than walking a dotted path — so pass the whole `providers` object as JSON (or +edit `shell.json` directly): + +```bash +omarchy bar plugin set omarchy.model-usage providers '{ + "claude": { + "enabled": true, + "statsPath": "~/.claude/stats-cache.json", + "credentialsPath": "~/.claude/.credentials.json", + "projectsPath": "~/.claude/projects" + }, + "codex": { "enabled": false } +}' --json +``` + +`enabled` defaults to `true` for both; set it to `false` to hide a +subscription that is installed. The paths above are the defaults. + +With `syncMode` on, every `*.json` snapshot in `syncDir` is merged, so today, +the last 7 days, and the all-time totals cover every machine you code on — +active days are unioned by date rather than summed. Rate limits stay +per-account and are never merged. + +One caveat on "all-time": the Codex scanner only reads native session files +touched in the last 30 days, so Codex totals and its day count cover that +window. Claude's cover every transcript still on disk. diff --git a/shell/plugins/model-usage/Widget.qml b/shell/plugins/model-usage/Widget.qml deleted file mode 100644 index 59583b9e..00000000 --- a/shell/plugins/model-usage/Widget.qml +++ /dev/null @@ -1,1267 +0,0 @@ -import QtQuick -import QtQuick.Controls -import QtQuick.Layouts -import Quickshell -import Quickshell.Io -import qs.Commons -import qs.Ui - -BarWidget { - id: root - moduleName: "omarchy.model-usage" - - property bool popupOpen: false - property bool settingsMode: false - property var draftSettings: ({}) - property string settingsStatusText: "" - property int selectedTabIndex: 0 - property bool refreshFlash: false - - readonly property color foreground: bar ? bar.foreground : Color.foreground - readonly property color background: Color.popups.background - readonly property color border: Color.popups.border - readonly property color urgent: bar ? bar.urgent : Color.urgent - readonly property color dim: Qt.darker(foreground, 1.45) - readonly property color card: Qt.rgba(foreground.r, foreground.g, foreground.b, 0.055) - readonly property color cardHover: Qt.rgba(foreground.r, foreground.g, foreground.b, 0.085) - readonly property color outline: Qt.rgba(foreground.r, foreground.g, foreground.b, 0.18) - readonly property color track: Qt.rgba(foreground.r, foreground.g, foreground.b, 0.24) - readonly property string fontFamily: bar ? bar.fontFamily : "JetBrainsMono Nerd Font" - - readonly property var providers: usageMain.enabledProviders - readonly property var selectedProvider: providers.length > 0 ? providers[Math.min(selectedTabIndex, providers.length - 1)] : null - - function close() { - popupOpen = false - settingsMode = false - } - - function triggerPress(button) { - root.handleChipPress(Math.max(0, Math.min(selectedTabIndex, providers.length - 1)), button, null) - } - - function triggerRefresh() { - refreshFlash = true - refreshFlashTimer.restart() - usageMain.refreshAll(true) - } - - function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)) } - function alpha(c, a) { return Qt.rgba(c.r, c.g, c.b, a) } - - function cloneObject(value, fallback) { - if (value === undefined || value === null) return fallback - try { - return JSON.parse(JSON.stringify(value)) - } catch (e) { - return fallback - } - } - - function defaultSettings() { - return { - providers: { - claude: { - enabled: true, - statsPath: "~/.claude/stats-cache.json", - credentialsPath: "~/.claude/.credentials.json", - projectsPath: "~/.claude/projects" - }, - codex: { enabled: true } - }, - refreshIntervalSec: 900, - syncMode: "Off", - syncDir: "", - syncFileName: "", - syncDeviceId: "" - } - } - - function parseSyncEnabledValue(value) { - if (value === true) return true - var text = String(value || "").trim().toLowerCase() - return text === "on" || text === "enabled" || text === "true" || text === "yes" || text === "1" - } - - function normalizedSettings(source) { - var defaults = defaultSettings() - var next = cloneObject(source, {}) || {} - if (!next.providers || typeof next.providers !== "object") next.providers = {} - - var claude = cloneObject(next.providers.claude, {}) || {} - var codex = cloneObject(next.providers.codex, {}) || {} - if (claude.enabled === undefined || claude.enabled === null) claude.enabled = defaults.providers.claude.enabled - if (codex.enabled === undefined || codex.enabled === null) codex.enabled = defaults.providers.codex.enabled - if (!claude.statsPath) claude.statsPath = defaults.providers.claude.statsPath - if (!claude.credentialsPath) claude.credentialsPath = defaults.providers.claude.credentialsPath - if (!claude.projectsPath) claude.projectsPath = defaults.providers.claude.projectsPath - next.providers.claude = claude - next.providers.codex = codex - - var refresh = Number(next.refreshIntervalSec === undefined || next.refreshIntervalSec === null ? defaults.refreshIntervalSec : next.refreshIntervalSec) - next.refreshIntervalSec = Math.round(clamp(isFinite(refresh) ? refresh : defaults.refreshIntervalSec, 30, 3600)) - next.syncMode = parseSyncEnabledValue(next.syncMode !== undefined ? next.syncMode : next.syncEnabled) ? "On" : "Off" - next.syncDir = String(next.syncDir || "") - next.syncFileName = String(next.syncFileName || "") - next.syncDeviceId = String(next.syncDeviceId || "") - return next - } - - function draftValue(name, fallback) { - var value = draftSettings ? draftSettings[name] : undefined - return value === undefined || value === null ? fallback : value - } - - function draftProviderValue(providerId, name, fallback) { - var provider = draftSettings && draftSettings.providers ? draftSettings.providers[providerId] : null - var value = provider ? provider[name] : undefined - return value === undefined || value === null ? fallback : value - } - - function setDraftValue(name, value) { - var next = normalizedSettings(draftSettings) - next[name] = value - draftSettings = next - } - - function setDraftProviderValue(providerId, name, value) { - var next = normalizedSettings(draftSettings) - if (!next.providers) next.providers = {} - if (!next.providers[providerId]) next.providers[providerId] = {} - next.providers[providerId][name] = value - draftSettings = next - } - - function openSettings() { - draftSettings = normalizedSettings(settings) - settingsStatusText = "" - settingsMode = true - popupOpen = true - Qt.callLater(function() { if (keyCatcher) keyCatcher.forceActiveFocus() }) - } - - function showUsage() { - settingsMode = false - settingsStatusText = "" - Qt.callLater(function() { if (keyCatcher) keyCatcher.forceActiveFocus() }) - } - - function canPersistSettings() { - return !!(bar && bar.shell && typeof bar.shell.updateEntryInline === "function") - } - - function saveSettings() { - var next = normalizedSettings(draftSettings) - draftSettings = next - root.settings = next - if (canPersistSettings()) { - bar.shell.updateEntryInline(root.moduleName, next) - settingsStatusText = "Saved to shell.json" - } else { - settingsStatusText = "Saved for this session" - } - usageMain.refreshAll(true) - } - - function colorChannelLuminance(value) { - var channel = Number(value) - if (!isFinite(channel)) return 0 - return channel <= 0.03928 ? channel / 12.92 : Math.pow((channel + 0.055) / 1.055, 2.4) - } - - function colorLuminance(color) { - return 0.2126 * colorChannelLuminance(color.r) - + 0.7152 * colorChannelLuminance(color.g) - + 0.0722 * colorChannelLuminance(color.b) - } - - function codexIconSource(surfaceColor) { - return colorLuminance(surfaceColor || Color.background) >= 0.5 - ? Qt.resolvedUrl("assets/codex-light.svg") - : Qt.resolvedUrl("assets/codex.svg") - } - - function iconSourceForProvider(provider, surfaceColor) { - if (!provider) return "" - if (provider.providerId === "claude") return Qt.resolvedUrl("assets/claude.svg") - if (provider.providerId === "codex") return codexIconSource(surfaceColor) - return "" - } - - function rateLimitLabelIsWeekly(label) { - var text = String(label || "").toLowerCase() - return text.indexOf("week") >= 0 || text.indexOf("7-day") >= 0 || text.indexOf("seven_day") >= 0 - } - - function usagePercent(provider) { - if (!provider) return -1 - var weekly = weeklyUsage(provider) - if (weekly.percent >= 0) return weekly.percent - - var values = [] - if (provider.rateLimitPercent >= 0) values.push(provider.rateLimitPercent) - if (provider.secondaryRateLimitPercent >= 0) values.push(provider.secondaryRateLimitPercent) - if (values.length === 0) return -1 - return Math.max.apply(Math, values) - } - - function formatUsagePercent(provider) { - var pct = usagePercent(provider) - return pct < 0 ? "—" : Math.round(pct * 100) + "%" - } - - function weeklyUsage(provider) { - if (!provider) return ({ percent: -1, resetAt: "", label: "" }) - if (root.rateLimitLabelIsWeekly(provider.rateLimitLabel)) - return { percent: provider.rateLimitPercent, resetAt: provider.rateLimitResetAt, label: provider.rateLimitLabel } - if (root.rateLimitLabelIsWeekly(provider.secondaryRateLimitLabel)) - return { percent: provider.secondaryRateLimitPercent, resetAt: provider.secondaryRateLimitResetAt, label: provider.secondaryRateLimitLabel } - return ({ percent: -1, resetAt: "", label: "" }) - } - - function paceInfo(provider) { - var weekly = weeklyUsage(provider) - if (weekly.percent < 0 || !weekly.resetAt) return ({ text: "", detail: "", deficit: false }) - var reset = new Date(weekly.resetAt).getTime() - var now = Date.now() - var period = 7 * 24 * 60 * 60 * 1000 - var remaining = reset - now - if (remaining <= 0 || remaining > period) return ({ text: "", detail: "", deficit: false }) - var elapsed = period - remaining - var expected = root.clamp(elapsed / period, 0, 1) - var used = root.clamp(weekly.percent, 0, 1) - var diff = used - expected - var abs = Math.abs(diff) - var label = abs <= 0.02 ? "On pace" : (diff > 0 ? Math.round(abs * 100) + "% in deficit" : Math.round(abs * 100) + "% in reserve") - var projection = "Lasts until reset" - if (used > 0 && elapsed > 0) { - var eta = elapsed / used * (1 - used) - if (eta < remaining) projection = "Runs out in " + provider.formatResetTime(new Date(now + eta).toISOString()) - } - return ({ text: label, detail: "Expected " + Math.round(expected * 100) + "% used · " + projection, deficit: diff > 0 && abs > 0.02 }) - } - - function tooltipText() { - if (providers.length === 0) return "Model Usage" - var lines = ["Model Usage"] - for (var i = 0; i < providers.length; i++) { - var provider = providers[i] - var line = provider.providerName + ": " + formatUsagePercent(provider) + " used" - var pace = paceInfo(provider) - if (pace.text !== "") line += " · " + pace.text - if (provider.syncEnabled && provider.syncDeviceCount > 1) line += " · " + provider.syncDeviceCount + " devices" - lines.push(line) - } - return lines.join("\n") - } - - function syncSummary(provider) { - if (usageMain.syncStatusText !== "") return usageMain.syncStatusText - if (!provider || !provider.syncEnabled) return "" - var count = Number(provider.syncDeviceCount || 0) - if (count <= 0) return "Synced usage" - return "Synced from " + count + " device" + (count === 1 ? "" : "s") - } - - function selectTab(index) { - if (providers.length === 0) { - selectedTabIndex = 0 - return - } - selectedTabIndex = ((index % providers.length) + providers.length) % providers.length - } - - function handleChipPress(index, button, target) { - if (root.bar && target) root.bar.hideTooltip(target) - if (button === Qt.RightButton) { - root.openSettings() - return - } - if (button === Qt.MiddleButton) { - root.triggerRefresh() - return - } - - var wasOpen = root.popupOpen - var wasSelected = root.selectedTabIndex === index && !root.settingsMode - root.showUsage() - root.selectTab(index) - if (wasOpen && wasSelected) root.popupOpen = false - else { - root.popupOpen = true - root.triggerRefresh() - } - } - - implicitWidth: button.implicitWidth - implicitHeight: button.implicitHeight - - onPopupOpenChanged: { - if (popupOpen) { - usageMain.refreshAll() - Qt.callLater(function() { if (keyCatcher) keyCatcher.forceActiveFocus() }) - } - } - - onProvidersChanged: if (selectedTabIndex >= providers.length) selectTab(0) - - Main { - id: usageMain - settings: root.settings - } - - Timer { - id: refreshFlashTimer - interval: 900 - repeat: false - onTriggered: root.refreshFlash = false - } - - IpcHandler { - target: "omarchy.model-usage" - function open(): string { root.showUsage(); root.popupOpen = true; return "ok" } - function close(): string { root.close(); return "ok" } - function toggle(): string { - if (root.popupOpen) root.close() - else { root.showUsage(); root.popupOpen = true } - return "ok" - } - function refresh(): string { root.triggerRefresh(); return "ok" } - function settings(): string { root.openSettings(); return "ok" } - function openSettings(): string { root.openSettings(); return "ok" } - } - - component UsageChip: Item { - id: chip - - required property var modelData - required property int index - readonly property real pct: root.usagePercent(modelData) - readonly property bool compact: root.vertical - readonly property bool tooltipHovered: mouseArea.containsMouse - - width: compact ? root.barSize : chipRow.implicitWidth - height: compact ? Math.max(root.barSize, chipColumn.implicitHeight + Style.space(2)) : root.barSize - - Row { - id: chipRow - visible: !chip.compact - anchors.centerIn: parent - spacing: 4 - - Image { - source: root.iconSourceForProvider(chip.modelData, root.bar ? root.bar.background : Color.bar.background) - width: 13 - height: 13 - sourceSize.width: 13 - sourceSize.height: 13 - fillMode: Image.PreserveAspectFit - anchors.verticalCenter: parent.verticalCenter - opacity: chip.pct >= 0.9 ? 0.75 : 1 - } - - Text { - text: root.formatUsagePercent(chip.modelData) - color: chip.pct >= 0.9 ? urgent : foreground - font.family: fontFamily - font.pixelSize: 10 - font.bold: chip.pct >= 0.9 - anchors.verticalCenter: parent.verticalCenter - } - } - - Column { - id: chipColumn - visible: chip.compact - anchors.centerIn: parent - spacing: 1 - - Image { - source: root.iconSourceForProvider(chip.modelData, root.bar ? root.bar.background : Color.bar.background) - width: 13 - height: 13 - sourceSize.width: 13 - sourceSize.height: 13 - fillMode: Image.PreserveAspectFit - anchors.horizontalCenter: parent.horizontalCenter - opacity: chip.pct >= 0.9 ? 0.75 : 1 - } - - Text { - width: root.barSize - text: root.formatUsagePercent(chip.modelData) - color: chip.pct >= 0.9 ? urgent : foreground - font.family: fontFamily - font.pixelSize: 10 - font.bold: chip.pct >= 0.9 - horizontalAlignment: Text.AlignHCenter - } - } - - property var registeredBar: null - - function triggerPress(button) { - root.handleChipPress(chip.index, button, chip) - } - - function syncClickRegistration() { - if (registeredBar && registeredBar.unregisterClickTarget) registeredBar.unregisterClickTarget(chip) - registeredBar = root.bar - if (registeredBar && registeredBar.registerClickTarget) registeredBar.registerClickTarget(chip) - } - - Component.onCompleted: syncClickRegistration() - Component.onDestruction: if (registeredBar && registeredBar.unregisterClickTarget) registeredBar.unregisterClickTarget(chip) - - Connections { - target: root - function onBarChanged() { chip.syncClickRegistration() } - } - - MouseArea { - id: mouseArea - anchors.fill: parent - acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - onEntered: if (root.bar) root.bar.showTooltip(chip, root.tooltipText()) - onExited: if (root.bar) root.bar.hideTooltip(chip) - onClicked: function(mouse) { root.handleChipPress(chip.index, mouse.button, chip) } - } - } - - component EmptyUsageChip: Item { - id: emptyChip - - readonly property bool tooltipHovered: emptyMouse.containsMouse - - visible: providers.length === 0 - width: root.vertical ? root.barSize : emptyLabel.implicitWidth - height: root.barSize - - property var registeredBar: null - - function triggerPress(button) { - root.handleChipPress(0, button, emptyChip) - } - - function syncClickRegistration() { - if (registeredBar && registeredBar.unregisterClickTarget) registeredBar.unregisterClickTarget(emptyChip) - registeredBar = root.bar - if (registeredBar && registeredBar.registerClickTarget) registeredBar.registerClickTarget(emptyChip) - } - - Component.onCompleted: syncClickRegistration() - Component.onDestruction: if (registeredBar && registeredBar.unregisterClickTarget) registeredBar.unregisterClickTarget(emptyChip) - - Connections { - target: root - function onBarChanged() { emptyChip.syncClickRegistration() } - } - - Text { - id: emptyLabel - anchors.centerIn: parent - text: "AI" - color: dim - font.family: fontFamily - font.pixelSize: 10 - font.bold: true - } - - MouseArea { - id: emptyMouse - anchors.fill: parent - acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - onEntered: if (root.bar) root.bar.showTooltip(emptyChip, "Model Usage") - onExited: if (root.bar) root.bar.hideTooltip(emptyChip) - onClicked: function(mouse) { root.handleChipPress(0, mouse.button, emptyChip) } - } - } - - Item { - id: button - anchors.fill: parent - implicitWidth: root.vertical ? root.barSize : barRow.implicitWidth + Style.space(10) - implicitHeight: root.vertical ? barColumn.implicitHeight : root.barSize - - Row { - id: barRow - visible: !root.vertical - anchors.centerIn: parent - spacing: Style.space(8) - - Repeater { - model: providers - UsageChip { visible: !root.vertical } - } - - EmptyUsageChip { visible: !root.vertical && providers.length === 0 } - } - - Column { - id: barColumn - visible: root.vertical - anchors.centerIn: parent - spacing: Style.space(2) - - Repeater { - model: providers - UsageChip { visible: root.vertical } - } - - EmptyUsageChip { visible: root.vertical && providers.length === 0 } - } - } - - KeyboardPanel { - id: panel - anchorItem: button - owner: root - bar: root.bar - open: root.popupOpen - focusTarget: keyCatcher - contentWidth: panel.fittedContentWidth(Style.space(370)) - contentHeight: panel.fittedContentHeight(contentColumn.implicitHeight, Style.space(560)) - - PanelKeyCatcher { - id: keyCatcher - anchors.fill: parent - blocked: root.settingsMode && settingsContent.editorActive - - onMoveRequested: function(dx, dy) { - if (root.settingsMode) { - if (dy !== 0) flick.contentY = root.clamp(flick.contentY + dy * 56, 0, Math.max(0, flick.contentHeight - flick.height)) - return - } - if (dx !== 0) root.selectTab(root.selectedTabIndex + dx) - if (dy !== 0) flick.contentY = root.clamp(flick.contentY + dy * 56, 0, Math.max(0, flick.contentHeight - flick.height)) - } - onCloseRequested: root.close() - onTextKey: function(t) { - if (t === "r" || t === "R") root.triggerRefresh() - if (t === "s" || t === "S") root.settingsMode ? root.saveSettings() : root.openSettings() - } - - ColumnLayout { - anchors.fill: parent - spacing: 10 - - Header { - visible: !root.settingsMode && !!root.selectedProvider - provider: root.selectedProvider - } - - SettingsHeader { visible: root.settingsMode } - - PanelSeparator { - Layout.fillWidth: true - foreground: root.foreground - } - - Item { - visible: !root.settingsMode && providers.length > 1 - Layout.fillWidth: true - Layout.preferredHeight: 30 - - Row { - anchors.fill: parent - spacing: 6 - - Repeater { - model: providers - - Button { - required property var modelData - required property int index - width: (parent.width - parent.spacing * Math.max(0, providers.length - 1)) / Math.max(1, providers.length) - height: parent.height - text: modelData.providerName - foreground: root.foreground - tooltipBackground: root.background - tooltipForeground: root.foreground - fontFamily: root.fontFamily - fontSize: 11 - horizontalPadding: 8 - verticalPadding: 5 - active: index === root.selectedTabIndex - hasCursor: index === root.selectedTabIndex - onClicked: { - root.selectTab(index) - keyCatcher.forceActiveFocus() - } - } - } - } - } - - Flickable { - id: flick - Layout.fillWidth: true - Layout.fillHeight: true - contentWidth: width - contentHeight: contentColumn.implicitHeight - clip: true - boundsBehavior: Flickable.StopAtBounds - flickableDirection: Flickable.VerticalFlick - ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded } - - ColumnLayout { - id: contentColumn - width: flick.width - spacing: 10 - - Text { - visible: !root.settingsMode && !root.selectedProvider - Layout.fillWidth: true - Layout.topMargin: 24 - text: "No providers enabled. Open Settings to enable Claude or Codex." - color: dim - font.family: fontFamily - font.pixelSize: 11 - horizontalAlignment: Text.AlignHCenter - } - - StatusCard { provider: root.settingsMode ? null : root.selectedProvider } - RateLimitCard { provider: root.settingsMode ? null : root.selectedProvider } - TodayCard { provider: root.settingsMode ? null : root.selectedProvider } - WeekCard { provider: root.settingsMode ? null : root.selectedProvider } - AllTimeCard { provider: root.settingsMode ? null : root.selectedProvider } - - UsageFooter { visible: !root.settingsMode } - SettingsContent { - id: settingsContent - visible: root.settingsMode - } - } - } - } - } - } - - component SettingsHeader: RowLayout { - Layout.fillWidth: true - spacing: 8 - - Text { - text: "Model Usage Settings" - color: foreground - font.family: fontFamily - font.pixelSize: 15 - font.bold: true - Layout.fillWidth: true - Layout.alignment: Qt.AlignVCenter - } - - Button { - text: "Usage" - foreground: root.foreground - tooltipText: "Back to usage" - tooltipBackground: root.background - tooltipForeground: root.foreground - fontFamily: root.fontFamily - fontSize: 10 - horizontalPadding: 8 - verticalPadding: 4 - onClicked: root.showUsage() - } - - Button { - text: "Save" - foreground: root.foreground - tooltipText: "Save settings" - tooltipBackground: root.background - tooltipForeground: root.foreground - fontFamily: root.fontFamily - fontSize: 10 - horizontalPadding: 8 - verticalPadding: 4 - active: true - onClicked: root.saveSettings() - } - } - - component UsageFooter: RowLayout { - Layout.fillWidth: true - spacing: 8 - - Text { - Layout.fillWidth: true - text: { - var sync = root.syncSummary(root.selectedProvider) - return (sync !== "" ? sync + " · " : "") + "←/→ switch · j/k scroll · r refresh · s/settings · esc close" - } - color: dim - font.family: fontFamily - font.pixelSize: 10 - horizontalAlignment: Text.AlignHCenter - wrapMode: Text.WordWrap - } - - } - - component SettingsContent: ColumnLayout { - id: settingsRoot - Layout.fillWidth: true - spacing: 10 - - readonly property bool syncOn: root.draftValue("syncMode", "Off") === "On" - readonly property bool claudeOn: root.draftProviderValue("claude", "enabled", true) !== false - readonly property bool editorActive: refreshIntervalField.field.activeFocus - || claudeStatsField.activeFocus - || claudeCredentialsField.activeFocus - || claudeProjectsField.activeFocus - || syncDirField.activeFocus - || syncFileNameField.activeFocus - || syncDeviceIdField.activeFocus - - SectionCard { - title: "Providers" - - ColumnLayout { - width: parent.width - spacing: 10 - - Toggle { - Layout.fillWidth: true - label: "Claude Code" - description: checked ? "Show Claude Code usage and rate-limit status" : "Hidden from the bar widget" - checked: root.draftProviderValue("claude", "enabled", true) !== false - foreground: root.foreground - accent: Color.accent - fontFamily: root.fontFamily - onClicked: root.setDraftProviderValue("claude", "enabled", !checked) - } - - ColumnLayout { - Layout.fillWidth: true - spacing: 6 - enabled: settingsRoot.claudeOn - opacity: enabled ? 1.0 : 0.45 - - FieldLabel { text: "Claude stats cache" } - TextField { - id: claudeStatsField - Layout.fillWidth: true - text: String(root.draftProviderValue("claude", "statsPath", "~/.claude/stats-cache.json")) - placeholderText: "~/.claude/stats-cache.json" - foreground: root.foreground - onTextChanged: if (text !== root.draftProviderValue("claude", "statsPath", "")) root.setDraftProviderValue("claude", "statsPath", text) - } - - FieldLabel { text: "Claude credentials" } - TextField { - id: claudeCredentialsField - Layout.fillWidth: true - text: String(root.draftProviderValue("claude", "credentialsPath", "~/.claude/.credentials.json")) - placeholderText: "~/.claude/.credentials.json" - foreground: root.foreground - onTextChanged: if (text !== root.draftProviderValue("claude", "credentialsPath", "")) root.setDraftProviderValue("claude", "credentialsPath", text) - } - - FieldLabel { text: "Claude projects folder" } - TextField { - id: claudeProjectsField - Layout.fillWidth: true - text: String(root.draftProviderValue("claude", "projectsPath", "~/.claude/projects")) - placeholderText: "~/.claude/projects" - foreground: root.foreground - onTextChanged: if (text !== root.draftProviderValue("claude", "projectsPath", "")) root.setDraftProviderValue("claude", "projectsPath", text) - } - } - - Toggle { - Layout.fillWidth: true - label: "Codex" - description: checked ? "Show Codex usage and limits" : "Hidden from the bar widget" - checked: root.draftProviderValue("codex", "enabled", true) !== false - foreground: root.foreground - accent: Color.accent - fontFamily: root.fontFamily - onClicked: root.setDraftProviderValue("codex", "enabled", !checked) - } - } - } - - SectionCard { - title: "Refresh" - - ColumnLayout { - width: parent.width - spacing: 8 - - NumberField { - id: refreshIntervalField - label: "Refresh interval (seconds)" - value: Number(root.draftValue("refreshIntervalSec", 900)) - from: 30 - to: 3600 - stepSize: 30 - fieldWidth: parent.width - foreground: root.foreground - accent: Color.accent - fontFamily: root.fontFamily - onModified: function(value) { root.setDraftValue("refreshIntervalSec", value) } - } - - Text { - Layout.fillWidth: true - text: "How often the widget refreshes local usage scans and sync snapshots." - color: dim - font.family: fontFamily - font.pixelSize: 10 - wrapMode: Text.WordWrap - } - } - } - - SectionCard { - title: "Synced aggregation" - - ColumnLayout { - width: parent.width - spacing: 10 - - Toggle { - Layout.fillWidth: true - label: "Aggregate across devices" - description: checked ? "Write this machine's snapshot and merge every *.json file in the sync folder" : "Only show this machine's local usage" - checked: settingsRoot.syncOn - foreground: root.foreground - accent: Color.accent - fontFamily: root.fontFamily - onClicked: root.setDraftValue("syncMode", checked ? "Off" : "On") - } - - ColumnLayout { - Layout.fillWidth: true - spacing: 6 - enabled: settingsRoot.syncOn - opacity: enabled ? 1.0 : 0.45 - - FieldLabel { text: "Sync folder" } - TextField { - id: syncDirField - Layout.fillWidth: true - text: String(root.draftValue("syncDir", "")) - placeholderText: "~/Sync/model-usage" - foreground: root.foreground - onTextChanged: if (text !== root.draftValue("syncDir", "")) root.setDraftValue("syncDir", text) - } - - FieldLabel { text: "Snapshot file name" } - TextField { - id: syncFileNameField - Layout.fillWidth: true - text: String(root.draftValue("syncFileName", "")) - placeholderText: "Defaults to .json" - foreground: root.foreground - onTextChanged: if (text !== root.draftValue("syncFileName", "")) root.setDraftValue("syncFileName", text) - } - - FieldLabel { text: "Device id" } - TextField { - id: syncDeviceIdField - Layout.fillWidth: true - text: String(root.draftValue("syncDeviceId", "")) - placeholderText: "Optional stable name for this machine" - foreground: root.foreground - onTextChanged: if (text !== root.draftValue("syncDeviceId", "")) root.setDraftValue("syncDeviceId", text) - } - } - } - } - - Text { - visible: root.settingsStatusText !== "" - Layout.fillWidth: true - text: root.settingsStatusText - color: dim - font.family: fontFamily - font.pixelSize: 10 - horizontalAlignment: Text.AlignHCenter - } - - Text { - Layout.fillWidth: true - text: "s saves · esc closes" - color: dim - font.family: fontFamily - font.pixelSize: 10 - horizontalAlignment: Text.AlignHCenter - } - } - - component FieldLabel: Text { - color: dim - font.family: fontFamily - font.pixelSize: 10 - font.bold: true - } - - component Header: RowLayout { - property var provider: null - visible: !!provider - Layout.fillWidth: true - spacing: 8 - - Image { - source: root.iconSourceForProvider(provider, root.background) - Layout.preferredWidth: 16 - Layout.preferredHeight: 16 - sourceSize.width: 16 - sourceSize.height: 16 - fillMode: Image.PreserveAspectFit - Layout.alignment: Qt.AlignVCenter - } - - Text { - text: provider ? provider.providerName + " Usage" : "" - color: foreground - font.family: fontFamily - font.pixelSize: 15 - font.bold: true - Layout.fillWidth: true - Layout.alignment: Qt.AlignVCenter - } - - Button { - visible: provider && String(provider.tierLabel || "") !== "" - text: provider ? provider.tierLabel : "" - foreground: root.foreground - tooltipBackground: root.background - tooltipForeground: root.foreground - fontFamily: root.fontFamily - fontSize: 10 - horizontalPadding: 6 - verticalPadding: 3 - active: true - enabled: false - } - - Button { - text: (root.refreshFlash || usageMain.refreshing) ? "Refreshing…" : "Refresh" - foreground: root.foreground - tooltipText: (root.refreshFlash || usageMain.refreshing) ? "Refreshing usage…" : "Refresh usage" - tooltipBackground: root.background - tooltipForeground: root.foreground - fontFamily: root.fontFamily - fontSize: 10 - horizontalPadding: 8 - verticalPadding: 4 - active: root.refreshFlash || usageMain.refreshing - onClicked: { - root.triggerRefresh() - keyCatcher.forceActiveFocus() - } - } - } - - component StatusCard: SectionCard { - property var provider: null - visible: !!provider && String(provider.usageStatusText || "") !== "" - titleColor: urgent - title: provider ? provider.usageStatusText : "" - subtitle: provider ? provider.authHelpText : "" - } - - component RateLimitCard: SectionCard { - id: rateLimitCard - property var provider: null - visible: !!provider && ((provider.rateLimitPercent >= 0) || (provider.secondaryRateLimitPercent >= 0)) - title: "Rate Limit Usage" - - ColumnLayout { - width: parent.width - spacing: 10 - ProgressRow { - visible: provider && provider.rateLimitPercent >= 0 - label: provider ? provider.rateLimitLabel : "" - value: provider ? provider.rateLimitPercent : -1 - resetText: provider && provider.rateLimitResetAt ? "Resets in " + provider.formatResetTime(provider.rateLimitResetAt) : "" - } - ProgressRow { - visible: provider && provider.secondaryRateLimitPercent >= 0 - label: provider ? provider.secondaryRateLimitLabel : "" - value: provider ? provider.secondaryRateLimitPercent : -1 - resetText: provider && provider.secondaryRateLimitResetAt ? "Resets in " + provider.formatResetTime(provider.secondaryRateLimitResetAt) : "" - } - PaceRow { provider: rateLimitCard.provider } - } - } - - component TodayCard: SectionCard { - property var provider: null - visible: !!provider && provider.ready && provider.hasLocalStats - title: "Today" - - ColumnLayout { - width: parent.width - spacing: 8 - RowLayout { - Layout.fillWidth: true - spacing: 20 - StatBlock { value: provider ? String(provider.todayPrompts || 0) : "0"; label: "prompts" } - StatBlock { value: provider ? String(provider.todaySessions || 0) : "0"; label: "sessions" } - } - Repeater { - model: { - var toks = provider ? (provider.todayTokensByModel || {}) : {} - var out = [] - for (var k in toks) out.push({ modelId: k, count: toks[k] }) - return out - } - delegate: RowLayout { - required property var modelData - Layout.fillWidth: true - Text { text: usageMain.friendlyModelName(modelData.modelId); color: dim; font.family: fontFamily; font.pixelSize: 11 } - Item { Layout.fillWidth: true } - Text { text: usageMain.formatTokenCount(modelData.count) + " tokens"; color: foreground; font.family: fontFamily; font.pixelSize: 11; font.bold: true } - } - } - } - } - - component WeekCard: SectionCard { - property var provider: null - visible: !!provider && provider.recentDays && provider.recentDays.length > 0 - title: "Last 7 Days" - - ColumnLayout { - width: parent.width - spacing: 6 - Repeater { - model: provider ? provider.recentDays : [] - delegate: RowLayout { - required property var modelData - Layout.fillWidth: true - spacing: 8 - readonly property real count: modelData ? Number(modelData.messageCount || 0) : 0 - readonly property real maxCount: { - var days = provider ? (provider.recentDays || []) : [] - var max = 1 - for (var i = 0; i < days.length; i++) if (Number(days[i].messageCount || 0) > max) max = Number(days[i].messageCount || 0) - return max - } - Text { - text: { - var d = modelData.date - if (!d) return "" - var dt = new Date(d + "T00:00:00") - var names = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"] - return names[dt.getDay()] + " " + String(dt.getMonth() + 1).padStart(2, "0") + "/" + String(dt.getDate()).padStart(2, "0") - } - color: dim - font.family: fontFamily - font.pixelSize: 10 - Layout.preferredWidth: 48 - } - Rectangle { - Layout.fillWidth: true - Layout.preferredHeight: 10 - color: track - radius: Math.max(1, Style.cornerRadius / 3) - Rectangle { - anchors.left: parent.left - anchors.top: parent.top - anchors.bottom: parent.bottom - width: parent.width * (count / maxCount) - color: root.alpha(foreground, 0.78) - radius: Math.max(1, Style.cornerRadius / 3) - Behavior on width { NumberAnimation { duration: 160; easing.type: Easing.OutCubic } } - } - } - Text { - text: usageMain.formatTokenCount(count) - color: foreground - font.family: fontFamily - font.pixelSize: 10 - font.bold: true - horizontalAlignment: Text.AlignRight - Layout.preferredWidth: 48 - } - } - } - } - } - - component AllTimeCard: SectionCard { - property var provider: null - visible: { - var usage = provider ? (provider.modelUsage || {}) : {} - return Object.keys(usage).length > 0 - } - title: "All-Time" - - ColumnLayout { - width: parent.width - spacing: 8 - RowLayout { - Layout.fillWidth: true - spacing: 20 - StatBlock { value: provider ? usageMain.formatTokenCount(provider.totalPrompts || 0) : "0"; label: "messages" } - StatBlock { value: provider ? String(provider.totalSessions || 0) : "0"; label: "sessions" } - } - PanelSeparator { Layout.fillWidth: true; foreground: root.foreground; strength: 0.18 } - Repeater { - model: { - var usage = provider ? (provider.modelUsage || {}) : {} - var out = [] - for (var k in usage) out.push({ modelId: k, data: usage[k] }) - return out - } - delegate: ColumnLayout { - required property var modelData - Layout.fillWidth: true - spacing: 4 - Text { text: usageMain.friendlyModelName(modelData.modelId); color: foreground; font.family: fontFamily; font.pixelSize: 11; font.bold: true } - GridLayout { - Layout.leftMargin: 10 - columns: 2 - columnSpacing: 18 - rowSpacing: 2 - DetailPair { name: "Input"; value: usageMain.formatTokenCount(modelData.data.inputTokens || 0) } - DetailPair { name: "Output"; value: usageMain.formatTokenCount(modelData.data.outputTokens || 0) } - DetailPair { name: "Cache Read"; value: usageMain.formatTokenCount(modelData.data.cacheReadInputTokens || 0) } - DetailPair { name: "Cache Write"; value: usageMain.formatTokenCount(modelData.data.cacheCreationInputTokens || 0) } - } - } - } - } - } - - component SectionCard: BorderSurface { - id: section - property string title: "" - property string subtitle: "" - property color titleColor: foreground - default property alias content: body.data - - Layout.fillWidth: true - color: card - borderSpec: Border.flat(Qt.rgba(foreground.r, foreground.g, foreground.b, 0.05), 1) - padding: 12 - radius: Style.cornerRadius - implicitHeight: body.implicitHeight + contentTopInset + contentBottomInset - - ColumnLayout { - id: body - anchors.left: parent.left - anchors.right: parent.right - anchors.top: parent.top - anchors.topMargin: section.contentTopInset - anchors.rightMargin: section.contentRightInset - anchors.bottomMargin: section.contentBottomInset - anchors.leftMargin: section.contentLeftInset - spacing: 8 - - PanelSectionHeader { - visible: section.title !== "" - Layout.fillWidth: true - text: section.title - foreground: section.titleColor - fontFamily: root.fontFamily - fontSize: 11 - } - Text { - visible: section.subtitle !== "" - Layout.fillWidth: true - text: section.subtitle - color: dim - font.family: fontFamily - font.pixelSize: 10 - wrapMode: Text.WordWrap - } - } - } - - component PaceRow: ColumnLayout { - property var provider: null - readonly property var pace: root.paceInfo(provider) - - visible: pace.text !== "" - spacing: 2 - Layout.fillWidth: true - - RowLayout { - Layout.fillWidth: true - Text { - text: "Pace" - color: dim - font.family: fontFamily - font.pixelSize: 10 - } - Item { Layout.fillWidth: true } - Text { - text: pace.text - color: pace.deficit ? urgent : foreground - font.family: fontFamily - font.pixelSize: 10 - font.bold: true - } - } - - Text { - Layout.fillWidth: true - text: pace.detail - color: dim - font.family: fontFamily - font.pixelSize: 10 - horizontalAlignment: Text.AlignRight - } - } - - component ProgressRow: ColumnLayout { - property string label: "" - property real value: -1 - property string resetText: "" - spacing: 5 - Layout.fillWidth: true - - RowLayout { - Layout.fillWidth: true - Text { text: label; color: dim; font.family: fontFamily; font.pixelSize: 11 } - Item { Layout.fillWidth: true } - Text { - text: value < 0 ? "—" : Math.round(value * 100) + "%" - color: value >= 0.9 ? urgent : foreground - font.family: fontFamily - font.pixelSize: 11 - font.bold: true - } - } - Rectangle { - Layout.fillWidth: true - Layout.preferredHeight: 8 - color: track - radius: Math.max(1, Style.cornerRadius / 3) - Rectangle { - anchors.left: parent.left - anchors.top: parent.top - anchors.bottom: parent.bottom - width: parent.width * root.clamp(value, 0, 1) - color: value >= 0.9 ? root.alpha(urgent, 0.72) : root.alpha(foreground, 0.78) - radius: Math.max(1, Style.cornerRadius / 3) - Behavior on width { NumberAnimation { duration: 160; easing.type: Easing.OutCubic } } - } - } - Text { visible: resetText !== ""; text: resetText; color: dim; font.family: fontFamily; font.pixelSize: 10 } - } - - component StatBlock: ColumnLayout { - property string value: "0" - property string label: "" - spacing: 2 - Text { text: value; color: foreground; font.family: fontFamily; font.pixelSize: 18; font.bold: true } - Text { text: label; color: dim; font.family: fontFamily; font.pixelSize: 10 } - } - - component DetailPair: RowLayout { - property string name: "" - property string value: "" - Text { text: name; color: dim; font.family: fontFamily; font.pixelSize: 10; Layout.preferredWidth: 76 } - Text { text: value; color: foreground; font.family: fontFamily; font.pixelSize: 10; font.bold: true } - } -} diff --git a/shell/plugins/model-usage/manifest.json b/shell/plugins/model-usage/manifest.json index d59c737b..2c294393 100644 --- a/shell/plugins/model-usage/manifest.json +++ b/shell/plugins/model-usage/manifest.json @@ -5,15 +5,15 @@ "version": "1.0.0", "author": "Omarchy", "license": "MIT", - "description": "Claude Code and Codex usage stats in a native Omarchy bar popup.", + "description": "Claude Code and Codex usage, limits, and pace in a native Omarchy bar panel.", "kinds": ["bar-widget"], "activation": "on-demand", "entryPoints": { - "barWidget": "Widget.qml" + "barWidget": "Panel.qml" }, "barWidget": { "displayName": "Model Usage", - "description": "Shows AI coding assistant usage stats with a tabbed popup panel.", + "description": "One bar icon and one panel: rate-limit meters with pace, today, the last week, and the all-time model breakdown for every subscription.", "category": "AI", "aliases": ["model-usage"], "allowMultiple": false, diff --git a/shell/plugins/model-usage/providers/Claude.qml b/shell/plugins/model-usage/providers/Claude.qml index b9a47e64..7ef19dc3 100644 --- a/shell/plugins/model-usage/providers/Claude.qml +++ b/shell/plugins/model-usage/providers/Claude.qml @@ -30,12 +30,17 @@ Item { property var recentDays: [] property int totalPrompts: 0 property int totalSessions: 0 + property int activeDays: 0 + property var activeDates: [] property var modelUsage: ({}) property var dailyActivity: [] property string tierLabel: "" property string authHelpText: "Run `claude auth login` to restore authoritative usage." property bool hasLocalStats: true + // Optimistic until the probe answers, so a present provider never blinks + // out of the panel on startup. + property bool installed: true property bool hasProjectStats: false property string oauthAccessToken: "" @@ -99,6 +104,17 @@ Item { } } + // Claude Code is "here" if its state directory or CLI exists. Cheap enough + // to re-run on every refresh, so installing it mid-session shows up. + Process { + id: presenceProbe + running: true + command: ["bash", "-c", "[[ -d \"$HOME/.claude\" ]] || command -v claude >/dev/null"] + onExited: function (exitCode) { + root.installed = exitCode === 0; + } + } + Process { id: projectScanner running: false @@ -155,6 +171,8 @@ Item { root.dailyActivity = data.dailyActivity ?? []; root.recentDays = root.dailyActivity.slice(-7); + if (!root.hasProjectStats) + root.applyFallbackActiveDays(root.dailyActivity); root.modelUsage = data.modelUsage ?? {}; root.totalPrompts = data.totalMessages ?? 0; root.totalSessions = data.totalSessions ?? 0; @@ -164,6 +182,21 @@ Item { } } + // stats-cache.json has no day count of its own, so recover one from the + // daily activity it does carry. The project scan overrides this when it + // finds transcripts. + function applyFallbackActiveDays(dailyActivity) { + const days = Array.isArray(dailyActivity) ? dailyActivity : []; + const dates = []; + for (var i = 0; i < days.length; i++) { + const day = days[i] || {}; + if (Number(day.messageCount || 0) > 0 && day.date) + dates.push(String(day.date)); + } + root.activeDates = dates; + root.activeDays = dates.length; + } + function applyProjectUsageSummary(content) { try { const data = JSON.parse(String(content || "{}")); @@ -180,6 +213,8 @@ Item { root.modelUsage = data.modelUsage || ({}); root.totalPrompts = prompts; root.totalSessions = Math.max(0, Number(data.totalSessions || 0)); + root.activeDays = Math.max(0, Number(data.activeDays || 0)); + root.activeDates = data.activeDates || []; root.dailyActivity = data.dailyActivity || root.recentDays; root.ready = true; } catch (e) { @@ -447,6 +482,8 @@ Item { function refresh(force) { root.refreshing = true; + if (!presenceProbe.running) + presenceProbe.running = true; statsFile.reload(); historyFile.reload(); credentialsFile.reload(); diff --git a/shell/plugins/model-usage/providers/Codex.qml b/shell/plugins/model-usage/providers/Codex.qml index 5a0ad6db..f3070df4 100644 --- a/shell/plugins/model-usage/providers/Codex.qml +++ b/shell/plugins/model-usage/providers/Codex.qml @@ -29,10 +29,15 @@ Item { property var recentDays: [] property int totalPrompts: 0 property int totalSessions: 0 + property int activeDays: 0 + property var activeDates: [] property var modelUsage: ({}) property string tierLabel: "" property string usageStatusText: "" + // Optimistic until the probe answers, so a present provider never blinks + // out of the panel on startup. + property bool installed: true property string authHelpText: "Run `codex login` to authenticate." property bool hasLocalStats: true @@ -41,6 +46,17 @@ Item { readonly property string scannerPath: String(Qt.resolvedUrl("../scripts/codex_usage_scanner.py")).replace("file://", "") + // Codex is "here" if its state directory, its session store, or the CLI + // exists. Re-runs on refresh so installing it mid-session shows up. + Process { + id: presenceProbe + running: true + command: ["bash", "-c", "[[ -d \"$HOME/.codex\" ]] || [[ -d \"$HOME/.pi/agent/sessions\" ]] || command -v codex >/dev/null"] + onExited: function (exitCode) { + root.installed = exitCode === 0 + } + } + Process { id: usageScanner command: ["python3", root.scannerPath] @@ -73,6 +89,8 @@ Item { } function refresh(force) { + if (!presenceProbe.running) + presenceProbe.running = true if (usageScanner.running) return root.refreshing = true @@ -96,6 +114,8 @@ Item { root.recentDays = data.recentDays || [] root.totalPrompts = data.totalPrompts || 0 root.totalSessions = data.totalSessions || 0 + root.activeDays = data.activeDays || 0 + root.activeDates = data.activeDates || [] root.modelUsage = data.modelUsage || ({}) root.rateLimitPercent = data.rateLimitPercent ?? -1 diff --git a/shell/plugins/model-usage/scripts/claude_usage_scanner.py b/shell/plugins/model-usage/scripts/claude_usage_scanner.py index 71d0a9e8..bfc19f0a 100755 --- a/shell/plugins/model-usage/scripts/claude_usage_scanner.py +++ b/shell/plugins/model-usage/scripts/claude_usage_scanner.py @@ -94,6 +94,7 @@ def scan(projects_path: Path) -> dict[str, Any]: seen: set[str] = set() sessions: set[str] = set() + active_days: set[str] = set() today_sessions: set[str] = set() today_tokens: dict[str, int] = {} usage_by_model: dict[str, dict[str, int]] = {} @@ -145,6 +146,7 @@ def scan(projects_path: Path) -> dict[str, Any]: day = local_date_from_timestamp(entry.get("timestamp") or message.get("timestamp")) session_key = str(entry.get("sessionId") or path) sessions.add(session_key) + active_days.add(day) prompts += 1 bucket = usage_by_model.setdefault(model, empty_bucket()) @@ -177,6 +179,11 @@ def scan(projects_path: Path) -> dict[str, Any]: "modelUsage": usage_by_model, "totalPrompts": prompts, "totalSessions": len(sessions), + # Days with any recorded usage, for the all-time "N days" summary. + # The dates travel too: merging snapshots from several machines needs + # their union, which a count alone cannot give. + "activeDays": len(active_days), + "activeDates": sorted(active_days), "dailyActivity": recent_days, "scannedFiles": scanned_files, "malformedLines": malformed_lines, diff --git a/shell/plugins/model-usage/scripts/codex_usage_scanner.py b/shell/plugins/model-usage/scripts/codex_usage_scanner.py index 86440221..44804c6a 100644 --- a/shell/plugins/model-usage/scripts/codex_usage_scanner.py +++ b/shell/plugins/model-usage/scripts/codex_usage_scanner.py @@ -71,6 +71,7 @@ today_tokens_by_model = {} model_usage = {} sessions_by_day = {day: set() for day in recent_dates} today_sessions = set() +active_days = set() today_prompts = 0 today_total_tokens = 0 @@ -86,6 +87,7 @@ def add_usage(day, session_key, model, input_tokens, output_tokens, cache_read, total = input_tokens + output_tokens + cache_read + cache_write total_prompts += 1 total_sessions.add(session_key) + active_days.add(day) bucket = model_usage.setdefault(model, { "inputTokens": 0, @@ -336,6 +338,11 @@ out = { "recentDays": [recent[day] for day in recent_dates], "totalPrompts": total_prompts, "totalSessions": len(total_sessions), + # Days with any recorded usage, for the all-time "N days" summary. + # The dates travel too: merging snapshots from several machines needs + # their union, which a count alone cannot give. + "activeDays": len(active_days), + "activeDates": sorted(active_days), "modelUsage": model_usage, "usageStatusText": usage_status, "authHelpText": usage_help,