Add the agents usage bar widget
This commit is contained in:
1 parent
854ce5fe5a
commit
6c83f7089c
18 files changed
+1936
-4
No files matched your search
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 |
@@ -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 }
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import QtQuick
|
||||
import qs.Commons
|
||||
import qs.Ui
|
||||
|
||||
BarWidget {
|
||||
id: root
|
||||
moduleName: "blob.claude-usage"
|
||||
|
||||
readonly property bool opened: panelLoader.item ? panelLoader.item.opened === true : false
|
||||
readonly property bool popoutSwitchClosing: panelLoader.item ? panelLoader.item.popoutSwitchClosing === true : false
|
||||
|
||||
function injectPanel() {
|
||||
var panel = panelLoader.item
|
||||
if (!panel) return
|
||||
panel.bar = root.bar
|
||||
panel.settings = root.settings
|
||||
panel.anchorItem = button
|
||||
panel.hostWidget = root
|
||||
}
|
||||
|
||||
function open() {
|
||||
if (panelLoader.item) panelLoader.item.open()
|
||||
}
|
||||
|
||||
function close() {
|
||||
if (panelLoader.item) panelLoader.item.close()
|
||||
}
|
||||
|
||||
function togglePanel() {
|
||||
if (panelLoader.item) panelLoader.item.toggle()
|
||||
}
|
||||
|
||||
function refresh() {
|
||||
if (panelLoader.item) panelLoader.item.refresh()
|
||||
}
|
||||
|
||||
function closeForPopoutSwitch() {
|
||||
if (panelLoader.item) panelLoader.item.closeForPopoutSwitch()
|
||||
}
|
||||
|
||||
implicitWidth: button.implicitWidth
|
||||
implicitHeight: button.implicitHeight
|
||||
|
||||
onBarChanged: injectPanel()
|
||||
onSettingsChanged: injectPanel()
|
||||
|
||||
Loader {
|
||||
id: panelLoader
|
||||
active: true
|
||||
source: Qt.resolvedUrl("Panel.qml")
|
||||
visible: false
|
||||
onLoaded: {
|
||||
root.injectPanel()
|
||||
Qt.callLater(root.injectPanel)
|
||||
}
|
||||
}
|
||||
|
||||
BarIconButton {
|
||||
id: button
|
||||
anchors.fill: parent
|
||||
bar: root.bar
|
||||
text: ""
|
||||
tooltipText: ""
|
||||
|
||||
onPressed: function(mouseButton) {
|
||||
if (!root.bar) return
|
||||
if (mouseButton === Qt.MiddleButton) root.refresh()
|
||||
else root.togglePanel()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
function parseUsage(raw) {
|
||||
var values = {}
|
||||
var lines = String(raw || "").split("\n")
|
||||
|
||||
for (var i = 0; i < lines.length; i++) {
|
||||
var tabIndex = lines[i].indexOf("\t")
|
||||
if (tabIndex === -1) continue
|
||||
values[lines[i].substring(0, tabIndex)] = lines[i].substring(tabIndex + 1).trim()
|
||||
}
|
||||
|
||||
return {
|
||||
session: limitFrom(values, "session"),
|
||||
weekly: limitFrom(values, "weekly"),
|
||||
weeklyOpus: limitFrom(values, "weekly_opus")
|
||||
}
|
||||
}
|
||||
|
||||
function limitFrom(values, name) {
|
||||
var percentText = values[name + "_percent"]
|
||||
if (percentText === undefined) return null
|
||||
|
||||
var percent = parseInt(percentText, 10)
|
||||
return {
|
||||
percent: isNaN(percent) ? 0 : Math.max(0, Math.min(100, percent)),
|
||||
resetsAt: values[name + "_resets"] || ""
|
||||
}
|
||||
}
|
||||
|
||||
function formatTimeUntil(resetsAt, nowMs) {
|
||||
if (!resetsAt) return ""
|
||||
|
||||
var resetMs = new Date(resetsAt).getTime()
|
||||
if (isNaN(resetMs)) return ""
|
||||
|
||||
var totalMinutes = Math.max(0, Math.round((resetMs - nowMs) / 60000))
|
||||
var days = Math.floor(totalMinutes / 1440)
|
||||
var hours = Math.floor((totalMinutes % 1440) / 60)
|
||||
var minutes = totalMinutes % 60
|
||||
|
||||
if (days > 0) return days + "d " + hours + "h"
|
||||
if (hours > 0) return hours + "h " + minutes + "m"
|
||||
return minutes + "m"
|
||||
}
|
||||
|
||||
function limitDetail(limit, nowMs) {
|
||||
if (!limit) return ""
|
||||
|
||||
var timeUntilReset = formatTimeUntil(limit.resetsAt, nowMs)
|
||||
if (timeUntilReset === "") return limit.percent + "%"
|
||||
return limit.percent + "% resets in " + timeUntilReset
|
||||
}
|
||||
|
||||
if (typeof module !== "undefined") {
|
||||
module.exports = {
|
||||
parseUsage: parseUsage,
|
||||
formatTimeUntil: formatTimeUntil,
|
||||
limitDetail: limitDetail
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import QtQuick
|
||||
import Quickshell.Io
|
||||
import qs.Commons
|
||||
import qs.Ui
|
||||
import "Model.js" as Model
|
||||
|
||||
Panel {
|
||||
id: root
|
||||
moduleName: "blob.claude-usage"
|
||||
ipcTarget: "blob.claude-usage"
|
||||
|
||||
property var anchorItem: null
|
||||
property var hostWidget: null
|
||||
readonly property var barIdentity: hostWidget || root
|
||||
|
||||
property var usage: ({ session: null, weekly: null, weeklyOpus: null })
|
||||
property string errorMessage: ""
|
||||
property bool loading: false
|
||||
property double nowMs: Date.now()
|
||||
|
||||
readonly property bool hasUsage: usage.session !== null || usage.weekly !== null
|
||||
readonly property int refreshMinutes: Math.max(1, parseInt(setting("refreshMinutes", 5), 10) || 5)
|
||||
|
||||
function open() {
|
||||
root.controller.show()
|
||||
root.refresh()
|
||||
}
|
||||
|
||||
function close() {
|
||||
root.controller.hide()
|
||||
}
|
||||
|
||||
function toggle() {
|
||||
if (root.opened) root.close()
|
||||
else root.open()
|
||||
}
|
||||
|
||||
function switchPanel(direction) {
|
||||
if (root.bar && typeof root.bar.switchPanelFrom === "function")
|
||||
return root.bar.switchPanelFrom(root.barIdentity, direction)
|
||||
return false
|
||||
}
|
||||
|
||||
function refresh() {
|
||||
root.nowMs = Date.now()
|
||||
if (usageProcess.running) return
|
||||
root.loading = true
|
||||
usageProcess.running = true
|
||||
}
|
||||
|
||||
Process {
|
||||
id: usageProcess
|
||||
command: ["blob-claude-usage"]
|
||||
|
||||
stdout: StdioCollector {
|
||||
waitForEnd: true
|
||||
onStreamFinished: {
|
||||
var parsedUsage = Model.parseUsage(text)
|
||||
if (parsedUsage.session !== null || parsedUsage.weekly !== null) {
|
||||
root.usage = parsedUsage
|
||||
root.errorMessage = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stderr: StdioCollector {
|
||||
waitForEnd: true
|
||||
onStreamFinished: {
|
||||
var message = String(text || "").trim()
|
||||
if (message !== "") root.errorMessage = message
|
||||
}
|
||||
}
|
||||
|
||||
onExited: function(exitCode) {
|
||||
root.loading = false
|
||||
if (exitCode !== 0 && root.errorMessage === "") root.errorMessage = "Could not load usage"
|
||||
}
|
||||
}
|
||||
|
||||
Timer {
|
||||
interval: root.refreshMinutes * 60 * 1000
|
||||
running: true
|
||||
repeat: true
|
||||
triggeredOnStart: true
|
||||
onTriggered: root.refresh()
|
||||
}
|
||||
|
||||
Timer {
|
||||
interval: 60 * 1000
|
||||
running: root.opened
|
||||
repeat: true
|
||||
onTriggered: root.nowMs = Date.now()
|
||||
}
|
||||
|
||||
KeyboardPanel {
|
||||
id: panel
|
||||
anchorItem: root.anchorItem
|
||||
owner: root.barIdentity
|
||||
bar: root.bar
|
||||
open: root.opened
|
||||
focusTarget: keyCatcher
|
||||
contentWidth: panel.fittedContentWidth(Style.space(340))
|
||||
contentHeight: panel.fittedContentHeight(usageColumn.implicitHeight)
|
||||
|
||||
PanelKeyCatcher {
|
||||
id: keyCatcher
|
||||
anchors.fill: parent
|
||||
onCloseRequested: root.close()
|
||||
onTabRequested: function(direction) { root.switchPanel(direction) }
|
||||
onTextKey: function(key) { if (key === "r") root.refresh() }
|
||||
|
||||
Column {
|
||||
id: usageColumn
|
||||
width: parent.width
|
||||
spacing: Style.space(14)
|
||||
|
||||
PanelSectionHeader {
|
||||
text: "CLAUDE CODE USAGE"
|
||||
foreground: root.barForeground
|
||||
}
|
||||
|
||||
MetricBar {
|
||||
width: parent.width
|
||||
visible: root.usage.session !== null
|
||||
icon: ""
|
||||
name: "Session"
|
||||
percent: root.usage.session ? root.usage.session.percent : 0
|
||||
detail: Model.limitDetail(root.usage.session, root.nowMs)
|
||||
}
|
||||
|
||||
MetricBar {
|
||||
width: parent.width
|
||||
visible: root.usage.weekly !== null
|
||||
icon: ""
|
||||
name: "Weekly"
|
||||
percent: root.usage.weekly ? root.usage.weekly.percent : 0
|
||||
detail: Model.limitDetail(root.usage.weekly, root.nowMs)
|
||||
}
|
||||
|
||||
MetricBar {
|
||||
width: parent.width
|
||||
visible: root.usage.weeklyOpus !== null
|
||||
icon: ""
|
||||
name: "Weekly Opus"
|
||||
percent: root.usage.weeklyOpus ? root.usage.weeklyOpus.percent : 0
|
||||
detail: Model.limitDetail(root.usage.weeklyOpus, root.nowMs)
|
||||
}
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
visible: root.errorMessage !== "" || (!root.hasUsage && root.loading)
|
||||
text: root.errorMessage !== "" ? root.errorMessage : "Loading usage..."
|
||||
textFormat: Text.PlainText
|
||||
wrapMode: Text.Wrap
|
||||
color: root.errorMessage !== "" ? Color.urgent : Qt.darker(root.barForeground, 1.5)
|
||||
font.family: Style.font.family
|
||||
font.pixelSize: Style.font.bodySmall
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"id": "blob.claude-usage",
|
||||
"name": "Claude Usage",
|
||||
"version": "1.0.0",
|
||||
"author": "Blob",
|
||||
"description": "Claude Code session and weekly usage limits",
|
||||
"kinds": [
|
||||
"bar-widget"
|
||||
],
|
||||
"entryPoints": {
|
||||
"barWidget": "BarWidget.qml"
|
||||
},
|
||||
"barWidget": {
|
||||
"displayName": "Claude Usage",
|
||||
"description": "Claude Code session and weekly usage limits",
|
||||
"category": "Info",
|
||||
"allowMultiple": false
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user