Add the agents usage bar widget

This commit is contained in:
SirBlob committed 2026-10-04 02:24:47 -04:00
1 parent 854ce5fe5a
commit 6c83f7089c
18 files changed
+1936 -4

No files matched your search

+88
View File
@@ -0,0 +1,88 @@
import QtQuick
import Quickshell.Io
Item {
id: root
visible: false
property var settings: ({})
property var record: null
property int dataRevision: 0
property string pendingUpdateKind: ""
readonly property int refreshIntervalSec: Math.max(30, Number(setting("refreshIntervalSec", 900)) || 900)
function setting(name, fallback) {
var value = settings ? settings[name] : undefined
return value === undefined || value === null ? fallback : value
}
function refresh() { runUpdate("force") }
function refreshLimits() { runUpdate("limits") }
function updateCommand(kind) {
var command = ["blob-agent-usage-claude"]
if (kind === "force") command.push("--force")
if (kind === "limits") command.push("--limits-only")
return command
}
function runUpdate(kind) {
if (collectorProcess.running) {
if (kind === "force" || pendingUpdateKind === "") pendingUpdateKind = kind
return
}
collectorProcess.command = updateCommand(kind)
collectorProcess.running = true
}
function applyRecord(text) {
try {
var parsed = JSON.parse(String(text || ""))
if (!parsed || typeof parsed !== "object") return
record = parsed
dataRevision++
if (parsed.retryAdvised === true) limitsRetry.restart()
else limitsRetry.stop()
} catch (error) {
console.warn("agents", "Ignoring bad usage record", error)
}
}
Process {
id: collectorProcess
running: false
stdout: StdioCollector {
waitForEnd: true
onStreamFinished: root.applyRecord(text)
}
stderr: StdioCollector {
waitForEnd: true
onStreamFinished: if (text.trim() !== "") console.warn("agents", text.trim())
}
onExited: {
if (root.pendingUpdateKind === "") return
var kind = root.pendingUpdateKind
root.pendingUpdateKind = ""
root.runUpdate(kind)
}
}
Timer {
interval: root.refreshIntervalSec * 1000
running: true
repeat: true
triggeredOnStart: true
onTriggered: root.runUpdate("normal")
}
Timer {
id: limitsRetry
interval: 30000
repeat: false
onTriggered: root.runUpdate("limits")
}
}
+233
View File
@@ -0,0 +1,233 @@
function numberValue(value) {
var n = Number(value || 0)
return isFinite(n) ? Math.round(n) : 0
}
function clamp(value, low, high) {
return Math.max(low, Math.min(high, value))
}
function formatTokenCount(count) {
if (count === undefined || count === null) return "0"
if (count >= 1e9) return (count / 1e9).toFixed(1) + "B"
if (count >= 1e6) return (count / 1e6).toFixed(1) + "M"
if (count >= 1e3) return (count / 1e3).toFixed(1) + "K"
return String(count)
}
function modelWordCase(word) {
if (word === "gpt") return "GPT"
return word.charAt(0).toUpperCase() + word.slice(1)
}
function friendlyModelName(modelId) {
if (!modelId) return "Unknown"
var name = String(modelId).replace(/^claude-/, "").replace(/-\d{8}$/, "")
var parts = name.split("-")
var words = []
var version = []
for (var i = 0; i < parts.length; i++) {
var part = parts[i]
if (part === "") continue
if (/^\d/.test(part)) {
version.push(part)
continue
}
if (version.length > 0) {
words.push(version.join("."))
version = []
}
words.push(modelWordCase(part))
}
if (version.length > 0) words.push(version.join("."))
return words.length > 0 ? words.join(" ") : "Unknown"
}
function dayName(date) {
var parsed = new Date(String(date || "") + "T00:00:00")
if (isNaN(parsed.getTime())) return String(date || "")
return ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"][parsed.getDay()]
}
function formatDuration(milliseconds) {
if (!(milliseconds > 0)) return "now"
var minutes = Math.floor(milliseconds / 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 planLabel(record) {
var tier = String(record && record.tierLabel || "")
return tier === "" ? "" : tier.charAt(0).toUpperCase() + tier.slice(1)
}
function needsSignIn(record) {
var status = String(record && record.usageStatusText || "")
return status === "Sign-in expired" || status === "Waiting for sign-in"
}
function troubleText(record) {
if (!record || String(record.usageStatusText || "") === "") return ""
return String(record.authHelpText || record.usageStatusText)
}
function windowIsLong(text) {
return text.indexOf("week") >= 0 || text.indexOf("7-day") >= 0 || text.indexOf("month") >= 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 || text.indexOf("hour") >= 0) return "Session"
var plain = String(label || "").replace(/\s*\(.*\)\s*/, "").trim()
return plain === "" ? "Limit" : plain
}
function limitWindows(record) {
var windows = []
var limits = record && record.limits ? record.limits : []
for (var i = 0; i < limits.length; i++) {
var entry = limits[i] || {}
var percent = Number(entry.percent)
if (!(percent >= 0)) continue
windows.push({
title: String(entry.title || "") !== "" ? String(entry.title) : windowTitle(entry.label),
percent: percent,
resetAt: String(entry.resetsAt || "")
})
}
return windows
}
function scopedPart(title) {
var match = String(title || "").match(/^(.+) (Session|Weekly|Monthly)$/)
return match ? { model: match[1], window: match[2] } : null
}
function displayWindows(record) {
var windows = limitWindows(record)
var shown = []
var byTitle = {}
for (var i = 0; i < windows.length; i++) {
if (scopedPart(windows[i].title)) continue
windows[i].scoped = []
shown.push(windows[i])
byTitle[windows[i].title] = windows[i]
}
for (var j = 0; j < windows.length; j++) {
var part = scopedPart(windows[j].title)
if (!part) continue
var base = byTitle[part.window]
if (base) {
base.scoped.push({ title: part.model, percent: windows[j].percent, resetAt: windows[j].resetAt })
} else {
windows[j].scoped = []
shown.push(windows[j])
}
}
return shown
}
function fullestWindow(record) {
var windows = limitWindows(record)
var fullest = null
for (var i = 0; i < windows.length; i++) {
if (!fullest || windows[i].percent > fullest.percent) fullest = windows[i]
}
return fullest
}
function resetMillisecondsFor(window, nowMs) {
if (!window || window.resetAt === "") return -1
var resetMs = new Date(window.resetAt).getTime()
return isFinite(resetMs) ? resetMs - nowMs : -1
}
function limitTooltip(window, stale, fetchedAt, nowMs) {
if (!window) return ""
var lines = []
var resetMs = resetMillisecondsFor(window, nowMs)
lines.push(window.title + ": " + Math.round(window.percent * 100) + "% used"
+ (resetMs > 0 ? " · resets in " + formatDuration(resetMs) : ""))
if (stale) {
lines.push(fetchedAt > 0 && nowMs - fetchedAt > 60000
? "As of " + formatDuration(nowMs - fetchedAt) + " ago"
: "Last known")
}
var scoped = window.scoped || []
for (var i = 0; i < scoped.length; i++) {
lines.push(scoped[i].title + ": " + Math.round(scoped[i].percent * 100) + "% of its "
+ window.title.toLowerCase() + " allowance")
}
return lines.join("\n")
}
function summaryPhrases(record) {
if (!record) return []
var weekTokens = 0
var tokensByDay = {}
var recentDays = record.recentDays || []
for (var d = 0; d < recentDays.length; d++) {
var dayTokens = Number(recentDays[d].messageCount || 0)
weekTokens += dayTokens
tokensByDay[recentDays[d].date] = (tokensByDay[recentDays[d].date] || 0) + dayTokens
}
var tokensByModel = {}
var modelUsage = record.modelUsage || {}
for (var modelId in modelUsage) {
var bucket = modelUsage[modelId] || {}
var total = Number(bucket.inputTokens || 0) + Number(bucket.outputTokens || 0)
+ Number(bucket.cacheReadInputTokens || 0) + Number(bucket.cacheCreationInputTokens || 0)
var name = friendlyModelName(modelId)
tokensByModel[name] = (tokensByModel[name] || 0) + total
}
var todayTokens = numberValue(record.todayTotalTokens)
var todayPrompts = numberValue(record.todayPrompts)
var todaySessions = numberValue(record.todaySessions)
var phrases = []
if (weekTokens > 0) phrases.push(formatTokenCount(weekTokens) + " tokens this week")
if (todayTokens > 0) phrases.push(formatTokenCount(todayTokens) + " tokens today")
var topModel = ""
for (var model in tokensByModel) if (topModel === "" || tokensByModel[model] > tokensByModel[topModel]) topModel = model
if (topModel !== "" && tokensByModel[topModel] > 0) phrases.push("Mostly " + topModel)
var busiestDay = ""
for (var date in tokensByDay) if (busiestDay === "" || tokensByDay[date] > tokensByDay[busiestDay]) busiestDay = date
if (busiestDay !== "" && tokensByDay[busiestDay] > 0) phrases.push("Busiest day: " + dayName(busiestDay))
if (todayPrompts > 0) phrases.push(todayPrompts + " prompt" + (todayPrompts === 1 ? "" : "s") + " today")
if (todaySessions > 0) phrases.push(todaySessions + " session" + (todaySessions === 1 ? "" : "s") + " today")
return phrases
}
if (typeof module !== "undefined") {
module.exports = {
numberValue: numberValue,
clamp: clamp,
formatTokenCount: formatTokenCount,
friendlyModelName: friendlyModelName,
dayName: dayName,
formatDuration: formatDuration,
planLabel: planLabel,
needsSignIn: needsSignIn,
troubleText: troubleText,
windowTitle: windowTitle,
limitWindows: limitWindows,
displayWindows: displayWindows,
fullestWindow: fullestWindow,
resetMillisecondsFor: resetMillisecondsFor,
limitTooltip: limitTooltip,
summaryPhrases: summaryPhrases
}
}
+385
View File
@@ -0,0 +1,385 @@
import QtQuick
import Quickshell.Io
import qs.Commons
import qs.Ui
import "Model.js" as Model
Panel {
id: root
moduleName: "blob.agents"
ipcTarget: "blob.agents"
manageIpc: false
readonly property color foreground: bar ? bar.foreground : Color.foreground
readonly property color urgent: Color.urgent
readonly property color dim: Qt.darker(foreground, 1.55)
readonly property color track: Style.selectedFillFor(foreground, Color.accent)
readonly property string fontFamily: bar ? bar.fontFamily : Style.font.family
readonly property string barGlyph: "󱚣"
readonly property string launchCommand: "blob-launch-terminal claude"
readonly property var record: usage.record
readonly property var windows: {
var revision = usage.dataRevision
return Model.displayWindows(record)
}
readonly property var phrases: {
var revision = usage.dataRevision
return Model.summaryPhrases(record)
}
readonly property bool alarming: {
var fullest = Model.fullestWindow(record)
return !!fullest && fullest.percent >= 0.9
}
property double nowMs: Date.now()
property int phraseIndex: 0
property string shownPhrase: ""
function showPhrase() {
shownPhrase = phrases.length > 0 ? phrases[phraseIndex % phrases.length] : ""
}
function launchAgent() {
if (root.bar) root.bar.run(root.launchCommand)
root.close()
}
onPhrasesChanged: if (shownPhrase === "" || phrases.length <= 1) showPhrase()
onOpenedChanged: if (opened) {
nowMs = Date.now()
usage.refreshLimits()
Qt.callLater(function() { keyCatcher.forceActiveFocus() })
}
implicitWidth: button.implicitWidth
implicitHeight: button.implicitHeight
Main {
id: usage
settings: root.settings
}
Timer {
interval: 30000
running: root.opened
repeat: true
onTriggered: root.nowMs = Date.now()
}
Timer {
interval: 2800
running: root.opened && root.phrases.length > 1
repeat: true
onTriggered: phraseSwap.restart()
}
SequentialAnimation {
id: phraseSwap
PropertyAnimation {
target: hero
property: "metaOpacity"
to: 0.0
duration: 180
easing.type: Easing.OutQuad
}
ScriptAction {
script: {
root.phraseIndex = (root.phraseIndex + 1) % Math.max(1, root.phrases.length)
root.showPhrase()
}
}
PropertyAnimation {
target: hero
property: "metaOpacity"
to: 1.0
duration: 260
easing.type: Easing.InQuad
}
}
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(): void { usage.refresh() }
}
BarIconButton {
id: button
anchors.fill: parent
bar: root.bar
text: root.barGlyph
active: root.alarming
onPressed: function(mouseButton) {
if (mouseButton === Qt.RightButton) root.launchAgent()
else if (mouseButton === Qt.MiddleButton) usage.refresh()
else root.toggle()
}
}
KeyboardPanel {
id: panel
anchorItem: button
owner: root
bar: root.bar
open: root.opened
focusTarget: keyCatcher
contentWidth: panel.fittedContentWidth(Style.space(380))
contentHeight: panel.fittedContentHeight(column.implicitHeight)
PanelKeyCatcher {
id: keyCatcher
anchors.fill: parent
onActivateRequested: root.launchAgent()
onCloseRequested: root.close()
onTabRequested: function(direction) { root.switchPanel(direction) }
onTextKey: function(key) { if (key === "r" || key === "R") usage.refresh() }
Column {
id: column
width: parent.width
spacing: Style.space(16)
PanelHero {
id: hero
width: parent.width
title: "Claude Code"
meta: root.shownPhrase !== "" ? root.shownPhrase : "Usage"
detail: Model.planLabel(root.record)
foreground: root.foreground
fontFamily: root.fontFamily
iconComponent: Component {
ClaudeMark {
size: Style.font.display
}
}
trailingControl: Component {
HeroButton {
glyph: "󰞷"
tooltip: "Start Claude Code"
onClicked: root.launchAgent()
}
}
}
PanelSeparator {
foreground: root.foreground
}
Text {
visible: text !== ""
width: parent.width
textFormat: Text.PlainText
text: Model.troubleText(root.record)
color: root.urgent
font.family: root.fontFamily
font.pixelSize: Style.font.caption
wrapMode: Text.WordWrap
}
Column {
visible: root.windows.length > 0
width: parent.width
spacing: Style.space(12)
Repeater {
model: root.windows
CompactLimit {
required property var modelData
width: column.width
window: modelData
stale: !!root.record && root.record.limitsStale === true
fetchedAt: root.record ? Number(root.record.limitsFetchedAt || 0) : 0
}
}
}
Text {
visible: !root.record
width: parent.width
text: "Loading usage..."
color: root.dim
font.family: root.fontFamily
font.pixelSize: Style.font.caption
}
}
}
}
component ClaudeMark: Item {
id: mark
property real size: Style.font.heading
width: size
height: size
Image {
id: markImage
anchors.fill: parent
source: Qt.resolvedUrl("assets/claude.svg")
sourceSize.width: mark.size * 2
sourceSize.height: mark.size * 2
fillMode: Image.PreserveAspectFit
}
Text {
anchors.centerIn: parent
visible: markImage.status !== Image.Ready
textFormat: Text.PlainText
text: root.barGlyph
color: root.foreground
font.family: root.fontFamily
font.pixelSize: mark.size
}
}
component HeroButton: Rectangle {
id: heroButton
signal clicked()
property string glyph: ""
property string tooltip: ""
implicitWidth: Style.space(34)
implicitHeight: implicitWidth
radius: Style.cornerRadius
color: Util.alpha(Color.accent, heroMouse.containsMouse ? 0.3 : 0.12)
border.width: heroMouse.containsMouse ? Math.max(1, Style.hoverBorderWidth) : 0
border.color: Color.accent
Text {
anchors.centerIn: parent
textFormat: Text.PlainText
text: heroButton.glyph
color: Color.accent
font.family: root.fontFamily
font.pixelSize: Style.font.heading
}
MouseArea {
id: heroMouse
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: heroButton.clicked()
}
PanelToolTip {
visible: heroMouse.containsMouse
text: heroButton.tooltip
}
}
component CompactLimit: Item {
id: compact
property var window: null
property bool stale: false
property real fetchedAt: 0
readonly property real resetMs: Model.resetMillisecondsFor(window, root.nowMs)
opacity: stale ? 0.5 : 1.0
implicitHeight: Math.max(compactTitle.implicitHeight, compactValue.implicitHeight)
HoverHandler { id: compactHover }
PanelToolTip {
visible: compactHover.hovered && !!compact.window
text: Model.limitTooltip(compact.window, compact.stale, compact.fetchedAt, root.nowMs)
}
Text {
id: compactTitle
textFormat: Text.PlainText
width: parent.width * 0.3
anchors.verticalCenter: parent.verticalCenter
text: compact.window ? compact.window.title : ""
color: root.foreground
font.family: root.fontFamily
font.pixelSize: Style.font.bodySmall
elide: Text.ElideRight
}
Meter {
anchors.left: compactTitle.right
anchors.right: compactValue.left
anchors.rightMargin: Style.space(20)
anchors.verticalCenter: parent.verticalCenter
value: compact.window ? compact.window.percent : -1
alarming: !!compact.window && compact.window.percent >= 0.9
markers: compact.window && compact.window.scoped ? compact.window.scoped : []
}
TextMetrics {
id: compactValueMetrics
font.family: root.fontFamily
font.pixelSize: Style.font.bodySmall
text: "00h 00m"
}
Text {
id: compactValue
width: Math.ceil(compactValueMetrics.advanceWidth)
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
textFormat: Text.PlainText
horizontalAlignment: Text.AlignRight
text: compact.resetMs > 0 ? Model.formatDuration(compact.resetMs) : ""
color: root.dim
font.family: root.fontFamily
font.pixelSize: Style.font.bodySmall
}
}
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))
property var markers: []
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 * Model.clamp(meter.value, 0, 1)
color: meter.alarming ? root.urgent : root.foreground
Behavior on width {
NumberAnimation { duration: 160; easing.type: Easing.OutCubic }
}
}
Repeater {
model: meter.markers
Rectangle {
required property var modelData
width: Math.max(2, Math.round(meter.thickness * 0.5))
height: meter.thickness * 2.5
radius: width / 2
anchors.verticalCenter: meterTrack.verticalCenter
x: Model.clamp(meterTrack.width * Model.clamp(Number(modelData.percent), 0, 1) - width / 2, 0, meterTrack.width - width)
color: meter.alarming ? root.urgent : root.foreground
}
}
}
}
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="257" preserveAspectRatio="xMidYMid" viewBox="0 0 256 257"><path fill="#D97757" d="m50.228 170.321 50.357-28.257.843-2.463-.843-1.361h-2.462l-8.426-.518-28.775-.778-24.952-1.037-24.175-1.296-6.092-1.297L0 125.796l.583-3.759 5.12-3.434 7.324.648 16.202 1.101 24.304 1.685 17.629 1.037 26.118 2.722h4.148l.583-1.685-1.426-1.037-1.101-1.037-25.147-17.045-27.22-18.017-14.258-10.37-7.713-5.25-3.888-4.925-1.685-10.758 7-7.713 9.397.649 2.398.648 9.527 7.323 20.35 15.75L94.817 91.9l3.889 3.24 1.555-1.102.195-.777-1.75-2.917-14.453-26.118-15.425-26.572-6.87-11.018-1.814-6.61c-.648-2.723-1.102-4.991-1.102-7.778l7.972-10.823L71.42 0 82.05 1.426l4.472 3.888 6.61 15.101 10.694 23.786 16.591 32.34 4.861 9.592 2.592 8.879.973 2.722h1.685v-1.556l1.36-18.211 2.528-22.36 2.463-28.776.843-8.1 4.018-9.722 7.971-5.25 6.222 2.981 5.12 7.324-.713 4.73-3.046 19.768-5.962 30.98-3.889 20.739h2.268l2.593-2.593 10.499-13.934 17.628-22.036 7.778-8.749 9.073-9.657 5.833-4.601h11.018l8.1 12.055-3.628 12.443-11.342 14.388-9.398 12.184-13.48 18.147-8.426 14.518.778 1.166 2.01-.194 30.46-6.481 16.462-2.982 19.637-3.37 8.88 4.148.971 4.213-3.5 8.62-20.998 5.184-24.628 4.926-36.682 8.685-.454.324.519.648 16.526 1.555 7.065.389h17.304l32.21 2.398 8.426 5.574 5.055 6.805-.843 5.184-12.962 6.611-17.498-4.148-40.83-9.721-14-3.5h-1.944v1.167l11.666 11.406 21.387 19.314 26.767 24.887 1.36 6.157-3.434 4.86-3.63-.518-23.526-17.693-9.073-7.972-20.545-17.304h-1.36v1.814l4.73 6.935 25.017 37.59 1.296 11.536-1.814 3.76-6.481 2.268-7.13-1.297-14.647-20.544-15.1-23.138-12.185-20.739-1.49.843-7.194 77.448-3.37 3.953-7.778 2.981-6.48-4.925-3.436-7.972 3.435-15.749 4.148-20.544 3.37-16.333 3.046-20.285 1.815-6.74-.13-.454-1.49.194-15.295 20.999-23.267 31.433-18.406 19.702-4.407 1.75-7.648-3.954.713-7.064 4.277-6.286 25.47-32.405 15.36-20.092 9.917-11.6-.065-1.686h-.583L44.07 198.125l-12.055 1.555-5.185-4.86.648-7.972 2.463-2.593 20.35-13.999-.064.065Z"/></svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

+26
View File
@@ -0,0 +1,26 @@
{
"schemaVersion": 1,
"id": "blob.agents",
"name": "Agents",
"version": "1.0.0",
"author": "Blob",
"description": "Claude Code usage limits and token stats",
"kinds": [
"bar-widget"
],
"entryPoints": {
"barWidget": "Panel.qml"
},
"barWidget": {
"displayName": "Agents",
"description": "Claude Code session and weekly limits, with token stats from local transcripts",
"category": "AI",
"allowMultiple": false,
"defaults": {
"refreshIntervalSec": 900
},
"schema": [
{ "key": "refreshIntervalSec", "type": "integer", "label": "Refresh interval (seconds)", "min": 30, "max": 3600, "step": 30, "defaultValue": 900 }
]
}
}