From 752ecaf6e7cc5bd9536cb222356e05cb79df7bfc Mon Sep 17 00:00:00 2001 From: Ryan Hughes Date: Wed, 27 May 2026 16:44:24 -0400 Subject: [PATCH] add model-usage plugin --- shell/plugins/README.md | 5 +- shell/plugins/bar/README.md | 5 +- shell/plugins/model-usage/Main.qml | 542 ++++++++ shell/plugins/model-usage/Widget.qml | 1187 +++++++++++++++++ shell/plugins/model-usage/assets/claude.svg | 1 + shell/plugins/model-usage/assets/codex.svg | 1 + shell/plugins/model-usage/manifest.json | 46 + .../plugins/model-usage/providers/Claude.qml | 501 +++++++ shell/plugins/model-usage/providers/Codex.qml | 135 ++ .../scripts/claude_usage_scanner.py | 238 ++++ .../scripts/codex_usage_scanner.py | 344 +++++ shell/shell.qml | 10 + 12 files changed, 3011 insertions(+), 4 deletions(-) create mode 100644 shell/plugins/model-usage/Main.qml create mode 100644 shell/plugins/model-usage/Widget.qml create mode 100644 shell/plugins/model-usage/assets/claude.svg create mode 100644 shell/plugins/model-usage/assets/codex.svg create mode 100644 shell/plugins/model-usage/manifest.json create mode 100644 shell/plugins/model-usage/providers/Claude.qml create mode 100644 shell/plugins/model-usage/providers/Codex.qml create mode 100755 shell/plugins/model-usage/scripts/claude_usage_scanner.py create mode 100644 shell/plugins/model-usage/scripts/codex_usage_scanner.py diff --git a/shell/plugins/README.md b/shell/plugins/README.md index 63d95160..b7aa6270 100644 --- a/shell/plugins/README.md +++ b/shell/plugins/README.md @@ -25,6 +25,7 @@ User-installed plugins live alongside these conceptually but on disk under | Network | `omarchy.network` | `bar-widget` | `panels/network/Panel.qml` | | Power | `omarchy.power` | `bar-widget` | `panels/power/Panel.qml` | | Tailscale | `omarchy.tailscale` | `bar-widget` | `panels/tailscale/Panel.qml` | +| Model usage | `omarchy.model-usage` | `bar-widget` | `model-usage/Widget.qml` | | Weather | `omarchy.weather` | `bar-widget` | `panels/weather/BarWidget.qml` | | Media | `omarchy.media` | `service`, `bar-widget` | `services/media/Service.qml`, `services/media/BarWidget.qml` | | Battery | `omarchy.battery` | `service` | `services/battery/Service.qml` | @@ -34,8 +35,8 @@ User-installed plugins live alongside these conceptually but on disk under | Polkit agent | `omarchy.polkit` | `service` | `polkit/PolkitAgent.qml` | First-party bar-only widgets also carry manifests next to their QML files, -e.g. `bar/widgets/Clock.manifest.json`. Rich popup widgets live under -`panels/`, each with its own `manifest.json`. +e.g. `bar/widgets/Clock.manifest.json`. Rich popup widgets live in their +own plugin directories, each with its own `manifest.json`. ## Bar diff --git a/shell/plugins/bar/README.md b/shell/plugins/bar/README.md index 48ee24b7..437a289a 100644 --- a/shell/plugins/bar/README.md +++ b/shell/plugins/bar/README.md @@ -8,7 +8,7 @@ the shell for its whole session. - `manifest.json` declares the plugin (`id: omarchy.bar`, `kind: bar`) and points at `Bar.qml` as the entry point. - `Bar.qml` is Omarchy-owned bar engine code, loaded by the omarchy-shell host. Users should not edit it directly. - `widgets/` holds simple first-party bar widgets with sibling manifests. -- Feature plugins such as `../panels/audio/`, `../panels/network/`, `../panels/power/`, and `../tailscale/` provide richer popup bar plugins. +- Feature plugins such as `../panels/audio/`, `../panels/network/`, `../panels/power/`, and `../model-usage/` provide richer popup bar plugins. - The bar receives its config from the host shell as a `barConfig` property; the host loads it from `~/.config/omarchy/shell.json` (or `config/omarchy/shell.json` when the user has no file). - `omarchy-style-bar-position` updates only the user shell.json file. @@ -69,6 +69,7 @@ Example `shell.json` (bar subtree only shown): | `omarchy.audio` | Volume icon + popup with master slider, output-device picker, per-app mixer | left = popup · right = mute · middle = popup · scroll = volume | | `omarchy.network` | Wi-Fi/Ethernet icon + popup with Wi-Fi scan, signal, connect, DNS provider selection | left = popup · right = nmtui | | `omarchy.tailscale` | Tailscale status, connection switcher, machine browser, and copy actions | left = popup · right = toggle · middle = refresh | +| `omarchy.model-usage` | Claude Code and Codex usage, limits, synced usage aggregation, and settings | left = popup · right = settings · middle = refresh | | `omarchy.power` | Battery/AC icon + popup with battery stats, power profiles, and system info | left = popup | | `omarchy.bluetooth` | Bluetooth icon + popup with device list, connect/disconnect, battery | left = popup · right = toggle radio · middle = bluetoothctl TUI | | `omarchy.monitor` | Brightness and laptop display controls | left = popup | @@ -169,7 +170,7 @@ Widgets receive `bar` (the shell root), `moduleName` (string), and `settings` (o First-party bar widgets are manifest-backed just like third-party widgets. Simple widgets carry sibling manifests such as `widgets/Clock.manifest.json`; richer popup plugins live in feature directories such as `../panels/audio/`, -`../panels/network/`, and `../tailscale/`; and feature plugins such as `omarchy.menu`, `omarchy.media`, and +`../panels/network/`, and `../model-usage/`; and feature plugins such as `omarchy.menu`, `omarchy.media`, and `omarchy.notifications` declare their bar-widget entry points in their own `manifest.json`. Bar layout ids are namespaced, e.g. `omarchy.audio`, `omarchy.network`, and `omarchy.clock`. Older UpperCamelCase ids such as diff --git a/shell/plugins/model-usage/Main.qml b/shell/plugins/model-usage/Main.qml new file mode 100644 index 00000000..2374592c --- /dev/null +++ b/shell/plugins/model-usage/Main.qml @@ -0,0 +1,542 @@ +import QtQuick +import Quickshell +import Quickshell.Io +import "providers" + +Item { + id: root + visible: false + + property var settings: ({}) + + Claude { + id: claudeProvider + enabled: root.providerEnabled("claude") + providerSettings: root.settings && root.settings.providers && root.settings.providers.claude ? root.settings.providers.claude : ({}) + onLastRefreshedAtMsChanged: root.scheduleSync() + onReadyChanged: root.scheduleSync() + } + + Codex { + id: codexProvider + enabled: root.providerEnabled("codex") + providerSettings: root.settings && root.settings.providers && root.settings.providers.codex ? root.settings.providers.codex : ({}) + onLastRefreshedAtMsChanged: root.scheduleSync() + onReadyChanged: root.scheduleSync() + } + + property var providers: [claudeProvider, codexProvider] + property var enabledProviders: { + var rev = syncRevision + var running = syncRunning + var result = [] + if (claudeProvider.enabled) result.push(displayProvider(claudeProvider)) + if (codexProvider.enabled) result.push(displayProvider(codexProvider)) + return result + } + + property int activeIndex: 0 + property var activeProvider: enabledProviders.length > 0 ? enabledProviders[Math.min(activeIndex, enabledProviders.length - 1)] : null + property bool refreshing: claudeProvider.refreshing || codexProvider.refreshing || syncRunning + property double aggregateUpdatedAtMs: aggregateData && aggregateData.updatedAtMs ? Number(aggregateData.updatedAtMs) : 0 + property double lastRefreshedAtMs: Math.max(aggregateUpdatedAtMs, claudeProvider.lastRefreshedAtMs || 0, codexProvider.lastRefreshedAtMs || 0) + property string barDisplayMode: setting("barDisplayMode", "active") + property int barCycleIntervalSec: Math.max(1, Number(setting("barCycleIntervalSec", 5))) + property string barMetric: setting("barMetric", "prompts") + property int refreshIntervalSec: Math.max(30, Number(setting("refreshIntervalSec", 900))) + + property var syncModeSetting: setting("syncMode", setting("syncEnabled", false)) + property bool syncEnabled: parseSyncEnabled(syncModeSetting) + property string syncDir: String(setting("syncDir", "")) + property string syncFileName: String(setting("syncFileName", "")) + property string syncDeviceId: String(setting("syncDeviceId", "")) + readonly property string home: Quickshell.env("HOME") || "" + property string detectedHostname: "" + readonly property string syncEffectiveDir: expandPath(syncDir) + readonly property string syncEffectiveFileName: safeSnapshotFileName(syncFileName, syncDeviceId) + readonly property string syncEffectiveDeviceId: safeDeviceId(syncDeviceId || syncEffectiveFileName.replace(/\.json$/i, "")) + readonly property string syncSnapshotPath: syncConfigured() ? syncEffectiveDir + "/" + syncEffectiveFileName : home + "/.cache/omarchy/model-usage-disabled.json" + property var aggregateData: ({}) + property int syncRevision: 0 + property bool syncRunning: false + property bool syncRequestedWhileRunning: false + property string syncStatusText: "" + property int syncDeviceCount: syncConfigured() && aggregateData && aggregateData.deviceCount ? Number(aggregateData.deviceCount) : 0 + + onSyncEnabledChanged: syncSettingsChanged() + onSyncDirChanged: syncSettingsChanged() + onSyncFileNameChanged: if (syncConfigured()) scheduleSync() + onSyncDeviceIdChanged: if (syncConfigured()) scheduleSync() + + Component.onCompleted: if (syncConfigured()) scheduleSync() + + function setting(name, fallback) { + var value = settings ? settings[name] : undefined + return value === undefined || value === null ? fallback : value + } + + Timer { + interval: root.barCycleIntervalSec * 1000 + running: root.barDisplayMode === "cycle" && root.enabledProviders.length > 1 + repeat: true + onTriggered: root.activeIndex = (root.activeIndex + 1) % root.enabledProviders.length + } + + Timer { + interval: root.refreshIntervalSec * 1000 + running: true + repeat: true + triggeredOnStart: true + onTriggered: root.refreshAll() + } + + Timer { + id: syncDebounce + interval: 1000 + repeat: false + onTriggered: root.runSync() + } + + Process { + id: syncMkdirProcess + running: false + onRunningChanged: root.updateSyncRunning() + onExited: function(exitCode) { + if (exitCode !== 0) { + if (root.syncConfigured()) root.syncStatusText = "Usage sync mkdir failed" + root.finishSyncRun() + return + } + root.writeSyncSnapshot() + } + } + + Process { + id: syncScanProcess + running: false + onRunningChanged: root.updateSyncRunning() + onExited: function(exitCode) { + if (exitCode !== 0 && root.syncConfigured()) root.syncStatusText = "Usage sync scan failed" + root.finishSyncRun() + } + + stdout: StdioCollector { + waitForEnd: true + onStreamFinished: root.parseSyncScanOutput(text) + } + + stderr: StdioCollector { + waitForEnd: true + onStreamFinished: if (text.trim() !== "") console.warn("model-usage/sync", text.trim()) + } + } + + FileView { + id: syncSnapshotFile + path: root.syncSnapshotPath + watchChanges: false + atomicWrites: true + printErrors: false + } + + FileView { + id: hostnameFile + path: "/etc/hostname" + watchChanges: false + printErrors: false + onLoaded: root.detectedHostname = String(text() || "").trim() + } + + onEnabledProvidersChanged: { + if (enabledProviders.length === 0 || activeIndex >= enabledProviders.length) activeIndex = 0 + } + + function providerEnabled(id) { + if (!settings || !settings.providers || !settings.providers[id]) return id === "claude" || id === "codex" + return settings.providers[id].enabled !== false + } + + function parseSyncEnabled(value) { + if (value === true) return true + var text = String(value || "").trim().toLowerCase() + return text === "on" || text === "enabled" || text === "true" || text === "yes" || text === "1" + } + + function syncConfigured() { + return root.syncEnabled === true && String(root.syncDir || "").trim() !== "" + } + + function syncSettingsChanged() { + if (syncConfigured()) { + scheduleSync() + } else { + syncDebounce.stop() + syncRequestedWhileRunning = false + aggregateData = ({}) + syncStatusText = "" + syncRevision++ + } + } + + function updateSyncRunning() { + root.syncRunning = syncMkdirProcess.running || syncScanProcess.running + } + + function scheduleSync() { + if (!syncConfigured()) return + syncDebounce.restart() + } + + function runSync() { + if (!syncConfigured()) return + if (root.syncRunning) { + syncRequestedWhileRunning = true + return + } + + syncRequestedWhileRunning = false + syncStatusText = "" + syncMkdirProcess.command = ["mkdir", "-p", root.syncEffectiveDir] + syncMkdirProcess.running = true + } + + function writeSyncSnapshot() { + if (!syncConfigured()) { + finishSyncRun() + return + } + syncSnapshotFile.setText(JSON.stringify(localSnapshot(), null, 2) + "\n") + Qt.callLater(root.startSyncScan) + } + + function startSyncScan() { + if (!syncConfigured()) { + finishSyncRun() + return + } + var script = "dir=$0; [[ -d \"$dir\" ]] || exit 0; shopt -s nullglob; for f in \"$dir\"/*.json; do [[ -f \"$f\" ]] || continue; printf '===%s===\\n' \"$f\"; cat \"$f\"; printf '\\n=== EOM ===\\n'; done" + syncScanProcess.command = ["bash", "-c", script, root.syncEffectiveDir] + syncScanProcess.running = true + } + + function finishSyncRun() { + if (syncRequestedWhileRunning && syncConfigured()) { + syncRequestedWhileRunning = false + scheduleSync() + } + } + + function expandPath(path) { + var value = String(path || "").trim() + if (value === "") return "" + if (value === "~") return home + if (value.indexOf("~/") === 0) return home + value.substring(1) + if (value.indexOf("$HOME/") === 0) return home + value.substring(5) + if (value.charAt(0) !== "/") return home + "/" + value + return value + } + + function safeDeviceId(raw) { + var value = String(raw || "").trim() + if (value === "") value = Quickshell.env("HOSTNAME") || root.detectedHostname || Quickshell.env("HOST") || Quickshell.env("USER") || "device" + value = value.replace(/[^A-Za-z0-9_.-]+/g, "-").replace(/^[._-]+|[._-]+$/g, "") + if (value === "") value = "device" + return value.length > 80 ? value.substring(0, 80) : value + } + + function safeSnapshotFileName(rawFileName, rawDeviceId) { + var value = String(rawFileName || "").trim() + if (value === "") value = safeDeviceId(rawDeviceId) + ".json" + value = value.split("/").pop().replace(/[^A-Za-z0-9_.-]+/g, "-").replace(/^[._-]+|[._-]+$/g, "") + if (value === "") value = safeDeviceId(rawDeviceId) + ".json" + if (!/\.json$/i.test(value)) value += ".json" + return value.length > 100 ? value.substring(0, 95) + ".json" : value + } + + function parseSyncScanOutput(output) { + var lines = String(output || "").split("\n") + var snapshots = [] + var currentPath = "" + var currentJson = [] + + function flush() { + if (currentPath === "") return + var raw = currentJson.join("\n").trim() + try { + var parsed = JSON.parse(raw) + if (parsed && parsed.providers) snapshots.push(parsed) + } catch (e) { + console.warn("model-usage/sync", "Ignoring bad snapshot", currentPath, e) + } + currentPath = "" + currentJson = [] + } + + for (var i = 0; i < lines.length; i++) { + var line = lines[i] + var start = line.match(/^===(.+)===$/) + if (start && line !== "=== EOM ===") { + flush() + currentPath = start[1] + currentJson = [] + continue + } + if (line === "=== EOM ===") { + flush() + continue + } + if (currentPath !== "") currentJson.push(line) + } + flush() + + aggregateData = aggregateSnapshots(snapshots) + syncStatusText = "" + syncRevision++ + } + + function cloneValue(value, fallback) { + if (value === undefined || value === null) return fallback + try { + return JSON.parse(JSON.stringify(value)) + } catch (e) { + return fallback + } + } + + function numberValue(value) { + var n = Number(value || 0) + return isFinite(n) ? Math.round(n) : 0 + } + + function dateString(date) { + var y = date.getFullYear() + var m = String(date.getMonth() + 1).padStart(2, "0") + var d = String(date.getDate()).padStart(2, "0") + return y + "-" + m + "-" + d + } + + function recentDateStrings() { + var result = [] + for (var offset = 6; offset >= 0; offset--) { + var date = new Date() + date.setDate(date.getDate() - offset) + result.push(dateString(date)) + } + return result + } + + function emptyTokenBucket() { + return { inputTokens: 0, outputTokens: 0, cacheReadInputTokens: 0, cacheCreationInputTokens: 0 } + } + + function addObjectNumbers(target, source) { + if (!source) return + for (var key in source) target[key] = numberValue(target[key]) + numberValue(source[key]) + } + + function aggregateSnapshots(snapshots) { + var dates = recentDateStrings() + var devices = {} + var providers = {} + + function providerAcc(id) { + if (providers[id]) return providers[id] + var recentByDay = {} + for (var d = 0; d < dates.length; d++) recentByDay[dates[d]] = 0 + providers[id] = { + providerId: id, + providerName: "", + ready: false, + hasLocalStats: false, + todayPrompts: 0, + todaySessions: 0, + todayTotalTokens: 0, + todayTokensByModel: ({}), + recentByDay: recentByDay, + totalPrompts: 0, + totalSessions: 0, + modelUsage: ({}), + devices: ({}) + } + return providers[id] + } + + for (var i = 0; i < snapshots.length; i++) { + var snapshot = snapshots[i] + var device = safeDeviceId(snapshot.deviceId || "device") + devices[device] = true + var snapshotProviders = snapshot.providers || {} + for (var providerId in snapshotProviders) { + var stats = snapshotProviders[providerId] || {} + var acc = providerAcc(String(providerId)) + acc.devices[device] = true + if (stats.providerName && acc.providerName === "") acc.providerName = String(stats.providerName) + acc.ready = acc.ready || stats.ready === true + acc.hasLocalStats = acc.hasLocalStats || stats.hasLocalStats !== false + acc.todayPrompts += numberValue(stats.todayPrompts) + acc.todaySessions += numberValue(stats.todaySessions) + acc.todayTotalTokens += numberValue(stats.todayTotalTokens) + acc.totalPrompts += numberValue(stats.totalPrompts) + acc.totalSessions += numberValue(stats.totalSessions) + addObjectNumbers(acc.todayTokensByModel, stats.todayTokensByModel || {}) + + var recent = Array.isArray(stats.recentDays) ? stats.recentDays : [] + for (var r = 0; r < recent.length; r++) { + var day = recent[r] || {} + var date = String(day.date || "") + if (acc.recentByDay[date] !== undefined) acc.recentByDay[date] += numberValue(day.messageCount) + } + + var usage = stats.modelUsage || {} + for (var modelId in usage) { + var bucket = acc.modelUsage[modelId] + if (!bucket) bucket = acc.modelUsage[modelId] = emptyTokenBucket() + var source = usage[modelId] || {} + bucket.inputTokens += numberValue(source.inputTokens) + bucket.outputTokens += numberValue(source.outputTokens) + bucket.cacheReadInputTokens += numberValue(source.cacheReadInputTokens) + bucket.cacheCreationInputTokens += numberValue(source.cacheCreationInputTokens) + } + } + } + + var outProviders = {} + for (var id in providers) { + var acc = providers[id] + var recentDays = [] + for (var di = 0; di < dates.length; di++) recentDays.push({ date: dates[di], messageCount: acc.recentByDay[dates[di]] || 0 }) + var providerDevices = Object.keys(acc.devices).sort() + outProviders[id] = { + providerId: acc.providerId, + providerName: acc.providerName, + ready: acc.ready || providerDevices.length > 0, + hasLocalStats: acc.hasLocalStats, + todayPrompts: acc.todayPrompts, + todaySessions: acc.todaySessions, + todayTotalTokens: acc.todayTotalTokens, + todayTokensByModel: acc.todayTokensByModel, + recentDays: recentDays, + totalPrompts: acc.totalPrompts, + totalSessions: acc.totalSessions, + modelUsage: acc.modelUsage, + deviceCount: providerDevices.length, + devices: providerDevices + } + } + + return { + schemaVersion: 1, + updatedAt: new Date().toISOString(), + updatedAtMs: Date.now(), + deviceCount: Object.keys(devices).length, + devices: Object.keys(devices).sort(), + providers: outProviders + } + } + + function providerSnapshot(provider) { + return { + providerId: provider.providerId, + providerName: provider.providerName, + ready: provider.ready === true, + hasLocalStats: provider.hasLocalStats !== false, + todayPrompts: numberValue(provider.todayPrompts), + todaySessions: numberValue(provider.todaySessions), + todayTotalTokens: numberValue(provider.todayTotalTokens), + todayTokensByModel: cloneValue(provider.todayTokensByModel, ({})), + recentDays: cloneValue(provider.recentDays, []), + totalPrompts: numberValue(provider.totalPrompts), + totalSessions: numberValue(provider.totalSessions), + modelUsage: cloneValue(provider.modelUsage, ({})) + } + } + + function localSnapshot() { + var providerMap = {} + for (var i = 0; i < providers.length; i++) { + var provider = providers[i] + if (provider.enabled) providerMap[provider.providerId] = providerSnapshot(provider) + } + return { + schemaVersion: 1, + deviceId: syncEffectiveDeviceId, + updatedAt: new Date().toISOString(), + providers: providerMap + } + } + + function syncedStatsFor(providerId) { + var rev = syncRevision + if (!syncConfigured() || !aggregateData || !aggregateData.providers) return null + return aggregateData.providers[providerId] || null + } + + function displayProvider(provider) { + var stats = syncedStatsFor(provider.providerId) + var synced = !!stats + var deviceCount = synced ? Number(stats.deviceCount || aggregateData.deviceCount || 0) : 0 + + return { + providerId: provider.providerId, + providerName: provider.providerName, + providerIcon: provider.providerIcon, + enabled: provider.enabled, + ready: provider.ready || synced, + refreshing: provider.refreshing || root.syncRunning, + lastRefreshedAtMs: Math.max(provider.lastRefreshedAtMs || 0, root.aggregateUpdatedAtMs || 0), + usageStatusText: provider.usageStatusText, + authHelpText: provider.authHelpText, + + rateLimitPercent: provider.rateLimitPercent, + rateLimitLabel: provider.rateLimitLabel, + rateLimitResetAt: provider.rateLimitResetAt, + secondaryRateLimitPercent: provider.secondaryRateLimitPercent, + secondaryRateLimitLabel: provider.secondaryRateLimitLabel, + secondaryRateLimitResetAt: provider.secondaryRateLimitResetAt, + tierLabel: provider.tierLabel, + + todayPrompts: synced ? numberValue(stats.todayPrompts) : provider.todayPrompts, + todaySessions: synced ? numberValue(stats.todaySessions) : provider.todaySessions, + todayTotalTokens: synced ? numberValue(stats.todayTotalTokens) : provider.todayTotalTokens, + todayTokensByModel: synced ? (stats.todayTokensByModel || ({})) : provider.todayTokensByModel, + recentDays: synced ? (stats.recentDays || []) : provider.recentDays, + totalPrompts: synced ? numberValue(stats.totalPrompts) : provider.totalPrompts, + totalSessions: synced ? numberValue(stats.totalSessions) : provider.totalSessions, + modelUsage: synced ? (stats.modelUsage || ({})) : provider.modelUsage, + hasLocalStats: synced ? (stats.hasLocalStats !== false) : provider.hasLocalStats, + + syncEnabled: synced, + syncDeviceCount: deviceCount, + syncUpdatedAt: aggregateData && aggregateData.updatedAt ? aggregateData.updatedAt : "", + + formatResetTime: function(isoTimestamp) { return provider.formatResetTime(isoTimestamp) } + } + } + + function refresh() { refreshAll(true) } + + function refreshAll(force) { + for (var i = 0; i < providers.length; i++) { + var p = providers[i] + if (p.enabled && typeof p.refresh === "function") p.refresh(force === true) + } + scheduleSync() + } + + 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" + var name = String(id).replace(/^claude-/, "").replace(/-\d{8}$/, "") + var parts = name.split("-") + if (parts.length >= 3) return parts[0].charAt(0).toUpperCase() + parts[0].slice(1) + " " + parts[1] + "." + parts[2] + if (parts.length === 2) return parts[0].charAt(0).toUpperCase() + parts[0].slice(1) + " " + parts[1] + return name.charAt(0).toUpperCase() + name.slice(1) + } +} diff --git a/shell/plugins/model-usage/Widget.qml b/shell/plugins/model-usage/Widget.qml new file mode 100644 index 00000000..89d63ae8 --- /dev/null +++ b/shell/plugins/model-usage/Widget.qml @@ -0,0 +1,1187 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell +import Quickshell.Io +import qs.Commons +import qs.Ui + +BarWidget { + id: root + moduleName: "omarchy.model-usage" + + property bool popupOpen: false + property bool settingsMode: false + property var draftSettings: ({}) + property string settingsStatusText: "" + property int selectedTabIndex: 0 + 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 providers: usageMain.enabledProviders + readonly property var selectedProvider: providers.length > 0 ? providers[Math.min(selectedTabIndex, providers.length - 1)] : null + + function close() { + popupOpen = false + settingsMode = false + } + + function triggerPress(button) { + root.handleChipPress(Math.max(0, Math.min(selectedTabIndex, providers.length - 1)), button, null) + } + + 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 { + providers: { + claude: { + enabled: true, + statsPath: "~/.claude/stats-cache.json", + credentialsPath: "~/.claude/.credentials.json", + projectsPath: "~/.claude/projects" + }, + codex: { enabled: true } + }, + refreshIntervalSec: 900, + syncMode: "Off", + syncDir: "", + syncFileName: "", + syncDeviceId: "" + } + } + + function parseSyncEnabledValue(value) { + if (value === true) return true + var text = String(value || "").trim().toLowerCase() + return text === "on" || text === "enabled" || text === "true" || text === "yes" || text === "1" + } + + function normalizedSettings(source) { + var defaults = defaultSettings() + var next = cloneObject(source, {}) || {} + if (!next.providers || typeof next.providers !== "object") next.providers = {} + + var claude = cloneObject(next.providers.claude, {}) || {} + var codex = cloneObject(next.providers.codex, {}) || {} + if (claude.enabled === undefined || claude.enabled === null) claude.enabled = defaults.providers.claude.enabled + if (codex.enabled === undefined || codex.enabled === null) codex.enabled = defaults.providers.codex.enabled + if (!claude.statsPath) claude.statsPath = defaults.providers.claude.statsPath + if (!claude.credentialsPath) claude.credentialsPath = defaults.providers.claude.credentialsPath + if (!claude.projectsPath) claude.projectsPath = defaults.providers.claude.projectsPath + next.providers.claude = claude + next.providers.codex = codex + + var refresh = Number(next.refreshIntervalSec === undefined || next.refreshIntervalSec === null ? defaults.refreshIntervalSec : next.refreshIntervalSec) + next.refreshIntervalSec = Math.round(clamp(isFinite(refresh) ? refresh : defaults.refreshIntervalSec, 30, 3600)) + next.syncMode = parseSyncEnabledValue(next.syncMode !== undefined ? next.syncMode : next.syncEnabled) ? "On" : "Off" + next.syncDir = String(next.syncDir || "") + next.syncFileName = String(next.syncFileName || "") + next.syncDeviceId = String(next.syncDeviceId || "") + return next + } + + function draftValue(name, fallback) { + var value = draftSettings ? draftSettings[name] : undefined + return value === undefined || value === null ? fallback : value + } + + function draftProviderValue(providerId, name, fallback) { + var provider = draftSettings && draftSettings.providers ? draftSettings.providers[providerId] : null + var value = provider ? provider[name] : undefined + return value === undefined || value === null ? fallback : value + } + + function setDraftValue(name, value) { + var next = normalizedSettings(draftSettings) + next[name] = value + draftSettings = next + } + + function setDraftProviderValue(providerId, name, value) { + var next = normalizedSettings(draftSettings) + if (!next.providers) next.providers = {} + if (!next.providers[providerId]) next.providers[providerId] = {} + next.providers[providerId][name] = value + draftSettings = 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 iconSourceForProvider(provider) { + if (!provider) return "" + if (provider.providerId === "claude") return Qt.resolvedUrl("assets/claude.svg") + if (provider.providerId === "codex") return Qt.resolvedUrl("assets/codex.svg") + return "" + } + + function usagePercent(provider) { + if (!provider) return -1 + var values = [] + if (provider.rateLimitPercent >= 0) values.push(provider.rateLimitPercent) + if (provider.secondaryRateLimitPercent >= 0) values.push(provider.secondaryRateLimitPercent) + if (values.length === 0) return -1 + return Math.max.apply(Math, values) + } + + function formatUsagePercent(provider) { + var pct = usagePercent(provider) + return pct < 0 ? "—" : Math.round(pct * 100) + "%" + } + + function weeklyUsage(provider) { + if (!provider) return ({ percent: -1, resetAt: "", label: "" }) + if (String(provider.rateLimitLabel || "").toLowerCase().indexOf("week") >= 0) + return { percent: provider.rateLimitPercent, resetAt: provider.rateLimitResetAt, label: provider.rateLimitLabel } + if (String(provider.secondaryRateLimitLabel || "").toLowerCase().indexOf("week") >= 0) + return { percent: provider.secondaryRateLimitPercent, resetAt: provider.secondaryRateLimitResetAt, label: provider.secondaryRateLimitLabel } + return ({ percent: -1, resetAt: "", label: "" }) + } + + function paceInfo(provider) { + var weekly = weeklyUsage(provider) + if (weekly.percent < 0 || !weekly.resetAt) return ({ text: "", detail: "", deficit: false }) + var reset = new Date(weekly.resetAt).getTime() + var now = Date.now() + var period = 7 * 24 * 60 * 60 * 1000 + var remaining = reset - now + if (remaining <= 0 || remaining > period) return ({ text: "", detail: "", deficit: false }) + var elapsed = period - remaining + var expected = root.clamp(elapsed / period, 0, 1) + var used = root.clamp(weekly.percent, 0, 1) + var diff = used - expected + var abs = Math.abs(diff) + var label = abs <= 0.02 ? "On pace" : (diff > 0 ? Math.round(abs * 100) + "% in deficit" : Math.round(abs * 100) + "% in reserve") + var projection = "Lasts until reset" + if (used > 0 && elapsed > 0) { + var eta = elapsed / used * (1 - used) + if (eta < remaining) projection = "Runs out in " + provider.formatResetTime(new Date(now + eta).toISOString()) + } + return ({ text: label, detail: "Expected " + Math.round(expected * 100) + "% used · " + projection, deficit: diff > 0 && abs > 0.02 }) + } + + function tooltipText() { + if (providers.length === 0) return "Model Usage" + var lines = ["Model Usage"] + for (var i = 0; i < providers.length; i++) { + var provider = providers[i] + var line = provider.providerName + ": " + formatUsagePercent(provider) + " used" + var pace = paceInfo(provider) + if (pace.text !== "") line += " · " + pace.text + if (provider.syncEnabled && provider.syncDeviceCount > 1) line += " · " + provider.syncDeviceCount + " devices" + lines.push(line) + } + return lines.join("\n") + } + + function syncSummary(provider) { + if (usageMain.syncStatusText !== "") return usageMain.syncStatusText + if (!provider || !provider.syncEnabled) return "" + var count = Number(provider.syncDeviceCount || 0) + if (count <= 0) return "Synced usage" + return "Synced from " + count + " device" + (count === 1 ? "" : "s") + } + + function selectTab(index) { + if (providers.length === 0) { + selectedTabIndex = 0 + return + } + selectedTabIndex = ((index % providers.length) + providers.length) % providers.length + } + + function handleChipPress(index, button, target) { + if (root.bar && target) root.bar.hideTooltip(target) + if (button === Qt.RightButton) { + root.openSettings() + return + } + if (button === Qt.MiddleButton) { + root.triggerRefresh() + return + } + + var wasOpen = root.popupOpen + var wasSelected = root.selectedTabIndex === index && !root.settingsMode + root.showUsage() + root.selectTab(index) + if (wasOpen && wasSelected) root.popupOpen = false + else { + root.popupOpen = true + root.triggerRefresh() + } + } + + implicitWidth: button.implicitWidth + implicitHeight: button.implicitHeight + + onPopupOpenChanged: { + if (popupOpen) { + usageMain.refreshAll() + Qt.callLater(function() { if (keyCatcher) keyCatcher.forceActiveFocus() }) + } + } + + onProvidersChanged: if (selectedTabIndex >= providers.length) selectTab(0) + + Main { + id: usageMain + settings: root.settings + } + + Timer { + id: refreshFlashTimer + interval: 900 + repeat: false + onTriggered: root.refreshFlash = false + } + + IpcHandler { + target: "omarchy.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" } + } + + Item { + id: button + anchors.fill: parent + implicitWidth: barRow.implicitWidth + 10 + implicitHeight: root.bar ? root.bar.barSize : 26 + + Row { + id: barRow + anchors.centerIn: parent + spacing: 8 + + Repeater { + model: providers + + Item { + id: chip + required property var modelData + required property int index + readonly property real pct: root.usagePercent(modelData) + readonly property bool tooltipHovered: mouseArea.containsMouse + + width: chipRow.implicitWidth + height: root.bar ? root.bar.barSize : 26 + + Row { + id: chipRow + anchors.centerIn: parent + spacing: 4 + + Image { + id: chipIcon + source: root.iconSourceForProvider(chip.modelData) + width: 13 + height: 13 + sourceSize.width: 13 + sourceSize.height: 13 + fillMode: Image.PreserveAspectFit + anchors.verticalCenter: parent.verticalCenter + opacity: chip.pct >= 0.9 ? 0.75 : 1 + } + + Text { + text: root.formatUsagePercent(chip.modelData) + color: chip.pct >= 0.9 ? urgent : foreground + font.family: fontFamily + font.pixelSize: 10 + font.bold: chip.pct >= 0.9 + anchors.verticalCenter: parent.verticalCenter + } + } + + property var registeredBar: null + + function triggerPress(button) { + root.handleChipPress(chip.index, button, chip) + } + + 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.handleChipPress(chip.index, mouse.button, chip) } + } + } + } + + Item { + id: emptyChip + visible: providers.length === 0 + width: emptyLabel.implicitWidth + height: root.bar ? root.bar.barSize : 26 + readonly property bool tooltipHovered: emptyMouse.containsMouse + property var registeredBar: null + + function triggerPress(button) { + root.handleChipPress(0, button, emptyChip) + } + + 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: "AI" + 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, "Model Usage") + onExited: if (root.bar) root.bar.hideTooltip(emptyChip) + onClicked: function(mouse) { root.handleChipPress(0, mouse.button, emptyChip) } + } + } + } + } + + 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: root.settingsMode && settingsContent.editorActive + + onMoveRequested: function(dx, dy) { + if (root.settingsMode) { + if (dy !== 0) flick.contentY = root.clamp(flick.contentY + dy * 56, 0, Math.max(0, flick.contentHeight - flick.height)) + return + } + if (dx !== 0) root.selectTab(root.selectedTabIndex + dx) + 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.selectedProvider + provider: root.selectedProvider + } + + SettingsHeader { visible: root.settingsMode } + + PanelSeparator { + Layout.fillWidth: true + foreground: root.foreground + } + + Item { + visible: !root.settingsMode && providers.length > 1 + Layout.fillWidth: true + Layout.preferredHeight: 30 + + Row { + anchors.fill: parent + spacing: 6 + + Repeater { + model: providers + + Button { + required property var modelData + required property int index + width: (parent.width - parent.spacing * Math.max(0, providers.length - 1)) / Math.max(1, providers.length) + height: parent.height + text: modelData.providerName + foreground: root.foreground + tooltipBackground: root.background + tooltipForeground: root.foreground + fontFamily: root.fontFamily + fontSize: 11 + horizontalPadding: 8 + verticalPadding: 5 + active: index === root.selectedTabIndex + hasCursor: index === root.selectedTabIndex + onClicked: { + root.selectTab(index) + keyCatcher.forceActiveFocus() + } + } + } + } + } + + 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.selectedProvider + Layout.fillWidth: true + Layout.topMargin: 24 + text: "No providers enabled. Open Settings to enable Claude or Codex." + color: dim + font.family: fontFamily + font.pixelSize: 11 + horizontalAlignment: Text.AlignHCenter + } + + StatusCard { provider: root.settingsMode ? null : root.selectedProvider } + RateLimitCard { provider: root.settingsMode ? null : root.selectedProvider } + TodayCard { provider: root.settingsMode ? null : root.selectedProvider } + WeekCard { provider: root.settingsMode ? null : root.selectedProvider } + AllTimeCard { provider: root.settingsMode ? null : root.selectedProvider } + + UsageFooter { visible: !root.settingsMode } + SettingsContent { + id: settingsContent + visible: root.settingsMode + } + } + } + } + } + } + + component SettingsHeader: RowLayout { + Layout.fillWidth: true + spacing: 8 + + Text { + text: "Model 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: { + var sync = root.syncSummary(root.selectedProvider) + return (sync !== "" ? sync + " · " : "") + "←/→ switch · 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 syncOn: root.draftValue("syncMode", "Off") === "On" + readonly property bool claudeOn: root.draftProviderValue("claude", "enabled", true) !== false + readonly property bool editorActive: refreshIntervalField.field.activeFocus + || claudeStatsField.activeFocus + || claudeCredentialsField.activeFocus + || claudeProjectsField.activeFocus + || syncDirField.activeFocus + || syncFileNameField.activeFocus + || syncDeviceIdField.activeFocus + + SectionCard { + title: "Providers" + + ColumnLayout { + width: parent.width + spacing: 10 + + Toggle { + Layout.fillWidth: true + label: "Claude Code" + description: checked ? "Show Claude Code usage and rate-limit status" : "Hidden from the bar widget" + checked: root.draftProviderValue("claude", "enabled", true) !== false + foreground: root.foreground + accent: Color.accent + fontFamily: root.fontFamily + onClicked: root.setDraftProviderValue("claude", "enabled", !checked) + } + + ColumnLayout { + Layout.fillWidth: true + spacing: 6 + enabled: settingsRoot.claudeOn + opacity: enabled ? 1.0 : 0.45 + + FieldLabel { text: "Claude stats cache" } + TextField { + id: claudeStatsField + Layout.fillWidth: true + text: String(root.draftProviderValue("claude", "statsPath", "~/.claude/stats-cache.json")) + placeholderText: "~/.claude/stats-cache.json" + foreground: root.foreground + onTextChanged: if (text !== root.draftProviderValue("claude", "statsPath", "")) root.setDraftProviderValue("claude", "statsPath", text) + } + + FieldLabel { text: "Claude credentials" } + TextField { + id: claudeCredentialsField + Layout.fillWidth: true + text: String(root.draftProviderValue("claude", "credentialsPath", "~/.claude/.credentials.json")) + placeholderText: "~/.claude/.credentials.json" + foreground: root.foreground + onTextChanged: if (text !== root.draftProviderValue("claude", "credentialsPath", "")) root.setDraftProviderValue("claude", "credentialsPath", text) + } + + FieldLabel { text: "Claude projects folder" } + TextField { + id: claudeProjectsField + Layout.fillWidth: true + text: String(root.draftProviderValue("claude", "projectsPath", "~/.claude/projects")) + placeholderText: "~/.claude/projects" + foreground: root.foreground + onTextChanged: if (text !== root.draftProviderValue("claude", "projectsPath", "")) root.setDraftProviderValue("claude", "projectsPath", text) + } + } + + Toggle { + Layout.fillWidth: true + label: "Codex" + description: checked ? "Show Codex usage and limits" : "Hidden from the bar widget" + checked: root.draftProviderValue("codex", "enabled", true) !== false + foreground: root.foreground + accent: Color.accent + fontFamily: root.fontFamily + onClicked: root.setDraftProviderValue("codex", "enabled", !checked) + } + } + } + + SectionCard { + title: "Refresh" + + ColumnLayout { + width: parent.width + spacing: 8 + + NumberField { + id: refreshIntervalField + label: "Refresh interval (seconds)" + value: Number(root.draftValue("refreshIntervalSec", 900)) + 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 refreshes local usage scans and sync snapshots." + color: dim + font.family: fontFamily + font.pixelSize: 10 + wrapMode: Text.WordWrap + } + } + } + + SectionCard { + title: "Synced aggregation" + + ColumnLayout { + width: parent.width + spacing: 10 + + Toggle { + Layout.fillWidth: true + label: "Aggregate across devices" + description: checked ? "Write this machine's snapshot and merge every *.json file in the sync folder" : "Only show this machine's local usage" + checked: settingsRoot.syncOn + foreground: root.foreground + accent: Color.accent + fontFamily: root.fontFamily + onClicked: root.setDraftValue("syncMode", checked ? "Off" : "On") + } + + ColumnLayout { + Layout.fillWidth: true + spacing: 6 + enabled: settingsRoot.syncOn + opacity: enabled ? 1.0 : 0.45 + + FieldLabel { text: "Sync folder" } + TextField { + id: syncDirField + Layout.fillWidth: true + text: String(root.draftValue("syncDir", "")) + placeholderText: "~/Sync/model-usage" + foreground: root.foreground + onTextChanged: if (text !== root.draftValue("syncDir", "")) root.setDraftValue("syncDir", text) + } + + FieldLabel { text: "Snapshot file name" } + TextField { + id: syncFileNameField + Layout.fillWidth: true + text: String(root.draftValue("syncFileName", "")) + placeholderText: "Defaults to .json" + foreground: root.foreground + onTextChanged: if (text !== root.draftValue("syncFileName", "")) root.setDraftValue("syncFileName", text) + } + + FieldLabel { text: "Device id" } + TextField { + id: syncDeviceIdField + Layout.fillWidth: true + text: String(root.draftValue("syncDeviceId", "")) + placeholderText: "Optional stable name for this machine" + foreground: root.foreground + onTextChanged: if (text !== root.draftValue("syncDeviceId", "")) root.setDraftValue("syncDeviceId", text) + } + } + } + } + + 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 + } + } + + component FieldLabel: Text { + color: dim + font.family: fontFamily + font.pixelSize: 10 + font.bold: true + } + + component Header: RowLayout { + property var provider: null + visible: !!provider + Layout.fillWidth: true + spacing: 8 + + Image { + source: root.iconSourceForProvider(provider) + 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 { + visible: provider && String(provider.tierLabel || "") !== "" + text: provider ? provider.tierLabel : "" + foreground: root.foreground + tooltipBackground: root.background + tooltipForeground: root.foreground + fontFamily: root.fontFamily + fontSize: 10 + horizontalPadding: 6 + verticalPadding: 3 + active: true + enabled: false + } + + Button { + text: (root.refreshFlash || usageMain.refreshing) ? "Refreshing…" : "Refresh" + foreground: root.foreground + tooltipText: (root.refreshFlash || usageMain.refreshing) ? "Refreshing usage…" : "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 RateLimitCard: SectionCard { + id: rateLimitCard + property var provider: null + visible: !!provider && ((provider.rateLimitPercent >= 0) || (provider.secondaryRateLimitPercent >= 0)) + title: "Rate Limit Usage" + + ColumnLayout { + width: parent.width + spacing: 10 + ProgressRow { + visible: provider && provider.rateLimitPercent >= 0 + label: provider ? provider.rateLimitLabel : "" + value: provider ? provider.rateLimitPercent : -1 + resetText: provider && provider.rateLimitResetAt ? "Resets in " + provider.formatResetTime(provider.rateLimitResetAt) : "" + } + ProgressRow { + visible: provider && provider.secondaryRateLimitPercent >= 0 + label: provider ? provider.secondaryRateLimitLabel : "" + value: provider ? provider.secondaryRateLimitPercent : -1 + resetText: provider && provider.secondaryRateLimitResetAt ? "Resets in " + provider.formatResetTime(provider.secondaryRateLimitResetAt) : "" + } + PaceRow { provider: rateLimitCard.provider } + } + } + + 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: "prompts" } + StatBlock { value: provider ? String(provider.todaySessions || 0) : "0"; label: "sessions" } + } + 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 ? usageMain.formatTokenCount(provider.totalPrompts || 0) : "0"; label: "messages" } + 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: Rectangle { + id: section + property string title: "" + property string subtitle: "" + property color titleColor: foreground + default property alias content: body.data + + Layout.fillWidth: true + color: card + border.color: Qt.rgba(foreground.r, foreground.g, foreground.b, 0.05) + border.width: 1 + radius: Style.cornerRadius + implicitHeight: body.implicitHeight + 22 + + ColumnLayout { + id: body + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.margins: 12 + 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 PaceRow: ColumnLayout { + property var provider: null + readonly property var pace: root.paceInfo(provider) + + visible: pace.text !== "" + spacing: 2 + Layout.fillWidth: true + + RowLayout { + Layout.fillWidth: true + Text { + text: "Pace" + color: dim + font.family: fontFamily + font.pixelSize: 10 + } + Item { Layout.fillWidth: true } + Text { + text: pace.text + color: pace.deficit ? urgent : foreground + font.family: fontFamily + font.pixelSize: 10 + font.bold: true + } + } + + Text { + Layout.fillWidth: true + text: pace.detail + color: dim + font.family: fontFamily + font.pixelSize: 10 + horizontalAlignment: Text.AlignRight + } + } + + component ProgressRow: ColumnLayout { + property string label: "" + property real value: -1 + property string resetText: "" + spacing: 5 + Layout.fillWidth: true + + RowLayout { + Layout.fillWidth: true + Text { text: label; color: dim; font.family: fontFamily; font.pixelSize: 11 } + Item { Layout.fillWidth: true } + Text { + text: value < 0 ? "—" : Math.round(value * 100) + "%" + color: value >= 0.9 ? urgent : foreground + font.family: fontFamily + font.pixelSize: 11 + font.bold: true + } + } + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 8 + 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 * root.clamp(value, 0, 1) + color: value >= 0.9 ? root.alpha(urgent, 0.72) : root.alpha(foreground, 0.78) + radius: Math.max(1, Style.cornerRadius / 3) + Behavior on width { NumberAnimation { duration: 160; easing.type: Easing.OutCubic } } + } + } + Text { visible: resetText !== ""; text: resetText; color: dim; font.family: fontFamily; font.pixelSize: 10 } + } + + 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/model-usage/assets/claude.svg b/shell/plugins/model-usage/assets/claude.svg new file mode 100644 index 00000000..d3007012 --- /dev/null +++ b/shell/plugins/model-usage/assets/claude.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/shell/plugins/model-usage/assets/codex.svg b/shell/plugins/model-usage/assets/codex.svg new file mode 100644 index 00000000..1716a131 --- /dev/null +++ b/shell/plugins/model-usage/assets/codex.svg @@ -0,0 +1 @@ +Codex \ No newline at end of file diff --git a/shell/plugins/model-usage/manifest.json b/shell/plugins/model-usage/manifest.json new file mode 100644 index 00000000..d59c737b --- /dev/null +++ b/shell/plugins/model-usage/manifest.json @@ -0,0 +1,46 @@ +{ + "schemaVersion": 1, + "id": "omarchy.model-usage", + "name": "Model Usage", + "version": "1.0.0", + "author": "Omarchy", + "license": "MIT", + "description": "Claude Code and Codex usage stats in a native Omarchy bar popup.", + "kinds": ["bar-widget"], + "activation": "on-demand", + "entryPoints": { + "barWidget": "Widget.qml" + }, + "barWidget": { + "displayName": "Model Usage", + "description": "Shows AI coding assistant usage stats with a tabbed popup panel.", + "category": "AI", + "aliases": ["model-usage"], + "allowMultiple": false, + "defaults": { + "providers": { + "claude": { + "enabled": true, + "statsPath": "~/.claude/stats-cache.json", + "credentialsPath": "~/.claude/.credentials.json", + "projectsPath": "~/.claude/projects" + }, + "codex": { + "enabled": true + } + }, + "refreshIntervalSec": 900, + "syncMode": "Off", + "syncDir": "", + "syncFileName": "", + "syncDeviceId": "" + }, + "schema": [ + { "key": "refreshIntervalSec", "type": "integer", "label": "Refresh interval (seconds)", "min": 30, "max": 3600, "step": 30, "defaultValue": 900 }, + { "key": "syncMode", "type": "enum", "label": "Synced aggregation", "options": ["Off", "On"], "defaultValue": "Off", "description": "When On, write this machine's local usage snapshot and merge snapshots from other machines." }, + { "key": "syncDir", "type": "path", "label": "Sync folder", "defaultValue": "", "description": "A folder synced by Syncthing, Dropbox, rsync, etc." }, + { "key": "syncFileName", "type": "string", "label": "Snapshot file name", "defaultValue": "", "description": "Optional. Defaults to .json. Use a different file name on each machine, such as laptop.json or desktop.json." }, + { "key": "syncDeviceId", "type": "string", "label": "Device id", "defaultValue": "", "description": "Optional stable device name used inside synced aggregate snapshots." } + ] + } +} diff --git a/shell/plugins/model-usage/providers/Claude.qml b/shell/plugins/model-usage/providers/Claude.qml new file mode 100644 index 00000000..fb5b08eb --- /dev/null +++ b/shell/plugins/model-usage/providers/Claude.qml @@ -0,0 +1,501 @@ +import QtQuick +import Quickshell +import Quickshell.Io + +Item { + id: root + visible: false + + property string providerId: "claude" + property string providerName: "Claude Code" + property string providerIcon: "ai" + property bool enabled: false + property bool ready: false + property bool refreshing: false + property double lastRefreshedAtMs: 0 + property string usageStatusText: "" + + property real rateLimitPercent: -1 + property string rateLimitLabel: "Weekly (7-day)" + property string rateLimitResetAt: "" + property real secondaryRateLimitPercent: -1 + property string secondaryRateLimitLabel: "Session (5-hour)" + property string secondaryRateLimitResetAt: "" + + 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 string tierLabel: "" + property string authHelpText: "Run `claude auth login` to restore authoritative usage." + property bool hasLocalStats: true + property bool hasProjectStats: false + + property string oauthAccessToken: "" + property double oauthExpiresAtMs: 0 + property string authMode: "none" + property string subscriptionType: "" + property string rateLimitTier: "" + property bool hasAuthoritativeRateLimit: false + + property double lastProbeAtMs: 0 + property int probeMinIntervalMs: 15 * 60 * 1000 + property bool projectScanRerunForce: false + + property var providerSettings: ({}) + + function resolvePath(p) { + if (p && p.startsWith("~")) + return (Quickshell.env("HOME") ?? "/home") + p.substring(1); + return p; + } + + function pathFromUrl(url) { + const value = String(url || ""); + if (value.indexOf("file://") === 0) + return decodeURIComponent(value.substring(7)); + return value; + } + + readonly property string projectScannerScriptPath: pathFromUrl(Qt.resolvedUrl("../scripts/claude_usage_scanner.py")) + + FileView { + id: statsFile + path: root.resolvePath(root.providerSettings?.statsPath ?? "~/.claude/stats-cache.json") + watchChanges: true + printErrors: false + onFileChanged: reload() + onLoaded: root.parseStats(text()) + } + + FileView { + id: historyFile + path: root.resolvePath("~/.claude/history.jsonl") + watchChanges: true + onFileChanged: reload() + onLoaded: root.parseHistory(text()) + onLoadFailed: error => { + if (error === FileViewError.FileNotFound) + console.error("model-usage/claude", "history.jsonl not found"); + } + } + + FileView { + id: credentialsFile + path: root.resolvePath(root.providerSettings?.credentialsPath ?? "~/.claude/.credentials.json") + watchChanges: true + onFileChanged: reload() + onLoaded: root.parseCredentials(text()) + onLoadFailed: error => { + if (error === FileViewError.FileNotFound) + console.error("model-usage/claude", "credentials.json not found at", credentialsFile.path); + } + } + + Process { + id: projectScanner + running: false + command: [] + + stdout: StdioCollector { + waitForEnd: true + onStreamFinished: root.applyProjectUsageSummary(text) + } + + stderr: StdioCollector { + waitForEnd: true + onStreamFinished: if (text.trim() !== "") console.warn("model-usage/claude", text.trim()) + } + + onExited: { + root.finishRefresh(); + if (root.projectScanRerunForce) { + root.projectScanRerunForce = false; + root.startProjectScanner(true); + } + } + } + + Timer { + interval: root.probeMinIntervalMs + running: root.enabled && root.oauthAccessToken !== "" + repeat: true + onTriggered: root.probeRateLimits(false) + } + + function localDateString() { + const now = new Date(); + const y = now.getFullYear(); + const m = String(now.getMonth() + 1).padStart(2, "0"); + const d = String(now.getDate()).padStart(2, "0"); + return y + "-" + m + "-" + d; + } + + function parseStats(content) { + try { + const data = JSON.parse(content); + const today = localDateString(); + + const dailyModelTokens = data.dailyModelTokens ?? []; + const todayTokenEntry = dailyModelTokens.find(d => d.date === today); + root.todayTokensByModel = todayTokenEntry?.tokensByModel ?? {}; + + let tokenSum = 0; + const toks = root.todayTokensByModel; + for (const k in toks) + tokenSum += toks[k]; + root.todayTotalTokens = tokenSum; + + root.dailyActivity = data.dailyActivity ?? []; + root.recentDays = root.dailyActivity.slice(-7); + root.modelUsage = data.modelUsage ?? {}; + root.totalPrompts = data.totalMessages ?? 0; + root.totalSessions = data.totalSessions ?? 0; + root.ready = true; + } catch (e) { + console.error("model-usage/claude", "Failed to parse stats-cache.json:", e); + } + } + + function applyProjectUsageSummary(content) { + try { + const data = JSON.parse(String(content || "{}")); + const prompts = Math.max(0, Number(data.totalPrompts || 0)); + if (prompts <= 0) + return; + + root.hasProjectStats = true; + 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 = prompts; + root.totalSessions = Math.max(0, Number(data.totalSessions || 0)); + root.dailyActivity = data.dailyActivity || root.recentDays; + root.ready = true; + } catch (e) { + console.error("model-usage/claude", "Failed to parse project usage summary:", e); + } + } + + function parseHistory(content) { + if (root.hasProjectStats) + return; + try { + const now = new Date(); + const startOfDay = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime(); + const lines = content.split("\n"); + let prompts = 0; + const sessions = {}; + + for (let i = lines.length - 1; i >= 0; i--) { + const line = lines[i].trim(); + if (!line) + continue; + try { + const entry = JSON.parse(line); + if ((entry.timestamp ?? 0) < startOfDay) + break; + prompts++; + if (entry.sessionId) + sessions[entry.sessionId] = true; + } catch (e) { + continue; + } + } + + root.todayPrompts = prompts; + root.todaySessions = Object.keys(sessions).length; + } catch (e) { + console.error("model-usage/claude", "Failed to parse history.jsonl:", e); + } + } + + function parseCredentials(content) { + try { + const data = JSON.parse(content); + const oauth = data.claudeAiOauth ?? {}; + const fileAccessToken = oauth.accessToken ?? ""; + const fileExpiresAtMs = root.normalizeExpiresAtMs(oauth.expiresAt); + const fileHasOAuth = fileAccessToken !== ""; + + const tokenChanged = (root.oauthAccessToken !== fileAccessToken || root.oauthExpiresAtMs !== fileExpiresAtMs); + root.oauthAccessToken = fileAccessToken; + root.oauthExpiresAtMs = fileExpiresAtMs; + if (tokenChanged) + root.clearAuthoritativeRateLimits(); + + root.authMode = fileHasOAuth ? "oauth" : "none"; + + root.subscriptionType = oauth.subscriptionType ?? ""; + root.rateLimitTier = oauth.rateLimitTier ?? ""; + root.tierLabel = formatTier(); + + if (root.oauthAccessToken && !root.oauthTokenExpired()) { + if (root.usageStatusText === "Waiting for auth") + root.clearUsageStatus(); + root.probeRateLimits(false); + } else if (!root.oauthAccessToken) { + root.usageStatusText = "Waiting for auth"; + root.clearAuthoritativeRateLimits(); + } else { + root.clearUsageStatus(); + } + } catch (e) { + console.error("model-usage/claude", "Failed to parse credentials.json:", e); + root.usageStatusText = "Waiting for auth"; + root.clearAuthoritativeRateLimits(); + } + } + + function formatTier() { + if (!root.rateLimitTier) + return root.subscriptionType || ""; + const match = root.rateLimitTier.match(/max_(\d+x)/i); + if (match) + return "Max " + match[1]; + if (root.subscriptionType) + return root.subscriptionType.charAt(0).toUpperCase() + root.subscriptionType.slice(1); + return ""; + } + + function normalizeExpiresAtMs(value) { + const n = Number(value ?? 0); + return (isFinite(n) && n > 0) ? n : 0; + } + + function oauthTokenExpired() { + if (!root.oauthAccessToken) + return true; + if (!root.oauthExpiresAtMs || !(root.oauthExpiresAtMs > 0)) + return false; + return root.oauthExpiresAtMs <= Date.now(); + } + + function clearAuthoritativeRateLimits() { + root.hasAuthoritativeRateLimit = false; + root.rateLimitPercent = -1; + root.rateLimitLabel = "Weekly (7-day)"; + root.rateLimitResetAt = ""; + root.secondaryRateLimitPercent = -1; + root.secondaryRateLimitLabel = "Session (5-hour)"; + root.secondaryRateLimitResetAt = ""; + } + + function clearUsageStatus() { + root.usageStatusText = ""; + } + + function parseNumber(value) { + if (value === null || value === undefined) + return NaN; + return parseFloat(String(value).trim().replace("%", "")); + } + + function utilizationPayloadUsesPercentScale(values) { + for (let i = 0; i < values.length; i++) { + const n = parseNumber(values[i]); + if (n >= 1) + return true; + } + return false; + } + + function normalizeUtilization(value, percentScale) { + const n = parseNumber(value); + if (!(n >= 0)) + return -1; + + // Anthropic's OAuth usage endpoint currently reports percentages + // (for example 37.0 or 1.0). Older clients/examples sometimes used + // fractions (0.37). Treat a payload containing any value >= 1 as + // percent-scaled so 1.0 renders as 1%, not 100%. + if (percentScale === true || n > 1) + return Math.min(1, n / 100); + return Math.min(1, n); + } + + function normalizeResetAt(value) { + if (value === null || value === undefined) + return ""; + const raw = String(value).trim(); + if (raw === "") + return ""; + if (/^\d+$/.test(raw)) { + let ts = parseInt(raw, 10); + if (ts < 1e12) + ts = ts * 1000; + const d = new Date(ts); + if (!isNaN(d.getTime())) + return d.toISOString(); + } + const parsed = new Date(raw); + if (!isNaN(parsed.getTime())) + return parsed.toISOString(); + return raw; + } + + function oauthUsageBucket(payload, key) { + const bucket = payload?.[key]; + if (bucket && typeof bucket === "object") + return bucket; + return null; + } + + function applyAuthoritativeRateLimits(weekly, weeklyReset, session, sessionReset, sourceLabel) { + const percentScale = root.utilizationPayloadUsesPercentScale([weekly, session]); + const weeklyNorm = root.normalizeUtilization(weekly, percentScale); + const sessionNorm = root.normalizeUtilization(session, percentScale); + if (weeklyNorm < 0 && sessionNorm < 0) + return false; + + root.hasAuthoritativeRateLimit = true; + root.rateLimitPercent = -1; + root.rateLimitLabel = "Weekly (7-day)"; + root.rateLimitResetAt = ""; + root.secondaryRateLimitPercent = -1; + root.secondaryRateLimitLabel = "Session (5-hour)"; + root.secondaryRateLimitResetAt = ""; + + if (weeklyNorm >= 0) + root.rateLimitPercent = weeklyNorm; + if (sessionNorm >= 0) + root.secondaryRateLimitPercent = sessionNorm; + if (weeklyReset !== null && weeklyReset !== undefined) + root.rateLimitResetAt = root.normalizeResetAt(weeklyReset); + if (sessionReset !== null && sessionReset !== undefined) + root.secondaryRateLimitResetAt = root.normalizeResetAt(sessionReset); + + if (root.rateLimitPercent < 0 && sessionNorm >= 0) { + root.rateLimitPercent = sessionNorm; + root.rateLimitLabel = root.secondaryRateLimitLabel; + root.rateLimitResetAt = root.secondaryRateLimitResetAt; + } + + if (sourceLabel) + root.rateLimitLabel = root.rateLimitLabel + " (" + sourceLabel + ")"; + return true; + } + + function finishRefresh() { + root.refreshing = false; + root.lastRefreshedAtMs = Date.now(); + } + + function probeOAuthUsage() { + root.refreshing = true; + const xhr = new XMLHttpRequest(); + xhr.open("GET", "https://api.anthropic.com/api/oauth/usage"); + xhr.setRequestHeader("Authorization", "Bearer " + root.oauthAccessToken); + xhr.setRequestHeader("anthropic-beta", "oauth-2025-04-20"); + xhr.setRequestHeader("Accept", "application/json"); + xhr.onreadystatechange = function () { + if (xhr.readyState !== XMLHttpRequest.DONE) + return; + + if (xhr.status >= 200 && xhr.status < 300) { + try { + const payload = JSON.parse(xhr.responseText ?? "{}"); + const weeklyBucket = root.oauthUsageBucket(payload, "seven_day_oauth_apps") || root.oauthUsageBucket(payload, "seven_day"); + const sessionBucket = root.oauthUsageBucket(payload, "five_hour"); + + if (root.applyAuthoritativeRateLimits(weeklyBucket?.utilization, weeklyBucket?.resets_at, sessionBucket?.utilization, sessionBucket?.resets_at, "")) { + root.clearUsageStatus(); + root.finishRefresh(); + return; + } + } catch (e) { + console.error("model-usage/claude", "Failed to parse oauth usage response:", e); + } + } + + const body = xhr.responseText ? String(xhr.responseText).slice(0, 220) : ""; + const retryAfter = xhr.getResponseHeader("retry-after") || ""; + console.warn("model-usage/claude", "OAuth usage probe unavailable (status " + xhr.status + ")" + (body ? " body=" + body : "")); + if (!root.hasAuthoritativeRateLimit) { + root.usageStatusText = "Claude limits unavailable"; + root.authHelpText = xhr.status === 429 + ? "Anthropic's usage endpoint is rate limiting checks right now" + (retryAfter ? " (retry after " + retryAfter + "s)" : "") + ". Local Claude Code stats are still shown." + : "Anthropic's usage endpoint returned status " + xhr.status + ". Local Claude Code stats are still shown."; + } + root.finishRefresh(); + }; + xhr.send(); + } + + function startProjectScanner(force) { + if (projectScanner.running) { + if (force === true) + root.projectScanRerunForce = true; + return false; + } + + const command = ["python3", root.projectScannerScriptPath, root.resolvePath(root.providerSettings?.projectsPath ?? "~/.claude/projects")]; + if (force === true) + command.push("--force"); + projectScanner.command = command; + projectScanner.running = true; + return true; + } + + function refresh(force) { + root.refreshing = true; + statsFile.reload(); + historyFile.reload(); + credentialsFile.reload(); + root.startProjectScanner(force === true); + + if (root.oauthAccessToken && root.authMode === "oauth" && !root.oauthTokenExpired()) + root.probeRateLimits(force === true); + } + + function formatResetTime(isoTimestamp) { + if (!isoTimestamp) + return ""; + const reset = new Date(isoTimestamp); + const now = new Date(); + const diffMs = reset.getTime() - now.getTime(); + if (diffMs <= 0) + return "now"; + const hours = Math.floor(diffMs / 3600000); + const mins = Math.floor((diffMs % 3600000) / 60000); + if (hours > 24) + return Math.floor(hours / 24) + "d " + (hours % 24) + "h"; + if (hours > 0) + return hours + "h " + mins + "m"; + return mins + "m"; + } + + function probeRateLimits(force) { + if (!root.oauthAccessToken || root.authMode !== "oauth") { + root.usageStatusText = "Waiting for auth"; + root.clearAuthoritativeRateLimits(); + root.finishRefresh(); + return; + } + + if (root.oauthTokenExpired()) { + root.clearUsageStatus(); + root.finishRefresh(); + return; + } + + const nowMs = Date.now(); + if (force !== true && root.lastProbeAtMs > 0 && (nowMs - root.lastProbeAtMs) < root.probeMinIntervalMs) { + root.finishRefresh(); + return; + } + root.lastProbeAtMs = nowMs; + + root.probeOAuthUsage(); + } +} diff --git a/shell/plugins/model-usage/providers/Codex.qml b/shell/plugins/model-usage/providers/Codex.qml new file mode 100644 index 00000000..5a0ad6db --- /dev/null +++ b/shell/plugins/model-usage/providers/Codex.qml @@ -0,0 +1,135 @@ +import QtQuick +import Quickshell +import Quickshell.Io + +Item { + id: root + visible: false + + property string providerId: "codex" + property string providerName: "Codex" + property string providerIcon: "ai" + property bool enabled: false + property bool ready: false + property bool refreshing: false + property double lastRefreshedAtMs: 0 + + property real rateLimitPercent: -1 + property string rateLimitLabel: "" + property string rateLimitResetAt: "" + property real secondaryRateLimitPercent: -1 + property string secondaryRateLimitLabel: "" + property string secondaryRateLimitResetAt: "" + + property int todayPrompts: 0 + property int todaySessions: 0 + property real todayTotalTokens: 0 + property var todayTokensByModel: ({}) + + property var recentDays: [] + property int totalPrompts: 0 + property int totalSessions: 0 + property var modelUsage: ({}) + + property string tierLabel: "" + property string usageStatusText: "" + property string authHelpText: "Run `codex login` to authenticate." + property bool hasLocalStats: true + + property string configModel: "" + property var providerSettings: ({}) + + readonly property string scannerPath: String(Qt.resolvedUrl("../scripts/codex_usage_scanner.py")).replace("file://", "") + + Process { + id: usageScanner + command: ["python3", root.scannerPath] + running: false + + stdout: StdioCollector { + onStreamFinished: root.parseScannerOutput(text) + } + + onExited: root.finishRefresh() + + stderr: StdioCollector { + onStreamFinished: if (text.trim() !== "") console.warn("model-usage/codex", text.trim()) + } + } + + Timer { + interval: 5 * 60 * 1000 + running: root.enabled + repeat: true + triggeredOnStart: true + onTriggered: root.refresh() + } + + onEnabledChanged: if (enabled) refresh() + + function finishRefresh() { + root.refreshing = false + root.lastRefreshedAtMs = Date.now() + } + + function refresh(force) { + if (usageScanner.running) + return + root.refreshing = true + usageScanner.running = true + } + + function parseScannerOutput(output) { + const raw = String(output || "").trim() + if (raw === "") + return + + try { + const data = JSON.parse(raw.split("\n").pop()) + root.ready = !!data.ready + root.hasLocalStats = data.hasLocalStats !== false + + root.todayPrompts = data.todayPrompts || 0 + root.todaySessions = data.todaySessions || 0 + root.todayTotalTokens = data.todayTotalTokens || 0 + root.todayTokensByModel = data.todayTokensByModel || ({}) + root.recentDays = data.recentDays || [] + root.totalPrompts = data.totalPrompts || 0 + root.totalSessions = data.totalSessions || 0 + root.modelUsage = data.modelUsage || ({}) + + root.rateLimitPercent = data.rateLimitPercent ?? -1 + root.rateLimitLabel = data.rateLimitLabel || "" + root.rateLimitResetAt = data.rateLimitResetAt || "" + root.secondaryRateLimitPercent = data.secondaryRateLimitPercent ?? -1 + root.secondaryRateLimitLabel = data.secondaryRateLimitLabel || "" + root.secondaryRateLimitResetAt = data.secondaryRateLimitResetAt || "" + + root.tierLabel = data.tierLabel || "" + root.usageStatusText = data.usageStatusText || "" + root.authHelpText = data.authHelpText || "Run `codex login` to authenticate." + } catch (e) { + console.error("model-usage/codex", "Failed to parse scanner output:", e, raw) + root.usageStatusText = "Codex scan failed" + root.authHelpText = String(e) + root.ready = true + } + } + + function formatResetTime(isoTimestamp) { + if (!isoTimestamp) + return "" + const reset = new Date(isoTimestamp) + const now = new Date() + const diffMs = reset.getTime() - now.getTime() + if (diffMs <= 0) + return "now" + const hours = Math.floor(diffMs / 3600000) + const mins = Math.floor((diffMs % 3600000) / 60000) + if (hours > 24) + return Math.floor(hours / 24) + "d " + (hours % 24) + "h" + if (hours > 0) + return hours + "h " + mins + "m" + return mins + "m" + } +} diff --git a/shell/plugins/model-usage/scripts/claude_usage_scanner.py b/shell/plugins/model-usage/scripts/claude_usage_scanner.py new file mode 100755 index 00000000..71d0a9e8 --- /dev/null +++ b/shell/plugins/model-usage/scripts/claude_usage_scanner.py @@ -0,0 +1,238 @@ +#!/usr/bin/env python3 +"""Stream Claude Code project JSONL files and emit compact usage stats. + +This replaces the QML-side `rg --json ... | StdioCollector` path, which can +materialize 100MB+ of ripgrep JSON in the Quickshell process. The helper keeps +that work in a short-lived Python process, parses line-by-line, and returns a +single compact JSON object that matches the fields Claude.qml expects. +""" + +from __future__ import annotations + +import argparse +import datetime as dt +import fcntl +import hashlib +import json +import os +import sys +import time +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 local_date_from_timestamp(value: Any) -> str: + if value is None: + return local_date_string() + + if isinstance(value, (int, float)): + try: + seconds = float(value) / 1000.0 if float(value) > 10_000_000_000 else float(value) + return date_string(dt.datetime.fromtimestamp(seconds).date()) + except Exception: + return local_date_string() + + raw = str(value).strip() + if not raw: + return local_date_string() + + # Claude JSONL timestamps are usually ISO-8601. Python accepts offsets but + # not a trailing Z until we normalize it to +00:00. + try: + parsed = dt.datetime.fromisoformat(raw.replace("Z", "+00:00")) + if parsed.tzinfo is not None: + parsed = parsed.astimezone() + return date_string(parsed.date()) + except Exception: + return local_date_string() + + +def usage_token(usage: dict[str, Any], snake_key: str, camel_key: str) -> int: + value = usage.get(snake_key, usage.get(camel_key, 0)) + try: + return round(float(value or 0)) + except Exception: + return 0 + + +def empty_bucket() -> dict[str, int]: + return { + "inputTokens": 0, + "outputTokens": 0, + "cacheReadInputTokens": 0, + "cacheCreationInputTokens": 0, + } + + +def iter_jsonl_files(projects_path: Path): + if not projects_path.is_dir(): + return + yield from projects_path.rglob("*.jsonl") + + +def scan(projects_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} + + seen: set[str] = set() + sessions: set[str] = set() + today_sessions: set[str] = set() + today_tokens: dict[str, int] = {} + usage_by_model: dict[str, dict[str, int]] = {} + prompts = 0 + today_prompt_count = 0 + today_token_total = 0 + malformed_lines = 0 + scanned_files = 0 + + for path in iter_jsonl_files(projects_path) or []: + scanned_files += 1 + try: + with path.open("r", encoding="utf-8", errors="replace") as handle: + for line_number, line in enumerate(handle, 1): + # Cheap pre-filter before JSON parsing. Matches the old rg + # search and keeps files with unrelated lines inexpensive. + if '"usage":' not in line: + continue + + try: + entry = json.loads(line) + except Exception: + malformed_lines += 1 + continue + + message = entry.get("message") if isinstance(entry.get("message"), dict) else {} + if entry.get("type") != "assistant" and message.get("role") != "assistant": + continue + + usage = message.get("usage") or entry.get("usage") + if not isinstance(usage, dict): + continue + + message_id = message.get("id") or entry.get("messageId") or "" + unique_key = str(message_id) if message_id else f"{path}:{entry.get('uuid') or entry.get('requestId') or line_number}" + if unique_key in seen: + continue + seen.add(unique_key) + + input_tokens = usage_token(usage, "input_tokens", "inputTokens") + output_tokens = usage_token(usage, "output_tokens", "outputTokens") + cache_read = usage_token(usage, "cache_read_input_tokens", "cacheReadInputTokens") + cache_write = usage_token(usage, "cache_creation_input_tokens", "cacheCreationInputTokens") + total = input_tokens + output_tokens + cache_read + cache_write + if total <= 0: + continue + + model = str(message.get("model") or entry.get("model") or "claude") + day = local_date_from_timestamp(entry.get("timestamp") or message.get("timestamp")) + session_key = str(entry.get("sessionId") or path) + sessions.add(session_key) + prompts += 1 + + bucket = usage_by_model.setdefault(model, empty_bucket()) + bucket["inputTokens"] += input_tokens + bucket["outputTokens"] += output_tokens + bucket["cacheReadInputTokens"] += cache_read + bucket["cacheCreationInputTokens"] += cache_write + + if day in recent: + # Preserve existing QML behavior: recentDays.messageCount + # is actually a token total, despite the legacy name. + recent[day]["messageCount"] += total + + if day == today: + today_prompt_count += 1 + today_sessions.add(session_key) + today_token_total += total + today_tokens[model] = today_tokens.get(model, 0) + total + except Exception as exc: + print(f"Ignoring unreadable Claude project file {path}: {exc}", file=sys.stderr) + + recent_days = [recent[day] for day in recent_dates] + return { + "schemaVersion": 1, + "todayPrompts": today_prompt_count, + "todaySessions": len(today_sessions), + "todayTotalTokens": today_token_total, + "todayTokensByModel": today_tokens, + "recentDays": recent_days, + "modelUsage": usage_by_model, + "totalPrompts": prompts, + "totalSessions": len(sessions), + "dailyActivity": recent_days, + "scannedFiles": scanned_files, + "malformedLines": malformed_lines, + } + + +def cache_paths(projects_path: Path) -> tuple[Path, Path]: + cache_root = Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache")) / "omarchy" / "model-usage" + cache_root.mkdir(parents=True, exist_ok=True) + digest = hashlib.sha1(str(projects_path).encode("utf-8")).hexdigest()[:16] + return cache_root / f"claude-projects-{digest}.json", cache_root / f"claude-projects-{digest}.lock" + + +def read_fresh_cache(path: Path, max_age_seconds: int) -> str | None: + if max_age_seconds <= 0 or not path.exists(): + return None + try: + if time.time() - path.stat().st_mtime <= max_age_seconds: + return path.read_text(encoding="utf-8") + except Exception: + return None + return None + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("projects_path", nargs="?", default="~/.claude/projects") + parser.add_argument("--cache-seconds", type=int, default=20) + parser.add_argument("--force", action="store_true") + args = parser.parse_args() + + projects_path = expand_path(args.projects_path) + cache_file, lock_file = cache_paths(projects_path) + + if not args.force: + cached = read_fresh_cache(cache_file, args.cache_seconds) + if cached is not None: + print(cached, end="" if cached.endswith("\n") else "\n") + return 0 + + with lock_file.open("w") as lock: + fcntl.flock(lock, fcntl.LOCK_EX) + if not args.force: + cached = read_fresh_cache(cache_file, args.cache_seconds) + if cached is not None: + print(cached, end="" if cached.endswith("\n") else "\n") + return 0 + + summary = scan(projects_path) + output = json.dumps(summary, separators=(",", ":"), sort_keys=True) + "\n" + tmp = cache_file.with_suffix(".json.tmp") + tmp.write_text(output, encoding="utf-8") + tmp.replace(cache_file) + print(output, end="") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/shell/plugins/model-usage/scripts/codex_usage_scanner.py b/shell/plugins/model-usage/scripts/codex_usage_scanner.py new file mode 100644 index 00000000..86440221 --- /dev/null +++ b/shell/plugins/model-usage/scripts/codex_usage_scanner.py @@ -0,0 +1,344 @@ +import json +import os +import select +import shutil +import subprocess +import sys +import time +from datetime import datetime, timedelta, timezone +from pathlib import Path + + +def local_day(value): + if value is None: + return datetime.now().strftime("%Y-%m-%d") + if isinstance(value, (int, float)): + # pi message timestamps are milliseconds; Codex timestamps are usually seconds. + if value > 10_000_000_000: + value = value / 1000 + return datetime.fromtimestamp(value).strftime("%Y-%m-%d") + text = str(value) + try: + if text.endswith("Z"): + dt = datetime.fromisoformat(text[:-1] + "+00:00") + else: + dt = datetime.fromisoformat(text) + if dt.tzinfo is not None: + dt = dt.astimezone() + return dt.strftime("%Y-%m-%d") + except Exception: + return datetime.now().strftime("%Y-%m-%d") + + +def number(value): + try: + return int(value or 0) + except Exception: + return 0 + + +def model_name(raw): + value = str(raw or "codex") + return value if value else "codex" + + +def runtime_env(): + home = str(Path.home()) + path_parts = [ + os.environ.get("PATH", ""), + f"{home}/.local/bin", + f"{home}/.npm-global/bin", + f"{home}/.local/share/mise/shims", + ] + env = os.environ.copy() + env["PATH"] = os.pathsep.join(part for part in path_parts if part) + return env + + +ENV = runtime_env() + + +def find_command(name): + return shutil.which(name, path=ENV.get("PATH")) + + +now = datetime.now() +today = now.strftime("%Y-%m-%d") +recent_dates = [(now - timedelta(days=offset)).strftime("%Y-%m-%d") for offset in range(6, -1, -1)] +recent_set = set(recent_dates) +recent = {day: {"date": day, "messageCount": 0} for day in recent_dates} +today_tokens_by_model = {} +model_usage = {} +sessions_by_day = {day: set() for day in recent_dates} +today_sessions = set() + +today_prompts = 0 +today_total_tokens = 0 +total_prompts = 0 +total_sessions = set() +seen_pi_messages = set() +usage_status = "" +usage_help = "" + + +def add_usage(day, session_key, model, input_tokens, output_tokens, cache_read, cache_write): + global today_prompts, today_total_tokens, total_prompts + total = input_tokens + output_tokens + cache_read + cache_write + total_prompts += 1 + total_sessions.add(session_key) + + bucket = model_usage.setdefault(model, { + "inputTokens": 0, + "outputTokens": 0, + "cacheReadInputTokens": 0, + "cacheCreationInputTokens": 0, + }) + bucket["inputTokens"] += input_tokens + bucket["outputTokens"] += output_tokens + bucket["cacheReadInputTokens"] += cache_read + bucket["cacheCreationInputTokens"] += cache_write + + if day in recent: + recent[day]["messageCount"] += total + sessions_by_day[day].add(session_key) + + if day == today: + today_prompts += 1 + today_sessions.add(session_key) + today_total_tokens += total + today_tokens_by_model[model] = today_tokens_by_model.get(model, 0) + total + + +def scan_pi_sessions(): + root = Path.home() / ".pi" / "agent" / "sessions" + if not root.exists(): + return + try: + rg = find_command("rg") or "rg" + proc = subprocess.Popen( + [rg, "--json", "-e", '"provider":"openai-codex"', "-e", '"api":"openai-codex"', str(root)], + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + text=True, + errors="replace", + env=ENV, + ) + except FileNotFoundError: + return + + assert proc.stdout is not None + for raw in proc.stdout: + try: + event = json.loads(raw) + if event.get("type") != "match": + continue + line = event.get("data", {}).get("lines", {}).get("text", "") + path = event.get("data", {}).get("path", {}).get("text", "pi-session") + entry = json.loads(line) + except Exception: + continue + + if entry.get("type") != "message": + continue + message_key = path + ":" + str(entry.get("id") or "") + if message_key in seen_pi_messages: + continue + seen_pi_messages.add(message_key) + message = entry.get("message") or {} + if message.get("role") != "assistant": + continue + provider = str(message.get("provider") or "") + api = str(message.get("api") or "") + if provider != "openai-codex" and not api.startswith("openai-codex"): + continue + + usage = message.get("usage") or {} + if not usage: + continue + total = number(usage.get("totalTokens")) + input_tokens = number(usage.get("input")) + output_tokens = number(usage.get("output")) + cache_read = number(usage.get("cacheRead")) + cache_write = number(usage.get("cacheWrite")) + if total and not (input_tokens or output_tokens or cache_read or cache_write): + input_tokens = total + if not (input_tokens or output_tokens or cache_read or cache_write): + continue + + day = local_day(entry.get("timestamp") or message.get("timestamp")) + session_key = path + add_usage(day, session_key, model_name(message.get("model")), input_tokens, output_tokens, cache_read, cache_write) + + try: + proc.wait(timeout=1) + except Exception: + proc.kill() + + +def scan_native_codex_sessions(): + codex_home = Path(os.environ.get("CODEX_HOME") or (Path.home() / ".codex")) + roots = [codex_home / "sessions", codex_home / "archived_sessions"] + files = [] + cutoff = time.time() - 30 * 24 * 60 * 60 + for root in roots: + if not root.exists(): + continue + for path in root.rglob("*.jsonl"): + try: + if path.stat().st_mtime >= cutoff: + files.append(path) + except OSError: + pass + + for path in files: + current_model = "codex" + try: + with path.open(errors="replace") as handle: + for raw in handle: + try: + entry = json.loads(raw) + except Exception: + continue + if entry.get("type") == "turn_context": + payload = entry.get("payload") or {} + current_model = model_name(payload.get("model") or payload.get("model_slug") or current_model) + continue + payload = entry.get("payload") or entry + if entry.get("type") == "response_item" and isinstance(payload, dict): + payload = payload.get("payload") or payload + if not isinstance(payload, dict): + continue + if payload.get("type") != "token_count": + continue + info = payload.get("info") or {} + usage = info.get("total_token_usage") or {} + input_tokens = number(usage.get("input_tokens")) + output_tokens = number(usage.get("output_tokens")) + number(usage.get("reasoning_output_tokens")) + cache_read = number(usage.get("cached_input_tokens")) + cache_write = 0 + if not (input_tokens or output_tokens or cache_read): + continue + day = local_day(entry.get("timestamp") or path.stat().st_mtime) + add_usage(day, str(path), current_model, input_tokens, output_tokens, cache_read, cache_write) + except Exception: + continue + + +def rpc_request(proc, request_id, method, params=None, timeout=8): + payload = {"id": request_id, "method": method, "params": params or {}} + proc.stdin.write(json.dumps(payload) + "\n") + proc.stdin.flush() + deadline = time.time() + timeout + while time.time() < deadline: + ready, _, _ = select.select([proc.stdout], [], [], 0.25) + if not ready: + continue + line = proc.stdout.readline() + if not line: + break + try: + message = json.loads(line) + except Exception: + continue + if message.get("id") == request_id: + return message + raise TimeoutError(method) + + +def fetch_codex_rpc(): + result = { + "rateLimitPercent": -1, + "rateLimitLabel": "", + "rateLimitResetAt": "", + "secondaryRateLimitPercent": -1, + "secondaryRateLimitLabel": "", + "secondaryRateLimitResetAt": "", + "tierLabel": "", + } + codex = find_command("codex") + if not codex: + result["usageStatusText"] = "Codex unavailable" + result["authHelpText"] = "codex not found in PATH" + return result + + try: + proc = subprocess.Popen( + [codex, "-s", "read-only", "-a", "untrusted", "app-server"], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + text=True, + env=ENV, + ) + except Exception as exc: + result["usageStatusText"] = "Codex unavailable" + result["authHelpText"] = str(exc) + return result + + try: + rpc_request(proc, 1, "initialize", {"clientInfo": {"name": "omarchy-model-usage", "version": "1"}}, timeout=8) + proc.stdin.write(json.dumps({"method": "initialized", "params": {}}) + "\n") + proc.stdin.flush() + account_msg = rpc_request(proc, 2, "account/read", timeout=4) + limits_msg = rpc_request(proc, 3, "account/rateLimits/read", timeout=4) + + account = (account_msg.get("result") or {}).get("account") or {} + limits = (limits_msg.get("result") or {}).get("rateLimits") or {} + plan = limits.get("planType") or account.get("planType") or account.get("type") or "" + result["tierLabel"] = str(plan) if plan else "" + + def fill(prefix, window): + if not isinstance(window, dict): + return + used = window.get("usedPercent") + if used is not None: + result[prefix + "Percent"] = float(used) / 100.0 + mins = number(window.get("windowDurationMins")) + if mins: + if mins == 10080: + result[prefix + "Label"] = "Weekly (7-day)" + elif mins % 60 == 0: + result[prefix + "Label"] = f"{mins // 60}h window" + else: + result[prefix + "Label"] = f"{mins}m window" + reset = window.get("resetsAt") + if reset: + result[prefix + "ResetAt"] = datetime.fromtimestamp(number(reset), timezone.utc).isoformat() + + fill("rateLimit", limits.get("primary")) + fill("secondaryRateLimit", limits.get("secondary")) + except Exception as exc: + result["usageStatusText"] = "Codex limits unavailable" + result["authHelpText"] = str(exc) + finally: + try: + proc.terminate() + proc.wait(timeout=1) + except Exception: + try: + proc.kill() + except Exception: + pass + return result + + +scan_pi_sessions() +scan_native_codex_sessions() +rpc = fetch_codex_rpc() + +out = { + "ready": True, + "hasLocalStats": True, + "todayPrompts": today_prompts, + "todaySessions": len(today_sessions), + "todayTotalTokens": today_total_tokens, + "todayTokensByModel": today_tokens_by_model, + "recentDays": [recent[day] for day in recent_dates], + "totalPrompts": total_prompts, + "totalSessions": len(total_sessions), + "modelUsage": model_usage, + "usageStatusText": usage_status, + "authHelpText": usage_help, +} +out.update(rpc) +print(json.dumps(out, separators=(",", ":"))) diff --git a/shell/shell.qml b/shell/shell.qml index 3de55722..dae7bf8d 100644 --- a/shell/shell.qml +++ b/shell/shell.qml @@ -55,6 +55,11 @@ ShellRoot { property var shellConfig: builtinShellConfig property bool suppressUserReload: false + onShellConfigChanged: { + pluginRegistry.registryRevision++ + pluginRegistry.pluginsChanged() + } + function applyShellConfig() { // Decide which source is canonical: a valid user shell.json overrides // defaults entirely; otherwise fall back to defaults. We do not deep-merge. @@ -621,6 +626,11 @@ ShellRoot { shell.pluginRegistry.rescan() } + function reloadConfig(): string { + userConfigFile.reload() + return "ok" + } + function setPluginEnabled(id: string, enabled: string): void { shell.pluginRegistry.setEnabled(id, enabled === "true") }