Refresh the look and layout of the model-usage plugin

This commit is contained in:
David Heinemeier Hansson
2026-07-28 09:18:24 -07:00
parent 234ac7b3b6
commit 9b693cca63
11 changed files with 1153 additions and 1293 deletions
+1 -1
View File
@@ -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` |
+1 -1
View File
@@ -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 |
+43 -21
View File
@@ -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"
}
}
+941
View File
@@ -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
}
}
}
+93
View File
@@ -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 <open|close|toggle|refresh|next>`.
## 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> <value>`:
| 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` | `<hostname>.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.
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -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,
@@ -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();
@@ -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
@@ -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,
@@ -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,