Refresh the look and layout of the model-usage plugin
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user