// The whole story behind the bar's one number. // // The bar shows the fullest window across every agent, because that is the one // about to interrupt you. This is what that number is made of: each agent's // limits with their reset times, what today cost, and where the tokens went. // // A Popover anchored under the widget, the way TrayMenu hangs off a tray icon — // the house pattern for anything that belongs to a bar item rather than to the // shell. Clicking outside closes it; so does Escape, which Popover's focus grab // handles for every popover in the shell. // // Nothing in here animates on a timer. One 30-second tick advances the clock // that "updated 4 minutes ago" and the reset countdowns read, and it only runs // while the panel is open. import QtQuick import qs.config import qs.services import qs.widgets Popover { id: root implicitWidth: Theme.popoverWidth implicitHeight: body.implicitHeight + contentPadding * 2 // Which agent's tab is showing. Empty means "whichever is first", so the // panel is never blank because a collector was switched off between opens. property string selectedId: "" readonly property var agents: AgentUsage.readyRecords readonly property var record: { const list = root.agents; if (list.length === 0) return null; for (const candidate of list) if (candidate.id === root.selectedId) return candidate; return list[0]; } // One clock for the whole panel, ticking only while it is open. Every // elapsed-time and countdown string in here reads this instead of calling // Date.now() in a binding, which would never invalidate. property double nowMs: Date.now() onVisibleChanged: { if (root.visible) { root.nowMs = Date.now(); // The local scans can be reused; the limits are what the panel is // being opened to read. AgentUsage.refreshLimits(); } } Timer { interval: 30 * 1000 running: root.visible repeat: true onTriggered: root.nowMs = Date.now() } // ── Formatting ────────────────────────────────────────────────────────── function tokenText(value: double): string { const n = Number(value) || 0; if (n >= 1e9) return (n / 1e9).toFixed(1) + "B"; if (n >= 1e6) return (n / 1e6).toFixed(1) + "M"; if (n >= 1e3) return Math.round(n / 1e3) + "k"; return String(Math.round(n)); } function percentText(fraction: real): string { return Math.round(Math.min(1, Math.max(0, Number(fraction) || 0)) * 100) + "%"; } function meterColor(fraction: real): color { const percent = (Number(fraction) || 0) * 100; if (percent >= 90) return Theme.danger; if (percent >= 75) return Theme.warn; return Theme.accent; } function parseTime(iso: string): double { const parsed = Date.parse(String(iso ?? "")); return isNaN(parsed) ? 0 : parsed; } // "Updated 4 minutes ago". A record with no timestamp says so rather than // implying it is current. function agoText(iso: string): string { const at = root.parseTime(iso); if (at <= 0) return "Never collected"; const minutes = Math.floor(Math.max(0, root.nowMs - at) / 60000); if (minutes < 1) return "Updated just now"; if (minutes === 1) return "Updated 1 minute ago"; if (minutes < 60) return `Updated ${minutes} minutes ago`; const hours = Math.floor(minutes / 60); return hours === 1 ? "Updated 1 hour ago" : `Updated ${hours} hours ago`; } // "resets 2:40 pm" for something today, "resets Thu" for something further // out. A window whose reset has passed says so rather than counting into // the negative — the collector keeps a cached limit only until its window // rolls over, so this is a record caught mid-rollover. function resetText(iso: string): string { const at = root.parseTime(iso); if (at <= 0) return ""; if (at <= root.nowMs) return "resetting"; const when = new Date(at); const sameDay = new Date(root.nowMs).toDateString() === when.toDateString(); if (sameDay) return "resets " + when.toLocaleTimeString(Qt.locale(), "h:mm ap"); return "resets " + when.toLocaleDateString(Qt.locale(), "ddd"); } function weekdayText(date: string): string { const parts = String(date ?? "").split("-"); if (parts.length !== 3) return ""; const when = new Date(Number(parts[0]), Number(parts[1]) - 1, Number(parts[2])); return when.toLocaleDateString(Qt.locale(), "ddd").slice(0, 2); } function isToday(date: string): bool { const parts = String(date ?? "").split("-"); if (parts.length !== 3) return false; const when = new Date(Number(parts[0]), Number(parts[1]) - 1, Number(parts[2])); return when.toDateString() === new Date(root.nowMs).toDateString(); } // ── Derived views of the selected record ──────────────────────────────── readonly property var limits: { const entries = root.record && Array.isArray(root.record.limits) ? root.record.limits : []; return entries.filter(entry => entry && Number.isFinite(Number(entry.percent))); } readonly property var days: { const entries = root.record && Array.isArray(root.record.recentDays) ? root.record.recentDays : []; // recentDays.messageCount is a token total, despite the legacy name the // collectors inherited. return entries.map(day => ({ date: String(day?.date ?? ""), tokens: Number(day?.messageCount) || 0 })); } readonly property double dayPeak: { let peak = 0; for (const day of root.days) peak = Math.max(peak, day.tokens); return peak; } // Top models by total tokens. Five rows is the whole point of the section: // more than that and it stops being a glance. readonly property var models: { const usage = root.record?.modelUsage; if (!usage || typeof usage !== "object") return []; const rows = []; for (const name of Object.keys(usage)) { const bucket = usage[name] || {}; const total = (Number(bucket.inputTokens) || 0) + (Number(bucket.outputTokens) || 0) + (Number(bucket.cacheReadInputTokens) || 0) + (Number(bucket.cacheCreationInputTokens) || 0); if (total > 0) rows.push({ name: name, tokens: total }); } rows.sort((a, b) => b.tokens - a.tokens); return rows.slice(0, 5); } readonly property double modelPeak: root.models.length > 0 ? root.models[0].tokens : 0 Column { id: body width: parent.width spacing: 8 // ── Agent tabs ────────────────────────────────────────────────────── // // Only worth drawing when there is a choice to make. Row { width: parent.width spacing: 6 visible: root.agents.length > 1 Repeater { model: root.agents delegate: Rectangle { id: tab required property var modelData readonly property bool current: root.record && root.record.id === tab.modelData.id width: (body.width - 6 * (root.agents.length - 1)) / root.agents.length height: 28 radius: 9 border.width: tab.current ? 1 : 0 border.color: Theme.alpha(Theme.accent, 0.3) color: tab.current ? Theme.alpha(Theme.accent, 0.14) : (tabMouse.containsMouse ? Theme.alpha(Theme.fg, Theme.hoverAlpha) : Theme.alpha(Theme.fg, 0.05)) Text { anchors.centerIn: parent text: tab.modelData.name || tab.modelData.id color: tab.current ? Theme.fg : Theme.fgDim elide: Text.ElideRight font.family: Theme.fontFamily font.pixelSize: Theme.fontSizeSmall font.weight: Font.DemiBold } MouseArea { id: tabMouse anchors.fill: parent hoverEnabled: true cursorShape: Qt.PointingHandCursor onClicked: root.selectedId = String(tab.modelData.id ?? "") } } } } // ── Who, and how current ──────────────────────────────────────────── Item { width: parent.width implicitHeight: Math.max(heroGlyph.implicitHeight, heroName.implicitHeight, tierChip.implicitHeight) Text { id: heroGlyph anchors.left: parent.left anchors.verticalCenter: parent.verticalCenter text: "\u{F1719}" // md-robot-outline color: Theme.accent font.family: Theme.fontMono font.pixelSize: Theme.fontSizeLarge } Text { id: heroName anchors.left: heroGlyph.right anchors.leftMargin: 9 anchors.right: tierChip.visible ? tierChip.left : parent.right anchors.rightMargin: 8 anchors.verticalCenter: parent.verticalCenter text: root.record?.name ?? "Agent usage" color: Theme.fg elide: Text.ElideRight font.family: Theme.fontFamily font.pixelSize: Theme.fontSize font.weight: Font.DemiBold } // The plan, when the collector could name one. It is the only thing // from the credential store allowed into a record. Rectangle { id: tierChip anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter visible: String(root.record?.tierLabel ?? "") !== "" implicitWidth: tierText.implicitWidth + 18 implicitHeight: tierText.implicitHeight + 6 radius: Theme.pillRadius border.width: 1 border.color: Theme.alpha(Theme.accent, 0.25) color: Theme.alpha(Theme.accent, 0.1) Text { id: tierText anchors.centerIn: parent text: String(root.record?.tierLabel ?? "").toUpperCase() color: Theme.accent font.family: Theme.fontFamily font.pixelSize: Math.max(8, Theme.fontSizeSmall - 2) font.weight: Font.DemiBold } } } Text { width: parent.width text: root.agoText(root.record?.updatedAt) color: Theme.fgMuted elide: Text.ElideRight font.family: Theme.fontFamily font.pixelSize: Theme.fontSizeSmall } // ── An honest word when the numbers are not authoritative ─────────── Rectangle { width: parent.width visible: String(root.record?.usageStatusText ?? "") !== "" implicitHeight: statusColumn.implicitHeight + 18 radius: Theme.cardRadius border.width: 0 color: Theme.alpha(Theme.warn, 0.12) Column { id: statusColumn anchors.left: parent.left anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter anchors.leftMargin: 10 anchors.rightMargin: 10 spacing: 3 Text { width: parent.width text: root.record?.usageStatusText ?? "" color: Theme.warn wrapMode: Text.WordWrap font.family: Theme.fontFamily font.pixelSize: Theme.fontSizeSmall font.weight: Font.DemiBold } Text { width: parent.width visible: String(root.record?.authHelpText ?? "") !== "" text: root.record?.authHelpText ?? "" color: Theme.fgDim wrapMode: Text.WordWrap font.family: Theme.fontFamily font.pixelSize: Theme.fontSizeSmall } } } // ── Limits ────────────────────────────────────────────────────────── // // Model-scoped windows sit in the same list as the flat ones: the // collector settles which window an entry belongs to and titles it, so // "Fable Weekly" reads beside "Weekly (7-day)" rather than under it. Repeater { model: root.limits delegate: Column { id: limitRow required property var modelData width: body.width topPadding: 5 spacing: 6 Item { width: parent.width implicitHeight: limitLabel.implicitHeight Text { id: limitLabel anchors.left: parent.left anchors.right: limitValue.left anchors.rightMargin: 8 text: limitRow.modelData.label ?? "Limit" color: Theme.fg elide: Text.ElideRight font.family: Theme.fontFamily font.pixelSize: Theme.fontSizeSmall font.weight: Font.DemiBold } Text { id: limitValue anchors.right: parent.right anchors.baseline: limitLabel.baseline text: { const reset = root.resetText(limitRow.modelData.resetsAt); const percent = root.percentText(limitRow.modelData.percent); return reset ? reset + " · " + percent : percent; } color: Theme.fgMuted font.family: Theme.fontFamily font.pixelSize: Theme.fontSizeSmall font.features: Theme.tabularFigures } } Rectangle { width: parent.width height: 8 radius: 4 border.width: 0 color: Theme.alpha(Theme.fg, 0.08) Rectangle { width: Math.max(0, Math.min(1, Number(limitRow.modelData.percent) || 0)) * parent.width height: parent.height radius: parent.radius border.width: 0 color: root.meterColor(limitRow.modelData.percent) } } } } Rectangle { width: parent.width visible: root.days.length > 0 || root.models.length > 0 height: 1 color: Theme.alpha(Theme.fg, 0.08) } // ── Tokens, last seven days ───────────────────────────────────────── Item { width: parent.width visible: root.dayPeak > 0 implicitHeight: dayHeader.implicitHeight + 8 + 56 Text { id: dayHeader anchors.left: parent.left text: "Tokens, last 7 days" color: Theme.fg font.family: Theme.fontFamily font.pixelSize: Theme.fontSizeSmall font.weight: Font.DemiBold } Text { anchors.right: parent.right anchors.baseline: dayHeader.baseline text: "today " + root.tokenText(root.record?.todayTotalTokens ?? 0) color: Theme.fgMuted font.family: Theme.fontFamily font.pixelSize: Theme.fontSizeSmall font.features: Theme.tabularFigures } Row { anchors.left: parent.left anchors.right: parent.right anchors.bottom: parent.bottom height: 56 spacing: 6 Repeater { model: root.days delegate: Column { id: dayColumn required property var modelData readonly property bool today: root.isToday(dayColumn.modelData.date) width: (body.width - 6 * 6) / 7 spacing: 4 Item { width: parent.width height: 56 - 4 - dayLabel.implicitHeight Rectangle { anchors.bottom: parent.bottom width: parent.width height: Math.max( dayColumn.modelData.tokens > 0 ? 2 : 0, root.dayPeak > 0 ? (dayColumn.modelData.tokens / root.dayPeak) * parent.height : 0) radius: 4 border.width: 0 color: dayColumn.today ? Theme.accent : Theme.alpha(Theme.accent, 0.35) } } Text { id: dayLabel width: parent.width text: root.weekdayText(dayColumn.modelData.date) color: dayColumn.today ? Theme.fgDim : Theme.fgMuted horizontalAlignment: Text.AlignHCenter font.family: Theme.fontFamily font.pixelSize: Math.max(8, Theme.fontSizeSmall - 2) font.weight: dayColumn.today ? Font.DemiBold : Font.Normal } } } } } // ── Where the tokens went ─────────────────────────────────────────── Text { width: parent.width visible: root.models.length > 0 topPadding: 4 text: "By model" color: Theme.fg font.family: Theme.fontFamily font.pixelSize: Theme.fontSizeSmall font.weight: Font.DemiBold } Repeater { model: root.models delegate: Item { id: modelRow required property var modelData width: body.width implicitHeight: 18 Text { id: modelName anchors.left: parent.left anchors.verticalCenter: parent.verticalCenter width: 108 text: modelRow.modelData.name color: Theme.fgDim elide: Text.ElideRight font.family: Theme.fontFamily font.pixelSize: Math.max(9, Theme.fontSizeSmall - 1) } Item { anchors.left: modelName.right anchors.leftMargin: 10 anchors.right: modelValue.left anchors.rightMargin: 10 anchors.verticalCenter: parent.verticalCenter height: 6 Rectangle { width: root.modelPeak > 0 ? Math.max(2, (modelRow.modelData.tokens / root.modelPeak) * parent.width) : 0 height: parent.height radius: 3 border.width: 0 color: Theme.alpha(Theme.accentAlt, 0.55) } } Text { id: modelValue anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter width: 52 text: root.tokenText(modelRow.modelData.tokens) color: Theme.fgDim horizontalAlignment: Text.AlignRight font.family: Theme.fontFamily font.pixelSize: Math.max(9, Theme.fontSizeSmall - 1) font.features: Theme.tabularFigures } } } // ── Sessions, when that is all there is ───────────────────────────── Text { width: parent.width visible: root.dayPeak <= 0 && root.models.length === 0 && root.record !== null text: `Sessions today: ${root.record?.todaySessions ?? 0}` color: Theme.fgDim font.family: Theme.fontFamily font.pixelSize: Theme.fontSizeSmall } Text { width: parent.width visible: root.record === null text: "No collector has anything to report yet." color: Theme.fgDim wrapMode: Text.WordWrap font.family: Theme.fontFamily font.pixelSize: Theme.fontSizeSmall } } }