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 bool alarming: !!headline && headline.percent >= 0.9 function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)) } function alpha(c, a) { return Qt.rgba(c.r, c.g, c.b, a) } function selectProvider(index) { if (providers.length === 0) return var wrapped = ((index % providers.length) + providers.length) % providers.length selectedProviderId = providers[wrapped].providerId } function refreshNow() { usage.refreshAll(true) } // ---------------------------------------------------------------- limits // // Both providers report the same two shapes: a short rolling session window // and a long weekly one. Everything below normalizes them into one record so // the meters 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 } function limitWindow(label, percent, resetAt) { return { 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)) } 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" } // ---------------------------------------------------------------- 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. if (today && provider) 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/-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/.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 } } // ---------- 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)) 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 text: limitRow.window ? limitRow.window.title : "" color: root.foreground font.family: root.fontFamily font.pixelSize: Style.font.body anchors.left: parent.left 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 } } }