386 lines
10 KiB
QML
386 lines
10 KiB
QML
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 : Colors.foreground
|
|
readonly property color urgent: Colors.urgent
|
|
readonly property color dim: Qt.darker(foreground, 1.55)
|
|
readonly property color track: Style.selectedFillFor(foreground, Colors.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(Colors.accent, heroMouse.containsMouse ? 0.3 : 0.12)
|
|
border.width: heroMouse.containsMouse ? Math.max(1, Style.hoverBorderWidth) : 0
|
|
border.color: Colors.accent
|
|
|
|
Text {
|
|
anchors.centerIn: parent
|
|
textFormat: Text.PlainText
|
|
text: heroButton.glyph
|
|
color: Colors.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
|
|
}
|
|
}
|
|
}
|
|
}
|