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:
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user