From f6f13d4a4649831b36840de4569df9233e7b02e0 Mon Sep 17 00:00:00 2001 From: JJDizz1L Date: Sat, 27 Jun 2026 21:42:38 -0400 Subject: [PATCH 1/2] feat: add opencode usage tracking plugin --- shell/plugins/opencode-model-usage/Main.qml | 91 ++ shell/plugins/opencode-model-usage/Widget.qml | 805 ++++++++++++++++++ .../opencode-model-usage/assets/opencode.svg | 4 + .../opencode-model-usage/manifest.json | 26 + .../providers/Opencode.qml | 116 +++ .../scripts/opencode_usage_scanner.py | 179 ++++ 6 files changed, 1221 insertions(+) create mode 100644 shell/plugins/opencode-model-usage/Main.qml create mode 100644 shell/plugins/opencode-model-usage/Widget.qml create mode 100644 shell/plugins/opencode-model-usage/assets/opencode.svg create mode 100644 shell/plugins/opencode-model-usage/manifest.json create mode 100644 shell/plugins/opencode-model-usage/providers/Opencode.qml create mode 100644 shell/plugins/opencode-model-usage/scripts/opencode_usage_scanner.py diff --git a/shell/plugins/opencode-model-usage/Main.qml b/shell/plugins/opencode-model-usage/Main.qml new file mode 100644 index 0000000000..fb46c7c1ce --- /dev/null +++ b/shell/plugins/opencode-model-usage/Main.qml @@ -0,0 +1,91 @@ +import QtQuick +import Quickshell +import "providers" + +Item { + id: root + visible: false + + property var settings: ({}) + + Opencode { + id: opencodeProvider + enabled: true + providerSettings: root.settings?.providers?.opencode ?? ({}) + } + + property var providers: [opencodeProvider] + property var enabledProviders: { + var result = [] + if (opencodeProvider.enabled) result.push(displayProvider(opencodeProvider)) + return result + } + + property bool refreshing: opencodeProvider.refreshing + property double lastRefreshedAtMs: opencodeProvider.lastRefreshedAtMs || 0 + property int refreshIntervalSec: Math.max(30, Number(root.setting("refreshIntervalSec", 300))) + + Timer { + interval: root.refreshIntervalSec * 1000 + running: true + repeat: true + triggeredOnStart: true + onTriggered: root.refreshAll() + } + + function setting(name, fallback) { + var value = settings ? settings[name] : undefined + return value === undefined || value === null ? fallback : value + } + + function displayProvider(provider) { + return { + providerId: provider.providerId, + providerName: provider.providerName, + providerIcon: provider.providerIcon, + enabled: provider.enabled, + ready: provider.ready, + refreshing: provider.refreshing, + lastRefreshedAtMs: provider.lastRefreshedAtMs, + usageStatusText: provider.usageStatusText, + authHelpText: provider.authHelpText, + todayPrompts: provider.todayPrompts, + todaySessions: provider.todaySessions, + todayTotalTokens: provider.todayTotalTokens, + todayTokensByModel: provider.todayTokensByModel, + recentDays: provider.recentDays, + totalPrompts: provider.totalPrompts, + totalSessions: provider.totalSessions, + modelUsage: provider.modelUsage, + hasLocalStats: provider.hasLocalStats, + formatResetTime: function(isoTimestamp) { return provider.formatResetTime(isoTimestamp) } + } + } + + function refreshAll(force) { + opencodeProvider.refresh(force === true) + } + + function formatTokenCount(n) { + if (n === undefined || n === null) return "0" + if (n >= 1e9) return (n / 1e9).toFixed(1) + "B" + if (n >= 1e6) return (n / 1e6).toFixed(1) + "M" + if (n >= 1e3) return (n / 1e3).toFixed(1) + "K" + return String(n) + } + + function friendlyModelName(id) { + if (!id) return "Unknown" + // Handle JSON string model ids like {"id":"deepseek","providerID":"opencode"} + if (id.charAt(0) === "{") { + try { + var parsed = JSON.parse(id) + var modelId = String(parsed.id || "") + var provider = String(parsed.providerID || "") + if (modelId && provider) return modelId + " (" + provider + ")" + if (modelId) return modelId + } catch (e) {} + } + return String(id) + } +} diff --git a/shell/plugins/opencode-model-usage/Widget.qml b/shell/plugins/opencode-model-usage/Widget.qml new file mode 100644 index 0000000000..3a27d62fe2 --- /dev/null +++ b/shell/plugins/opencode-model-usage/Widget.qml @@ -0,0 +1,805 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell +import Quickshell.Io +import qs.Commons +import qs.Ui + +BarWidget { + id: root + moduleName: "omarchy.opencode-model-usage" + + property bool popupOpen: false + property bool settingsMode: false + property var draftSettings: ({}) + property string settingsStatusText: "" + property bool refreshFlash: false + + readonly property color foreground: bar ? bar.foreground : Color.foreground + readonly property color background: Color.popups.background + readonly property color border: Color.popups.border + readonly property color urgent: bar ? bar.urgent : Color.urgent + readonly property color dim: Qt.darker(foreground, 1.45) + readonly property color card: Qt.rgba(foreground.r, foreground.g, foreground.b, 0.055) + readonly property color cardHover: Qt.rgba(foreground.r, foreground.g, foreground.b, 0.085) + readonly property color outline: Qt.rgba(foreground.r, foreground.g, foreground.b, 0.18) + readonly property color track: Qt.rgba(foreground.r, foreground.g, foreground.b, 0.24) + readonly property string fontFamily: bar ? bar.fontFamily : "JetBrainsMono Nerd Font" + + readonly property var provider: usageMain.enabledProviders.length > 0 ? usageMain.enabledProviders[0] : null + + function close() { + popupOpen = false + settingsMode = false + } + + function triggerPress(button) { + if (button === Qt.RightButton) { + openSettings() + return + } + if (button === Qt.MiddleButton) { + triggerRefresh() + return + } + + if (popupOpen) { + popupOpen = false + } else { + popupOpen = true + triggerRefresh() + } + } + + function triggerRefresh() { + refreshFlash = true + refreshFlashTimer.restart() + usageMain.refreshAll(true) + } + + function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)) } + function alpha(c, a) { return Qt.rgba(c.r, c.g, c.b, a) } + + function cloneObject(value, fallback) { + if (value === undefined || value === null) return fallback + try { return JSON.parse(JSON.stringify(value)) } + catch (e) { return fallback } + } + + function defaultSettings() { + return { refreshIntervalSec: 300 } + } + + function normalizedSettings(source) { + var next = cloneObject(source, {}) || {} + var refresh = Number(next.refreshIntervalSec === undefined || next.refreshIntervalSec === null ? 300 : next.refreshIntervalSec) + next.refreshIntervalSec = Math.round(clamp(isFinite(refresh) ? refresh : 300, 30, 3600)) + return next + } + + function openSettings() { + draftSettings = normalizedSettings(settings) + settingsStatusText = "" + settingsMode = true + popupOpen = true + Qt.callLater(function() { if (keyCatcher) keyCatcher.forceActiveFocus() }) + } + + function showUsage() { + settingsMode = false + settingsStatusText = "" + Qt.callLater(function() { if (keyCatcher) keyCatcher.forceActiveFocus() }) + } + + function canPersistSettings() { + return !!(bar && bar.shell && typeof bar.shell.updateEntryInline === "function") + } + + function saveSettings() { + var next = normalizedSettings(draftSettings) + draftSettings = next + root.settings = next + if (canPersistSettings()) { + bar.shell.updateEntryInline(root.moduleName, next) + settingsStatusText = "Saved to shell.json" + } else { + settingsStatusText = "Saved for this session" + } + usageMain.refreshAll(true) + } + + function colorChannelLuminance(value) { + var channel = Number(value) + if (!isFinite(channel)) return 0 + return channel <= 0.03928 ? channel / 12.92 : Math.pow((channel + 0.055) / 1.055, 2.4) + } + + function colorLuminance(color) { + return 0.2126 * colorChannelLuminance(color.r) + + 0.7152 * colorChannelLuminance(color.g) + + 0.0722 * colorChannelLuminance(color.b) + } + + function iconSource() { + return Qt.resolvedUrl("assets/opencode.svg") + } + + function usagePercent() { + return -1 + } + + function formatUsagePercent() { + var toks = provider ? (provider.todayTotalTokens || 0) : 0 + if (toks <= 0) return "" + return usageMain.formatTokenCount(toks) + } + + function tooltipText() { + if (!provider) return "OpenCode Usage" + var line = provider.providerName + ": " + usageMain.formatTokenCount(provider.todayTotalTokens || 0) + " tokens today" + return line + } + + implicitWidth: button.implicitWidth + implicitHeight: button.implicitHeight + + onPopupOpenChanged: { + if (popupOpen) + Qt.callLater(function() { if (keyCatcher) keyCatcher.forceActiveFocus() }) + } + + Main { + id: usageMain + settings: root.settings + } + + Timer { + id: refreshFlashTimer + interval: 900 + repeat: false + onTriggered: root.refreshFlash = false + } + + IpcHandler { + target: "omarchy.opencode-model-usage" + function open(): string { root.showUsage(); root.popupOpen = true; return "ok" } + function close(): string { root.close(); return "ok" } + function toggle(): string { + if (root.popupOpen) root.close() + else { root.showUsage(); root.popupOpen = true } + return "ok" + } + function refresh(): string { root.triggerRefresh(); return "ok" } + function settings(): string { root.openSettings(); return "ok" } + function openSettings(): string { root.openSettings(); return "ok" } + } + + component UsageChip: Item { + id: chip + + readonly property bool compact: root.vertical + readonly property bool tooltipHovered: mouseArea.containsMouse + + width: compact ? root.barSize : chipRow.implicitWidth + height: compact ? Math.max(root.barSize, chipColumn.implicitHeight + Style.space(2)) : root.barSize + + Row { + id: chipRow + visible: !chip.compact + anchors.centerIn: parent + spacing: 4 + + Image { + source: root.iconSource() + width: 13 + height: 13 + sourceSize.width: 13 + sourceSize.height: 13 + fillMode: Image.PreserveAspectFit + anchors.verticalCenter: parent.verticalCenter + } + + Text { + text: root.formatUsagePercent() + color: foreground + font.family: fontFamily + font.pixelSize: 10 + font.bold: true + anchors.verticalCenter: parent.verticalCenter + visible: text !== "" + } + } + + Column { + id: chipColumn + visible: chip.compact + anchors.centerIn: parent + spacing: 1 + + Image { + source: root.iconSource() + width: 13 + height: 13 + sourceSize.width: 13 + sourceSize.height: 13 + fillMode: Image.PreserveAspectFit + anchors.horizontalCenter: parent.horizontalCenter + } + + Text { + width: root.barSize + text: root.formatUsagePercent() + color: foreground + font.family: fontFamily + font.pixelSize: 10 + font.bold: true + horizontalAlignment: Text.AlignHCenter + } + } + + property var registeredBar: null + + function triggerPress(button) { + root.triggerPress(button) + } + + function syncClickRegistration() { + if (registeredBar && registeredBar.unregisterClickTarget) registeredBar.unregisterClickTarget(chip) + registeredBar = root.bar + if (registeredBar && registeredBar.registerClickTarget) registeredBar.registerClickTarget(chip) + } + + Component.onCompleted: syncClickRegistration() + Component.onDestruction: if (registeredBar && registeredBar.unregisterClickTarget) registeredBar.unregisterClickTarget(chip) + + Connections { + target: root + function onBarChanged() { chip.syncClickRegistration() } + } + + MouseArea { + id: mouseArea + anchors.fill: parent + acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onEntered: if (root.bar) root.bar.showTooltip(chip, root.tooltipText()) + onExited: if (root.bar) root.bar.hideTooltip(chip) + onClicked: function(mouse) { root.triggerPress(mouse.button) } + } + } + + component EmptyUsageChip: Item { + id: emptyChip + readonly property bool tooltipHovered: emptyMouse.containsMouse + visible: !provider + width: root.vertical ? root.barSize : emptyLabel.implicitWidth + height: root.barSize + + property var registeredBar: null + + function triggerPress(button) { root.triggerPress(button) } + + function syncClickRegistration() { + if (registeredBar && registeredBar.unregisterClickTarget) registeredBar.unregisterClickTarget(emptyChip) + registeredBar = root.bar + if (registeredBar && registeredBar.registerClickTarget) registeredBar.registerClickTarget(emptyChip) + } + + Component.onCompleted: syncClickRegistration() + Component.onDestruction: if (registeredBar && registeredBar.unregisterClickTarget) registeredBar.unregisterClickTarget(emptyChip) + + Connections { + target: root + function onBarChanged() { emptyChip.syncClickRegistration() } + } + + Text { + id: emptyLabel + anchors.centerIn: parent + text: "OC" + color: dim + font.family: fontFamily + font.pixelSize: 10 + font.bold: true + } + + MouseArea { + id: emptyMouse + anchors.fill: parent + acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onEntered: if (root.bar) root.bar.showTooltip(emptyChip, "OpenCode Usage") + onExited: if (root.bar) root.bar.hideTooltip(emptyChip) + onClicked: function(mouse) { root.triggerPress(mouse.button) } + } + } + + Item { + id: button + anchors.fill: parent + implicitWidth: root.vertical ? root.barSize : barRow.implicitWidth + Style.space(10) + implicitHeight: root.vertical ? barColumn.implicitHeight : root.barSize + + Row { + id: barRow + visible: !root.vertical + anchors.centerIn: parent + spacing: Style.space(8) + UsageChip { visible: !root.vertical } + EmptyUsageChip { visible: !root.vertical && !provider } + } + + Column { + id: barColumn + visible: root.vertical + anchors.centerIn: parent + spacing: Style.space(2) + UsageChip { visible: root.vertical } + EmptyUsageChip { visible: root.vertical && !provider } + } + } + + KeyboardPanel { + id: panel + anchorItem: button + owner: root + bar: root.bar + open: root.popupOpen + focusTarget: keyCatcher + contentWidth: panel.fittedContentWidth(Style.space(370)) + contentHeight: panel.fittedContentHeight(contentColumn.implicitHeight, Style.space(560)) + + PanelKeyCatcher { + id: keyCatcher + anchors.fill: parent + blocked: settingsMode && settingsContent.editorActive + + onMoveRequested: function(dx, dy) { + if (dy !== 0) flick.contentY = root.clamp(flick.contentY + dy * 56, 0, Math.max(0, flick.contentHeight - flick.height)) + } + onCloseRequested: root.close() + onTextKey: function(t) { + if (t === "r" || t === "R") root.triggerRefresh() + if (t === "s" || t === "S") root.settingsMode ? root.saveSettings() : root.openSettings() + } + + ColumnLayout { + anchors.fill: parent + spacing: 10 + + Header { + visible: !root.settingsMode && !!root.provider + provider: root.provider + } + + SettingsHeader { visible: root.settingsMode } + + PanelSeparator { + Layout.fillWidth: true + foreground: root.foreground + } + + Flickable { + id: flick + Layout.fillWidth: true + Layout.fillHeight: true + contentWidth: width + contentHeight: contentColumn.implicitHeight + clip: true + boundsBehavior: Flickable.StopAtBounds + flickableDirection: Flickable.VerticalFlick + ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded } + + ColumnLayout { + id: contentColumn + width: flick.width + spacing: 10 + + Text { + visible: !root.settingsMode && !root.provider + Layout.fillWidth: true + Layout.topMargin: 24 + text: "No usage data yet. Start an OpenCode session to see stats." + color: dim + font.family: fontFamily + font.pixelSize: 11 + horizontalAlignment: Text.AlignHCenter + } + + StatusCard { provider: root.settingsMode ? null : root.provider } + TodayCard { provider: root.settingsMode ? null : root.provider } + WeekCard { provider: root.settingsMode ? null : root.provider } + AllTimeCard { provider: root.settingsMode ? null : root.provider } + + UsageFooter { visible: !root.settingsMode } + SettingsContent { + id: settingsContent + visible: root.settingsMode + } + } + } + } + } + } + + component SettingsHeader: RowLayout { + Layout.fillWidth: true + spacing: 8 + + Text { + text: "OpenCode Usage Settings" + color: foreground + font.family: fontFamily + font.pixelSize: 15 + font.bold: true + Layout.fillWidth: true + Layout.alignment: Qt.AlignVCenter + } + + Button { + text: "Usage" + foreground: root.foreground + tooltipText: "Back to usage" + tooltipBackground: root.background + tooltipForeground: root.foreground + fontFamily: root.fontFamily + fontSize: 10 + horizontalPadding: 8 + verticalPadding: 4 + onClicked: root.showUsage() + } + + Button { + text: "Save" + foreground: root.foreground + tooltipText: "Save settings" + tooltipBackground: root.background + tooltipForeground: root.foreground + fontFamily: root.fontFamily + fontSize: 10 + horizontalPadding: 8 + verticalPadding: 4 + active: true + onClicked: root.saveSettings() + } + } + + component UsageFooter: RowLayout { + Layout.fillWidth: true + spacing: 8 + + Text { + Layout.fillWidth: true + text: "j/k scroll · r refresh · s/settings · esc close" + color: dim + font.family: fontFamily + font.pixelSize: 10 + horizontalAlignment: Text.AlignHCenter + wrapMode: Text.WordWrap + } + } + + component SettingsContent: ColumnLayout { + id: settingsRoot + Layout.fillWidth: true + spacing: 10 + + readonly property bool editorActive: refreshIntervalField.field.activeFocus + + SectionCard { + title: "Refresh" + + ColumnLayout { + width: parent.width + spacing: 8 + + NumberField { + id: refreshIntervalField + label: "Refresh interval (seconds)" + value: Number(root.draftValue("refreshIntervalSec", 300)) + from: 30 + to: 3600 + stepSize: 30 + fieldWidth: parent.width + foreground: root.foreground + accent: Color.accent + fontFamily: root.fontFamily + onModified: function(value) { root.setDraftValue("refreshIntervalSec", value) } + } + + Text { + Layout.fillWidth: true + text: "How often the widget rescans the opencode database." + color: dim + font.family: fontFamily + font.pixelSize: 10 + wrapMode: Text.WordWrap + } + } + } + + Text { + visible: root.settingsStatusText !== "" + Layout.fillWidth: true + text: root.settingsStatusText + color: dim + font.family: fontFamily + font.pixelSize: 10 + horizontalAlignment: Text.AlignHCenter + } + + Text { + Layout.fillWidth: true + text: "s saves · esc closes" + color: dim + font.family: fontFamily + font.pixelSize: 10 + horizontalAlignment: Text.AlignHCenter + } + } + + function draftValue(name, fallback) { + var value = draftSettings ? draftSettings[name] : undefined + return value === undefined || value === null ? fallback : value + } + + function setDraftValue(name, value) { + var next = normalizedSettings(draftSettings) + next[name] = value + draftSettings = next + } + + component Header: RowLayout { + property var provider: null + visible: !!provider + Layout.fillWidth: true + spacing: 8 + + Image { + source: root.iconSource() + Layout.preferredWidth: 16 + Layout.preferredHeight: 16 + sourceSize.width: 16 + sourceSize.height: 16 + fillMode: Image.PreserveAspectFit + Layout.alignment: Qt.AlignVCenter + } + + Text { + text: provider ? provider.providerName + " Usage" : "" + color: foreground + font.family: fontFamily + font.pixelSize: 15 + font.bold: true + Layout.fillWidth: true + Layout.alignment: Qt.AlignVCenter + } + + Button { + text: (root.refreshFlash || usageMain.refreshing) ? "Refreshing\u2026" : "Refresh" + foreground: root.foreground + tooltipText: (root.refreshFlash || usageMain.refreshing) ? "Refreshing usage\u2026" : "Refresh usage" + tooltipBackground: root.background + tooltipForeground: root.foreground + fontFamily: root.fontFamily + fontSize: 10 + horizontalPadding: 8 + verticalPadding: 4 + active: root.refreshFlash || usageMain.refreshing + onClicked: { + root.triggerRefresh() + keyCatcher.forceActiveFocus() + } + } + } + + component StatusCard: SectionCard { + property var provider: null + visible: !!provider && String(provider.usageStatusText || "") !== "" + titleColor: urgent + title: provider ? provider.usageStatusText : "" + subtitle: provider ? provider.authHelpText : "" + } + + component TodayCard: SectionCard { + property var provider: null + visible: !!provider && provider.ready && provider.hasLocalStats + title: "Today" + + ColumnLayout { + width: parent.width + spacing: 8 + RowLayout { + Layout.fillWidth: true + spacing: 20 + StatBlock { value: provider ? String(provider.todayPrompts || 0) : "0"; label: "sessions" } + StatBlock { value: provider ? usageMain.formatTokenCount(provider.todayTotalTokens || 0) : "0"; label: "tokens" } + } + Repeater { + model: { + var toks = provider ? (provider.todayTokensByModel || {}) : {} + var out = [] + for (var k in toks) out.push({ modelId: k, count: toks[k] }) + return out + } + delegate: RowLayout { + required property var modelData + Layout.fillWidth: true + Text { text: usageMain.friendlyModelName(modelData.modelId); color: dim; font.family: fontFamily; font.pixelSize: 11 } + Item { Layout.fillWidth: true } + Text { text: usageMain.formatTokenCount(modelData.count) + " tokens"; color: foreground; font.family: fontFamily; font.pixelSize: 11; font.bold: true } + } + } + } + } + + component WeekCard: SectionCard { + property var provider: null + visible: !!provider && provider.recentDays && provider.recentDays.length > 0 + title: "Last 7 Days" + + ColumnLayout { + width: parent.width + spacing: 6 + Repeater { + model: provider ? provider.recentDays : [] + delegate: RowLayout { + required property var modelData + Layout.fillWidth: true + spacing: 8 + readonly property real count: modelData ? Number(modelData.messageCount || 0) : 0 + readonly property real maxCount: { + var days = provider ? (provider.recentDays || []) : [] + var max = 1 + for (var i = 0; i < days.length; i++) if (Number(days[i].messageCount || 0) > max) max = Number(days[i].messageCount || 0) + return max + } + Text { + text: { + var d = modelData.date + if (!d) return "" + var dt = new Date(d + "T00:00:00") + var names = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"] + return names[dt.getDay()] + " " + String(dt.getMonth() + 1).padStart(2, "0") + "/" + String(dt.getDate()).padStart(2, "0") + } + color: dim + font.family: fontFamily + font.pixelSize: 10 + Layout.preferredWidth: 48 + } + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 10 + color: track + radius: Math.max(1, Style.cornerRadius / 3) + Rectangle { + anchors.left: parent.left + anchors.top: parent.top + anchors.bottom: parent.bottom + width: parent.width * (count / maxCount) + color: root.alpha(foreground, 0.78) + radius: Math.max(1, Style.cornerRadius / 3) + Behavior on width { NumberAnimation { duration: 160; easing.type: Easing.OutCubic } } + } + } + Text { + text: usageMain.formatTokenCount(count) + color: foreground + font.family: fontFamily + font.pixelSize: 10 + font.bold: true + horizontalAlignment: Text.AlignRight + Layout.preferredWidth: 48 + } + } + } + } + } + + component AllTimeCard: SectionCard { + property var provider: null + visible: { + var usage = provider ? (provider.modelUsage || {}) : {} + return Object.keys(usage).length > 0 + } + title: "All-Time" + + ColumnLayout { + width: parent.width + spacing: 8 + RowLayout { + Layout.fillWidth: true + spacing: 20 + StatBlock { value: provider ? String(provider.totalSessions || 0) : "0"; label: "sessions" } + } + PanelSeparator { Layout.fillWidth: true; foreground: root.foreground; strength: 0.18 } + Repeater { + model: { + var usage = provider ? (provider.modelUsage || {}) : {} + var out = [] + for (var k in usage) out.push({ modelId: k, data: usage[k] }) + return out + } + delegate: ColumnLayout { + required property var modelData + Layout.fillWidth: true + spacing: 4 + Text { text: usageMain.friendlyModelName(modelData.modelId); color: foreground; font.family: fontFamily; font.pixelSize: 11; font.bold: true } + GridLayout { + Layout.leftMargin: 10 + columns: 2 + columnSpacing: 18 + rowSpacing: 2 + DetailPair { name: "Input"; value: usageMain.formatTokenCount(modelData.data.inputTokens || 0) } + DetailPair { name: "Output"; value: usageMain.formatTokenCount(modelData.data.outputTokens || 0) } + DetailPair { name: "Cache Read"; value: usageMain.formatTokenCount(modelData.data.cacheReadInputTokens || 0) } + DetailPair { name: "Cache Write"; value: usageMain.formatTokenCount(modelData.data.cacheCreationInputTokens || 0) } + } + } + } + } + } + + component SectionCard: BorderSurface { + id: section + property string title: "" + property string subtitle: "" + property color titleColor: foreground + default property alias content: body.data + + Layout.fillWidth: true + color: card + borderSpec: Border.flat(Qt.rgba(foreground.r, foreground.g, foreground.b, 0.05), 1) + padding: 12 + radius: Style.cornerRadius + implicitHeight: body.implicitHeight + contentTopInset + contentBottomInset + + ColumnLayout { + id: body + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.topMargin: section.contentTopInset + anchors.rightMargin: section.contentRightInset + anchors.bottomMargin: section.contentBottomInset + anchors.leftMargin: section.contentLeftInset + spacing: 8 + + PanelSectionHeader { + visible: section.title !== "" + Layout.fillWidth: true + text: section.title + foreground: section.titleColor + fontFamily: root.fontFamily + fontSize: 11 + } + Text { + visible: section.subtitle !== "" + Layout.fillWidth: true + text: section.subtitle + color: dim + font.family: fontFamily + font.pixelSize: 10 + wrapMode: Text.WordWrap + } + } + } + + component StatBlock: ColumnLayout { + property string value: "0" + property string label: "" + spacing: 2 + Text { text: value; color: foreground; font.family: fontFamily; font.pixelSize: 18; font.bold: true } + Text { text: label; color: dim; font.family: fontFamily; font.pixelSize: 10 } + } + + component DetailPair: RowLayout { + property string name: "" + property string value: "" + Text { text: name; color: dim; font.family: fontFamily; font.pixelSize: 10; Layout.preferredWidth: 76 } + Text { text: value; color: foreground; font.family: fontFamily; font.pixelSize: 10; font.bold: true } + } +} diff --git a/shell/plugins/opencode-model-usage/assets/opencode.svg b/shell/plugins/opencode-model-usage/assets/opencode.svg new file mode 100644 index 0000000000..d10ae96d28 --- /dev/null +++ b/shell/plugins/opencode-model-usage/assets/opencode.svg @@ -0,0 +1,4 @@ + + + + diff --git a/shell/plugins/opencode-model-usage/manifest.json b/shell/plugins/opencode-model-usage/manifest.json new file mode 100644 index 0000000000..863433d971 --- /dev/null +++ b/shell/plugins/opencode-model-usage/manifest.json @@ -0,0 +1,26 @@ +{ + "schemaVersion": 1, + "id": "omarchy.opencode-model-usage", + "name": "OpenCode Usage", + "version": "1.0.0", + "author": "Omarchy", + "license": "MIT", + "description": "OpenCode usage stats in the Omarchy bar.", + "kinds": ["bar-widget"], + "activation": "on-demand", + "entryPoints": { + "barWidget": "Widget.qml" + }, + "barWidget": { + "displayName": "OpenCode Usage", + "description": "Shows OpenCode session and token usage.", + "category": "AI", + "allowMultiple": false, + "defaults": { + "refreshIntervalSec": 300 + }, + "schema": [ + { "key": "refreshIntervalSec", "type": "integer", "label": "Refresh interval (seconds)", "min": 30, "max": 3600, "step": 30, "defaultValue": 300 } + ] + } +} diff --git a/shell/plugins/opencode-model-usage/providers/Opencode.qml b/shell/plugins/opencode-model-usage/providers/Opencode.qml new file mode 100644 index 0000000000..cfef9aa07b --- /dev/null +++ b/shell/plugins/opencode-model-usage/providers/Opencode.qml @@ -0,0 +1,116 @@ +import QtQuick +import Quickshell +import Quickshell.Io + +Item { + id: root + visible: false + + property string providerId: "opencode" + property string providerName: "OpenCode" + property string providerIcon: "code" + property bool enabled: false + property bool ready: false + property bool refreshing: false + property double lastRefreshedAtMs: 0 + property string usageStatusText: "" + property string authHelpText: "" + + property int todayPrompts: 0 + property int todaySessions: 0 + property int todayTotalTokens: 0 + property var todayTokensByModel: ({}) + + property var recentDays: [] + property int totalPrompts: 0 + property int totalSessions: 0 + property var modelUsage: ({}) + property var dailyActivity: [] + + property bool hasLocalStats: true + property var providerSettings: ({}) + + property string dbPath: "~/.local/share/opencode/opencode.db" + + readonly property string scannerScriptPath: pathFromUrl(Qt.resolvedUrl("../scripts/opencode_usage_scanner.py")) + + function pathFromUrl(url) { + var value = String(url || "") + if (value.indexOf("file://") === 0) + return decodeURIComponent(value.substring(7)) + return value + } + + function resolvePath(p) { + if (p && p.startsWith("~")) + return (Quickshell.env("HOME") ?? "/home") + p.substring(1) + return p + } + + FileView { + id: dbFile + path: root.resolvePath(root.dbPath) + watchChanges: true + onFileChanged: reload() + onLoaded: root.refresh(false) + } + + Process { + id: scanner + running: false + command: [] + + stdout: StdioCollector { + waitForEnd: true + onStreamFinished: root.applyUsage(text) + } + + stderr: StdioCollector { + waitForEnd: true + onStreamFinished: function(text) { + if (text && text.trim() !== "") + console.warn("model-usage/opencode", text.trim()) + } + } + + onExited: { + root.refreshing = false + root.lastRefreshedAtMs = Date.now() + } + } + + function applyUsage(content) { + try { + var data = JSON.parse(String(content || "{}")) + if (!data.ready) + return + + root.ready = true + root.hasLocalStats = data.hasLocalStats !== false + root.todayPrompts = Math.max(0, Number(data.todayPrompts || 0)) + root.todaySessions = Math.max(0, Number(data.todaySessions || 0)) + root.todayTotalTokens = Math.max(0, Number(data.todayTotalTokens || 0)) + root.todayTokensByModel = data.todayTokensByModel || ({}) + root.recentDays = data.recentDays || [] + root.modelUsage = data.modelUsage || ({}) + root.totalPrompts = Math.max(0, Number(data.totalPrompts || 0)) + root.totalSessions = Math.max(0, Number(data.totalSessions || 0)) + root.dailyActivity = data.recentDays || [] + } catch (e) { + console.error("model-usage/opencode", "Failed to parse scanner output:", e) + } + } + + function refresh(force) { + if (scanner.running) + return + + root.refreshing = true + scanner.command = ["python3", root.scannerScriptPath, root.resolvePath(root.dbPath)] + scanner.running = true + } + + function formatResetTime(isoTimestamp) { + return "" + } +} diff --git a/shell/plugins/opencode-model-usage/scripts/opencode_usage_scanner.py b/shell/plugins/opencode-model-usage/scripts/opencode_usage_scanner.py new file mode 100644 index 0000000000..bba4516024 --- /dev/null +++ b/shell/plugins/opencode-model-usage/scripts/opencode_usage_scanner.py @@ -0,0 +1,179 @@ +#!/usr/bin/env python3 +"""Query opencode's sqlite database and emit compact usage stats.""" + +from __future__ import annotations + +import argparse +import datetime as dt +import json +import os +import sqlite3 +import sys +from pathlib import Path +from typing import Any + + +def expand_path(value: str) -> Path: + return Path(os.path.expandvars(os.path.expanduser(value))).resolve() + + +def date_string(value: dt.date) -> str: + return value.strftime("%Y-%m-%d") + + +def recent_date_strings() -> list[str]: + today = dt.datetime.now().date() + return [date_string(today - dt.timedelta(days=offset)) for offset in range(6, -1, -1)] + + +def local_date_string() -> str: + return date_string(dt.datetime.now().date()) + + +def number(value: Any) -> int: + try: + return int(value or 0) + except Exception: + return 0 + + +def scan(db_path: Path) -> dict[str, Any]: + today = local_date_string() + recent_dates = recent_date_strings() + recent = {day: {"date": day, "messageCount": 0} for day in recent_dates} + + today_prompts = 0 + today_sessions: set[str] = set() + today_total_tokens = 0 + today_tokens_by_model: dict[str, int] = {} + + total_prompts = 0 + total_sessions: set[str] = set() + model_usage: dict[str, dict[str, int]] = {} + + if not db_path.exists(): + return empty_result() + + try: + conn = sqlite3.connect(str(db_path)) + conn.row_factory = sqlite3.Row + cursor = conn.cursor() + + cursor.execute(""" + SELECT + model, + tokens_input, + tokens_output, + tokens_reasoning, + tokens_cache_read, + tokens_cache_write, + cost, + time_created, + id, + title + FROM session + WHERE time_created > 0 + ORDER BY time_created DESC + """) + + for row in cursor.fetchall(): + raw_model = row["model"] + if raw_model and isinstance(raw_model, str) and raw_model.strip().startswith("{"): + try: + parsed = json.loads(raw_model) + model_id = str(parsed.get("id", raw_model)) + provider = str(parsed.get("providerID", "")) + model = f"{model_id} ({provider})" if provider else model_id + except Exception: + model = str(raw_model) + else: + model = str(raw_model or "unknown") + input_t = number(row["tokens_input"]) + output_t = number(row["tokens_output"]) + reasoning_t = number(row["tokens_reasoning"]) + cache_read = number(row["tokens_cache_read"]) + cache_write = number(row["tokens_cache_write"]) + total = input_t + output_t + reasoning_t + cache_read + cache_write + + if total <= 0: + continue + + ts = number(row["time_created"]) + if ts > 10_000_000_000: + ts = round(ts / 1000) + day = date_string(dt.datetime.fromtimestamp(ts).date()) if ts > 0 else today + session_id = str(row["id"]) + + total_sessions.add(session_id) + total_prompts += 1 + + bucket = model_usage.setdefault(model, { + "inputTokens": 0, + "outputTokens": 0, + "cacheReadInputTokens": 0, + "cacheCreationInputTokens": 0, + }) + bucket["inputTokens"] += input_t + reasoning_t + bucket["outputTokens"] += output_t + bucket["cacheReadInputTokens"] += cache_read + bucket["cacheCreationInputTokens"] += cache_write + + if day in recent: + recent[day]["messageCount"] += total + + if day == today: + today_prompts += 1 + today_sessions.add(session_id) + today_total_tokens += total + today_tokens_by_model[model] = today_tokens_by_model.get(model, 0) + total + + conn.close() + except Exception as exc: + print(f"Error querying opencode db: {exc}", file=sys.stderr) + return empty_result() + + return { + "schemaVersion": 1, + "todayPrompts": today_prompts, + "todaySessions": len(today_sessions), + "todayTotalTokens": today_total_tokens, + "todayTokensByModel": today_tokens_by_model, + "recentDays": [recent[day] for day in recent_dates], + "modelUsage": model_usage, + "totalPrompts": total_prompts, + "totalSessions": len(total_sessions), + "ready": True, + "hasLocalStats": True, + } + + +def empty_result() -> dict[str, Any]: + recent_dates = recent_date_strings() + return { + "schemaVersion": 1, + "todayPrompts": 0, + "todaySessions": 0, + "todayTotalTokens": 0, + "todayTokensByModel": {}, + "recentDays": [{"date": day, "messageCount": 0} for day in recent_dates], + "modelUsage": {}, + "totalPrompts": 0, + "totalSessions": 0, + "ready": True, + "hasLocalStats": False, + } + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("db_path", nargs="?", default="~/.local/share/opencode/opencode.db") + args = parser.parse_args() + + db_path = expand_path(args.db_path) + summary = scan(db_path) + print(json.dumps(summary, separators=(",", ":"), sort_keys=True)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From 2e3d5f4005db1e1c534e5a93f8fce672397e63f8 Mon Sep 17 00:00:00 2001 From: JJDizz1L Date: Sat, 27 Jun 2026 22:19:10 -0400 Subject: [PATCH 2/2] address review: empty-state, indentation, read-only DB, scanner errors --- shell/plugins/opencode-model-usage/Widget.qml | 1517 +++++++++-------- .../providers/Opencode.qml | 6 + .../scripts/opencode_usage_scanner.py | 4 +- 3 files changed, 767 insertions(+), 760 deletions(-) diff --git a/shell/plugins/opencode-model-usage/Widget.qml b/shell/plugins/opencode-model-usage/Widget.qml index 3a27d62fe2..b3e351a024 100644 --- a/shell/plugins/opencode-model-usage/Widget.qml +++ b/shell/plugins/opencode-model-usage/Widget.qml @@ -7,799 +7,800 @@ import qs.Commons import qs.Ui BarWidget { - id: root - moduleName: "omarchy.opencode-model-usage" - - property bool popupOpen: false - property bool settingsMode: false - property var draftSettings: ({}) - property string settingsStatusText: "" - property bool refreshFlash: false - - readonly property color foreground: bar ? bar.foreground : Color.foreground - readonly property color background: Color.popups.background - readonly property color border: Color.popups.border - readonly property color urgent: bar ? bar.urgent : Color.urgent - readonly property color dim: Qt.darker(foreground, 1.45) - readonly property color card: Qt.rgba(foreground.r, foreground.g, foreground.b, 0.055) - readonly property color cardHover: Qt.rgba(foreground.r, foreground.g, foreground.b, 0.085) - readonly property color outline: Qt.rgba(foreground.r, foreground.g, foreground.b, 0.18) - readonly property color track: Qt.rgba(foreground.r, foreground.g, foreground.b, 0.24) - readonly property string fontFamily: bar ? bar.fontFamily : "JetBrainsMono Nerd Font" - - readonly property var provider: usageMain.enabledProviders.length > 0 ? usageMain.enabledProviders[0] : null - - function close() { - popupOpen = false - settingsMode = false - } + id: root + moduleName: "omarchy.opencode-model-usage" + + property bool popupOpen: false + property bool settingsMode: false + property var draftSettings: ({}) + property string settingsStatusText: "" + property bool refreshFlash: false + + readonly property color foreground: bar ? bar.foreground : Color.foreground + readonly property color background: Color.popups.background + readonly property color border: Color.popups.border + readonly property color urgent: bar ? bar.urgent : Color.urgent + readonly property color dim: Qt.darker(foreground, 1.45) + readonly property color card: Qt.rgba(foreground.r, foreground.g, foreground.b, 0.055) + readonly property color cardHover: Qt.rgba(foreground.r, foreground.g, foreground.b, 0.085) + readonly property color outline: Qt.rgba(foreground.r, foreground.g, foreground.b, 0.18) + readonly property color track: Qt.rgba(foreground.r, foreground.g, foreground.b, 0.24) + readonly property string fontFamily: bar ? bar.fontFamily : "JetBrainsMono Nerd Font" + + readonly property var provider: usageMain.enabledProviders.length > 0 ? usageMain.enabledProviders[0] : null + + function close() { + popupOpen = false + settingsMode = false + } + + function triggerPress(button) { + if (button === Qt.RightButton) { + openSettings() + return + } + if (button === Qt.MiddleButton) { + triggerRefresh() + return + } + + if (popupOpen) { + popupOpen = false + } else { + popupOpen = true + triggerRefresh() + } + } + + function triggerRefresh() { + refreshFlash = true + refreshFlashTimer.restart() + usageMain.refreshAll(true) + } + + function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)) } + function alpha(c, a) { return Qt.rgba(c.r, c.g, c.b, a) } + + function cloneObject(value, fallback) { + if (value === undefined || value === null) return fallback + try { return JSON.parse(JSON.stringify(value)) } + catch (e) { return fallback } + } + + function defaultSettings() { + return { refreshIntervalSec: 300 } + } + + function normalizedSettings(source) { + var next = cloneObject(source, {}) || {} + var refresh = Number(next.refreshIntervalSec === undefined || next.refreshIntervalSec === null ? 300 : next.refreshIntervalSec) + next.refreshIntervalSec = Math.round(clamp(isFinite(refresh) ? refresh : 300, 30, 3600)) + return next + } + + function openSettings() { + draftSettings = normalizedSettings(settings) + settingsStatusText = "" + settingsMode = true + popupOpen = true + Qt.callLater(function() { if (keyCatcher) keyCatcher.forceActiveFocus() }) + } + + function showUsage() { + settingsMode = false + settingsStatusText = "" + Qt.callLater(function() { if (keyCatcher) keyCatcher.forceActiveFocus() }) + } + + function canPersistSettings() { + return !!(bar && bar.shell && typeof bar.shell.updateEntryInline === "function") + } + + function saveSettings() { + var next = normalizedSettings(draftSettings) + draftSettings = next + root.settings = next + if (canPersistSettings()) { + bar.shell.updateEntryInline(root.moduleName, next) + settingsStatusText = "Saved to shell.json" + } else { + settingsStatusText = "Saved for this session" + } + usageMain.refreshAll(true) + } + + function colorChannelLuminance(value) { + var channel = Number(value) + if (!isFinite(channel)) return 0 + return channel <= 0.03928 ? channel / 12.92 : Math.pow((channel + 0.055) / 1.055, 2.4) + } + + function colorLuminance(color) { + return 0.2126 * colorChannelLuminance(color.r) + + 0.7152 * colorChannelLuminance(color.g) + + 0.0722 * colorChannelLuminance(color.b) + } + + function iconSource() { + return Qt.resolvedUrl("assets/opencode.svg") + } + + function usagePercent() { + return -1 + } + + function formatUsagePercent() { + var toks = provider ? (provider.todayTotalTokens || 0) : 0 + if (toks <= 0) return "" + return usageMain.formatTokenCount(toks) + } + + function tooltipText() { + if (!provider) return "OpenCode Usage" + var line = provider.providerName + ": " + usageMain.formatTokenCount(provider.todayTotalTokens || 0) + " tokens today" + return line + } + + implicitWidth: button.implicitWidth + implicitHeight: button.implicitHeight + + onPopupOpenChanged: { + if (popupOpen) + Qt.callLater(function() { if (keyCatcher) keyCatcher.forceActiveFocus() }) + } + + Main { + id: usageMain + settings: root.settings + } + + Timer { + id: refreshFlashTimer + interval: 900 + repeat: false + onTriggered: root.refreshFlash = false + } + + IpcHandler { + target: "omarchy.opencode-model-usage" + function open(): string { root.showUsage(); root.popupOpen = true; return "ok" } + function close(): string { root.close(); return "ok" } + function toggle(): string { + if (root.popupOpen) root.close() + else { root.showUsage(); root.popupOpen = true } + return "ok" + } + function refresh(): string { root.triggerRefresh(); return "ok" } + function settings(): string { root.openSettings(); return "ok" } + function openSettings(): string { root.openSettings(); return "ok" } + } + + component UsageChip: Item { + id: chip + + readonly property bool compact: root.vertical + readonly property bool tooltipHovered: mouseArea.containsMouse + + width: compact ? root.barSize : chipRow.implicitWidth + height: compact ? Math.max(root.barSize, chipColumn.implicitHeight + Style.space(2)) : root.barSize + + Row { + id: chipRow + visible: !chip.compact + anchors.centerIn: parent + spacing: 4 + + Image { + source: root.iconSource() + width: 13 + height: 13 + sourceSize.width: 13 + sourceSize.height: 13 + fillMode: Image.PreserveAspectFit + anchors.verticalCenter: parent.verticalCenter + } + + Text { + text: root.formatUsagePercent() + color: foreground + font.family: fontFamily + font.pixelSize: 10 + font.bold: true + anchors.verticalCenter: parent.verticalCenter + visible: text !== "" + } + } + + Column { + id: chipColumn + visible: chip.compact + anchors.centerIn: parent + spacing: 1 + + Image { + source: root.iconSource() + width: 13 + height: 13 + sourceSize.width: 13 + sourceSize.height: 13 + fillMode: Image.PreserveAspectFit + anchors.horizontalCenter: parent.horizontalCenter + } + + Text { + width: root.barSize + text: root.formatUsagePercent() + color: foreground + font.family: fontFamily + font.pixelSize: 10 + font.bold: true + horizontalAlignment: Text.AlignHCenter + } + } + + property var registeredBar: null function triggerPress(button) { - if (button === Qt.RightButton) { - openSettings() - return - } - if (button === Qt.MiddleButton) { - triggerRefresh() - return - } - - if (popupOpen) { - popupOpen = false - } else { - popupOpen = true - triggerRefresh() - } - } - - function triggerRefresh() { - refreshFlash = true - refreshFlashTimer.restart() - usageMain.refreshAll(true) - } - - function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)) } - function alpha(c, a) { return Qt.rgba(c.r, c.g, c.b, a) } - - function cloneObject(value, fallback) { - if (value === undefined || value === null) return fallback - try { return JSON.parse(JSON.stringify(value)) } - catch (e) { return fallback } - } - - function defaultSettings() { - return { refreshIntervalSec: 300 } - } - - function normalizedSettings(source) { - var next = cloneObject(source, {}) || {} - var refresh = Number(next.refreshIntervalSec === undefined || next.refreshIntervalSec === null ? 300 : next.refreshIntervalSec) - next.refreshIntervalSec = Math.round(clamp(isFinite(refresh) ? refresh : 300, 30, 3600)) - return next - } - - function openSettings() { - draftSettings = normalizedSettings(settings) - settingsStatusText = "" - settingsMode = true - popupOpen = true - Qt.callLater(function() { if (keyCatcher) keyCatcher.forceActiveFocus() }) - } - - function showUsage() { - settingsMode = false - settingsStatusText = "" - Qt.callLater(function() { if (keyCatcher) keyCatcher.forceActiveFocus() }) + root.triggerPress(button) } - function canPersistSettings() { - return !!(bar && bar.shell && typeof bar.shell.updateEntryInline === "function") - } + function syncClickRegistration() { + if (registeredBar && registeredBar.unregisterClickTarget) registeredBar.unregisterClickTarget(chip) + registeredBar = root.bar + if (registeredBar && registeredBar.registerClickTarget) registeredBar.registerClickTarget(chip) + } + + Component.onCompleted: syncClickRegistration() + Component.onDestruction: if (registeredBar && registeredBar.unregisterClickTarget) registeredBar.unregisterClickTarget(chip) + + Connections { + target: root + function onBarChanged() { chip.syncClickRegistration() } + } + + MouseArea { + id: mouseArea + anchors.fill: parent + acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onEntered: if (root.bar) root.bar.showTooltip(chip, root.tooltipText()) + onExited: if (root.bar) root.bar.hideTooltip(chip) + onClicked: function(mouse) { root.triggerPress(mouse.button) } + } + } + + component EmptyUsageChip: Item { + id: emptyChip + readonly property bool tooltipHovered: emptyMouse.containsMouse + visible: !provider + width: root.vertical ? root.barSize : emptyLabel.implicitWidth + height: root.barSize + + property var registeredBar: null + + function triggerPress(button) { root.triggerPress(button) } + + function syncClickRegistration() { + if (registeredBar && registeredBar.unregisterClickTarget) registeredBar.unregisterClickTarget(emptyChip) + registeredBar = root.bar + if (registeredBar && registeredBar.registerClickTarget) registeredBar.registerClickTarget(emptyChip) + } + + Component.onCompleted: syncClickRegistration() + Component.onDestruction: if (registeredBar && registeredBar.unregisterClickTarget) registeredBar.unregisterClickTarget(emptyChip) + + Connections { + target: root + function onBarChanged() { emptyChip.syncClickRegistration() } + } + + Text { + id: emptyLabel + anchors.centerIn: parent + text: "OC" + color: dim + font.family: fontFamily + font.pixelSize: 10 + font.bold: true + } + + MouseArea { + id: emptyMouse + anchors.fill: parent + acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onEntered: if (root.bar) root.bar.showTooltip(emptyChip, "OpenCode Usage") + onExited: if (root.bar) root.bar.hideTooltip(emptyChip) + onClicked: function(mouse) { root.triggerPress(mouse.button) } + } + } + + Item { + id: button + anchors.fill: parent + implicitWidth: root.vertical ? root.barSize : barRow.implicitWidth + Style.space(10) + implicitHeight: root.vertical ? barColumn.implicitHeight : root.barSize + + Row { + id: barRow + visible: !root.vertical + anchors.centerIn: parent + spacing: Style.space(8) + UsageChip { visible: !root.vertical } + EmptyUsageChip { visible: !root.vertical && (!provider || !provider.hasLocalStats) } + } + + Column { + id: barColumn + visible: root.vertical + anchors.centerIn: parent + spacing: Style.space(2) + UsageChip { visible: root.vertical } + EmptyUsageChip { visible: root.vertical && (!provider || !provider.hasLocalStats) } + } + } + + KeyboardPanel { + id: panel + anchorItem: button + owner: root + bar: root.bar + open: root.popupOpen + focusTarget: keyCatcher + contentWidth: panel.fittedContentWidth(Style.space(370)) + contentHeight: panel.fittedContentHeight(contentColumn.implicitHeight, Style.space(560)) + + PanelKeyCatcher { + id: keyCatcher + anchors.fill: parent + blocked: settingsMode && settingsContent.editorActive + + onMoveRequested: function(dx, dy) { + if (dy !== 0) flick.contentY = root.clamp(flick.contentY + dy * 56, 0, Math.max(0, flick.contentHeight - flick.height)) + } + onCloseRequested: root.close() + onTextKey: function(t) { + if (t === "r" || t === "R") root.triggerRefresh() + if (t === "s" || t === "S") root.settingsMode ? root.saveSettings() : root.openSettings() + } + + ColumnLayout { + anchors.fill: parent + spacing: 10 - function saveSettings() { - var next = normalizedSettings(draftSettings) - draftSettings = next - root.settings = next - if (canPersistSettings()) { - bar.shell.updateEntryInline(root.moduleName, next) - settingsStatusText = "Saved to shell.json" - } else { - settingsStatusText = "Saved for this session" + Header { + visible: !root.settingsMode && !!root.provider + provider: root.provider } - usageMain.refreshAll(true) - } - - function colorChannelLuminance(value) { - var channel = Number(value) - if (!isFinite(channel)) return 0 - return channel <= 0.03928 ? channel / 12.92 : Math.pow((channel + 0.055) / 1.055, 2.4) - } - - function colorLuminance(color) { - return 0.2126 * colorChannelLuminance(color.r) - + 0.7152 * colorChannelLuminance(color.g) - + 0.0722 * colorChannelLuminance(color.b) - } - - function iconSource() { - return Qt.resolvedUrl("assets/opencode.svg") - } - - function usagePercent() { - return -1 - } - - function formatUsagePercent() { - var toks = provider ? (provider.todayTotalTokens || 0) : 0 - if (toks <= 0) return "" - return usageMain.formatTokenCount(toks) - } - - function tooltipText() { - if (!provider) return "OpenCode Usage" - var line = provider.providerName + ": " + usageMain.formatTokenCount(provider.todayTotalTokens || 0) + " tokens today" - return line - } - - implicitWidth: button.implicitWidth - implicitHeight: button.implicitHeight - - onPopupOpenChanged: { - if (popupOpen) - Qt.callLater(function() { if (keyCatcher) keyCatcher.forceActiveFocus() }) - } - - Main { - id: usageMain - settings: root.settings - } - Timer { - id: refreshFlashTimer - interval: 900 - repeat: false - onTriggered: root.refreshFlash = false - } + SettingsHeader { visible: root.settingsMode } - IpcHandler { - target: "omarchy.opencode-model-usage" - function open(): string { root.showUsage(); root.popupOpen = true; return "ok" } - function close(): string { root.close(); return "ok" } - function toggle(): string { - if (root.popupOpen) root.close() - else { root.showUsage(); root.popupOpen = true } - return "ok" + PanelSeparator { + Layout.fillWidth: true + foreground: root.foreground } - function refresh(): string { root.triggerRefresh(); return "ok" } - function settings(): string { root.openSettings(); return "ok" } - function openSettings(): string { root.openSettings(); return "ok" } - } - - component UsageChip: Item { - id: chip - - readonly property bool compact: root.vertical - readonly property bool tooltipHovered: mouseArea.containsMouse - - width: compact ? root.barSize : chipRow.implicitWidth - height: compact ? Math.max(root.barSize, chipColumn.implicitHeight + Style.space(2)) : root.barSize - - Row { - id: chipRow - visible: !chip.compact - anchors.centerIn: parent - spacing: 4 - - Image { - source: root.iconSource() - width: 13 - height: 13 - sourceSize.width: 13 - sourceSize.height: 13 - fillMode: Image.PreserveAspectFit - anchors.verticalCenter: parent.verticalCenter - } - Text { - text: root.formatUsagePercent() - color: foreground - font.family: fontFamily - font.pixelSize: 10 - font.bold: true - anchors.verticalCenter: parent.verticalCenter - visible: text !== "" - } - } + Flickable { + id: flick + Layout.fillWidth: true + Layout.fillHeight: true + contentWidth: width + contentHeight: contentColumn.implicitHeight + clip: true + boundsBehavior: Flickable.StopAtBounds + flickableDirection: Flickable.VerticalFlick + ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded } - Column { - id: chipColumn - visible: chip.compact - anchors.centerIn: parent - spacing: 1 - - Image { - source: root.iconSource() - width: 13 - height: 13 - sourceSize.width: 13 - sourceSize.height: 13 - fillMode: Image.PreserveAspectFit - anchors.horizontalCenter: parent.horizontalCenter - } + ColumnLayout { + id: contentColumn + width: flick.width + spacing: 10 Text { - width: root.barSize - text: root.formatUsagePercent() - color: foreground - font.family: fontFamily - font.pixelSize: 10 - font.bold: true - horizontalAlignment: Text.AlignHCenter + visible: !root.settingsMode && (!root.provider || !root.provider.hasLocalStats) + Layout.fillWidth: true + Layout.topMargin: 24 + text: "No usage data yet. Start an OpenCode session to see stats." + color: dim + font.family: fontFamily + font.pixelSize: 11 + horizontalAlignment: Text.AlignHCenter } - } - - property var registeredBar: null - - function triggerPress(button) { - root.triggerPress(button) - } - - function syncClickRegistration() { - if (registeredBar && registeredBar.unregisterClickTarget) registeredBar.unregisterClickTarget(chip) - registeredBar = root.bar - if (registeredBar && registeredBar.registerClickTarget) registeredBar.registerClickTarget(chip) - } - - Component.onCompleted: syncClickRegistration() - Component.onDestruction: if (registeredBar && registeredBar.unregisterClickTarget) registeredBar.unregisterClickTarget(chip) - - Connections { - target: root - function onBarChanged() { chip.syncClickRegistration() } - } - - MouseArea { - id: mouseArea - anchors.fill: parent - acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - onEntered: if (root.bar) root.bar.showTooltip(chip, root.tooltipText()) - onExited: if (root.bar) root.bar.hideTooltip(chip) - onClicked: function(mouse) { root.triggerPress(mouse.button) } - } - } - - component EmptyUsageChip: Item { - id: emptyChip - readonly property bool tooltipHovered: emptyMouse.containsMouse - visible: !provider - width: root.vertical ? root.barSize : emptyLabel.implicitWidth - height: root.barSize - - property var registeredBar: null - - function triggerPress(button) { root.triggerPress(button) } - - function syncClickRegistration() { - if (registeredBar && registeredBar.unregisterClickTarget) registeredBar.unregisterClickTarget(emptyChip) - registeredBar = root.bar - if (registeredBar && registeredBar.registerClickTarget) registeredBar.registerClickTarget(emptyChip) - } - - Component.onCompleted: syncClickRegistration() - Component.onDestruction: if (registeredBar && registeredBar.unregisterClickTarget) registeredBar.unregisterClickTarget(emptyChip) - - Connections { - target: root - function onBarChanged() { emptyChip.syncClickRegistration() } - } - - Text { - id: emptyLabel - anchors.centerIn: parent - text: "OC" - color: dim - font.family: fontFamily - font.pixelSize: 10 - font.bold: true - } - - MouseArea { - id: emptyMouse - anchors.fill: parent - acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - onEntered: if (root.bar) root.bar.showTooltip(emptyChip, "OpenCode Usage") - onExited: if (root.bar) root.bar.hideTooltip(emptyChip) - onClicked: function(mouse) { root.triggerPress(mouse.button) } - } - } - - Item { - id: button - anchors.fill: parent - implicitWidth: root.vertical ? root.barSize : barRow.implicitWidth + Style.space(10) - implicitHeight: root.vertical ? barColumn.implicitHeight : root.barSize - - Row { - id: barRow - visible: !root.vertical - anchors.centerIn: parent - spacing: Style.space(8) - UsageChip { visible: !root.vertical } - EmptyUsageChip { visible: !root.vertical && !provider } - } - - Column { - id: barColumn - visible: root.vertical - anchors.centerIn: parent - spacing: Style.space(2) - UsageChip { visible: root.vertical } - EmptyUsageChip { visible: root.vertical && !provider } - } - } - - KeyboardPanel { - id: panel - anchorItem: button - owner: root - bar: root.bar - open: root.popupOpen - focusTarget: keyCatcher - contentWidth: panel.fittedContentWidth(Style.space(370)) - contentHeight: panel.fittedContentHeight(contentColumn.implicitHeight, Style.space(560)) - PanelKeyCatcher { - id: keyCatcher - anchors.fill: parent - blocked: settingsMode && settingsContent.editorActive + StatusCard { provider: root.settingsMode ? null : root.provider } + TodayCard { provider: root.settingsMode ? null : root.provider } + WeekCard { provider: root.settingsMode ? null : root.provider } + AllTimeCard { provider: root.settingsMode ? null : root.provider } - onMoveRequested: function(dx, dy) { - if (dy !== 0) flick.contentY = root.clamp(flick.contentY + dy * 56, 0, Math.max(0, flick.contentHeight - flick.height)) + UsageFooter { visible: !root.settingsMode } + SettingsContent { + id: settingsContent + visible: root.settingsMode } - onCloseRequested: root.close() - onTextKey: function(t) { - if (t === "r" || t === "R") root.triggerRefresh() - if (t === "s" || t === "S") root.settingsMode ? root.saveSettings() : root.openSettings() - } - - ColumnLayout { - anchors.fill: parent - spacing: 10 - - Header { - visible: !root.settingsMode && !!root.provider - provider: root.provider - } - - SettingsHeader { visible: root.settingsMode } - - PanelSeparator { - Layout.fillWidth: true - foreground: root.foreground - } - - Flickable { - id: flick - Layout.fillWidth: true - Layout.fillHeight: true - contentWidth: width - contentHeight: contentColumn.implicitHeight - clip: true - boundsBehavior: Flickable.StopAtBounds - flickableDirection: Flickable.VerticalFlick - ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded } - - ColumnLayout { - id: contentColumn - width: flick.width - spacing: 10 - - Text { - visible: !root.settingsMode && !root.provider - Layout.fillWidth: true - Layout.topMargin: 24 - text: "No usage data yet. Start an OpenCode session to see stats." - color: dim - font.family: fontFamily - font.pixelSize: 11 - horizontalAlignment: Text.AlignHCenter - } - - StatusCard { provider: root.settingsMode ? null : root.provider } - TodayCard { provider: root.settingsMode ? null : root.provider } - WeekCard { provider: root.settingsMode ? null : root.provider } - AllTimeCard { provider: root.settingsMode ? null : root.provider } - - UsageFooter { visible: !root.settingsMode } - SettingsContent { - id: settingsContent - visible: root.settingsMode - } - } - } - } - } - } - - component SettingsHeader: RowLayout { - Layout.fillWidth: true + } + } + } + } + } + + component SettingsHeader: RowLayout { + Layout.fillWidth: true + spacing: 8 + + Text { + text: "OpenCode Usage Settings" + color: foreground + font.family: fontFamily + font.pixelSize: 15 + font.bold: true + Layout.fillWidth: true + Layout.alignment: Qt.AlignVCenter + } + + Button { + text: "Usage" + foreground: root.foreground + tooltipText: "Back to usage" + tooltipBackground: root.background + tooltipForeground: root.foreground + fontFamily: root.fontFamily + fontSize: 10 + horizontalPadding: 8 + verticalPadding: 4 + onClicked: root.showUsage() + } + + Button { + text: "Save" + foreground: root.foreground + tooltipText: "Save settings" + tooltipBackground: root.background + tooltipForeground: root.foreground + fontFamily: root.fontFamily + fontSize: 10 + horizontalPadding: 8 + verticalPadding: 4 + active: true + onClicked: root.saveSettings() + } + } + + component UsageFooter: RowLayout { + Layout.fillWidth: true + spacing: 8 + + Text { + Layout.fillWidth: true + text: "j/k scroll · r refresh · s/settings · esc close" + color: dim + font.family: fontFamily + font.pixelSize: 10 + horizontalAlignment: Text.AlignHCenter + wrapMode: Text.WordWrap + } + } + + component SettingsContent: ColumnLayout { + id: settingsRoot + Layout.fillWidth: true + spacing: 10 + + readonly property bool editorActive: refreshIntervalField.field.activeFocus + + SectionCard { + title: "Refresh" + + ColumnLayout { + width: parent.width spacing: 8 - Text { - text: "OpenCode Usage Settings" - color: foreground - font.family: fontFamily - font.pixelSize: 15 - font.bold: true - Layout.fillWidth: true - Layout.alignment: Qt.AlignVCenter - } - - Button { - text: "Usage" - foreground: root.foreground - tooltipText: "Back to usage" - tooltipBackground: root.background - tooltipForeground: root.foreground - fontFamily: root.fontFamily - fontSize: 10 - horizontalPadding: 8 - verticalPadding: 4 - onClicked: root.showUsage() + NumberField { + id: refreshIntervalField + label: "Refresh interval (seconds)" + value: Number(root.draftValue("refreshIntervalSec", 300)) + from: 30 + to: 3600 + stepSize: 30 + fieldWidth: parent.width + foreground: root.foreground + accent: Color.accent + fontFamily: root.fontFamily + onModified: function(value) { root.setDraftValue("refreshIntervalSec", value) } } - Button { - text: "Save" - foreground: root.foreground - tooltipText: "Save settings" - tooltipBackground: root.background - tooltipForeground: root.foreground - fontFamily: root.fontFamily - fontSize: 10 - horizontalPadding: 8 - verticalPadding: 4 - active: true - onClicked: root.saveSettings() - } - } - - component UsageFooter: RowLayout { - Layout.fillWidth: true - spacing: 8 - Text { - Layout.fillWidth: true - text: "j/k scroll · r refresh · s/settings · esc close" - color: dim - font.family: fontFamily - font.pixelSize: 10 - horizontalAlignment: Text.AlignHCenter - wrapMode: Text.WordWrap - } - } - - component SettingsContent: ColumnLayout { - id: settingsRoot + Layout.fillWidth: true + text: "How often the widget rescans the opencode database." + color: dim + font.family: fontFamily + font.pixelSize: 10 + wrapMode: Text.WordWrap + } + } + } + + Text { + visible: root.settingsStatusText !== "" + Layout.fillWidth: true + text: root.settingsStatusText + color: dim + font.family: fontFamily + font.pixelSize: 10 + horizontalAlignment: Text.AlignHCenter + } + + Text { + Layout.fillWidth: true + text: "s saves · esc closes" + color: dim + font.family: fontFamily + font.pixelSize: 10 + horizontalAlignment: Text.AlignHCenter + } + } + + function draftValue(name, fallback) { + var value = draftSettings ? draftSettings[name] : undefined + return value === undefined || value === null ? fallback : value + } + + function setDraftValue(name, value) { + var next = normalizedSettings(draftSettings) + next[name] = value + draftSettings = next + } + + component Header: RowLayout { + property var provider: null + visible: !!provider + Layout.fillWidth: true + spacing: 8 + + Image { + source: root.iconSource() + Layout.preferredWidth: 16 + Layout.preferredHeight: 16 + sourceSize.width: 16 + sourceSize.height: 16 + fillMode: Image.PreserveAspectFit + Layout.alignment: Qt.AlignVCenter + } + + Text { + text: provider ? provider.providerName + " Usage" : "" + color: foreground + font.family: fontFamily + font.pixelSize: 15 + font.bold: true + Layout.fillWidth: true + Layout.alignment: Qt.AlignVCenter + } + + Button { + text: (root.refreshFlash || usageMain.refreshing) ? "Refreshing\u2026" : "Refresh" + foreground: root.foreground + tooltipText: (root.refreshFlash || usageMain.refreshing) ? "Refreshing usage\u2026" : "Refresh usage" + tooltipBackground: root.background + tooltipForeground: root.foreground + fontFamily: root.fontFamily + fontSize: 10 + horizontalPadding: 8 + verticalPadding: 4 + active: root.refreshFlash || usageMain.refreshing + onClicked: { + root.triggerRefresh() + keyCatcher.forceActiveFocus() + } + } + } + + component StatusCard: SectionCard { + property var provider: null + visible: !!provider && String(provider.usageStatusText || "") !== "" + titleColor: urgent + title: provider ? provider.usageStatusText : "" + subtitle: provider ? provider.authHelpText : "" + } + + component TodayCard: SectionCard { + property var provider: null + visible: !!provider && provider.ready && provider.hasLocalStats + title: "Today" + + ColumnLayout { + width: parent.width + spacing: 8 + RowLayout { Layout.fillWidth: true - spacing: 10 - - readonly property bool editorActive: refreshIntervalField.field.activeFocus - - SectionCard { - title: "Refresh" - - ColumnLayout { - width: parent.width - spacing: 8 - - NumberField { - id: refreshIntervalField - label: "Refresh interval (seconds)" - value: Number(root.draftValue("refreshIntervalSec", 300)) - from: 30 - to: 3600 - stepSize: 30 - fieldWidth: parent.width - foreground: root.foreground - accent: Color.accent - fontFamily: root.fontFamily - onModified: function(value) { root.setDraftValue("refreshIntervalSec", value) } - } - - Text { - Layout.fillWidth: true - text: "How often the widget rescans the opencode database." - color: dim - font.family: fontFamily - font.pixelSize: 10 - wrapMode: Text.WordWrap - } + spacing: 20 + StatBlock { value: provider ? String(provider.todaySessions || 0) : "0"; label: "sessions" } + StatBlock { value: provider ? usageMain.formatTokenCount(provider.todayTotalTokens || 0) : "0"; label: "tokens" } + } + Repeater { + model: { + var toks = provider ? (provider.todayTokensByModel || {}) : {} + var out = [] + for (var k in toks) out.push({ modelId: k, count: toks[k] }) + return out + } + delegate: RowLayout { + required property var modelData + Layout.fillWidth: true + Text { text: usageMain.friendlyModelName(modelData.modelId); color: dim; font.family: fontFamily; font.pixelSize: 11 } + Item { Layout.fillWidth: true } + Text { text: usageMain.formatTokenCount(modelData.count) + " tokens"; color: foreground; font.family: fontFamily; font.pixelSize: 11; font.bold: true } + } + } + } + } + + component WeekCard: SectionCard { + property var provider: null + visible: !!provider && provider.recentDays && provider.recentDays.length > 0 + && provider.recentDays.some(function(d) { return d.messageCount > 0 }) + title: "Last 7 Days" + + ColumnLayout { + width: parent.width + spacing: 6 + Repeater { + model: provider ? provider.recentDays : [] + delegate: RowLayout { + required property var modelData + Layout.fillWidth: true + spacing: 8 + readonly property real count: modelData ? Number(modelData.messageCount || 0) : 0 + readonly property real maxCount: { + var days = provider ? (provider.recentDays || []) : [] + var max = 1 + for (var i = 0; i < days.length; i++) if (Number(days[i].messageCount || 0) > max) max = Number(days[i].messageCount || 0) + return max + } + Text { + text: { + var d = modelData.date + if (!d) return "" + var dt = new Date(d + "T00:00:00") + var names = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"] + return names[dt.getDay()] + " " + String(dt.getMonth() + 1).padStart(2, "0") + "/" + String(dt.getDate()).padStart(2, "0") } - } - - Text { - visible: root.settingsStatusText !== "" - Layout.fillWidth: true - text: root.settingsStatusText color: dim font.family: fontFamily font.pixelSize: 10 - horizontalAlignment: Text.AlignHCenter - } - - Text { + Layout.preferredWidth: 48 + } + Rectangle { Layout.fillWidth: true - text: "s saves · esc closes" - color: dim - font.family: fontFamily - font.pixelSize: 10 - horizontalAlignment: Text.AlignHCenter - } - } - - function draftValue(name, fallback) { - var value = draftSettings ? draftSettings[name] : undefined - return value === undefined || value === null ? fallback : value - } - - function setDraftValue(name, value) { - var next = normalizedSettings(draftSettings) - next[name] = value - draftSettings = next - } - - component Header: RowLayout { - property var provider: null - visible: !!provider - Layout.fillWidth: true - spacing: 8 - - Image { - source: root.iconSource() - Layout.preferredWidth: 16 - Layout.preferredHeight: 16 - sourceSize.width: 16 - sourceSize.height: 16 - fillMode: Image.PreserveAspectFit - Layout.alignment: Qt.AlignVCenter - } - - Text { - text: provider ? provider.providerName + " Usage" : "" + Layout.preferredHeight: 10 + color: track + radius: Math.max(1, Style.cornerRadius / 3) + Rectangle { + anchors.left: parent.left + anchors.top: parent.top + anchors.bottom: parent.bottom + width: parent.width * (count / maxCount) + color: root.alpha(foreground, 0.78) + radius: Math.max(1, Style.cornerRadius / 3) + Behavior on width { NumberAnimation { duration: 160; easing.type: Easing.OutCubic } } + } + } + Text { + text: usageMain.formatTokenCount(count) color: foreground font.family: fontFamily - font.pixelSize: 15 + font.pixelSize: 10 font.bold: true - Layout.fillWidth: true - Layout.alignment: Qt.AlignVCenter - } - - Button { - text: (root.refreshFlash || usageMain.refreshing) ? "Refreshing\u2026" : "Refresh" - foreground: root.foreground - tooltipText: (root.refreshFlash || usageMain.refreshing) ? "Refreshing usage\u2026" : "Refresh usage" - tooltipBackground: root.background - tooltipForeground: root.foreground - fontFamily: root.fontFamily - fontSize: 10 - horizontalPadding: 8 - verticalPadding: 4 - active: root.refreshFlash || usageMain.refreshing - onClicked: { - root.triggerRefresh() - keyCatcher.forceActiveFocus() - } + horizontalAlignment: Text.AlignRight + Layout.preferredWidth: 48 + } } + } } + } - component StatusCard: SectionCard { - property var provider: null - visible: !!provider && String(provider.usageStatusText || "") !== "" - titleColor: urgent - title: provider ? provider.usageStatusText : "" - subtitle: provider ? provider.authHelpText : "" + component AllTimeCard: SectionCard { + property var provider: null + visible: { + var usage = provider ? (provider.modelUsage || {}) : {} + return Object.keys(usage).length > 0 } + title: "All-Time" - component TodayCard: SectionCard { - property var provider: null - visible: !!provider && provider.ready && provider.hasLocalStats - title: "Today" - - ColumnLayout { - width: parent.width - spacing: 8 - RowLayout { - Layout.fillWidth: true - spacing: 20 - StatBlock { value: provider ? String(provider.todayPrompts || 0) : "0"; label: "sessions" } - StatBlock { value: provider ? usageMain.formatTokenCount(provider.todayTotalTokens || 0) : "0"; label: "tokens" } - } - Repeater { - model: { - var toks = provider ? (provider.todayTokensByModel || {}) : {} - var out = [] - for (var k in toks) out.push({ modelId: k, count: toks[k] }) - return out - } - delegate: RowLayout { - required property var modelData - Layout.fillWidth: true - Text { text: usageMain.friendlyModelName(modelData.modelId); color: dim; font.family: fontFamily; font.pixelSize: 11 } - Item { Layout.fillWidth: true } - Text { text: usageMain.formatTokenCount(modelData.count) + " tokens"; color: foreground; font.family: fontFamily; font.pixelSize: 11; font.bold: true } - } - } - } - } - - component WeekCard: SectionCard { - property var provider: null - visible: !!provider && provider.recentDays && provider.recentDays.length > 0 - title: "Last 7 Days" - - ColumnLayout { - width: parent.width - spacing: 6 - Repeater { - model: provider ? provider.recentDays : [] - delegate: RowLayout { - required property var modelData - Layout.fillWidth: true - spacing: 8 - readonly property real count: modelData ? Number(modelData.messageCount || 0) : 0 - readonly property real maxCount: { - var days = provider ? (provider.recentDays || []) : [] - var max = 1 - for (var i = 0; i < days.length; i++) if (Number(days[i].messageCount || 0) > max) max = Number(days[i].messageCount || 0) - return max - } - Text { - text: { - var d = modelData.date - if (!d) return "" - var dt = new Date(d + "T00:00:00") - var names = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"] - return names[dt.getDay()] + " " + String(dt.getMonth() + 1).padStart(2, "0") + "/" + String(dt.getDate()).padStart(2, "0") - } - color: dim - font.family: fontFamily - font.pixelSize: 10 - Layout.preferredWidth: 48 - } - Rectangle { - Layout.fillWidth: true - Layout.preferredHeight: 10 - color: track - radius: Math.max(1, Style.cornerRadius / 3) - Rectangle { - anchors.left: parent.left - anchors.top: parent.top - anchors.bottom: parent.bottom - width: parent.width * (count / maxCount) - color: root.alpha(foreground, 0.78) - radius: Math.max(1, Style.cornerRadius / 3) - Behavior on width { NumberAnimation { duration: 160; easing.type: Easing.OutCubic } } - } - } - Text { - text: usageMain.formatTokenCount(count) - color: foreground - font.family: fontFamily - font.pixelSize: 10 - font.bold: true - horizontalAlignment: Text.AlignRight - Layout.preferredWidth: 48 - } - } - } - } - } - - component AllTimeCard: SectionCard { - property var provider: null - visible: { - var usage = provider ? (provider.modelUsage || {}) : {} - return Object.keys(usage).length > 0 - } - title: "All-Time" - - ColumnLayout { - width: parent.width - spacing: 8 - RowLayout { - Layout.fillWidth: true - spacing: 20 - StatBlock { value: provider ? String(provider.totalSessions || 0) : "0"; label: "sessions" } - } - PanelSeparator { Layout.fillWidth: true; foreground: root.foreground; strength: 0.18 } - Repeater { - model: { - var usage = provider ? (provider.modelUsage || {}) : {} - var out = [] - for (var k in usage) out.push({ modelId: k, data: usage[k] }) - return out - } - delegate: ColumnLayout { - required property var modelData - Layout.fillWidth: true - spacing: 4 - Text { text: usageMain.friendlyModelName(modelData.modelId); color: foreground; font.family: fontFamily; font.pixelSize: 11; font.bold: true } - GridLayout { - Layout.leftMargin: 10 - columns: 2 - columnSpacing: 18 - rowSpacing: 2 - DetailPair { name: "Input"; value: usageMain.formatTokenCount(modelData.data.inputTokens || 0) } - DetailPair { name: "Output"; value: usageMain.formatTokenCount(modelData.data.outputTokens || 0) } - DetailPair { name: "Cache Read"; value: usageMain.formatTokenCount(modelData.data.cacheReadInputTokens || 0) } - DetailPair { name: "Cache Write"; value: usageMain.formatTokenCount(modelData.data.cacheCreationInputTokens || 0) } - } - } - } - } - } - - component SectionCard: BorderSurface { - id: section - property string title: "" - property string subtitle: "" - property color titleColor: foreground - default property alias content: body.data - + ColumnLayout { + width: parent.width + spacing: 8 + RowLayout { Layout.fillWidth: true - color: card - borderSpec: Border.flat(Qt.rgba(foreground.r, foreground.g, foreground.b, 0.05), 1) - padding: 12 - radius: Style.cornerRadius - implicitHeight: body.implicitHeight + contentTopInset + contentBottomInset - - ColumnLayout { - id: body - anchors.left: parent.left - anchors.right: parent.right - anchors.top: parent.top - anchors.topMargin: section.contentTopInset - anchors.rightMargin: section.contentRightInset - anchors.bottomMargin: section.contentBottomInset - anchors.leftMargin: section.contentLeftInset - spacing: 8 - - PanelSectionHeader { - visible: section.title !== "" - Layout.fillWidth: true - text: section.title - foreground: section.titleColor - fontFamily: root.fontFamily - fontSize: 11 - } - Text { - visible: section.subtitle !== "" - Layout.fillWidth: true - text: section.subtitle - color: dim - font.family: fontFamily - font.pixelSize: 10 - wrapMode: Text.WordWrap - } - } - } - - component StatBlock: ColumnLayout { - property string value: "0" - property string label: "" - spacing: 2 - Text { text: value; color: foreground; font.family: fontFamily; font.pixelSize: 18; font.bold: true } - Text { text: label; color: dim; font.family: fontFamily; font.pixelSize: 10 } - } - - component DetailPair: RowLayout { - property string name: "" - property string value: "" - Text { text: name; color: dim; font.family: fontFamily; font.pixelSize: 10; Layout.preferredWidth: 76 } - Text { text: value; color: foreground; font.family: fontFamily; font.pixelSize: 10; font.bold: true } - } + spacing: 20 + StatBlock { value: provider ? String(provider.totalSessions || 0) : "0"; label: "sessions" } + } + PanelSeparator { Layout.fillWidth: true; foreground: root.foreground; strength: 0.18 } + Repeater { + model: { + var usage = provider ? (provider.modelUsage || {}) : {} + var out = [] + for (var k in usage) out.push({ modelId: k, data: usage[k] }) + return out + } + delegate: ColumnLayout { + required property var modelData + Layout.fillWidth: true + spacing: 4 + Text { text: usageMain.friendlyModelName(modelData.modelId); color: foreground; font.family: fontFamily; font.pixelSize: 11; font.bold: true } + GridLayout { + Layout.leftMargin: 10 + columns: 2 + columnSpacing: 18 + rowSpacing: 2 + DetailPair { name: "Input"; value: usageMain.formatTokenCount(modelData.data.inputTokens || 0) } + DetailPair { name: "Output"; value: usageMain.formatTokenCount(modelData.data.outputTokens || 0) } + DetailPair { name: "Cache Read"; value: usageMain.formatTokenCount(modelData.data.cacheReadInputTokens || 0) } + DetailPair { name: "Cache Write"; value: usageMain.formatTokenCount(modelData.data.cacheCreationInputTokens || 0) } + } + } + } + } + } + + component SectionCard: BorderSurface { + id: section + property string title: "" + property string subtitle: "" + property color titleColor: foreground + default property alias content: body.data + + Layout.fillWidth: true + color: card + borderSpec: Border.flat(Qt.rgba(foreground.r, foreground.g, foreground.b, 0.05), 1) + padding: 12 + radius: Style.cornerRadius + implicitHeight: body.implicitHeight + contentTopInset + contentBottomInset + + ColumnLayout { + id: body + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.topMargin: section.contentTopInset + anchors.rightMargin: section.contentRightInset + anchors.bottomMargin: section.contentBottomInset + anchors.leftMargin: section.contentLeftInset + spacing: 8 + + PanelSectionHeader { + visible: section.title !== "" + Layout.fillWidth: true + text: section.title + foreground: section.titleColor + fontFamily: root.fontFamily + fontSize: 11 + } + Text { + visible: section.subtitle !== "" + Layout.fillWidth: true + text: section.subtitle + color: dim + font.family: fontFamily + font.pixelSize: 10 + wrapMode: Text.WordWrap + } + } + } + + component StatBlock: ColumnLayout { + property string value: "0" + property string label: "" + spacing: 2 + Text { text: value; color: foreground; font.family: fontFamily; font.pixelSize: 18; font.bold: true } + Text { text: label; color: dim; font.family: fontFamily; font.pixelSize: 10 } + } + + component DetailPair: RowLayout { + property string name: "" + property string value: "" + Text { text: name; color: dim; font.family: fontFamily; font.pixelSize: 10; Layout.preferredWidth: 76 } + Text { text: value; color: foreground; font.family: fontFamily; font.pixelSize: 10; font.bold: true } + } } diff --git a/shell/plugins/opencode-model-usage/providers/Opencode.qml b/shell/plugins/opencode-model-usage/providers/Opencode.qml index cfef9aa07b..4dbd09f826 100644 --- a/shell/plugins/opencode-model-usage/providers/Opencode.qml +++ b/shell/plugins/opencode-model-usage/providers/Opencode.qml @@ -53,6 +53,10 @@ Item { watchChanges: true onFileChanged: reload() onLoaded: root.refresh(false) + onLoadFailed: function(error) { + root.usageStatusText = "OpenCode DB not found" + root.authHelpText = "Start an opencode session to create " + root.dbPath + } } Process { @@ -97,6 +101,8 @@ Item { root.totalSessions = Math.max(0, Number(data.totalSessions || 0)) root.dailyActivity = data.recentDays || [] } catch (e) { + root.usageStatusText = "Scanner error" + root.authHelpText = String(e) console.error("model-usage/opencode", "Failed to parse scanner output:", e) } } diff --git a/shell/plugins/opencode-model-usage/scripts/opencode_usage_scanner.py b/shell/plugins/opencode-model-usage/scripts/opencode_usage_scanner.py index bba4516024..a813773103 100644 --- a/shell/plugins/opencode-model-usage/scripts/opencode_usage_scanner.py +++ b/shell/plugins/opencode-model-usage/scripts/opencode_usage_scanner.py @@ -55,7 +55,7 @@ def scan(db_path: Path) -> dict[str, Any]: return empty_result() try: - conn = sqlite3.connect(str(db_path)) + conn = sqlite3.connect(f"file:{db_path}?mode=ro&immutable=1", uri=True, timeout=5) conn.row_factory = sqlite3.Row cursor = conn.cursor() @@ -76,7 +76,7 @@ def scan(db_path: Path) -> dict[str, Any]: ORDER BY time_created DESC """) - for row in cursor.fetchall(): + for row in cursor: raw_model = row["model"] if raw_model and isinstance(raw_model, str) and raw_model.strip().startswith("{"): try: