pragma Singleton // ───────────────────────────────────────────────────────────────────────────── // How much of each agent subscription this account has used. // // A collector per agent writes one display-ready record into // $XDG_STATE_HOME/panama/agents/usage/.json, and this only ever reads // that directory. The split is the point: adding a third agent is a collector, // not a change here, in the widget, or in the panel -- and nothing in QML ever // sees a credential. // // Discovery is by listing the directory rather than by naming the agents, so a // record that appears is an agent that appears. Each record gets its own // FileView with watchChanges, so a collector finishing mid-session updates the // panel without waiting for the next tick. // // Every record carries its own honesty: `ready` says whether it has anything // worth showing, `usageStatusText` says why not when it does not, and // `retryAdvised` says a transport failure is worth retrying sooner than the // interval. The widget hides when nothing is ready, which is right -- a bar // indicator that says "unknown" is worse than an empty space. // ───────────────────────────────────────────────────────────────────────────── import Quickshell import Quickshell.Io import QtQuick import qs.config Singleton { id: root readonly property string usageDir: (Quickshell.env("XDG_STATE_HOME") || `${Quickshell.env("HOME")}/.local/state`) + "/panama/agents/usage" readonly property string updaterPath: Quickshell.shellDir + "/scripts/panama-agent-usage-update" // Agent ids with a record file on disk, in the order the directory listed // them. Reassigned rather than mutated: QML does not notify on in-place // changes to a var property's contents. property var agentIds: [] // id -> parsed record, for the ids above. Same reassignment rule. property var records: ({}) // The records worth drawing. A collector that ran but has nothing to say // (never signed in, never used) reports ready:false rather than zeros. readonly property var readyRecords: root.agentIds .map(id => root.records[id]) .filter(record => record && record.ready === true) // The number worth showing when there is only room for one: whichever // window across every agent is closest to its limit is the one about to // interrupt you. -1 when nothing has a limit to report. readonly property int headline: { let fullest = -1; for (const record of root.readyRecords) { const limits = Array.isArray(record.limits) ? record.limits : []; for (const limit of limits) { const percent = Number(limit?.percent); if (Number.isFinite(percent)) fullest = Math.max(fullest, percent); } } return fullest < 0 ? -1 : Math.round(Math.min(1, Math.max(0, fullest)) * 100); } readonly property bool available: root.headline >= 0 // Any agent asking to be retried sooner than the interval. A first probe // after login often fires before DHCP has handed out a route, and waiting a // quarter of an hour to find out is not an answer. readonly property bool retryAdvised: root.agentIds .some(id => root.records[id]?.retryAdvised === true) // Minutes, never a repaint. Usage moves slowly and the collectors make a // network call; anything faster would spend someone's battery watching a // number that changes a few times an hour. Clamped so a hand-edited // settings.json cannot turn the bar into a request loop. readonly property int refreshMinutes: Math.max(5, Math.min(60, Settings.agentUsageRefreshMinutes)) function refresh(force: bool): void { if (collect.running) return; collect.command = force ? [root.updaterPath, "--force"] : [root.updaterPath]; collect.running = true; } // Called by the panel when it opens: the local scans may be reused, but the // limits are what the panel is being opened to read. function refreshLimits(): void { if (collect.running) return; collect.command = [root.updaterPath, "--limits-only"]; collect.running = true; } function absorb(id: string, text: string): void { const next = Object.assign({}, root.records); try { const parsed = JSON.parse(text); next[id] = (parsed && typeof parsed === "object") ? parsed : null; } catch (error) { next[id] = null; } root.records = next; } function forget(id: string): void { const next = Object.assign({}, root.records); delete next[id]; root.records = next; } Timer { id: tick interval: root.refreshMinutes * 60 * 1000 running: Settings.showAgentUsage repeat: true triggeredOnStart: true onTriggered: root.refresh(false) } // The one short timer in here, and it only ever runs while a collector has // said its failure was a transport failure rather than an answer. Timer { interval: 30 * 1000 running: Settings.showAgentUsage && root.retryAdvised repeat: true onTriggered: root.refresh(false) } Process { id: collect command: [root.updaterPath] onExited: scan.running = true } // Which records exist. `sh -c` with the directory passed as an argument // rather than interpolated: a path is data, and a state directory is not // somewhere to build a command string from. Process { id: scan command: ["sh", "-c", 'ls -1 "$1" 2>/dev/null', "sh", root.usageDir] stdout: StdioCollector { onStreamFinished: { const found = []; for (const line of String(this.text ?? "").split("\n")) { const name = line.trim(); if (name.endsWith(".json") && name.length > 5) found.push(name.slice(0, -5)); } for (const id of root.agentIds) if (found.indexOf(id) < 0) root.forget(id); root.agentIds = found; } } } Instantiator { model: root.agentIds delegate: FileView { required property var modelData path: root.usageDir + "/" + modelData + ".json" printErrors: false watchChanges: true onFileChanged: this.reload() onLoaded: root.absorb(modelData, this.text()) onLoadFailed: root.forget(modelData) } } // Whatever the collectors last left behind is worth showing before the // first tick lands, so the panel is not empty for the length of a probe. // The directory may not exist yet on a machine where no collector has ever // run; the listing fails quietly and the next fan-out creates it. Component.onCompleted: scan.running = true }