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,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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user