Files
omarchycn/shell/plugins/model-usage/Panel.qml
T
David Heinemeier HanssonandClaude Opus 5 24dc914d28 Recover model-usage limits from a boot-time probe failure
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>
2026-07-28 11:00:43 -07:00

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
}
}
}