The shell starts before DHCP finishes, so Claude's first probe of the OAuth usage endpoint came back as a transport failure and pinned "Claude limits unavailable" for a full refresh interval. Opening the panel could not clear it either: the open path refreshed unforced and hit the same throttle it was trying to escape. A transport failure now arms a short retry that goes straight to the probe, since a retry answering to the throttle never gets off the ground. Any real answer disarms it — a server that replied, 429 included, is one to stop pestering. Probes are single-flight, a fresh token skips the throttle its predecessor set, and opening the panel asks for the wire without re-walking every transcript on disk. The module also earns its place in the bar now instead of sitting there dimmed with nothing to say: it collapses out entirely until a provider has actually recorded usage, here or on a synced machine. That made both presence probes redundant, so they are gone. Selection follows the provider rather than its slot, so one appearing while the panel is open no longer swaps out what you were reading. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
949 lines
31 KiB
QML
949 lines
31 KiB
QML
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
|
|
// The selection follows the provider, not the slot it happens to sit in: a
|
|
// provider whose first scan lands while the panel is open would otherwise
|
|
// shift the list underneath you and swap out what you were reading.
|
|
property string selectedProviderId: ""
|
|
readonly property int providerIndex: {
|
|
for (var i = 0; i < providers.length; i++)
|
|
if (providers[i].providerId === selectedProviderId) return i
|
|
return 0
|
|
}
|
|
readonly property var provider: providers.length > 0 ? providers[providerIndex] : 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) return
|
|
var wrapped = ((index % providers.length) + providers.length) % providers.length
|
|
selectedProviderId = providers[wrapped].providerId
|
|
}
|
|
|
|
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 ""
|
|
}
|
|
|
|
// Nothing to report, nothing in the bar: Bar.qml collapses a slot whose item
|
|
// is invisible, so the icon appears the moment the first scan finds usage and
|
|
// stays away entirely on a machine that has never run either CLI.
|
|
visible: providers.length > 0
|
|
implicitWidth: button.implicitWidth
|
|
implicitHeight: button.implicitHeight
|
|
|
|
onProviderIndexChanged: if (panelFlick) panelFlick.contentY = 0
|
|
onOpenedChanged: if (opened) {
|
|
cursorActive = false
|
|
nowMs = Date.now()
|
|
if (panelFlick) panelFlick.contentY = 0
|
|
usage.refreshAll()
|
|
usage.refreshLimits()
|
|
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
|
|
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 you've used them."
|
|
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
|
|
}
|
|
}
|
|
}
|