From f3958bba4b697ad1d9ce5eeacca8c80d9d708df4 Mon Sep 17 00:00:00 2001 From: Andre Padez Date: Mon, 17 Aug 2026 17:33:00 +0100 Subject: [PATCH] Baseline: omarchy.agents as shipped Panel.qml, Main.qml, Agent.qml and the provider assets exactly as they ship in omarchy-dev 4.0.0.r1744.gf002044 (shell/plugins/agents/), alongside the manifest `omarchy plugin clone` generates. Kept on its own branch so `git diff stock..master` shows precisely what this fork changes. --- Agent.qml | 34 ++ Main.qml | 748 +++++++++++++++++++++++++++++++++ Panel.qml | 930 +++++++++++++++++++++++++++++++++++++++++ README.md | 150 +++++++ assets/claude.svg | 1 + assets/codex-light.svg | 1 + assets/codex.svg | 1 + assets/fireworks.svg | 7 + manifest.json | 90 ++++ 9 files changed, 1962 insertions(+) create mode 100644 Agent.qml create mode 100644 Main.qml create mode 100644 Panel.qml create mode 100644 README.md create mode 100644 assets/claude.svg create mode 100644 assets/codex-light.svg create mode 100644 assets/codex.svg create mode 100644 assets/fireworks.svg create mode 100644 manifest.json diff --git a/Agent.qml b/Agent.qml new file mode 100644 index 0000000..c9ba4c5 --- /dev/null +++ b/Agent.qml @@ -0,0 +1,34 @@ +import QtQuick +import Quickshell.Io + +// One agent's usage record, read straight off the data file that +// omarchy-agent-usage-update maintains. The panel never learns how the +// numbers were made — a record that appears in the usage directory is an +// agent, whoever wrote it. +Item { + id: root + visible: false + + property string agentId: "" + property string path: "" + property var record: null + + FileView { + path: root.path + watchChanges: true + printErrors: false + onFileChanged: reload() + onLoaded: root.parse(text()) + onLoadFailed: root.record = null + } + + function parse(content) { + try { + var parsed = JSON.parse(String(content || "")) + root.record = parsed && typeof parsed === "object" ? parsed : null + } catch (e) { + console.warn("agents", "Ignoring bad usage record", root.path, e) + root.record = null + } + } +} diff --git a/Main.qml b/Main.qml new file mode 100644 index 0000000..cb8e8c2 --- /dev/null +++ b/Main.qml @@ -0,0 +1,748 @@ +import QtQuick +import Quickshell +import Quickshell.Io + +// The display side of agent usage. All extraction lives behind +// omarchy-agent-usage-update, which writes one JSON record per agent into +// the usage directory; this file only discovers those records, watches them +// for changes, and optionally merges snapshots synced from other machines. +Item { + id: root + visible: false + + property var settings: ({}) + + readonly property string home: Quickshell.env("HOME") || "" + readonly property string usageDir: (Quickshell.env("XDG_STATE_HOME") || home + "/.local/state") + "/omarchy/agents/usage" + + // ------------------------------------------------------------- discovery + + property var agentIds: [] + property var agents: [] + property int dataRevision: 0 + + Process { + id: listProcess + running: false + command: ["find", root.usageDir, "-maxdepth", "1", "-name", "*.json", "-printf", "%f\n"] + + stdout: StdioCollector { + waitForEnd: true + onStreamFinished: root.applyAgentListing(text) + } + } + + function rescanAgents() { + if (!listProcess.running) listProcess.running = true + } + + function applyAgentListing(output) { + var ids = [] + var lines = String(output || "").split("\n") + for (var i = 0; i < lines.length; i++) { + var name = lines[i].trim() + if (name.slice(-5) === ".json") ids.push(name.slice(0, -5)) + } + ids.sort() + // Same list, same objects: reassigning the model would tear down every + // FileView just to build identical ones. + if (JSON.stringify(ids) !== JSON.stringify(agentIds)) agentIds = ids + } + + Instantiator { + id: agentInstantiator + model: root.agentIds + + delegate: Agent { + required property var modelData + agentId: modelData + path: root.usageDir + "/" + modelData + ".json" + onRecordChanged: root.recordsChanged() + } + + onObjectAdded: (index, object) => root.rebuildAgents() + onObjectRemoved: (index, object) => root.rebuildAgents() + } + + function rebuildAgents() { + var result = [] + for (var i = 0; i < agentInstantiator.count; i++) { + var agent = agentInstantiator.objectAt(i) + if (agent) result.push(agent) + } + agents = result + recordsChanged() + } + + function recordsChanged() { + dataRevision++ + scheduleLimitsRetry() + scheduleSync() + } + + // A collector that could not reach its limits endpoint at all — typically + // the seconds after login before the network is up — writes retryAdvised + // into its record. Honor it with one sooner try instead of waiting out the + // full refresh interval; a run that reaches the endpoint clears the flag. + // Only the advising agents rerun, so an outage at one provider does not + // put every other collector on a 30-second treadmill. + property var retryAgentIds: [] + + Timer { + id: limitsRetry + interval: 30000 + repeat: false + onTriggered: root.runUpdate("limits", root.retryAgentIds) + } + + function scheduleLimitsRetry() { + var advising = [] + for (var i = 0; i < agents.length; i++) { + var record = agents[i] ? agents[i].record : null + if (record && record.retryAdvised === true && providerEnabled(String(record.id || ""))) + advising.push(String(record.id)) + } + retryAgentIds = advising + if (advising.length > 0) limitsRetry.restart() + else limitsRetry.stop() + } + + Component.onCompleted: { + rescanAgents() + if (syncConfigured()) scheduleSync() + } + + // -------------------------------------------------------------- refresh + + property int refreshIntervalSec: Math.max(30, Number(setting("refreshIntervalSec", 900))) + property string pendingUpdateKind: "" + + Timer { + interval: root.refreshIntervalSec * 1000 + running: true + repeat: true + triggeredOnStart: true + onTriggered: root.runUpdate("normal") + } + + Process { + id: updateProcess + running: false + onExited: { + root.rescanAgents() + if (root.pendingUpdateKind !== "") { + var kind = root.pendingUpdateKind + root.pendingUpdateKind = "" + root.runUpdate(kind) + } + } + + stderr: StdioCollector { + waitForEnd: true + onStreamFinished: if (text.trim() !== "") console.warn("agents", text.trim()) + } + } + + function updateCommand(kind, agentIds) { + var command = ["omarchy-agent-usage-update"] + if (kind === "force") command.push("--force") + if (kind === "limits") command.push("--limits-only") + var providers = settings && settings.providers ? settings.providers : {} + for (var id in providers) { + if (providers[id] && providers[id].enabled === false) command.push("--except", id) + } + if (agentIds) { + for (var i = 0; i < agentIds.length; i++) command.push(agentIds[i]) + } + return command + } + + function runUpdate(kind, agentIds) { + if (updateProcess.running) { + // Collapse queued requests to one full rerun; a forced refresh outranks + // the cheaper kinds it might have been queued behind. + if (kind === "force" || root.pendingUpdateKind === "") root.pendingUpdateKind = kind + return + } + updateProcess.command = updateCommand(kind, agentIds) + updateProcess.running = true + } + + function refresh() { refreshAll(true) } + function refreshAll(force) { runUpdate(force === true ? "force" : "normal") } + + // Opening the panel wants the numbers that go stale on the wire, not + // another walk over every transcript on disk — the collectors reuse their + // recent scans in this mode. + function refreshLimits() { runUpdate("limits") } + + // ------------------------------------------------------------- providers + + // An agent earns a place in the bar and the panel by being switched on in + // settings and having actually produced numbers — locally or on a synced + // device. With nothing to show, the whole module collapses out of the bar + // rather than sitting there dimmed. + property var enabledProviders: { + var rev = dataRevision + var syncRev = syncRevision + var result = [] + var localIds = {} + for (var i = 0; i < agents.length; i++) { + var record = agents[i] ? agents[i].record : null + if (!record || !record.id) continue + var id = String(record.id) + localIds[id] = true + if (!providerEnabled(id)) continue + var display = displayProvider(record) + if (providerHasData(display)) result.push(display) + } + // An agent that only ever ran on another machine has no local record, but + // its synced numbers still deserve a tab. Rate limits stay blank — they + // are per-account and never travel. + var syncedProviders = syncConfigured() && aggregateData && aggregateData.providers ? aggregateData.providers : {} + for (var syncedId in syncedProviders) { + if (localIds[syncedId] || !providerEnabled(syncedId)) continue + var stats = syncedProviders[syncedId] || {} + var syncedDisplay = displayProvider({ id: syncedId, name: stats.providerName || syncedId }) + if (providerHasData(syncedDisplay)) result.push(syncedDisplay) + } + return result + } + + function providerEnabled(id) { + if (!settings || !settings.providers || !settings.providers[id]) return true + return settings.providers[id].enabled !== false + } + + // All-time keeps a quiet day from hiding an agent; today's counts admit a + // machine whose only source is history.jsonl, which knows nothing older. + function providerHasData(p) { + return numberValue(p.totalPrompts) > 0 || numberValue(p.totalSessions) > 0 + || numberValue(p.activeDays) > 0 || numberValue(p.todayPrompts) > 0 + || numberValue(p.todaySessions) > 0 || (p.limits && p.limits.length > 0) + || !!p.balance + } + + // A prepaid agent's credit ledger. Like rate limits, the balance is + // per-account and never merged across devices. + function balanceValue(raw) { + if (!raw || typeof raw !== "object") return null + var remaining = Number(raw.remaining) + var funded = Number(raw.funded) + if (!isFinite(remaining) || remaining < 0) return null + return { + remaining: remaining, + funded: isFinite(funded) && funded > 0 ? funded : 0, + spent: Math.max(0, Number(raw.spent) || 0), + currency: String(raw.currency || "USD"), + estimated: raw.estimated === true + } + } + + function displayProvider(record) { + var stats = syncedStatsFor(String(record.id)) + var synced = !!stats + var deviceCount = synced ? Number(stats.deviceCount || aggregateData.deviceCount || 0) : 0 + + return { + providerId: String(record.id), + providerName: String(record.name || record.id), + ready: record.ready === true || synced, + usageStatusText: String(record.usageStatusText || ""), + authHelpText: String(record.authHelpText || ""), + + // Rate limits and balances stay per-account and are never merged + // across devices. + limits: Array.isArray(record.limits) ? record.limits : [], + tierLabel: String(record.tierLabel || ""), + balance: balanceValue(record.balance), + + todayPrompts: synced ? numberValue(stats.todayPrompts) : numberValue(record.todayPrompts), + todaySessions: synced ? numberValue(stats.todaySessions) : numberValue(record.todaySessions), + todayTotalTokens: synced ? numberValue(stats.todayTotalTokens) : numberValue(record.todayTotalTokens), + todayTokensByModel: synced ? (stats.todayTokensByModel || ({})) : (record.todayTokensByModel || ({})), + recentDays: synced ? (stats.recentDays || []) : (record.recentDays || []), + totalPrompts: synced ? numberValue(stats.totalPrompts) : numberValue(record.totalPrompts), + totalSessions: synced ? numberValue(stats.totalSessions) : numberValue(record.totalSessions), + activeDays: synced ? numberValue(stats.activeDays) : numberValue(record.activeDays), + modelUsage: synced ? (stats.modelUsage || ({})) : (record.modelUsage || ({})), + hasLocalStats: synced ? (stats.hasLocalStats !== false) : (record.hasLocalStats !== false), + hasPromptStats: synced ? (stats.hasPromptStats !== false) : (record.hasPromptStats !== false), + + syncEnabled: synced, + syncDeviceCount: deviceCount, + syncUpdatedAt: aggregateData && aggregateData.updatedAt ? aggregateData.updatedAt : "" + } + } + + function setting(name, fallback) { + var value = settings ? settings[name] : undefined + return value === undefined || value === null ? fallback : value + } + + // ------------------------------------------------------------------ sync + + 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", "")) + 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/agents-disabled.json" + property var aggregateData: ({}) + property int syncRevision: 0 + property bool syncRunning: false + property bool syncRequestedWhileRunning: false + property string syncStatusText: "" + property double aggregateUpdatedAtMs: aggregateData && aggregateData.updatedAtMs ? Number(aggregateData.updatedAtMs) : 0 + + onSyncEnabledChanged: syncSettingsChanged() + onSyncDirChanged: syncSettingsChanged() + onSyncFileNameChanged: if (syncConfigured()) scheduleSync() + onSyncDeviceIdChanged: if (syncConfigured()) scheduleSync() + + 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("agents/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() + } + + 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("agents/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 } + } + + // Device-scoped stats add up across machines; account-scoped stats + // (Fireworks' billing API) are replicas of the same upstream truth on + // every synced device, so the widest value wins — summing them would + // double every token per machine. + function combineNumber(additive, current, value) { + return additive ? numberValue(current) + numberValue(value) : Math.max(numberValue(current), numberValue(value)) + } + + function combineObjectNumbers(additive, target, source) { + if (!source) return + for (var key in source) target[key] = combineNumber(additive, target[key], 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, + hasPromptStats: false, + todayPrompts: 0, + todaySessions: 0, + todayTotalTokens: 0, + todayTokensByModel: ({}), + recentByDay: recentByDay, + totalPrompts: 0, + totalSessions: 0, + activeDays: 0, + activeDates: ({}), + 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 + // Snapshots from before the field existed only came from agents that + // count prompts, so a missing value reads as true. + acc.hasPromptStats = acc.hasPromptStats || stats.hasPromptStats !== false + var additive = String(stats.scope || "device") !== "account" + acc.todayPrompts = combineNumber(additive, acc.todayPrompts, stats.todayPrompts) + acc.todaySessions = combineNumber(additive, acc.todaySessions, stats.todaySessions) + acc.todayTotalTokens = combineNumber(additive, acc.todayTotalTokens, stats.todayTotalTokens) + acc.totalPrompts = combineNumber(additive, acc.totalPrompts, stats.totalPrompts) + acc.totalSessions = combineNumber(additive, acc.totalSessions, stats.totalSessions) + // Active days overlap between machines, so union the dates rather than + // summing counts. Snapshots written before activeDates existed only + // carry a count; the widest one stands in for them. + var activeDates = Array.isArray(stats.activeDates) ? stats.activeDates : [] + for (var ad = 0; ad < activeDates.length; ad++) acc.activeDates[String(activeDates[ad])] = true + acc.activeDays = Math.max(acc.activeDays, numberValue(stats.activeDays)) + combineObjectNumbers(additive, 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] = combineNumber(additive, acc.recentByDay[date], day.messageCount) + } + + var usage = stats.modelUsage || {} + for (var modelId in usage) { + var bucket = acc.modelUsage[modelId] + if (!bucket) bucket = acc.modelUsage[modelId] = emptyTokenBucket() + combineObjectNumbers(additive, bucket, usage[modelId] || {}) + } + } + } + + 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, + hasPromptStats: acc.hasPromptStats, + todayPrompts: acc.todayPrompts, + todaySessions: acc.todaySessions, + todayTotalTokens: acc.todayTotalTokens, + todayTokensByModel: acc.todayTokensByModel, + recentDays: recentDays, + totalPrompts: acc.totalPrompts, + totalSessions: acc.totalSessions, + activeDays: Math.max(acc.activeDays, Object.keys(acc.activeDates).length), + 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 + } + } + + // Snapshots keep the field names older Omarchy versions wrote, so a fleet + // of machines on mixed versions still merges cleanly in both directions. + function providerSnapshot(record) { + return { + providerId: String(record.id), + providerName: String(record.name || record.id), + ready: record.ready === true, + hasLocalStats: record.hasLocalStats !== false, + hasPromptStats: record.hasPromptStats !== false, + scope: String(record.scope || "device"), + todayPrompts: numberValue(record.todayPrompts), + todaySessions: numberValue(record.todaySessions), + todayTotalTokens: numberValue(record.todayTotalTokens), + todayTokensByModel: cloneValue(record.todayTokensByModel, ({})), + recentDays: cloneValue(record.recentDays, []), + totalPrompts: numberValue(record.totalPrompts), + totalSessions: numberValue(record.totalSessions), + activeDays: numberValue(record.activeDays), + activeDates: cloneValue(record.activeDates, []), + modelUsage: cloneValue(record.modelUsage, ({})) + } + } + + function localSnapshot() { + var providerMap = {} + for (var i = 0; i < agents.length; i++) { + var record = agents[i] ? agents[i].record : null + if (!record || !record.id) continue + if (!providerEnabled(String(record.id))) continue + providerMap[String(record.id)] = providerSnapshot(record) + } + 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 + } + + // ---------------------------------------------------------------- format + + 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 modelWordCase(word) { + if (word === "gpt") return "GPT" + if (word === "deepseek") return "DeepSeek" + return word.charAt(0).toUpperCase() + word.slice(1) + } + + // Model ids arrive hyphenated with the version split across segments + // (`claude-opus-4-8`, `gpt-5.6-sol`). Rejoin the numeric run into one + // version and title-case the words around it. + function friendlyModelName(id) { + if (!id) return "Unknown" + var name = String(id).replace(/^claude-/, "").replace(/-\d{8}$/, "") + var parts = name.split("-") + var words = [] + var version = [] + for (var i = 0; i < parts.length; i++) { + var part = parts[i] + if (part === "") continue + if (/^\d/.test(part)) { + version.push(part) + continue + } + if (version.length > 0) { + words.push(version.join(".")) + version = [] + } + words.push(modelWordCase(part)) + } + if (version.length > 0) words.push(version.join(".")) + return words.length > 0 ? words.join(" ") : "Unknown" + } +} diff --git a/Panel.qml b/Panel.qml new file mode 100644 index 0000000..6637531 --- /dev/null +++ b/Panel.qml @@ -0,0 +1,930 @@ +import QtQuick +import QtQuick.Controls +import Quickshell +import Quickshell.Io +import qs.Commons +import qs.Ui + +Panel { + id: root + moduleName: "omarchy.agents" + ipcTarget: "omarchy.agents" + manageIpc: false + + readonly property color foreground: bar ? bar.foreground : Color.foreground + readonly property color urgent: bar ? bar.urgent : Color.urgent + readonly property color dim: Qt.darker(foreground, 1.55) + readonly property color surface: Color.popups.background + readonly property color track: Style.selectedFillFor(foreground, Color.accent) + readonly property string fontFamily: bar ? bar.fontFamily : Style.font.family + + readonly property var providers: usage.enabledProviders + // The selection follows the provider, not the slot it happens to sit in: a + // provider whose first scan lands while the panel is open would otherwise + // shift the list underneath you and swap out what you were reading. + property string selectedProviderId: "" + readonly property int providerIndex: { + for (var i = 0; i < providers.length; i++) + if (providers[i].providerId === selectedProviderId) return i + return 0 + } + readonly property var provider: providers.length > 0 ? providers[providerIndex] : null + + property bool cursorActive: false + + // Countdowns and "updated" read this instead of Date.now() so the + // panel keeps telling the truth while it sits open. + property double nowMs: Date.now() + + readonly property var limits: limitWindows(provider) + readonly property var models: modelRows(provider) + readonly property var headline: bindingWindow(provider) + readonly property var balance: provider ? (provider.balance || null) : null + // A prepaid account runs low the way a subscription window fills up: the + // last 10% of the funded credits lights the same alarm. + readonly property bool balanceAlarming: !!balance && balance.funded > 0 + && balance.remaining / balance.funded <= 0.1 + readonly property bool alarming: (!!headline && headline.percent >= 0.9) || balanceAlarming + + 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 selectProvider(index) { + if (providers.length === 0) return + var wrapped = ((index % providers.length) + providers.length) % providers.length + selectedProviderId = providers[wrapped].providerId + } + + function refreshNow() { + usage.refreshAll(true) + } + + function launchAgent() { + if (root.bar) root.bar.run("omarchy-agent --pick") + root.close() + } + + // ---------------------------------------------------------------- limits + // + // Both providers report the same two shapes: a short rolling session window + // and a long weekly one. Everything below normalizes them into one record so + // the meters and the hero speak a single language. + + // Claude spells its windows out ("Session (5-hour)"), Codex abbreviates + // them ("5h window", "30m window"). Both have to land on the same record. + function windowIsLong(text) { + return text.indexOf("week") >= 0 || text.indexOf("7-day") >= 0 || text.indexOf("seven") >= 0 + || text.indexOf("month") >= 0 || text.indexOf("30-day") >= 0 + } + + function windowSpanMs(label) { + var text = String(label || "").toLowerCase() + if (text.indexOf("month") >= 0 || text.indexOf("30-day") >= 0) return 30 * 24 * 3600 * 1000 + if (windowIsLong(text)) return 7 * 24 * 3600 * 1000 + var hours = text.match(/(\d+)\s*-?\s*h(?:our)?\b/) + if (hours) return Number(hours[1]) * 3600 * 1000 + var minutes = text.match(/(\d+)\s*-?\s*m(?:in(?:ute)?s?)?\b/) + if (minutes) return Number(minutes[1]) * 60 * 1000 + return 0 + } + + function windowTitle(label) { + var text = String(label || "").toLowerCase() + if (text.indexOf("month") >= 0) return "Monthly" + if (windowIsLong(text)) return "Weekly" + if (text.indexOf("session") >= 0 || windowSpanMs(label) > 0) return "Session" + var plain = String(label || "").replace(/\s*\(.*\)\s*/, "").trim() + return plain === "" ? "Limit" : plain + } + + // A collector that already knows which window a limit belongs to says so, + // and that beats reading it back out of the label: a model-scoped limit is + // titled after its model, and a name like "Opus 5 (1M context)" would parse + // as a one-minute window. + function limitWindow(label, percent, resetAt, title) { + return { + title: String(title || "") !== "" ? String(title) : windowTitle(label), + percent: Number(percent), + resetAt: String(resetAt || "") + } + } + + function limitWindows(p) { + if (!p) return [] + var out = [] + var list = p.limits || [] + for (var i = 0; i < list.length; i++) { + var entry = list[i] || {} + var percent = Number(entry.percent) + if (percent >= 0) out.push(limitWindow(entry.label, percent, entry.resetsAt, entry.title)) + } + return out + } + + // The window that decides how much room is left — the fullest one, since + // that is what stops the next prompt. + function bindingWindow(p) { + var windows = limitWindows(p) + var best = null + for (var i = 0; i < windows.length; i++) { + if (!best || windows[i].percent > best.percent) best = windows[i] + } + return best + } + + function resetMsFor(w) { + if (!w || w.resetAt === "") return -1 + var ms = new Date(w.resetAt).getTime() + return isFinite(ms) ? ms - root.nowMs : -1 + } + + function formatDuration(ms) { + if (!(ms > 0)) return "now" + var minutes = Math.floor(ms / 60000) + var hours = Math.floor(minutes / 60) + var days = Math.floor(hours / 24) + if (days > 0) return days + "d " + (hours % 24) + "h" + if (hours > 0) return hours + "h " + (minutes % 60) + "m" + return Math.max(1, minutes) + "m" + } + + // ---------------------------------------------------------------- balance + // + // Prepaid agents report a credit ledger instead of rate-limit windows: the + // record's balance object carries remaining, funded, and spent amounts. + + function currencyPrefix(currency) { + var code = String(currency || "USD").toUpperCase() + if (code === "USD") return "$" + if (code === "EUR") return "€" + if (code === "GBP") return "£" + return code + " " + } + + function formatMoney(value, currency) { + var amount = Number(value) + if (!isFinite(amount)) amount = 0 + return currencyPrefix(currency) + amount.toFixed(2) + } + + function balanceDetailText(b) { + if (!b || !(b.funded > 0)) return "" + var text = formatMoney(b.spent, b.currency) + " spent of " + formatMoney(b.funded, b.currency) + " funded" + if (b.estimated) text += " · estimated" + return text + } + + // ---------------------------------------------------------------- content + + // The plan you pay for, under the name of the tool it pays for. Limits live + // in their own section; the hero just says what this is. + function heroMeta(p) { + if (!p) return "" + if (String(p.usageStatusText || "") !== "") return p.usageStatusText + var tier = String(p.tierLabel || "") + if (tier === "") return "Subscription" + return tier.charAt(0).toUpperCase() + tier.slice(1) + } + + // Local calendar date, recomputed from nowMs so a panel left open across + // midnight moves the "Today" row with the clock. + function todayDate() { + var now = new Date(root.nowMs) + return now.getFullYear() + + "-" + String(now.getMonth() + 1).padStart(2, "0") + + "-" + String(now.getDate()).padStart(2, "0") + } + + function dayName(date) { + var parsed = new Date(String(date || "") + "T00:00:00") + if (isNaN(parsed.getTime())) return String(date || "") + return ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"][parsed.getDay()] + } + + function dayLabel(date, today) { + if (today) return "Today" + return dayName(date) + } + + function dayTooltip(day, today) { + if (!day) return "" + var parsed = new Date(String(day.date) + "T00:00:00") + var label = isNaN(parsed.getTime()) + ? String(day.date) + : dayName(day.date) + " " + (parsed.getMonth() + 1) + "/" + parsed.getDate() + var text = label + " · " + usage.formatTokenCount(Number(day.messageCount || 0)) + " tokens" + // Prompt and session counts only exist for today, so they ride along here + // instead of taking a section of their own. Billing-API agents never + // count prompts, and "0 prompts" would read as a quiet day, not a gap. + if (today && provider && provider.hasPromptStats !== false) + text += " · " + Number(provider.todayPrompts || 0) + " prompts · " + + Number(provider.todaySessions || 0) + " sessions" + return text + } + + function weekPeak(p) { + var days = p ? (p.recentDays || []) : [] + var peak = 0 + for (var i = 0; i < days.length; i++) peak = Math.max(peak, Number(days[i].messageCount || 0)) + return peak + } + + function modelRows(p) { + var usageByModel = p ? (p.modelUsage || {}) : {} + var rows = [] + for (var id in usageByModel) { + var bucket = usageByModel[id] || {} + var input = Number(bucket.inputTokens || 0) + var output = Number(bucket.outputTokens || 0) + var cacheRead = Number(bucket.cacheReadInputTokens || 0) + var cacheWrite = Number(bucket.cacheCreationInputTokens || 0) + rows.push({ + name: usage.friendlyModelName(id), + total: input + output + cacheRead + cacheWrite, + input: input, + output: output, + cacheRead: cacheRead, + cacheWrite: cacheWrite + }) + } + rows.sort(function(a, b) { return b.total - a.total }) + return rows.slice(0, 4) + } + + function modelTooltip(row) { + if (!row) return "" + return "In " + usage.formatTokenCount(row.input) + + " · out " + usage.formatTokenCount(row.output) + + " · cache read " + usage.formatTokenCount(row.cacheRead) + + " · cache write " + usage.formatTokenCount(row.cacheWrite) + } + + // Only speaks up when the numbers cover more than this machine. + function footerText() { + if (usage.syncStatusText !== "") return usage.syncStatusText + if (provider && provider.syncEnabled && provider.syncDeviceCount > 0) + return "Merged from " + provider.syncDeviceCount + " device" + (provider.syncDeviceCount === 1 ? "" : "s") + return "" + } + + // Agents that ship a white mark carry an `assets/-light.svg` twin for + // light surfaces; marks that work on both (Claude's brand-orange) ship one + // file. The luminance check decides which candidate to try first. + function colorChannelLuminance(value) { + var channel = Number(value) + if (!isFinite(channel)) return 0 + return channel <= 0.03928 ? channel / 12.92 : Math.pow((channel + 0.055) / 1.055, 2.4) + } + + function colorLuminance(color) { + return 0.2126 * colorChannelLuminance(color.r) + + 0.7152 * colorChannelLuminance(color.g) + + 0.0722 * colorChannelLuminance(color.b) + } + + // Marks resolve by convention, so a new agent's data file needs nothing + // from this panel: assets/.svg if it ships one, the module's bar glyph + // if it doesn't. + function iconCandidatesForProvider(p, surfaceColor) { + if (!p) return [] + var candidates = [] + if (colorLuminance(surfaceColor || Color.background) >= 0.5) + candidates.push(Qt.resolvedUrl("assets/" + p.providerId + "-light.svg")) + candidates.push(Qt.resolvedUrl("assets/" + p.providerId + ".svg")) + return candidates + } + + // Nothing to report, nothing in the bar: Bar.qml collapses a slot whose item + // is invisible, so the icon appears the moment the first scan finds usage and + // stays away entirely on a machine that has never run either CLI. + visible: providers.length > 0 + implicitWidth: button.implicitWidth + implicitHeight: button.implicitHeight + + onProviderIndexChanged: if (panelFlick) panelFlick.contentY = 0 + onOpenedChanged: if (opened) { + cursorActive = false + nowMs = Date.now() + if (panelFlick) panelFlick.contentY = 0 + usage.refreshLimits() + Qt.callLater(function() { keyCatcher.forceActiveFocus() }) + } + + Main { + id: usage + settings: root.settings + } + + // Cheap enough to keep running: it only re-evaluates text bindings, and a + // stale "resets in 2h" on a panel that is open is worse than a timer. + Timer { + interval: 30000 + running: root.opened + repeat: true + onTriggered: root.nowMs = Date.now() + } + + IpcHandler { + target: root.ipcTarget + function open(): void { root.open() } + function close(): void { root.close() } + function show(): void { root.open() } + function hide(): void { root.close() } + function toggle(): void { root.toggle() } + function refresh(): string { root.refreshNow(); return "ok" } + function next(): string { root.selectProvider(root.providerIndex + 1); return "ok" } + } + + BarIconButton { + id: button + anchors.fill: parent + bar: root.bar + text: "󱚣" + active: root.alarming + onPressed: function(buttonCode) { + if (buttonCode === Qt.RightButton) root.launchAgent() + else if (buttonCode === Qt.MiddleButton) root.selectProvider(root.providerIndex + 1) + else root.toggle() + } + } + + KeyboardPanel { + id: panel + anchorItem: button + owner: root + bar: root.bar + open: root.opened + focusTarget: keyCatcher + contentWidth: panel.fittedContentWidth(Style.space(380)) + // Taller than the control panels on purpose: this one is a dashboard, and + // the whole point is reading limits and history without scrolling. + contentHeight: panel.fittedContentHeight(column.implicitHeight, Style.space(640)) + + PanelKeyCatcher { + id: keyCatcher + anchors.fill: parent + + onMoveRequested: function(dx, dy) { + if (dx !== 0) { + root.cursorActive = true + root.selectProvider(root.providerIndex + dx) + } + if (dy !== 0) + panelFlick.contentY = root.clamp(panelFlick.contentY + dy * Style.space(56), 0, + Math.max(0, panelFlick.contentHeight - panelFlick.height)) + } + onActivateRequested: root.refreshNow() + onCloseRequested: root.close() + onTabRequested: function(direction) { root.switchPanel(direction) } + onTextKey: function(t) { if (t === "r" || t === "R") root.refreshNow() } + + Flickable { + id: panelFlick + anchors.fill: parent + contentWidth: width + contentHeight: column.implicitHeight + clip: true + boundsBehavior: Flickable.StopAtBounds + flickableDirection: Flickable.VerticalFlick + interactive: contentHeight > height + ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded } + + Column { + id: column + width: panelFlick.width + spacing: Style.space(12) + + // ---------- Hero: provider mark · name · plan ---------- + PanelHero { + id: hero + visible: !!root.provider + width: parent.width + title: root.provider ? root.provider.providerName : "" + meta: root.heroMeta(root.provider) + foreground: root.foreground + fontFamily: root.fontFamily + + iconComponent: Component { + Item { + id: heroMark + property var candidates: root.iconCandidatesForProvider(root.provider, root.surface) + // Provider objects are rebuilt on every refresh, which churns the + // array's identity without changing its content. Restart the fallback + // walk only when the URLs change: re-pointing source at a URL whose + // load already failed emits no statusChanged, so an identity-only + // reset would strand the walker on a missing -light twin. + property string candidatesKey: candidates.join("\n") + property int candidateIndex: 0 + onCandidatesKeyChanged: candidateIndex = 0 + + width: Style.font.display + height: Style.font.display + + Image { + id: heroMarkImage + anchors.fill: parent + source: heroMark.candidateIndex < heroMark.candidates.length ? heroMark.candidates[heroMark.candidateIndex] : "" + sourceSize.width: Style.font.display * 2 + sourceSize.height: Style.font.display * 2 + fillMode: Image.PreserveAspectFit + // Advancing source from inside its own status change trips the + // binding-loop detector; defer the step one tick. + onStatusChanged: if (status === Image.Error && heroMark.candidateIndex < heroMark.candidates.length) + Qt.callLater(function() { heroMark.candidateIndex++ }) + } + + Text { + anchors.centerIn: parent + visible: heroMarkImage.status !== Image.Ready + text: button.text + color: root.foreground + font.family: root.fontFamily + font.pixelSize: Style.font.display + } + } + } + } + + Text { + visible: root.providers.length === 0 + width: parent.width + topPadding: Style.space(24) + text: "No AI coding subscriptions found.\nAgents show up here once you've used them." + color: root.dim + font.family: root.fontFamily + font.pixelSize: Style.font.body + horizontalAlignment: Text.AlignHCenter + wrapMode: Text.WordWrap + } + + // ---------- Provider switch ---------- + Row { + id: providerSwitch + visible: root.providers.length > 1 + width: parent.width + spacing: Style.spacing.md + + readonly property real cellWidth: root.providers.length > 0 + ? (width - spacing * (root.providers.length - 1)) / root.providers.length + : 0 + + Repeater { + model: root.providers + + Button { + required property var modelData + required property int index + + width: providerSwitch.cellWidth + text: modelData.providerName + selected: index === root.providerIndex + hasCursor: root.cursorActive && index === root.providerIndex + bordered: true + foreground: root.foreground + fontFamily: root.fontFamily + fontSize: Style.font.bodySmall + verticalPadding: Style.spacing.controlPaddingY + onClicked: { + root.cursorActive = true + root.selectProvider(index) + } + onHovered: function(isHovered) { if (isHovered) root.cursorActive = true } + } + } + } + + // ---------- Status ---------- + BorderSurface { + visible: !!root.provider && String(root.provider.usageStatusText || "") !== "" + width: parent.width + implicitHeight: statusText.implicitHeight + Style.spacing.xl * 2 + color: root.alpha(root.urgent, 0.10) + borderSpec: Border.flat(root.alpha(root.urgent, 0.35), 1) + radius: Style.cornerRadius + + Text { + id: statusText + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.leftMargin: Style.space(12) + anchors.rightMargin: Style.space(12) + text: root.provider ? String(root.provider.authHelpText || "") : "" + color: root.dim + font.family: root.fontFamily + font.pixelSize: Style.font.caption + wrapMode: Text.WordWrap + } + } + + // ---------- Balance / limits ---------- + PanelSeparator { + visible: balanceSection.visible || limitsSection.visible + foreground: root.foreground + } + + Column { + id: balanceSection + visible: !!root.balance + width: parent.width + spacing: Style.space(10) + + // The meter shows what is left, not what is used: a prepaid + // account drains toward empty rather than filling toward a cap. + readonly property real ratio: root.balance && root.balance.funded > 0 + ? root.clamp(root.balance.remaining / root.balance.funded, 0, 1) + : -1 + + PanelSectionHeader { + width: parent.width + text: "BALANCE" + foreground: root.foreground + fontFamily: root.fontFamily + } + + Item { + width: parent.width + implicitHeight: Math.max(balanceLabel.implicitHeight, balanceValue.implicitHeight) + + Text { + id: balanceLabel + text: "Prepaid credits" + color: root.foreground + font.family: root.fontFamily + font.pixelSize: Style.font.body + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + } + + Text { + id: balanceValue + text: root.balance ? root.formatMoney(root.balance.remaining, root.balance.currency) : "" + color: root.balanceAlarming ? root.urgent : root.foreground + font.family: root.fontFamily + font.pixelSize: Style.font.caption + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + } + } + + Meter { + visible: balanceSection.ratio >= 0 + width: parent.width + value: balanceSection.ratio + alarming: root.balanceAlarming + } + + Text { + visible: text !== "" + width: parent.width + text: root.balanceDetailText(root.balance) + color: root.dim + font.family: root.fontFamily + font.pixelSize: Style.font.caption + } + } + + Column { + id: limitsSection + visible: root.limits.length > 0 + width: parent.width + spacing: Style.space(10) + + PanelSectionHeader { + text: "LIMITS" + foreground: root.foreground + fontFamily: root.fontFamily + } + + Repeater { + model: root.limits + + LimitRow { + required property var modelData + width: limitsSection.width + window: modelData + } + } + } + + // ---------- Usage ---------- + PanelSeparator { + visible: usageSection.visible + foreground: root.foreground + } + + Column { + id: usageSection + visible: !!root.provider && root.provider.recentDays && root.provider.recentDays.length > 0 + width: parent.width + spacing: Style.spacing.md + + readonly property var days: root.provider ? (root.provider.recentDays || []) : [] + readonly property real peak: Math.max(1, root.weekPeak(root.provider)) + + PanelSectionHeader { + width: parent.width + text: "TOKENS BY DAY" + foreground: root.foreground + fontFamily: root.fontFamily + } + + Repeater { + model: usageSection.days + + DayRow { + required property var modelData + required property int index + + width: usageSection.width + day: modelData + ratio: Number(modelData.messageCount || 0) / usageSection.peak + // By date, not by position: the Claude stats-cache fallback can + // hand us a window that stops short of today. + today: String(modelData.date || "") === root.todayDate() + } + } + } + + // ---------- Models ---------- + PanelSeparator { + visible: modelSection.visible + foreground: root.foreground + } + + Column { + id: modelSection + visible: root.models.length > 0 + width: parent.width + spacing: Style.spacing.md + + PanelSectionHeader { + width: parent.width + text: "TOKENS BY MODEL" + foreground: root.foreground + fontFamily: root.fontFamily + } + + Repeater { + model: root.models + + ModelRow { + required property var modelData + width: modelSection.width + row: modelData + // Scaled to the heaviest model, so the top row is always full — + // the same scale-to-peak the weekly chart uses for its busiest day. + share: modelData.total / Math.max(1, root.models[0].total) + } + } + } + + Text { + visible: text !== "" + width: parent.width + topPadding: Style.space(2) + text: root.footerText() + color: root.dim + font.family: root.fontFamily + font.pixelSize: Style.font.caption + horizontalAlignment: Text.AlignHCenter + elide: Text.ElideRight + } + } + } + } + } + + // A limit window: label and percentage, meter, and reset countdown. + component LimitRow: Column { + id: limitRow + property var window: null + + readonly property bool alarming: window && window.percent >= 0.9 + + spacing: Style.space(6) + + Item { + width: parent.width + implicitHeight: Math.max(limitLabel.implicitHeight, limitValue.implicitHeight) + + Text { + id: limitLabel + // A model-scoped window is titled after its model, and those names run + // long enough to reach the percentage, so the title gives way first. + text: limitRow.window ? limitRow.window.title : "" + color: root.foreground + font.family: root.fontFamily + font.pixelSize: Style.font.body + elide: Text.ElideRight + anchors.left: parent.left + anchors.right: limitValue.left + anchors.rightMargin: Style.spacing.sm + anchors.verticalCenter: parent.verticalCenter + } + + Text { + id: limitValue + text: limitRow.window && limitRow.window.percent >= 0 + ? Math.round(limitRow.window.percent * 100) + "%" + : "—" + color: limitRow.alarming ? root.urgent : root.foreground + font.family: root.fontFamily + font.pixelSize: Style.font.caption + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + } + } + + Meter { + width: parent.width + value: limitRow.window ? limitRow.window.percent : -1 + alarming: limitRow.alarming + } + + Text { + id: resetText + width: parent.width + text: { + var remainingMs = root.resetMsFor(limitRow.window) + return remainingMs > 0 ? "Resets in " + root.formatDuration(remainingMs) : "" + } + color: root.dim + font.family: root.fontFamily + font.pixelSize: Style.font.caption + } + } + + // Rounded track showing the percentage of the allowance used. + component Meter: Item { + id: meter + property real value: -1 + property bool alarming: false + property real thickness: Math.max(Style.space(4), Math.round(Style.spacing.controlHeight * 0.14)) + + implicitHeight: thickness + + Rectangle { + id: meterTrack + anchors.fill: parent + radius: height / 2 + color: root.track + } + + Rectangle { + anchors.left: meterTrack.left + anchors.verticalCenter: meterTrack.verticalCenter + height: meterTrack.height + radius: meterTrack.radius + width: meterTrack.width * root.clamp(meter.value, 0, 1) + color: meter.alarming ? root.urgent : root.foreground + + Behavior on width { + NumberAnimation { duration: 160; easing.type: Easing.OutCubic } + } + } + + } + + // One row per day: label, bar, tokens. Today is picked out in full + // foreground so the week reads as a run-up to right now. + component DayRow: Item { + id: dayRow + property var day: null + property real ratio: 0 + property bool today: false + + implicitHeight: Math.max(dayLabel.implicitHeight, dayValue.implicitHeight) + Style.spacing.sm + + Text { + id: dayLabel + text: root.dayLabel(dayRow.day ? dayRow.day.date : "", dayRow.today) + color: dayRow.today ? root.foreground : root.dim + font.family: root.fontFamily + font.pixelSize: Style.font.caption + font.bold: dayRow.today + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + width: Style.space(52) + } + + Rectangle { + id: dayTrack + anchors.left: dayLabel.right + anchors.right: dayValue.left + anchors.leftMargin: Style.space(8) + anchors.rightMargin: Style.space(10) + anchors.verticalCenter: parent.verticalCenter + height: Math.max(Style.space(4), Math.round(Style.spacing.controlHeight * 0.14)) + radius: height / 2 + color: root.track + + Rectangle { + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + height: parent.height + radius: parent.radius + width: parent.width * root.clamp(dayRow.ratio, 0, 1) + color: dayRow.today ? root.foreground : root.alpha(root.foreground, 0.55) + + Behavior on width { + NumberAnimation { duration: 160; easing.type: Easing.OutCubic } + } + } + } + + Text { + id: dayValue + text: usage.formatTokenCount(dayRow.day ? Number(dayRow.day.messageCount || 0) : 0) + color: dayRow.today ? root.foreground : root.dim + font.family: root.fontFamily + font.pixelSize: Style.font.caption + font.bold: true + horizontalAlignment: Text.AlignRight + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + width: Style.space(52) + } + + MouseArea { + id: dayHover + anchors.fill: parent + hoverEnabled: true + acceptedButtons: Qt.NoButton + } + + PanelToolTip { + visible: dayHover.containsMouse + text: root.dayTooltip(dayRow.day, dayRow.today) + fontFamily: root.fontFamily + } + } + + // Model rows read as a table: the share bar fills the row behind the label + // instead of stacking under it, which keeps the whole dashboard on one screen. + component ModelRow: Item { + id: modelRow + property var row: null + property real share: 0 + + implicitHeight: modelName.implicitHeight + Style.spacing.lg + + Rectangle { + anchors.fill: parent + radius: Style.cornerRadius + color: root.alpha(root.foreground, 0.05) + } + + Rectangle { + anchors.left: parent.left + anchors.top: parent.top + anchors.bottom: parent.bottom + width: parent.width * root.clamp(modelRow.share, 0, 1) + radius: Style.cornerRadius + color: root.alpha(root.foreground, 0.14) + + Behavior on width { + NumberAnimation { duration: 160; easing.type: Easing.OutCubic } + } + } + + Text { + id: modelName + text: modelRow.row ? modelRow.row.name : "" + color: root.foreground + font.family: root.fontFamily + font.pixelSize: Style.font.bodySmall + elide: Text.ElideRight + anchors.left: parent.left + anchors.leftMargin: Style.space(8) + anchors.right: modelTokens.left + anchors.rightMargin: Style.space(8) + anchors.verticalCenter: parent.verticalCenter + } + + Text { + id: modelTokens + text: modelRow.row ? usage.formatTokenCount(modelRow.row.total) : "" + color: root.dim + font.family: root.fontFamily + font.pixelSize: Style.font.bodySmall + font.bold: true + anchors.right: parent.right + anchors.rightMargin: Style.space(8) + anchors.verticalCenter: parent.verticalCenter + } + + MouseArea { + id: modelHover + anchors.fill: parent + hoverEnabled: true + acceptedButtons: Qt.NoButton + } + + PanelToolTip { + visible: modelHover.containsMouse + text: root.modelTooltip(modelRow.row) + fontFamily: root.fontFamily + } + } +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..51ad4e4 --- /dev/null +++ b/README.md @@ -0,0 +1,150 @@ +# Agents + +One bar icon and one panel for every AI coding subscription on the machine. +The panel is strictly a display: it watches the usage records that +`omarchy-agent-usage-update` writes to `~/.local/state/omarchy/agents/usage/` +and draws whatever appears there. `Panel.qml` owns the bar button and the +popup; `Main.qml` discovers and watches the records (and handles the optional +cross-device aggregation); `Agent.qml` is the per-record file watcher. + +## Panel + +- **Hero** — the mark, the tool, and the plan it runs on ("Max 20x", "Pro"). + Auth and endpoint problems replace the plan line and repeat in a card. +- **Subscription switch** — one chip per enabled agent (`h`/`l` or click). + It appears only when more than one agent is enabled. +- **Limits** — the percentage of each allowance used, a matching meter, and + the time until the session or weekly window resets. +- **Balance** — prepaid agents report a credit ledger instead of limits: + remaining credit, a fuel-gauge meter that drains toward empty, and + funded-versus-spent detail. +- **Tokens by day** — one row per day for the last week: day, bar, tokens, with today + bolded at the bottom. Hover today for its prompt and session count. +- **Tokens by model** — tokens per model with the bar behind each row scaled + to the heaviest model, + the same way the weekly chart scales to its busiest day. Hover for the + input / output / cache split. + +A subscription appears only when it is enabled in settings and has actually +recorded usage — on this machine or on a synced one. With one such agent +there is no switch row at all; with none, the module leaves the bar entirely +rather than sitting there with nothing to say. A CLI installed mid-session +shows up at the next refresh, so nothing polls the disk waiting for it. + +That self-hiding is why the widget ships in the default bar layout: a machine +that has never run an AI coding agent draws nothing, and the icon arrives on +its own the first time a scan finds usage. Drop it with +`omarchy plugin disable omarchy.agents`. + +## Data + +Each agent is one JSON record in `~/.local/state/omarchy/agents/usage/`, +written by `omarchy-agent-usage-update`. That command runs one +`omarchy-agent-usage-` collector per agent; the widget invokes it +on its refresh timer and whenever you ask for a refresh, and picks up any +record that lands in the directory regardless of who wrote it. + +Adding an agent therefore never touches this plugin: ship a collector that +prints the record contract (see the `claude` and `codex` collectors in +`bin/`), and the panel gains a tab. An `assets/.svg` mark is optional — +with an `assets/-light.svg` twin if the mark needs a dark variant for +light surfaces — and the bar glyph stands in when there is none. + +| Collector | Limits | Local stats | +|---|---|---| +| `claude` | Anthropic's OAuth usage endpoint (5-hour session + 7-day weekly) | `~/.claude/projects` transcripts, opencode sessions on an Anthropic provider, plus `stats-cache.json` and `history.jsonl` as fallback | +| `codex` | The Codex app-server RPC | native Codex CLI session files (plus pi and opencode sessions) | +| `fireworks` | Estimated prepaid balance: configured funding minus rated account costs | Fireworks billing API, grouped by day and model for the last 30 days | + +Claude limits need a signed-in CLI; without credentials the panel says so and +falls back to local stats only. A non-default Claude directory is honored via +`CLAUDE_CONFIG_DIR`, Codex via `CODEX_HOME`. Fireworks reads +`FIREWORKS_API_KEY` and `FIREWORKS_ACCOUNT_ID` first, then +`~/.fireworks/auth.ini` (which `firectl set-api-key` creates), then the key +opencode stores in `~/.local/share/opencode/auth.json` when Fireworks is +signed in there. + +### Fireworks balance + +The collector first asks the account's `:getBalance` endpoint for the real +prepaid ledger. That endpoint exists but is permission-gated, and as of +August 2026 no console-issued API key passes it — Fireworks appears to +reserve it for the dashboard session. The probe stays because it is cheap +and the live figure lights up automatically if Fireworks ever opens it to +keys. Until then the collector falls back to estimating the balance from +configuration in `~/.config/omarchy/agents/fireworks.json`: + +```json +{ + "accountId": "", + "fundedAmount": 20, + "fundedAt": "2026-07-01" +} +``` + +Set `fundedAmount` to the credits purchased and optionally `fundedAt` to the +purchase date; with no date, the collector uses the account creation time. It +subtracts rated account costs and the panel labels the result as estimated. +For a later top-up, increase `fundedAmount` by the new credit while keeping +the original `fundedAt`, so both the funding and spend still cover the same +period. `accountId` only matters when one API key can access several +accounts. Without a configured `fundedAmount` the tab still shows token +usage, just no balance. With a live ledger, `fundedAmount` is optional and +only adds the meter and the spent-of-funded line under the real figure. + +## Interactions + +- Bar icon: left = panel, right = launch agent, middle = next subscription. +- Panel: `h`/`l` switch subscription, `j`/`k` scroll, `r` or Enter refresh, + Tab moves to the neighboring bar panel, Esc closes. +- IPC: `omarchy-shell omarchy.agents `. + +## Settings + +Settings live in the widget's entry in `~/.config/omarchy/shell.json`. The +top-level keys can be set with +`omarchy bar set omarchy.agents `: + +| Key | Default | What it does | +|---|---|---| +| `refreshIntervalSec` | `900` | How often the usage records regenerate | +| `syncMode` | `"Off"` | `"On"` writes this machine's snapshot and merges the others | +| `syncDir` | `""` | A folder synced by Syncthing, Dropbox, rsync, … | +| `syncFileName` | `.json` | This machine's snapshot file | +| `syncDeviceId` | hostname | Stable device name inside the snapshot | + +Numbers need `--json`, or they land in `shell.json` as strings: + +```bash +omarchy bar set omarchy.agents refreshIntervalSec 300 --json +omarchy bar set omarchy.agents syncDir '~/Sync/agent-usage' +``` + +Per-agent enablement is nested, and `set` writes its key literally rather +than walking a dotted path — so pass the whole `providers` object as JSON (or +edit `shell.json` directly): + +```bash +omarchy bar set omarchy.agents providers '{ + "claude": { "enabled": true }, + "codex": { "enabled": false }, + "fireworks": { "enabled": true } +}' --json +``` + +`enabled` defaults to `true` for every discovered agent; set it to `false` to +hide a subscription that is installed. Disabled agents are also skipped when +the records regenerate. + +With `syncMode` on, every `*.json` snapshot in `syncDir` is merged, so today, +the last 7 days, and the all-time totals cover every machine you code on — +active days are unioned by date rather than summed. Rate limits stay +per-account and are never merged. A record may declare `"scope": "account"` +when its stats are account-global rather than machine-local (Fireworks' +billing API); those merge by taking the widest value instead of summing, so +the same account synced from two machines is not counted twice. + +One caveat on "all-time": the Codex collector only reads native session files +touched in the last 30 days, and Fireworks requests the last 30 days from its +billing API, so their totals and day counts cover that window. Claude's cover +every transcript still on disk. diff --git a/assets/claude.svg b/assets/claude.svg new file mode 100644 index 0000000..d300701 --- /dev/null +++ b/assets/claude.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/codex-light.svg b/assets/codex-light.svg new file mode 100644 index 0000000..62b6686 --- /dev/null +++ b/assets/codex-light.svg @@ -0,0 +1 @@ +Codex \ No newline at end of file diff --git a/assets/codex.svg b/assets/codex.svg new file mode 100644 index 0000000..1716a13 --- /dev/null +++ b/assets/codex.svg @@ -0,0 +1 @@ +Codex \ No newline at end of file diff --git a/assets/fireworks.svg b/assets/fireworks.svg new file mode 100644 index 0000000..a659bed --- /dev/null +++ b/assets/fireworks.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..ee45a53 --- /dev/null +++ b/manifest.json @@ -0,0 +1,90 @@ +{ + "schemaVersion": 1, + "id": "pastilhas.agents", + "name": "Agents", + "version": "1.0.0", + "author": "pastilhas", + "license": "MIT", + "description": "Claude Code, Codex, and Fireworks usage, limits, and pace in a native Omarchy bar panel.", + "kinds": [ + "bar-widget" + ], + "activation": "on-demand", + "entryPoints": { + "barWidget": "Panel.qml" + }, + "barWidget": { + "displayName": "Agents", + "description": "One bar icon and one panel: rate-limit meters with pace, today, the last week, and the all-time model breakdown for every subscription.", + "category": "AI", + "aliases": [ + "agents", + "model-usage" + ], + "allowMultiple": false, + "defaults": { + "providers": { + "claude": { + "enabled": true + }, + "codex": { + "enabled": true + }, + "fireworks": { + "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." + } + ] + }, + "omarchy": { + "clonedFrom": "omarchy.agents" + } +}