Refresh the look and layout of the model-usage plugin
This commit is contained in:
@@ -27,7 +27,7 @@ User-installed plugins live alongside these conceptually but on disk under
|
|||||||
| Network | `omarchy.network` | `bar-widget` | `panels/network/Panel.qml` |
|
| Network | `omarchy.network` | `bar-widget` | `panels/network/Panel.qml` |
|
||||||
| Power | `omarchy.power` | `bar-widget` | `panels/power/Panel.qml` |
|
| Power | `omarchy.power` | `bar-widget` | `panels/power/Panel.qml` |
|
||||||
| Tailscale | `omarchy.tailscale` | `bar-widget` | `panels/tailscale/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` |
|
| Weather | `omarchy.weather` | `bar-widget` | `panels/weather/BarWidget.qml` |
|
||||||
| Media | `omarchy.media` | `service`, `bar-widget` | `services/media/Service.qml`, `services/media/BarWidget.qml` |
|
| Media | `omarchy.media` | `service`, `bar-widget` | `services/media/Service.qml`, `services/media/BarWidget.qml` |
|
||||||
| Battery | `omarchy.battery` | `service` | `services/battery/Service.qml` |
|
| Battery | `omarchy.battery` | `service` | `services/battery/Service.qml` |
|
||||||
|
|||||||
@@ -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.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.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.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.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.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 |
|
| `omarchy.monitor` | Brightness and laptop display controls | left = popup |
|
||||||
|
|||||||
@@ -26,23 +26,22 @@ Item {
|
|||||||
}
|
}
|
||||||
|
|
||||||
property var providers: [claudeProvider, codexProvider]
|
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: {
|
property var enabledProviders: {
|
||||||
var rev = syncRevision
|
var rev = syncRevision
|
||||||
var running = syncRunning
|
var running = syncRunning
|
||||||
var result = []
|
var result = []
|
||||||
if (claudeProvider.enabled) result.push(displayProvider(claudeProvider))
|
if (claudeProvider.enabled && claudeProvider.installed) result.push(displayProvider(claudeProvider))
|
||||||
if (codexProvider.enabled) result.push(displayProvider(codexProvider))
|
if (codexProvider.enabled && codexProvider.installed) result.push(displayProvider(codexProvider))
|
||||||
return result
|
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 bool refreshing: claudeProvider.refreshing || codexProvider.refreshing || syncRunning
|
||||||
property double aggregateUpdatedAtMs: aggregateData && aggregateData.updatedAtMs ? Number(aggregateData.updatedAtMs) : 0
|
property double aggregateUpdatedAtMs: aggregateData && aggregateData.updatedAtMs ? Number(aggregateData.updatedAtMs) : 0
|
||||||
property double lastRefreshedAtMs: Math.max(aggregateUpdatedAtMs, claudeProvider.lastRefreshedAtMs || 0, codexProvider.lastRefreshedAtMs || 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 int refreshIntervalSec: Math.max(30, Number(setting("refreshIntervalSec", 900)))
|
||||||
|
|
||||||
property var syncModeSetting: setting("syncMode", setting("syncEnabled", false))
|
property var syncModeSetting: setting("syncMode", setting("syncEnabled", false))
|
||||||
@@ -75,13 +74,6 @@ Item {
|
|||||||
return value === undefined || value === null ? fallback : value
|
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 {
|
Timer {
|
||||||
interval: root.refreshIntervalSec * 1000
|
interval: root.refreshIntervalSec * 1000
|
||||||
running: true
|
running: true
|
||||||
@@ -147,10 +139,6 @@ Item {
|
|||||||
onLoaded: root.detectedHostname = String(text() || "").trim()
|
onLoaded: root.detectedHostname = String(text() || "").trim()
|
||||||
}
|
}
|
||||||
|
|
||||||
onEnabledProvidersChanged: {
|
|
||||||
if (enabledProviders.length === 0 || activeIndex >= enabledProviders.length) activeIndex = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
function providerEnabled(id) {
|
function providerEnabled(id) {
|
||||||
if (!settings || !settings.providers || !settings.providers[id]) return id === "claude" || id === "codex"
|
if (!settings || !settings.providers || !settings.providers[id]) return id === "claude" || id === "codex"
|
||||||
return settings.providers[id].enabled !== false
|
return settings.providers[id].enabled !== false
|
||||||
@@ -355,6 +343,8 @@ Item {
|
|||||||
recentByDay: recentByDay,
|
recentByDay: recentByDay,
|
||||||
totalPrompts: 0,
|
totalPrompts: 0,
|
||||||
totalSessions: 0,
|
totalSessions: 0,
|
||||||
|
activeDays: 0,
|
||||||
|
activeDates: ({}),
|
||||||
modelUsage: ({}),
|
modelUsage: ({}),
|
||||||
devices: ({})
|
devices: ({})
|
||||||
}
|
}
|
||||||
@@ -378,6 +368,12 @@ Item {
|
|||||||
acc.todayTotalTokens += numberValue(stats.todayTotalTokens)
|
acc.todayTotalTokens += numberValue(stats.todayTotalTokens)
|
||||||
acc.totalPrompts += numberValue(stats.totalPrompts)
|
acc.totalPrompts += numberValue(stats.totalPrompts)
|
||||||
acc.totalSessions += numberValue(stats.totalSessions)
|
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 || {})
|
addObjectNumbers(acc.todayTokensByModel, stats.todayTokensByModel || {})
|
||||||
|
|
||||||
var recent = Array.isArray(stats.recentDays) ? stats.recentDays : []
|
var recent = Array.isArray(stats.recentDays) ? stats.recentDays : []
|
||||||
@@ -418,6 +414,7 @@ Item {
|
|||||||
recentDays: recentDays,
|
recentDays: recentDays,
|
||||||
totalPrompts: acc.totalPrompts,
|
totalPrompts: acc.totalPrompts,
|
||||||
totalSessions: acc.totalSessions,
|
totalSessions: acc.totalSessions,
|
||||||
|
activeDays: Math.max(acc.activeDays, Object.keys(acc.activeDates).length),
|
||||||
modelUsage: acc.modelUsage,
|
modelUsage: acc.modelUsage,
|
||||||
deviceCount: providerDevices.length,
|
deviceCount: providerDevices.length,
|
||||||
devices: providerDevices
|
devices: providerDevices
|
||||||
@@ -447,6 +444,8 @@ Item {
|
|||||||
recentDays: cloneValue(provider.recentDays, []),
|
recentDays: cloneValue(provider.recentDays, []),
|
||||||
totalPrompts: numberValue(provider.totalPrompts),
|
totalPrompts: numberValue(provider.totalPrompts),
|
||||||
totalSessions: numberValue(provider.totalSessions),
|
totalSessions: numberValue(provider.totalSessions),
|
||||||
|
activeDays: numberValue(provider.activeDays),
|
||||||
|
activeDates: cloneValue(provider.activeDates, []),
|
||||||
modelUsage: cloneValue(provider.modelUsage, ({}))
|
modelUsage: cloneValue(provider.modelUsage, ({}))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -502,6 +501,7 @@ Item {
|
|||||||
recentDays: synced ? (stats.recentDays || []) : provider.recentDays,
|
recentDays: synced ? (stats.recentDays || []) : provider.recentDays,
|
||||||
totalPrompts: synced ? numberValue(stats.totalPrompts) : provider.totalPrompts,
|
totalPrompts: synced ? numberValue(stats.totalPrompts) : provider.totalPrompts,
|
||||||
totalSessions: synced ? numberValue(stats.totalSessions) : provider.totalSessions,
|
totalSessions: synced ? numberValue(stats.totalSessions) : provider.totalSessions,
|
||||||
|
activeDays: synced ? numberValue(stats.activeDays) : provider.activeDays,
|
||||||
modelUsage: synced ? (stats.modelUsage || ({})) : provider.modelUsage,
|
modelUsage: synced ? (stats.modelUsage || ({})) : provider.modelUsage,
|
||||||
hasLocalStats: synced ? (stats.hasLocalStats !== false) : provider.hasLocalStats,
|
hasLocalStats: synced ? (stats.hasLocalStats !== false) : provider.hasLocalStats,
|
||||||
|
|
||||||
@@ -531,12 +531,34 @@ Item {
|
|||||||
return String(n)
|
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) {
|
function friendlyModelName(id) {
|
||||||
if (!id) return "Unknown"
|
if (!id) return "Unknown"
|
||||||
var name = String(id).replace(/^claude-/, "").replace(/-\d{8}$/, "")
|
var name = String(id).replace(/^claude-/, "").replace(/-\d{8}$/, "")
|
||||||
var parts = name.split("-")
|
var parts = name.split("-")
|
||||||
if (parts.length >= 3) return parts[0].charAt(0).toUpperCase() + parts[0].slice(1) + " " + parts[1] + "." + parts[2]
|
var words = []
|
||||||
if (parts.length === 2) return parts[0].charAt(0).toUpperCase() + parts[0].slice(1) + " " + parts[1]
|
var version = []
|
||||||
return name.charAt(0).toUpperCase() + name.slice(1)
|
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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -5,15 +5,15 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"author": "Omarchy",
|
"author": "Omarchy",
|
||||||
"license": "MIT",
|
"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"],
|
"kinds": ["bar-widget"],
|
||||||
"activation": "on-demand",
|
"activation": "on-demand",
|
||||||
"entryPoints": {
|
"entryPoints": {
|
||||||
"barWidget": "Widget.qml"
|
"barWidget": "Panel.qml"
|
||||||
},
|
},
|
||||||
"barWidget": {
|
"barWidget": {
|
||||||
"displayName": "Model Usage",
|
"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",
|
"category": "AI",
|
||||||
"aliases": ["model-usage"],
|
"aliases": ["model-usage"],
|
||||||
"allowMultiple": false,
|
"allowMultiple": false,
|
||||||
|
|||||||
@@ -30,12 +30,17 @@ Item {
|
|||||||
property var recentDays: []
|
property var recentDays: []
|
||||||
property int totalPrompts: 0
|
property int totalPrompts: 0
|
||||||
property int totalSessions: 0
|
property int totalSessions: 0
|
||||||
|
property int activeDays: 0
|
||||||
|
property var activeDates: []
|
||||||
property var modelUsage: ({})
|
property var modelUsage: ({})
|
||||||
property var dailyActivity: []
|
property var dailyActivity: []
|
||||||
|
|
||||||
property string tierLabel: ""
|
property string tierLabel: ""
|
||||||
property string authHelpText: "Run `claude auth login` to restore authoritative usage."
|
property string authHelpText: "Run `claude auth login` to restore authoritative usage."
|
||||||
property bool hasLocalStats: true
|
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 bool hasProjectStats: false
|
||||||
|
|
||||||
property string oauthAccessToken: ""
|
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 {
|
Process {
|
||||||
id: projectScanner
|
id: projectScanner
|
||||||
running: false
|
running: false
|
||||||
@@ -155,6 +171,8 @@ Item {
|
|||||||
|
|
||||||
root.dailyActivity = data.dailyActivity ?? [];
|
root.dailyActivity = data.dailyActivity ?? [];
|
||||||
root.recentDays = root.dailyActivity.slice(-7);
|
root.recentDays = root.dailyActivity.slice(-7);
|
||||||
|
if (!root.hasProjectStats)
|
||||||
|
root.applyFallbackActiveDays(root.dailyActivity);
|
||||||
root.modelUsage = data.modelUsage ?? {};
|
root.modelUsage = data.modelUsage ?? {};
|
||||||
root.totalPrompts = data.totalMessages ?? 0;
|
root.totalPrompts = data.totalMessages ?? 0;
|
||||||
root.totalSessions = data.totalSessions ?? 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) {
|
function applyProjectUsageSummary(content) {
|
||||||
try {
|
try {
|
||||||
const data = JSON.parse(String(content || "{}"));
|
const data = JSON.parse(String(content || "{}"));
|
||||||
@@ -180,6 +213,8 @@ Item {
|
|||||||
root.modelUsage = data.modelUsage || ({});
|
root.modelUsage = data.modelUsage || ({});
|
||||||
root.totalPrompts = prompts;
|
root.totalPrompts = prompts;
|
||||||
root.totalSessions = Math.max(0, Number(data.totalSessions || 0));
|
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.dailyActivity = data.dailyActivity || root.recentDays;
|
||||||
root.ready = true;
|
root.ready = true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -447,6 +482,8 @@ Item {
|
|||||||
|
|
||||||
function refresh(force) {
|
function refresh(force) {
|
||||||
root.refreshing = true;
|
root.refreshing = true;
|
||||||
|
if (!presenceProbe.running)
|
||||||
|
presenceProbe.running = true;
|
||||||
statsFile.reload();
|
statsFile.reload();
|
||||||
historyFile.reload();
|
historyFile.reload();
|
||||||
credentialsFile.reload();
|
credentialsFile.reload();
|
||||||
|
|||||||
@@ -29,10 +29,15 @@ Item {
|
|||||||
property var recentDays: []
|
property var recentDays: []
|
||||||
property int totalPrompts: 0
|
property int totalPrompts: 0
|
||||||
property int totalSessions: 0
|
property int totalSessions: 0
|
||||||
|
property int activeDays: 0
|
||||||
|
property var activeDates: []
|
||||||
property var modelUsage: ({})
|
property var modelUsage: ({})
|
||||||
|
|
||||||
property string tierLabel: ""
|
property string tierLabel: ""
|
||||||
property string usageStatusText: ""
|
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 string authHelpText: "Run `codex login` to authenticate."
|
||||||
property bool hasLocalStats: true
|
property bool hasLocalStats: true
|
||||||
|
|
||||||
@@ -41,6 +46,17 @@ Item {
|
|||||||
|
|
||||||
readonly property string scannerPath: String(Qt.resolvedUrl("../scripts/codex_usage_scanner.py")).replace("file://", "")
|
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 {
|
Process {
|
||||||
id: usageScanner
|
id: usageScanner
|
||||||
command: ["python3", root.scannerPath]
|
command: ["python3", root.scannerPath]
|
||||||
@@ -73,6 +89,8 @@ Item {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function refresh(force) {
|
function refresh(force) {
|
||||||
|
if (!presenceProbe.running)
|
||||||
|
presenceProbe.running = true
|
||||||
if (usageScanner.running)
|
if (usageScanner.running)
|
||||||
return
|
return
|
||||||
root.refreshing = true
|
root.refreshing = true
|
||||||
@@ -96,6 +114,8 @@ Item {
|
|||||||
root.recentDays = data.recentDays || []
|
root.recentDays = data.recentDays || []
|
||||||
root.totalPrompts = data.totalPrompts || 0
|
root.totalPrompts = data.totalPrompts || 0
|
||||||
root.totalSessions = data.totalSessions || 0
|
root.totalSessions = data.totalSessions || 0
|
||||||
|
root.activeDays = data.activeDays || 0
|
||||||
|
root.activeDates = data.activeDates || []
|
||||||
root.modelUsage = data.modelUsage || ({})
|
root.modelUsage = data.modelUsage || ({})
|
||||||
|
|
||||||
root.rateLimitPercent = data.rateLimitPercent ?? -1
|
root.rateLimitPercent = data.rateLimitPercent ?? -1
|
||||||
|
|||||||
@@ -94,6 +94,7 @@ def scan(projects_path: Path) -> dict[str, Any]:
|
|||||||
|
|
||||||
seen: set[str] = set()
|
seen: set[str] = set()
|
||||||
sessions: set[str] = set()
|
sessions: set[str] = set()
|
||||||
|
active_days: set[str] = set()
|
||||||
today_sessions: set[str] = set()
|
today_sessions: set[str] = set()
|
||||||
today_tokens: dict[str, int] = {}
|
today_tokens: dict[str, int] = {}
|
||||||
usage_by_model: dict[str, 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"))
|
day = local_date_from_timestamp(entry.get("timestamp") or message.get("timestamp"))
|
||||||
session_key = str(entry.get("sessionId") or path)
|
session_key = str(entry.get("sessionId") or path)
|
||||||
sessions.add(session_key)
|
sessions.add(session_key)
|
||||||
|
active_days.add(day)
|
||||||
prompts += 1
|
prompts += 1
|
||||||
|
|
||||||
bucket = usage_by_model.setdefault(model, empty_bucket())
|
bucket = usage_by_model.setdefault(model, empty_bucket())
|
||||||
@@ -177,6 +179,11 @@ def scan(projects_path: Path) -> dict[str, Any]:
|
|||||||
"modelUsage": usage_by_model,
|
"modelUsage": usage_by_model,
|
||||||
"totalPrompts": prompts,
|
"totalPrompts": prompts,
|
||||||
"totalSessions": len(sessions),
|
"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,
|
"dailyActivity": recent_days,
|
||||||
"scannedFiles": scanned_files,
|
"scannedFiles": scanned_files,
|
||||||
"malformedLines": malformed_lines,
|
"malformedLines": malformed_lines,
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ today_tokens_by_model = {}
|
|||||||
model_usage = {}
|
model_usage = {}
|
||||||
sessions_by_day = {day: set() for day in recent_dates}
|
sessions_by_day = {day: set() for day in recent_dates}
|
||||||
today_sessions = set()
|
today_sessions = set()
|
||||||
|
active_days = set()
|
||||||
|
|
||||||
today_prompts = 0
|
today_prompts = 0
|
||||||
today_total_tokens = 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 = input_tokens + output_tokens + cache_read + cache_write
|
||||||
total_prompts += 1
|
total_prompts += 1
|
||||||
total_sessions.add(session_key)
|
total_sessions.add(session_key)
|
||||||
|
active_days.add(day)
|
||||||
|
|
||||||
bucket = model_usage.setdefault(model, {
|
bucket = model_usage.setdefault(model, {
|
||||||
"inputTokens": 0,
|
"inputTokens": 0,
|
||||||
@@ -336,6 +338,11 @@ out = {
|
|||||||
"recentDays": [recent[day] for day in recent_dates],
|
"recentDays": [recent[day] for day in recent_dates],
|
||||||
"totalPrompts": total_prompts,
|
"totalPrompts": total_prompts,
|
||||||
"totalSessions": len(total_sessions),
|
"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,
|
"modelUsage": model_usage,
|
||||||
"usageStatusText": usage_status,
|
"usageStatusText": usage_status,
|
||||||
"authHelpText": usage_help,
|
"authHelpText": usage_help,
|
||||||
|
|||||||
Reference in New Issue
Block a user