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.
This commit is contained in:
2026-08-17 17:33:00 +01:00
commit f3958bba4b
9 changed files with 1962 additions and 0 deletions
+34
View File
@@ -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
}
}
}
+748
View File
@@ -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"
}
}
+930
View File
@@ -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/<id>-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/<id>.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
}
}
}
+150
View File
@@ -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-<agent>` 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/<id>.svg` mark is optional —
with an `assets/<id>-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 <open|close|toggle|refresh|next>`.
## 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> <value>`:
| 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` | `<hostname>.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.
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="257" preserveAspectRatio="xMidYMid" viewBox="0 0 256 257"><path fill="#D97757" d="m50.228 170.321 50.357-28.257.843-2.463-.843-1.361h-2.462l-8.426-.518-28.775-.778-24.952-1.037-24.175-1.296-6.092-1.297L0 125.796l.583-3.759 5.12-3.434 7.324.648 16.202 1.101 24.304 1.685 17.629 1.037 26.118 2.722h4.148l.583-1.685-1.426-1.037-1.101-1.037-25.147-17.045-27.22-18.017-14.258-10.37-7.713-5.25-3.888-4.925-1.685-10.758 7-7.713 9.397.649 2.398.648 9.527 7.323 20.35 15.75L94.817 91.9l3.889 3.24 1.555-1.102.195-.777-1.75-2.917-14.453-26.118-15.425-26.572-6.87-11.018-1.814-6.61c-.648-2.723-1.102-4.991-1.102-7.778l7.972-10.823L71.42 0 82.05 1.426l4.472 3.888 6.61 15.101 10.694 23.786 16.591 32.34 4.861 9.592 2.592 8.879.973 2.722h1.685v-1.556l1.36-18.211 2.528-22.36 2.463-28.776.843-8.1 4.018-9.722 7.971-5.25 6.222 2.981 5.12 7.324-.713 4.73-3.046 19.768-5.962 30.98-3.889 20.739h2.268l2.593-2.593 10.499-13.934 17.628-22.036 7.778-8.749 9.073-9.657 5.833-4.601h11.018l8.1 12.055-3.628 12.443-11.342 14.388-9.398 12.184-13.48 18.147-8.426 14.518.778 1.166 2.01-.194 30.46-6.481 16.462-2.982 19.637-3.37 8.88 4.148.971 4.213-3.5 8.62-20.998 5.184-24.628 4.926-36.682 8.685-.454.324.519.648 16.526 1.555 7.065.389h17.304l32.21 2.398 8.426 5.574 5.055 6.805-.843 5.184-12.962 6.611-17.498-4.148-40.83-9.721-14-3.5h-1.944v1.167l11.666 11.406 21.387 19.314 26.767 24.887 1.36 6.157-3.434 4.86-3.63-.518-23.526-17.693-9.073-7.972-20.545-17.304h-1.36v1.814l4.73 6.935 25.017 37.59 1.296 11.536-1.814 3.76-6.481 2.268-7.13-1.297-14.647-20.544-15.1-23.138-12.185-20.739-1.49.843-7.194 77.448-3.37 3.953-7.778 2.981-6.48-4.925-3.436-7.972 3.435-15.749 4.148-20.544 3.37-16.333 3.046-20.285 1.815-6.74-.13-.454-1.49.194-15.295 20.999-23.267 31.433-18.406 19.702-4.407 1.75-7.648-3.954.713-7.064 4.277-6.286 25.47-32.405 15.36-20.092 9.917-11.6-.065-1.686h-.583L44.07 198.125l-12.055 1.555-5.185-4.86.648-7.972 2.463-2.593 20.35-13.999-.064.065Z"/></svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

+1
View File
@@ -0,0 +1 @@
<svg fill="#111" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Codex</title><path clip-rule="evenodd" d="M8.086.457a6.105 6.105 0 013.046-.415c1.333.153 2.521.72 3.564 1.7a.117.117 0 00.107.029c1.408-.346 2.762-.224 4.061.366l.063.03.154.076c1.357.703 2.33 1.77 2.918 3.198.278.679.418 1.388.421 2.126a5.655 5.655 0 01-.18 1.631.167.167 0 00.04.155 5.982 5.982 0 011.578 2.891c.385 1.901-.01 3.615-1.183 5.14l-.182.22a6.063 6.063 0 01-2.934 1.851.162.162 0 00-.108.102c-.255.736-.511 1.364-.987 1.992-1.199 1.582-2.962 2.462-4.948 2.451-1.583-.008-2.986-.587-4.21-1.736a.145.145 0 00-.14-.032c-.518.167-1.04.191-1.604.185a5.924 5.924 0 01-2.595-.622 6.058 6.058 0 01-2.146-1.781c-.203-.269-.404-.522-.551-.821a7.74 7.74 0 01-.495-1.283 6.11 6.11 0 01-.017-3.064.166.166 0 00.008-.074.115.115 0 00-.037-.064 5.958 5.958 0 01-1.38-2.202 5.196 5.196 0 01-.333-1.589 6.915 6.915 0 01.188-2.132c.45-1.484 1.309-2.648 2.577-3.493.282-.188.55-.334.802-.438.286-.12.573-.22.861-.304a.129.129 0 00.087-.087A6.016 6.016 0 015.635 2.31C6.315 1.464 7.132.846 8.086.457zm-.804 7.85a.848.848 0 00-1.473.842l1.694 2.965-1.688 2.848a.849.849 0 001.46.864l1.94-3.272a.849.849 0 00.007-.854l-1.94-3.393zm5.446 6.24a.849.849 0 000 1.695h4.848a.849.849 0 000-1.696h-4.848z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

+1
View File
@@ -0,0 +1 @@
<svg fill="#fff" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Codex</title><path clip-rule="evenodd" d="M8.086.457a6.105 6.105 0 013.046-.415c1.333.153 2.521.72 3.564 1.7a.117.117 0 00.107.029c1.408-.346 2.762-.224 4.061.366l.063.03.154.076c1.357.703 2.33 1.77 2.918 3.198.278.679.418 1.388.421 2.126a5.655 5.655 0 01-.18 1.631.167.167 0 00.04.155 5.982 5.982 0 011.578 2.891c.385 1.901-.01 3.615-1.183 5.14l-.182.22a6.063 6.063 0 01-2.934 1.851.162.162 0 00-.108.102c-.255.736-.511 1.364-.987 1.992-1.199 1.582-2.962 2.462-4.948 2.451-1.583-.008-2.986-.587-4.21-1.736a.145.145 0 00-.14-.032c-.518.167-1.04.191-1.604.185a5.924 5.924 0 01-2.595-.622 6.058 6.058 0 01-2.146-1.781c-.203-.269-.404-.522-.551-.821a7.74 7.74 0 01-.495-1.283 6.11 6.11 0 01-.017-3.064.166.166 0 00.008-.074.115.115 0 00-.037-.064 5.958 5.958 0 01-1.38-2.202 5.196 5.196 0 01-.333-1.589 6.915 6.915 0 01.188-2.132c.45-1.484 1.309-2.648 2.577-3.493.282-.188.55-.334.802-.438.286-.12.573-.22.861-.304a.129.129 0 00.087-.087A6.016 6.016 0 015.635 2.31C6.315 1.464 7.132.846 8.086.457zm-.804 7.85a.848.848 0 00-1.473.842l1.694 2.965-1.688 2.848a.849.849 0 001.46.864l1.94-3.272a.849.849 0 00.007-.854l-1.94-3.393zm5.446 6.24a.849.849 0 000 1.695h4.848a.849.849 0 000-1.696h-4.848z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

+7
View File
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<g fill="none" stroke="#ff6b22" stroke-linecap="round" stroke-width="5">
<path d="M32 5v12M32 47v12M5 32h12M47 32h12"/>
<path d="m13 13 8.5 8.5M42.5 42.5 51 51M51 13l-8.5 8.5M21.5 42.5 13 51"/>
</g>
<circle cx="32" cy="32" r="4" fill="#ff6b22"/>
</svg>

After

Width:  |  Height:  |  Size: 328 B

+90
View File
@@ -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 <hostname>.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"
}
}