* Show the per-model weekly limit Claude's usage endpoint reports Model-scoped allowances arrive in the payload's limits array, not in the seven_day_<model> buckets, which come back null. Read them so a window like Fable's own weekly limit stops being spent against invisibly. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Read every model-scoped window, and title it for what it is A model can hold more than one scoped window, so keying the dedupe on the model alone dropped whichever came second — including a fuller one that decides the headline. The model and the window kind together make the key, and both make the title, so one model's two rows read apart. The panel guesses a window out of the label, and that guess cannot survive a model name: "Opus 5 (1M context)" parses as a one-minute window and renders as a second "Session". The collector states the title outright now and the panel takes it, eliding a long one rather than running it into the percentage. Scoped percentages are read on whatever scale the payload speaks, the way the flat buckets already are, rather than assuming percentages, and a model that names only an id still names a window worth showing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: David Heinemeier Hansson <david@hey.com>
926 lines
31 KiB
QML
926 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.agents"
|
|
ipcTarget: "omarchy.agents"
|
|
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 and "updated" 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 var balance: provider ? (provider.balance || null) : null
|
|
// A prepaid account runs low the way a subscription window fills up: the
|
|
// last 10% of the funded credits lights the same alarm.
|
|
readonly property bool balanceAlarming: !!balance && balance.funded > 0
|
|
&& balance.remaining / balance.funded <= 0.1
|
|
readonly property bool alarming: (!!headline && headline.percent >= 0.9) || balanceAlarming
|
|
|
|
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 and the hero 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
|
|
}
|
|
|
|
// A collector that already knows which window a limit belongs to says so,
|
|
// and that beats reading it back out of the label: a model-scoped limit is
|
|
// titled after its model, and a name like "Opus 5 (1M context)" would parse
|
|
// as a one-minute window.
|
|
function limitWindow(label, percent, resetAt, title) {
|
|
return {
|
|
title: String(title || "") !== "" ? String(title) : windowTitle(label),
|
|
percent: Number(percent),
|
|
resetAt: String(resetAt || "")
|
|
}
|
|
}
|
|
|
|
function limitWindows(p) {
|
|
if (!p) return []
|
|
var out = []
|
|
var list = p.limits || []
|
|
for (var i = 0; i < list.length; i++) {
|
|
var entry = list[i] || {}
|
|
var percent = Number(entry.percent)
|
|
if (percent >= 0) out.push(limitWindow(entry.label, percent, entry.resetsAt, entry.title))
|
|
}
|
|
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
|
|
}
|
|
|
|
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"
|
|
}
|
|
|
|
// ---------------------------------------------------------------- balance
|
|
//
|
|
// Prepaid agents report a credit ledger instead of rate-limit windows: the
|
|
// record's balance object carries remaining, funded, and spent amounts.
|
|
|
|
function currencyPrefix(currency) {
|
|
var code = String(currency || "USD").toUpperCase()
|
|
if (code === "USD") return "$"
|
|
if (code === "EUR") return "€"
|
|
if (code === "GBP") return "£"
|
|
return code + " "
|
|
}
|
|
|
|
function formatMoney(value, currency) {
|
|
var amount = Number(value)
|
|
if (!isFinite(amount)) amount = 0
|
|
return currencyPrefix(currency) + amount.toFixed(2)
|
|
}
|
|
|
|
function balanceDetailText(b) {
|
|
if (!b || !(b.funded > 0)) return ""
|
|
var text = formatMoney(b.spent, b.currency) + " spent of " + formatMoney(b.funded, b.currency) + " funded"
|
|
if (b.estimated) text += " · estimated"
|
|
return text
|
|
}
|
|
|
|
// ---------------------------------------------------------------- content
|
|
|
|
// The plan you pay for, under the name of the tool it pays for. Limits 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. Billing-API agents never
|
|
// count prompts, and "0 prompts" would read as a quiet day, not a gap.
|
|
if (today && provider && provider.hasPromptStats !== false)
|
|
text += " · " + Number(provider.todayPrompts || 0) + " prompts · "
|
|
+ Number(provider.todaySessions || 0) + " sessions"
|
|
return text
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
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 ""
|
|
}
|
|
|
|
// Agents that ship a white mark carry an `assets/<id>-light.svg` twin for
|
|
// light surfaces; marks that work on both (Claude's brand-orange) ship one
|
|
// file. The luminance check decides which candidate to try first.
|
|
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)
|
|
}
|
|
|
|
// Marks resolve by convention, so a new agent's data file needs nothing
|
|
// from this panel: assets/<id>.svg if it ships one, the module's bar glyph
|
|
// if it doesn't.
|
|
function iconCandidatesForProvider(p, surfaceColor) {
|
|
if (!p) return []
|
|
var candidates = []
|
|
if (colorLuminance(surfaceColor || Color.background) >= 0.5)
|
|
candidates.push(Qt.resolvedUrl("assets/" + p.providerId + "-light.svg"))
|
|
candidates.push(Qt.resolvedUrl("assets/" + p.providerId + ".svg"))
|
|
return candidates
|
|
}
|
|
|
|
// 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.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 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 {
|
|
Item {
|
|
id: heroMark
|
|
property var candidates: root.iconCandidatesForProvider(root.provider, root.surface)
|
|
// Provider objects are rebuilt on every refresh, which churns the
|
|
// array's identity without changing its content. Restart the fallback
|
|
// walk only when the URLs change: re-pointing source at a URL whose
|
|
// load already failed emits no statusChanged, so an identity-only
|
|
// reset would strand the walker on a missing -light twin.
|
|
property string candidatesKey: candidates.join("\n")
|
|
property int candidateIndex: 0
|
|
onCandidatesKeyChanged: candidateIndex = 0
|
|
|
|
width: Style.font.display
|
|
height: Style.font.display
|
|
|
|
Image {
|
|
id: heroMarkImage
|
|
anchors.fill: parent
|
|
source: heroMark.candidateIndex < heroMark.candidates.length ? heroMark.candidates[heroMark.candidateIndex] : ""
|
|
sourceSize.width: Style.font.display * 2
|
|
sourceSize.height: Style.font.display * 2
|
|
fillMode: Image.PreserveAspectFit
|
|
// Advancing source from inside its own status change trips the
|
|
// binding-loop detector; defer the step one tick.
|
|
onStatusChanged: if (status === Image.Error && heroMark.candidateIndex < heroMark.candidates.length)
|
|
Qt.callLater(function() { heroMark.candidateIndex++ })
|
|
}
|
|
|
|
Text {
|
|
anchors.centerIn: parent
|
|
visible: heroMarkImage.status !== Image.Ready
|
|
text: button.text
|
|
color: root.foreground
|
|
font.family: root.fontFamily
|
|
font.pixelSize: Style.font.display
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Text {
|
|
visible: root.providers.length === 0
|
|
width: parent.width
|
|
topPadding: Style.space(24)
|
|
text: "No AI coding subscriptions found.\nAgents 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
|
|
}
|
|
}
|
|
|
|
// ---------- Balance / limits ----------
|
|
PanelSeparator {
|
|
visible: balanceSection.visible || limitsSection.visible
|
|
foreground: root.foreground
|
|
}
|
|
|
|
Column {
|
|
id: balanceSection
|
|
visible: !!root.balance
|
|
width: parent.width
|
|
spacing: Style.space(10)
|
|
|
|
// The meter shows what is left, not what is used: a prepaid
|
|
// account drains toward empty rather than filling toward a cap.
|
|
readonly property real ratio: root.balance && root.balance.funded > 0
|
|
? root.clamp(root.balance.remaining / root.balance.funded, 0, 1)
|
|
: -1
|
|
|
|
PanelSectionHeader {
|
|
width: parent.width
|
|
text: "BALANCE"
|
|
foreground: root.foreground
|
|
fontFamily: root.fontFamily
|
|
}
|
|
|
|
Item {
|
|
width: parent.width
|
|
implicitHeight: Math.max(balanceLabel.implicitHeight, balanceValue.implicitHeight)
|
|
|
|
Text {
|
|
id: balanceLabel
|
|
text: "Prepaid credits"
|
|
color: root.foreground
|
|
font.family: root.fontFamily
|
|
font.pixelSize: Style.font.body
|
|
anchors.left: parent.left
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
}
|
|
|
|
Text {
|
|
id: balanceValue
|
|
text: root.balance ? root.formatMoney(root.balance.remaining, root.balance.currency) : ""
|
|
color: root.balanceAlarming ? root.urgent : root.foreground
|
|
font.family: root.fontFamily
|
|
font.pixelSize: Style.font.caption
|
|
anchors.right: parent.right
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
}
|
|
}
|
|
|
|
Meter {
|
|
visible: balanceSection.ratio >= 0
|
|
width: parent.width
|
|
value: balanceSection.ratio
|
|
alarming: root.balanceAlarming
|
|
}
|
|
|
|
Text {
|
|
visible: text !== ""
|
|
width: parent.width
|
|
text: root.balanceDetailText(root.balance)
|
|
color: root.dim
|
|
font.family: root.fontFamily
|
|
font.pixelSize: Style.font.caption
|
|
}
|
|
}
|
|
|
|
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))
|
|
|
|
PanelSectionHeader {
|
|
width: parent.width
|
|
text: "TOKENS BY DAY"
|
|
foreground: root.foreground
|
|
fontFamily: root.fontFamily
|
|
}
|
|
|
|
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
|
|
|
|
PanelSectionHeader {
|
|
width: parent.width
|
|
text: "TOKENS BY MODEL"
|
|
foreground: root.foreground
|
|
fontFamily: root.fontFamily
|
|
}
|
|
|
|
Repeater {
|
|
model: root.models
|
|
|
|
ModelRow {
|
|
required property var modelData
|
|
width: modelSection.width
|
|
row: modelData
|
|
// Scaled to the heaviest model, so the top row is always full —
|
|
// the same scale-to-peak the weekly chart uses for its busiest day.
|
|
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 and percentage, meter, and reset countdown.
|
|
component LimitRow: Column {
|
|
id: limitRow
|
|
property var window: null
|
|
|
|
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
|
|
// A model-scoped window is titled after its model, and those names run
|
|
// long enough to reach the percentage, so the title gives way first.
|
|
text: limitRow.window ? limitRow.window.title : ""
|
|
color: root.foreground
|
|
font.family: root.fontFamily
|
|
font.pixelSize: Style.font.body
|
|
elide: Text.ElideRight
|
|
anchors.left: parent.left
|
|
anchors.right: limitValue.left
|
|
anchors.rightMargin: Style.spacing.sm
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
}
|
|
|
|
Text {
|
|
id: limitValue
|
|
text: limitRow.window && limitRow.window.percent >= 0
|
|
? Math.round(limitRow.window.percent * 100) + "%"
|
|
: "—"
|
|
color: limitRow.alarming ? root.urgent : root.foreground
|
|
font.family: root.fontFamily
|
|
font.pixelSize: Style.font.caption
|
|
anchors.right: parent.right
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
}
|
|
}
|
|
|
|
Meter {
|
|
width: parent.width
|
|
value: limitRow.window ? limitRow.window.percent : -1
|
|
alarming: limitRow.alarming
|
|
}
|
|
|
|
Text {
|
|
id: resetText
|
|
width: parent.width
|
|
text: {
|
|
var remainingMs = root.resetMsFor(limitRow.window)
|
|
return remainingMs > 0 ? "Resets in " + root.formatDuration(remainingMs) : ""
|
|
}
|
|
color: root.dim
|
|
font.family: root.fontFamily
|
|
font.pixelSize: Style.font.caption
|
|
}
|
|
}
|
|
|
|
// Rounded track showing the percentage of the allowance used.
|
|
component Meter: Item {
|
|
id: meter
|
|
property real value: -1
|
|
property bool alarming: false
|
|
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 }
|
|
}
|
|
}
|
|
|
|
}
|
|
|
|
// 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
|
|
}
|
|
}
|
|
}
|