Files

593 lines
22 KiB
QML

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