plasmoids: añade commandoutput y codexbar al tracking
Ambos ya estaban puestos en un panel real pero no viajaban a la otra máquina -- detectado en la misma auditoría que quitó claudeusageswitcher y tt.launchpadPlasma (sin usar). Sin esto, una reinstalación desde arch-setup dejaría el panel sin estos dos widgets. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
d434a3c635
commit
b98999cde9
+65
@@ -0,0 +1,65 @@
|
||||
import QtQuick
|
||||
import QtQuick.Layouts
|
||||
import org.kde.plasma.plasmoid
|
||||
import org.kde.plasma.core as PlasmaCore
|
||||
import org.kde.kirigami as Kirigami
|
||||
import "../code/parser.js" as Parser
|
||||
|
||||
// Panel view: one two-ring radial gauge per provider
|
||||
// (outer ring = session left, inner ring = weekly left).
|
||||
Item {
|
||||
id: compact
|
||||
|
||||
readonly property bool vertical: Plasmoid.formFactor === PlasmaCore.Types.Vertical
|
||||
readonly property int gaugeSize: compact.vertical
|
||||
? Math.min(compact.width, Kirigami.Units.gridUnit * 2.4)
|
||||
: Math.min(compact.height, Kirigami.Units.gridUnit * 2.4)
|
||||
readonly property int count: Math.max(1, root.models.length)
|
||||
|
||||
Layout.minimumWidth: compact.vertical ? compact.gaugeSize : compact.count * (compact.gaugeSize + gaugeGrid.spacing)
|
||||
Layout.minimumHeight: compact.vertical ? compact.count * (compact.gaugeSize + gaugeGrid.spacing) : compact.gaugeSize
|
||||
|
||||
Grid {
|
||||
id: gaugeGrid
|
||||
anchors.centerIn: parent
|
||||
columns: compact.vertical ? 1 : compact.count
|
||||
spacing: Kirigami.Units.smallSpacing
|
||||
|
||||
Kirigami.Icon {
|
||||
visible: root.models.length === 0
|
||||
source: Qt.resolvedUrl("../icons/codexbar.svg")
|
||||
width: compact.gaugeSize
|
||||
height: compact.gaugeSize
|
||||
opacity: root.loading ? 0.5 : 1
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: root.models
|
||||
|
||||
delegate: RadialGauge {
|
||||
id: meter
|
||||
required property var modelData
|
||||
readonly property var rings: Parser.gaugeRings(modelData)
|
||||
readonly property int centerPercent: Parser.gaugeCenterPercent(modelData)
|
||||
|
||||
width: compact.gaugeSize
|
||||
height: compact.gaugeSize
|
||||
outerColor: root.sessionColor
|
||||
innerColor: root.weeklyColor
|
||||
outerPercent: rings.outerIdx >= 0
|
||||
? modelData.windows[rings.outerIdx].usedPercent : -1
|
||||
innerPercent: rings.innerIdx >= 0
|
||||
? modelData.windows[rings.innerIdx].usedPercent : -1
|
||||
centerText: modelData.error
|
||||
? "!"
|
||||
: (meter.centerPercent >= 0 ? String(meter.centerPercent) : "")
|
||||
centerTextScale: 0.34
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
onClicked: root.expanded = !root.expanded
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls as QQC2
|
||||
import QtQuick.Layouts
|
||||
import org.kde.kirigami as Kirigami
|
||||
|
||||
Kirigami.FormLayout {
|
||||
id: page
|
||||
|
||||
property alias cfg_codexbarPath: pathField.text
|
||||
property alias cfg_refreshMinutes: refreshSpin.value
|
||||
property alias cfg_showPace: paceCheck.checked
|
||||
property alias cfg_showCost: costCheck.checked
|
||||
property alias cfg_showHistory: historyCheck.checked
|
||||
property alias cfg_proxyUrl: proxyField.text
|
||||
|
||||
QQC2.TextField {
|
||||
id: pathField
|
||||
Kirigami.FormData.label: i18n("codexbar binary:")
|
||||
placeholderText: "codexbar"
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
|
||||
QQC2.Label {
|
||||
text: i18n("Plain name is resolved via PATH (including ~/.local/bin); an absolute path also works.")
|
||||
font.pointSize: Kirigami.Theme.smallFont.pointSize
|
||||
opacity: 0.6
|
||||
Layout.fillWidth: true
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
|
||||
QQC2.SpinBox {
|
||||
id: refreshSpin
|
||||
Kirigami.FormData.label: i18n("Refresh every:")
|
||||
from: 1
|
||||
to: 120
|
||||
textFromValue: function(value) { return i18np("%1 minute", "%1 minutes", value) }
|
||||
valueFromText: function(text) { return parseInt(text) || 5 }
|
||||
}
|
||||
|
||||
QQC2.CheckBox {
|
||||
id: paceCheck
|
||||
Kirigami.FormData.label: i18n("Details:")
|
||||
text: i18n("Show pace summaries")
|
||||
}
|
||||
|
||||
QQC2.CheckBox {
|
||||
id: costCheck
|
||||
text: i18n("Show local token costs (Codex/Claude)")
|
||||
}
|
||||
|
||||
QQC2.CheckBox {
|
||||
id: historyCheck
|
||||
text: i18n("Show daily spend history chart")
|
||||
}
|
||||
|
||||
QQC2.TextField {
|
||||
id: proxyField
|
||||
Kirigami.FormData.label: i18n("Proxy:")
|
||||
placeholderText: "socks5://127.0.0.1:1080"
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
|
||||
QQC2.Label {
|
||||
text: i18n("Exported as http_proxy/https_proxy/ALL_PROXY for every CLI call. Useful when provider APIs are blocked in your region. Leave empty for a direct connection.")
|
||||
font.pointSize: Kirigami.Theme.smallFont.pointSize
|
||||
opacity: 0.6
|
||||
Layout.fillWidth: true
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls as QQC2
|
||||
import QtQuick.Layouts
|
||||
import org.kde.kirigami as Kirigami
|
||||
import "../code/parser.js" as Parser
|
||||
|
||||
ColumnLayout {
|
||||
id: page
|
||||
|
||||
property string cfg_selectedProviders: ""
|
||||
|
||||
readonly property var selectedList: Parser.selectionList(page.cfg_selectedProviders)
|
||||
|
||||
function isSelected(id) {
|
||||
return page.selectedList.indexOf(id) !== -1
|
||||
}
|
||||
|
||||
function toggle(id, checked) {
|
||||
page.cfg_selectedProviders = Parser.toggleSelection(page.cfg_selectedProviders, id, checked)
|
||||
}
|
||||
|
||||
QQC2.Label {
|
||||
Layout.fillWidth: true
|
||||
wrapMode: Text.WordWrap
|
||||
text: i18n("Pick the coding agents to display. With nothing selected, the widget shows the providers enabled in the CodexBar CLI config (~/.config/codexbar/config.json).")
|
||||
}
|
||||
|
||||
Kirigami.SearchField {
|
||||
id: searchField
|
||||
Layout.fillWidth: true
|
||||
placeholderText: i18n("Filter providers…")
|
||||
}
|
||||
|
||||
QQC2.ScrollView {
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
|
||||
ListView {
|
||||
id: providerListView
|
||||
clip: true
|
||||
model: {
|
||||
var query = searchField.text.toLowerCase().trim()
|
||||
if (query.length === 0) {
|
||||
return Parser.PROVIDER_CATALOG
|
||||
}
|
||||
return Parser.PROVIDER_CATALOG.filter(function(p) {
|
||||
return p.id.indexOf(query) !== -1
|
||||
|| p.name.toLowerCase().indexOf(query) !== -1
|
||||
})
|
||||
}
|
||||
|
||||
delegate: QQC2.CheckDelegate {
|
||||
required property var modelData
|
||||
width: providerListView.width
|
||||
text: modelData.name + " (" + modelData.id + ")"
|
||||
checked: page.isSelected(modelData.id)
|
||||
onToggled: page.toggle(modelData.id, checked)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
QQC2.Label {
|
||||
Layout.fillWidth: true
|
||||
text: page.selectedList.length > 0
|
||||
? i18np("%1 provider selected", "%1 providers selected", page.selectedList.length)
|
||||
: i18n("Following the CLI config")
|
||||
opacity: 0.6
|
||||
font.pointSize: Kirigami.Theme.smallFont.pointSize
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,372 @@
|
||||
import QtQuick
|
||||
import QtQuick.Layouts
|
||||
import org.kde.plasma.plasmoid
|
||||
import org.kde.plasma.components as PlasmaComponents3
|
||||
import org.kde.plasma.extras as PlasmaExtras
|
||||
import org.kde.kirigami as Kirigami
|
||||
import "../code/parser.js" as Parser
|
||||
|
||||
PlasmaExtras.Representation {
|
||||
id: full
|
||||
|
||||
Layout.preferredWidth: Kirigami.Units.gridUnit * 22
|
||||
Layout.preferredHeight: Kirigami.Units.gridUnit * 26
|
||||
Layout.minimumWidth: Kirigami.Units.gridUnit * 16
|
||||
Layout.minimumHeight: Kirigami.Units.gridUnit * 10
|
||||
|
||||
collapseMarginsHint: true
|
||||
|
||||
function stageColor(usedPercent) {
|
||||
var stage = Parser.usageStage(usedPercent)
|
||||
if (stage === "crit") {
|
||||
return Kirigami.Theme.negativeTextColor
|
||||
}
|
||||
if (stage === "warn") {
|
||||
return Kirigami.Theme.neutralTextColor
|
||||
}
|
||||
return Kirigami.Theme.positiveTextColor
|
||||
}
|
||||
|
||||
function resetText(w) {
|
||||
var countdown = Parser.formatCountdown(w.resetsAt, root.nowMs)
|
||||
if (countdown === "now") {
|
||||
return i18n("resets soon")
|
||||
}
|
||||
if (countdown.length > 0) {
|
||||
return i18n("resets in %1", countdown)
|
||||
}
|
||||
return w.resetDescription
|
||||
}
|
||||
|
||||
function costLine(cost, kind) {
|
||||
var money = Parser.formatMoney(kind === "today" ? cost.todayCostUSD : cost.month30CostUSD)
|
||||
var tokens = Parser.humanTokens(kind === "today" ? cost.todayTokens : cost.month30Tokens)
|
||||
if (money.length === 0 && tokens.length === 0) {
|
||||
return ""
|
||||
}
|
||||
return money + (tokens.length > 0 ? " · " + tokens + " tok" : "")
|
||||
}
|
||||
|
||||
header: PlasmaExtras.PlasmoidHeading {
|
||||
RowLayout {
|
||||
anchors.fill: parent
|
||||
spacing: Kirigami.Units.smallSpacing
|
||||
|
||||
Kirigami.Heading {
|
||||
Layout.fillWidth: true
|
||||
Layout.leftMargin: Kirigami.Units.smallSpacing
|
||||
level: 2
|
||||
text: i18n("CodexBar")
|
||||
}
|
||||
|
||||
PlasmaComponents3.Label {
|
||||
visible: root.lastUpdatedMs > 0
|
||||
text: i18n("updated %1", Parser.formatAgo(root.lastUpdatedMs, root.nowMs))
|
||||
opacity: 0.6
|
||||
font.pointSize: Kirigami.Theme.smallFont.pointSize
|
||||
}
|
||||
|
||||
PlasmaComponents3.ToolButton {
|
||||
id: refreshButton
|
||||
icon.name: "view-refresh"
|
||||
enabled: !root.loading
|
||||
onClicked: root.refresh()
|
||||
PlasmaComponents3.ToolTip { text: i18n("Refresh") }
|
||||
|
||||
rotation: 0
|
||||
RotationAnimation on rotation {
|
||||
running: root.loading
|
||||
from: 0
|
||||
to: 360
|
||||
duration: 1100
|
||||
loops: Animation.Infinite
|
||||
}
|
||||
onEnabledChanged: if (enabled) { rotation = 0 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
contentItem: ListView {
|
||||
id: providerList
|
||||
clip: true
|
||||
spacing: Kirigami.Units.smallSpacing
|
||||
model: root.models
|
||||
topMargin: Kirigami.Units.smallSpacing
|
||||
bottomMargin: Kirigami.Units.smallSpacing
|
||||
leftMargin: Kirigami.Units.smallSpacing
|
||||
rightMargin: Kirigami.Units.smallSpacing
|
||||
|
||||
PlasmaExtras.PlaceholderMessage {
|
||||
anchors.centerIn: parent
|
||||
width: parent.width - Kirigami.Units.gridUnit * 2
|
||||
visible: root.models.length === 0
|
||||
iconName: root.globalError.length > 0 ? "data-warning" : "office-chart-bar"
|
||||
text: {
|
||||
if (root.globalError.length > 0) {
|
||||
return root.globalError
|
||||
}
|
||||
if (root.loading) {
|
||||
return i18n("Fetching usage…")
|
||||
}
|
||||
return i18n("No providers configured. Pick providers in the widget settings or enable them with “codexbar config enable --provider <id>”.")
|
||||
}
|
||||
}
|
||||
|
||||
delegate: Item {
|
||||
id: card
|
||||
required property var modelData
|
||||
readonly property var rings: Parser.gaugeRings(modelData)
|
||||
readonly property int centerPercent: Parser.gaugeCenterPercent(modelData)
|
||||
readonly property var cost: root.showCost ? (root.costById[modelData.id] || null) : null
|
||||
|
||||
width: providerList.width - providerList.leftMargin - providerList.rightMargin
|
||||
height: cardContent.implicitHeight + Kirigami.Units.largeSpacing * 2
|
||||
|
||||
function ringColor(index) {
|
||||
if (index === card.rings.outerIdx) {
|
||||
return root.sessionColor
|
||||
}
|
||||
if (index === card.rings.innerIdx) {
|
||||
return root.weeklyColor
|
||||
}
|
||||
return Qt.alpha(Kirigami.Theme.textColor, 0.4)
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
radius: Kirigami.Units.cornerRadius !== undefined ? Kirigami.Units.cornerRadius : 5
|
||||
color: Qt.alpha(Kirigami.Theme.textColor, 0.06)
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
id: cardContent
|
||||
anchors.fill: parent
|
||||
anchors.margins: Kirigami.Units.largeSpacing
|
||||
spacing: Kirigami.Units.largeSpacing
|
||||
|
||||
RadialGauge {
|
||||
Layout.preferredWidth: Kirigami.Units.gridUnit * 4.5
|
||||
Layout.preferredHeight: Kirigami.Units.gridUnit * 4.5
|
||||
Layout.alignment: Qt.AlignTop
|
||||
outerColor: root.sessionColor
|
||||
innerColor: root.weeklyColor
|
||||
outerPercent: card.rings.outerIdx >= 0
|
||||
? card.modelData.windows[card.rings.outerIdx].usedPercent : -1
|
||||
innerPercent: card.rings.innerIdx >= 0
|
||||
? card.modelData.windows[card.rings.innerIdx].usedPercent : -1
|
||||
centerText: card.modelData.error
|
||||
? "!"
|
||||
: (card.centerPercent >= 0 ? card.centerPercent + "%" : "")
|
||||
centerTextScale: 0.2
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: Kirigami.Units.smallSpacing
|
||||
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: Kirigami.Units.smallSpacing
|
||||
|
||||
Kirigami.Heading {
|
||||
level: 4
|
||||
text: card.modelData.name
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
visible: card.modelData.status !== null
|
||||
width: Kirigami.Units.smallSpacing * 2
|
||||
height: width
|
||||
radius: width / 2
|
||||
color: Kirigami.Theme.negativeTextColor
|
||||
|
||||
PlasmaComponents3.ToolTip {
|
||||
text: card.modelData.status
|
||||
? (card.modelData.status.indicator + ": " + card.modelData.status.description)
|
||||
: ""
|
||||
}
|
||||
}
|
||||
|
||||
Item { Layout.fillWidth: true }
|
||||
|
||||
PlasmaComponents3.Label {
|
||||
visible: !!card.modelData.plan
|
||||
text: card.modelData.plan || ""
|
||||
opacity: 0.6
|
||||
font.pointSize: Kirigami.Theme.smallFont.pointSize
|
||||
}
|
||||
}
|
||||
|
||||
PlasmaComponents3.Label {
|
||||
visible: !!card.modelData.account
|
||||
Layout.fillWidth: true
|
||||
text: card.modelData.account || ""
|
||||
elide: Text.ElideMiddle
|
||||
opacity: 0.6
|
||||
font.pointSize: Kirigami.Theme.smallFont.pointSize
|
||||
}
|
||||
|
||||
PlasmaComponents3.Label {
|
||||
visible: !!card.modelData.error
|
||||
Layout.fillWidth: true
|
||||
text: card.modelData.error || ""
|
||||
wrapMode: Text.WordWrap
|
||||
color: Kirigami.Theme.negativeTextColor
|
||||
font.pointSize: Kirigami.Theme.smallFont.pointSize
|
||||
}
|
||||
|
||||
PlasmaComponents3.Label {
|
||||
visible: !!card.modelData.staleError
|
||||
Layout.fillWidth: true
|
||||
text: i18n("Refresh failed, showing data from %1: %2",
|
||||
Parser.formatAgo(card.modelData.fetchedAtMs !== undefined ? card.modelData.fetchedAtMs : -1, root.nowMs),
|
||||
card.modelData.staleError || "")
|
||||
wrapMode: Text.WordWrap
|
||||
color: Kirigami.Theme.neutralTextColor
|
||||
font.pointSize: Kirigami.Theme.smallFont.pointSize
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: card.modelData.windows
|
||||
|
||||
delegate: ColumnLayout {
|
||||
id: windowRow
|
||||
required property var modelData
|
||||
required property int index
|
||||
Layout.fillWidth: true
|
||||
spacing: 0
|
||||
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: Kirigami.Units.smallSpacing
|
||||
|
||||
Rectangle {
|
||||
width: Kirigami.Units.smallSpacing * 2
|
||||
height: width
|
||||
radius: width / 2
|
||||
color: card.ringColor(windowRow.index)
|
||||
}
|
||||
|
||||
PlasmaComponents3.Label {
|
||||
text: windowRow.modelData.label
|
||||
font.pointSize: Kirigami.Theme.smallFont.pointSize
|
||||
}
|
||||
|
||||
Item { Layout.fillWidth: true }
|
||||
|
||||
PlasmaComponents3.Label {
|
||||
text: windowRow.modelData.usageKnown === false
|
||||
? i18n("usage n/a")
|
||||
: i18n("%1% used", windowRow.modelData.usedPercent)
|
||||
font.pointSize: Kirigami.Theme.smallFont.pointSize
|
||||
color: windowRow.modelData.usageKnown === false
|
||||
? Kirigami.Theme.disabledTextColor
|
||||
: full.stageColor(windowRow.modelData.usedPercent)
|
||||
}
|
||||
|
||||
PlasmaComponents3.Label {
|
||||
text: "· " + full.resetText(windowRow.modelData)
|
||||
visible: full.resetText(windowRow.modelData).length > 0
|
||||
opacity: 0.6
|
||||
font.pointSize: Kirigami.Theme.smallFont.pointSize
|
||||
}
|
||||
}
|
||||
|
||||
PlasmaComponents3.Label {
|
||||
visible: root.showPace && windowRow.modelData.paceSummary.length > 0
|
||||
Layout.fillWidth: true
|
||||
Layout.leftMargin: Kirigami.Units.smallSpacing * 3
|
||||
text: windowRow.modelData.paceSummary
|
||||
elide: Text.ElideRight
|
||||
opacity: 0.5
|
||||
font.pointSize: Kirigami.Theme.smallFont.pointSize
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
PlasmaComponents3.Label {
|
||||
visible: card.modelData.credits !== null && card.modelData.credits > 0
|
||||
text: i18n("Credits: %1", card.modelData.credits !== null
|
||||
? Number(card.modelData.credits).toLocaleString(Qt.locale(), "f", 1)
|
||||
: "")
|
||||
font.pointSize: Kirigami.Theme.smallFont.pointSize
|
||||
opacity: 0.7
|
||||
}
|
||||
|
||||
// Local token cost scan (Codex/Claude): spend and tokens.
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
Layout.topMargin: Kirigami.Units.smallSpacing
|
||||
visible: card.cost !== null
|
||||
spacing: Kirigami.Units.largeSpacing
|
||||
|
||||
ColumnLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 0
|
||||
|
||||
PlasmaComponents3.Label {
|
||||
text: i18n("Today")
|
||||
opacity: 0.6
|
||||
font.pointSize: Kirigami.Theme.smallFont.pointSize
|
||||
}
|
||||
|
||||
PlasmaComponents3.Label {
|
||||
text: card.cost ? full.costLine(card.cost, "today") : ""
|
||||
font.bold: true
|
||||
}
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 0
|
||||
|
||||
PlasmaComponents3.Label {
|
||||
text: i18n("Last 30 days")
|
||||
opacity: 0.6
|
||||
font.pointSize: Kirigami.Theme.smallFont.pointSize
|
||||
horizontalAlignment: Text.AlignRight
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
|
||||
PlasmaComponents3.Label {
|
||||
text: card.cost ? full.costLine(card.cost, "month30") : ""
|
||||
font.bold: true
|
||||
horizontalAlignment: Text.AlignRight
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
HistoryChart {
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredHeight: Kirigami.Units.gridUnit * 2.2
|
||||
visible: root.showHistory && card.cost !== null && card.cost.daily.length > 0
|
||||
accentColor: root.sessionColor
|
||||
series: card.cost !== null
|
||||
? Parser.chartSeries(card.cost.daily, 14, new Date(root.nowMs).toISOString().slice(0, 10))
|
||||
: []
|
||||
}
|
||||
|
||||
PlasmaComponents3.Label {
|
||||
Layout.fillWidth: true
|
||||
visible: text.length > 0
|
||||
text: {
|
||||
var parts = []
|
||||
if (card.modelData.source) {
|
||||
parts.push(i18n("Source: %1", card.modelData.source))
|
||||
}
|
||||
if (card.modelData.version) {
|
||||
parts.push(i18n("Version: %1", card.modelData.version))
|
||||
}
|
||||
return parts.join(" · ")
|
||||
}
|
||||
opacity: 0.45
|
||||
font.pointSize: Kirigami.Theme.smallFont.pointSize
|
||||
horizontalAlignment: Text.AlignRight
|
||||
elide: Text.ElideLeft
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import QtQuick
|
||||
import QtQuick.Layouts
|
||||
import org.kde.kirigami as Kirigami
|
||||
|
||||
// Rounded per-day spend bars for the last N days (zero days render as dots).
|
||||
Item {
|
||||
id: chart
|
||||
|
||||
property var series: []
|
||||
property color accentColor: Kirigami.Theme.highlightColor
|
||||
|
||||
readonly property real maxValue: {
|
||||
var max = 0
|
||||
for (var i = 0; i < chart.series.length; i++) {
|
||||
if (chart.series[i].value > max) {
|
||||
max = chart.series[i].value
|
||||
}
|
||||
}
|
||||
return max
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
anchors.fill: parent
|
||||
spacing: 2
|
||||
|
||||
Repeater {
|
||||
model: chart.series
|
||||
|
||||
delegate: Item {
|
||||
required property var modelData
|
||||
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
|
||||
Rectangle {
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.bottom: parent.bottom
|
||||
height: chart.maxValue > 0
|
||||
? Math.max(3, parent.height * modelData.value / chart.maxValue)
|
||||
: 3
|
||||
radius: Math.min(width / 2, 4)
|
||||
color: chart.accentColor
|
||||
opacity: modelData.value > 0 ? 0.75 : 0.2
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import QtQuick
|
||||
import QtQuick.Shapes
|
||||
import org.kde.kirigami as Kirigami
|
||||
import "../code/parser.js" as Parser
|
||||
|
||||
// Two-ring radial gauge: outer ring = session window, inner ring = weekly
|
||||
// window. A percent of -1 leaves the ring as a bare track.
|
||||
Item {
|
||||
id: gauge
|
||||
|
||||
property real outerPercent: -1
|
||||
property real innerPercent: -1
|
||||
property color outerColor: Kirigami.Theme.highlightColor
|
||||
property color innerColor: "#f67400"
|
||||
property string centerText: ""
|
||||
property real centerTextScale: 0.32
|
||||
|
||||
readonly property real ringWidth: Math.max(2, Math.min(width, height) / 15)
|
||||
readonly property real ringGap: ringWidth * 1.2
|
||||
readonly property real outerRadius: (Math.min(width, height) - ringWidth) / 2
|
||||
readonly property real innerRadius: outerRadius - ringWidth - ringGap
|
||||
readonly property color trackColor: Qt.alpha(Kirigami.Theme.textColor, 0.18)
|
||||
|
||||
Shape {
|
||||
anchors.fill: parent
|
||||
preferredRendererType: Shape.CurveRenderer
|
||||
|
||||
// Outer track.
|
||||
ShapePath {
|
||||
strokeColor: gauge.trackColor
|
||||
fillColor: "transparent"
|
||||
strokeWidth: gauge.ringWidth
|
||||
capStyle: ShapePath.FlatCap
|
||||
PathAngleArc {
|
||||
centerX: gauge.width / 2
|
||||
centerY: gauge.height / 2
|
||||
radiusX: gauge.outerRadius
|
||||
radiusY: gauge.outerRadius
|
||||
startAngle: 0
|
||||
sweepAngle: 360
|
||||
}
|
||||
}
|
||||
|
||||
// Inner track.
|
||||
ShapePath {
|
||||
strokeColor: gauge.trackColor
|
||||
fillColor: "transparent"
|
||||
strokeWidth: gauge.ringWidth
|
||||
capStyle: ShapePath.FlatCap
|
||||
PathAngleArc {
|
||||
centerX: gauge.width / 2
|
||||
centerY: gauge.height / 2
|
||||
radiusX: gauge.innerRadius
|
||||
radiusY: gauge.innerRadius
|
||||
startAngle: 0
|
||||
sweepAngle: 360
|
||||
}
|
||||
}
|
||||
|
||||
// Outer progress (session).
|
||||
ShapePath {
|
||||
strokeColor: gauge.outerColor
|
||||
fillColor: "transparent"
|
||||
strokeWidth: gauge.ringWidth
|
||||
capStyle: ShapePath.RoundCap
|
||||
PathAngleArc {
|
||||
centerX: gauge.width / 2
|
||||
centerY: gauge.height / 2
|
||||
radiusX: gauge.outerRadius
|
||||
radiusY: gauge.outerRadius
|
||||
startAngle: -90
|
||||
sweepAngle: gauge.outerPercent >= 0 ? Parser.sweepAngle(gauge.outerPercent) : 0
|
||||
}
|
||||
}
|
||||
|
||||
// Inner progress (weekly).
|
||||
ShapePath {
|
||||
strokeColor: gauge.innerColor
|
||||
fillColor: "transparent"
|
||||
strokeWidth: gauge.ringWidth
|
||||
capStyle: ShapePath.RoundCap
|
||||
PathAngleArc {
|
||||
centerX: gauge.width / 2
|
||||
centerY: gauge.height / 2
|
||||
radiusX: gauge.innerRadius
|
||||
radiusY: gauge.innerRadius
|
||||
startAngle: -90
|
||||
sweepAngle: gauge.innerPercent >= 0 ? Parser.sweepAngle(gauge.innerPercent) : 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
visible: gauge.centerText.length > 0
|
||||
text: gauge.centerText
|
||||
color: Kirigami.Theme.textColor
|
||||
font.pixelSize: Math.max(7, Math.min(gauge.width, gauge.height) * gauge.centerTextScale)
|
||||
font.bold: true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
import QtQuick
|
||||
import org.kde.plasma.plasmoid
|
||||
import org.kde.plasma.core as PlasmaCore
|
||||
import org.kde.plasma.plasma5support as Plasma5Support
|
||||
import org.kde.kirigami as Kirigami
|
||||
import "../code/parser.js" as Parser
|
||||
|
||||
PlasmoidItem {
|
||||
id: root
|
||||
|
||||
// Cached provider view models keyed by provider id. Entries are only
|
||||
// replaced when fresh data arrives, so the panel never blanks out while
|
||||
// a background refresh is in flight.
|
||||
property var modelsById: ({})
|
||||
// Display order of provider ids (selection order, or arrival order when
|
||||
// following the CLI config).
|
||||
property var modelOrder: []
|
||||
// Ordered list consumed by the representations.
|
||||
property var models: []
|
||||
property string globalError: ""
|
||||
property bool loading: false
|
||||
property int pendingCount: 0
|
||||
property double lastUpdatedMs: -1
|
||||
// Ticks every 30s so countdown and "ago" labels stay fresh.
|
||||
property double nowMs: Date.now()
|
||||
|
||||
// Ring palette, shared by compact and full representations. The weekly
|
||||
// orange is darkened on light themes to keep contrast against the track.
|
||||
readonly property bool darkTheme: Kirigami.Theme.textColor.hslLightness > 0.5
|
||||
readonly property color sessionColor: root.darkTheme ? "#3daee9" : "#2980b9"
|
||||
readonly property color weeklyColor: root.darkTheme ? "#f67400" : "#c85400"
|
||||
|
||||
// Local token-cost stats keyed by provider id (Codex/Claude scans).
|
||||
property var costById: ({})
|
||||
|
||||
readonly property string codexbarPath: Plasmoid.configuration.codexbarPath || "codexbar"
|
||||
readonly property bool showPace: Plasmoid.configuration.showPace
|
||||
readonly property bool showCost: Plasmoid.configuration.showCost
|
||||
readonly property bool showHistory: Plasmoid.configuration.showHistory
|
||||
readonly property string proxyUrl: Plasmoid.configuration.proxyUrl || ""
|
||||
readonly property var selectedProviders: Parser.selectionList(Plasmoid.configuration.selectedProviders || "")
|
||||
|
||||
readonly property var commands: Parser.buildCommands(root.codexbarPath, root.selectedProviders, root.proxyUrl)
|
||||
readonly property string costCommand: Parser.buildCostCommand(root.codexbarPath, root.proxyUrl)
|
||||
|
||||
switchWidth: Kirigami.Units.gridUnit * 15
|
||||
switchHeight: Kirigami.Units.gridUnit * 12
|
||||
|
||||
Plasmoid.icon: "office-chart-bar"
|
||||
toolTipMainText: i18n("CodexBar")
|
||||
toolTipSubText: {
|
||||
var lines = []
|
||||
for (var i = 0; i < root.models.length; i++) {
|
||||
var m = root.models[i]
|
||||
if (m.error) {
|
||||
lines.push(m.name + ": " + m.error)
|
||||
continue
|
||||
}
|
||||
var parts = []
|
||||
for (var j = 0; j < m.windows.length; j++) {
|
||||
var w = m.windows[j]
|
||||
if (w.usageKnown === false) {
|
||||
continue
|
||||
}
|
||||
parts.push(w.label + " " + w.usedPercent + "% used")
|
||||
}
|
||||
lines.push(m.name + ": " + (parts.length ? parts.join(", ") : i18n("no data")))
|
||||
}
|
||||
if (root.globalError.length > 0 && root.models.length === 0) {
|
||||
lines.push(root.globalError)
|
||||
}
|
||||
var ago = Parser.formatAgo(root.lastUpdatedMs, root.nowMs)
|
||||
if (root.loading) {
|
||||
lines.push(ago.length > 0
|
||||
? i18n("Updating… (last update %1)", ago)
|
||||
: i18n("Updating…"))
|
||||
} else if (ago.length > 0) {
|
||||
lines.push(i18n("Updated %1", ago))
|
||||
}
|
||||
return lines.join("\n")
|
||||
}
|
||||
|
||||
onCommandsChanged: root.refresh()
|
||||
|
||||
function refresh() {
|
||||
if (root.loading) {
|
||||
return
|
||||
}
|
||||
root.loading = true
|
||||
root.pendingCount = root.commands.length + (root.showCost ? 1 : 0)
|
||||
// Old models stay on screen; the executable engine reruns each
|
||||
// command and results merge in as they arrive.
|
||||
executable.connectedSources = []
|
||||
for (var i = 0; i < root.commands.length; i++) {
|
||||
executable.connectSource(root.commands[i])
|
||||
}
|
||||
if (root.showCost) {
|
||||
executable.connectSource(root.costCommand)
|
||||
}
|
||||
}
|
||||
|
||||
function handleCostResult(stdout) {
|
||||
var parsed = Parser.parseCostJson(stdout)
|
||||
if (parsed.ok) {
|
||||
root.costById = parsed.byId
|
||||
root.persistCache()
|
||||
}
|
||||
// A failed cost scan keeps the previous numbers; usage is unaffected.
|
||||
}
|
||||
|
||||
function handleResult(stdout, stderr, exitCode) {
|
||||
var parsed = Parser.parseUsageJson(stdout)
|
||||
if (!parsed.ok) {
|
||||
var message = stderr && stderr.trim().length > 0
|
||||
? stderr.trim().split("\n").pop()
|
||||
: parsed.error
|
||||
if (exitCode === 127) {
|
||||
message = i18n("codexbar binary not found — set its path in the widget settings")
|
||||
}
|
||||
// No parseable payload: keep whatever is cached, surface the error.
|
||||
root.globalError = root.models.length === 0 ? message : ""
|
||||
return
|
||||
}
|
||||
root.globalError = ""
|
||||
var now = Date.now()
|
||||
var state = Parser.applyUpdate(root.modelsById, parsed.models, now)
|
||||
root.modelsById = state.byId
|
||||
var hadFreshData = parsed.models.some(function(m) { return !m.error })
|
||||
if (hadFreshData) {
|
||||
root.lastUpdatedMs = now
|
||||
}
|
||||
for (var i = 0; i < parsed.models.length; i++) {
|
||||
var id = parsed.models[i].id
|
||||
if (root.modelOrder.indexOf(id) === -1) {
|
||||
root.modelOrder = root.modelOrder.concat([id])
|
||||
}
|
||||
}
|
||||
root.rebuildModels()
|
||||
root.persistCache()
|
||||
}
|
||||
|
||||
function rebuildModels() {
|
||||
var order = root.selectedProviders.length > 0
|
||||
? root.selectedProviders
|
||||
: root.modelOrder
|
||||
var list = []
|
||||
for (var i = 0; i < order.length; i++) {
|
||||
var m = root.modelsById[order[i]]
|
||||
if (m) {
|
||||
list.push(m)
|
||||
}
|
||||
}
|
||||
root.models = list
|
||||
}
|
||||
|
||||
function persistCache() {
|
||||
Plasmoid.configuration.cacheJson = JSON.stringify({
|
||||
byId: root.modelsById,
|
||||
order: root.modelOrder,
|
||||
costById: root.costById,
|
||||
updatedAtMs: root.lastUpdatedMs,
|
||||
})
|
||||
}
|
||||
|
||||
function loadCache() {
|
||||
var raw = Plasmoid.configuration.cacheJson || ""
|
||||
if (raw.length === 0) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
var cache = JSON.parse(raw)
|
||||
root.modelsById = cache.byId || {}
|
||||
root.modelOrder = cache.order || []
|
||||
root.costById = cache.costById || {}
|
||||
root.lastUpdatedMs = cache.updatedAtMs !== undefined ? cache.updatedAtMs : -1
|
||||
root.rebuildModels()
|
||||
} catch (e) {
|
||||
// Corrupt cache is not fatal; the next refresh rewrites it.
|
||||
}
|
||||
}
|
||||
|
||||
Plasma5Support.DataSource {
|
||||
id: executable
|
||||
engine: "executable"
|
||||
connectedSources: []
|
||||
onNewData: function(source, data) {
|
||||
executable.disconnectSource(source)
|
||||
root.pendingCount = Math.max(0, root.pendingCount - 1)
|
||||
if (root.pendingCount === 0) {
|
||||
root.loading = false
|
||||
}
|
||||
if (source === root.costCommand) {
|
||||
root.handleCostResult(data["stdout"] || "")
|
||||
} else {
|
||||
root.handleResult(
|
||||
data["stdout"] || "",
|
||||
data["stderr"] || "",
|
||||
data["exit code"])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Timer {
|
||||
interval: Math.max(1, Plasmoid.configuration.refreshMinutes) * 60 * 1000
|
||||
running: true
|
||||
repeat: true
|
||||
onTriggered: root.refresh()
|
||||
}
|
||||
|
||||
Timer {
|
||||
interval: 30 * 1000
|
||||
running: true
|
||||
repeat: true
|
||||
onTriggered: root.nowMs = Date.now()
|
||||
}
|
||||
|
||||
Component.onCompleted: {
|
||||
root.loadCache()
|
||||
root.refresh()
|
||||
}
|
||||
|
||||
compactRepresentation: CompactRepresentation {}
|
||||
fullRepresentation: FullRepresentation {}
|
||||
}
|
||||
Reference in New Issue
Block a user