@cad0p/pi-usage 0.11.3 → 0.11.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * Pi quota line — read-only quota for the providers Pi already owns.
3
3
  *
4
- * Surface: nothing at session start. /usage box, /usage line or /usage panel pins
4
+ * Surface: nothing at session start. Bare /usage shows the panel until the next
5
+ * prompt or a second /usage. /usage box, /usage line or /usage panel pins
5
6
  * a surface above the editor, /usage status enables the footer, and /usage hide
6
7
  * removes it. No timer or post-turn refresh runs until a surface is on.
7
8
  *
@@ -69,12 +70,49 @@ type QuotaProvider = {
69
70
  expiresInMin: number | null;
70
71
  };
71
72
 
73
+ type LocalModelUsage = {
74
+ inputTokens?: number;
75
+ outputTokens?: number;
76
+ cacheReadInputTokens?: number;
77
+ cacheCreationInputTokens?: number;
78
+ };
79
+
80
+ type LocalAccountStats = {
81
+ todayTokens?: number;
82
+ weekTokens?: number;
83
+ todayPrompts?: number;
84
+ totalPrompts?: number;
85
+ totalTokens?: number;
86
+ };
87
+
88
+ /**
89
+ * Pi's own token counts for one family, computed by the CLI from the session
90
+ * files. It only ever holds aggregate counts: no prompt text, no identities.
91
+ */
92
+ type LocalFamilyStats = {
93
+ hasLocalStats?: boolean;
94
+ todayPrompts?: number;
95
+ todaySessions?: number;
96
+ todayTotalTokens?: number;
97
+ todayTokensByModel?: Record<string, number>;
98
+ weekTokensByModel?: Record<string, number>;
99
+ recentDays?: Array<{ date: string; messageCount: number }>;
100
+ totalPrompts?: number;
101
+ totalSessions?: number;
102
+ activeDays?: number;
103
+ activeDates?: string[];
104
+ modelUsage?: Record<string, LocalModelUsage>;
105
+ byAccount?: Record<string, LocalAccountStats>;
106
+ };
107
+
72
108
  type QuotaReport = {
73
109
  providers: QuotaProvider[];
74
110
  generatedAt: string;
75
111
  sources: string[];
76
112
  warnings: string[];
77
113
  byFamily: Record<string, QuotaProvider[]>;
114
+ localStats?: Record<string, LocalFamilyStats>;
115
+ localStatsMeta?: { partial?: boolean } | null;
78
116
  };
79
117
 
80
118
  type Theme = {
@@ -550,6 +588,89 @@ function summarize(report: QuotaReport): string {
550
588
  .join(" · ");
551
589
  }
552
590
 
591
+ /** Window names for the one-line account rows (same words as the Omarchy panel). */
592
+ const WINDOW_TAG: Record<string, string> = { "5h": "session", weekly: "week", monthly: "month" };
593
+
594
+ /** A reset countdown in its largest unit, so a row of three stays narrow. */
595
+ function shortDuration(seconds: number | null): string {
596
+ if (seconds === null || !Number.isFinite(seconds)) return "";
597
+ if (seconds <= 0) return "now";
598
+ if (seconds >= 86400) return `${Math.floor(seconds / 86400)}d`;
599
+ if (seconds >= 3600) return `${Math.floor(seconds / 3600)}h`;
600
+ return `${Math.max(1, Math.floor(seconds / 60))}m`;
601
+ }
602
+
603
+ /** Token counts stay readable in a widget: 812, 4.1k, 1.2M. */
604
+ function formatTokens(value: number): string {
605
+ const tokens = Math.max(0, Math.round(Number(value) || 0));
606
+ if (tokens < 1_000) return String(tokens);
607
+ if (tokens < 100_000) return `${(tokens / 1_000).toFixed(1)}k`;
608
+ if (tokens < 1_000_000) return `${Math.round(tokens / 1_000)}k`;
609
+ if (tokens < 1_000_000_000) return `${(tokens / 1_000_000).toFixed(1)}M`;
610
+ return `${(tokens / 1_000_000_000).toFixed(1)}B`;
611
+ }
612
+
613
+ /** Model ids are long and provider-prefixed; the panel only needs the tail. */
614
+ function shortModelName(name: string): string {
615
+ const text = String(name ?? "");
616
+ const tail = text.split("/").pop() || text;
617
+ return tail.length <= 22 ? tail : `${tail.slice(0, 21)}…`;
618
+ }
619
+
620
+ /**
621
+ * The heaviest models of the last seven days, plus how many were left out.
622
+ * The all-time buckets are the fallback for a report from a CLI that predates
623
+ * the weekly breakdown, and for a week with no usage at all.
624
+ */
625
+ function topModels(usage: Record<string, LocalModelUsage> | undefined, weekUsage: Record<string, number> | undefined, limit: number) {
626
+ const byName = (a: { name: string; total: number }, b: { name: string; total: number }) =>
627
+ b.total - a.total || a.name.localeCompare(b.name);
628
+ const weekRows = Object.entries(weekUsage ?? {})
629
+ .map(([name, total]) => ({ name, total: Number(total) || 0 }))
630
+ .filter((row) => row.total > 0)
631
+ .sort(byName);
632
+ const rows = weekRows.length > 0
633
+ ? weekRows
634
+ : Object.entries(usage ?? {})
635
+ .map(([name, bucket]) => ({
636
+ name,
637
+ total:
638
+ (Number(bucket?.inputTokens) || 0) +
639
+ (Number(bucket?.outputTokens) || 0) +
640
+ (Number(bucket?.cacheReadInputTokens) || 0) +
641
+ (Number(bucket?.cacheCreationInputTokens) || 0),
642
+ }))
643
+ .sort(byName);
644
+ return { rows: rows.slice(0, limit), more: Math.max(0, rows.length - limit) };
645
+ }
646
+
647
+ /**
648
+ * The token section of one provider: today and the last seven days, then the
649
+ * models that account for that usage. Two lines at most, aligned with the
650
+ * window rows above it.
651
+ */
652
+ function renderTokenRows(stats: LocalFamilyStats | undefined, labelWidth: number, theme: Theme, partial = false): string[] {
653
+ if (!stats || stats.hasLocalStats === false) return [];
654
+ const week = (stats.recentDays ?? []).reduce((sum, day) => sum + (Number(day?.messageCount) || 0), 0);
655
+ const today = Number(stats.todayTotalTokens) || 0;
656
+ const prompts = Number(stats.todayPrompts) || 0;
657
+ const parts = [
658
+ `${theme.fg("dim", "today")} ${formatTokens(today)}`,
659
+ `${theme.fg("dim", "7d")} ${formatTokens(week)}`,
660
+ ];
661
+ if (prompts > 0) parts.push(theme.fg("dim", `${prompts} prompt${prompts === 1 ? "" : "s"}`));
662
+ if (partial) parts.push(theme.fg("warning", "scan partial"));
663
+ const lines = [` ${theme.fg("dim", "tokens".padEnd(labelWidth))} ${parts.join(theme.fg("dim", " · "))}`];
664
+
665
+ const { rows, more } = topModels(stats.modelUsage, stats.weekTokensByModel, 3);
666
+ if (rows.length > 0) {
667
+ const cells = rows.map((row) => `${shortModelName(row.name)} ${formatTokens(row.total)}`);
668
+ if (more > 0) cells.push(theme.fg("dim", `+${more} more`));
669
+ lines.push(` ${" ".repeat(labelWidth)} ${cells.join(theme.fg("dim", " · "))}`);
670
+ }
671
+ return lines;
672
+ }
673
+
553
674
  /** The account's fullest window: the one that stops its next prompt. */
554
675
  function bindingWindow(provider: QuotaProvider): QuotaWindow | null {
555
676
  let best: QuotaWindow | null = null;
@@ -579,8 +700,10 @@ function isMultiAccount(providers: QuotaProvider[], provider: QuotaProvider): bo
579
700
  /**
580
701
  * One family, many accounts: a header plus one line per account with only its
581
702
  * binding window, so fourteen keys stay fourteen lines instead of fifty-six.
703
+ * Each line carries the account's own tokens for today, because on a
704
+ * fourteen-key provider "which key burned today's quota" is the question.
582
705
  */
583
- function renderAccountRows(accounts: QuotaProvider[], theme: Theme): string[] {
706
+ function renderAccountRows(accounts: QuotaProvider[], theme: Theme, stats?: LocalFamilyStats): string[] {
584
707
  const family = accounts[0].family;
585
708
  const healthy = accounts.filter((account) => account.ok).length;
586
709
  const header = `${paint(BRAND[family] ?? "#8B949E", SHORT_NAME[family] ?? accounts[0].label)} ${theme.fg("dim", `· ${healthy} of ${accounts.length} accounts ok`)}`;
@@ -600,13 +723,19 @@ function renderAccountRows(accounts: QuotaProvider[], theme: Theme): string[] {
600
723
  lines.push(` ${label} ${theme.fg("dim", "no rate-limit windows reported")}`);
601
724
  continue;
602
725
  }
726
+ // Every window on one line, each in its own band colour; the leading glyph
727
+ // is the fullest window's, so the column still sorts the accounts by eye.
603
728
  const band = bandFor(used);
604
- const reset = window.resetsInSec === null ? "" : theme.fg("dim", ` · reset in ${humanDuration(window.resetsInSec)}`);
605
- lines.push(
606
- ` ${label} ${paint(band.color, band.glyph)} ${paint(band.color, bar(used))} ` +
607
- `${paint(band.color, `${used.toFixed(used < 10 ? 1 : 0)}%`.padStart(5))} ` +
608
- `${theme.fg("dim", window.id)}${reset}`,
609
- );
729
+ const cells = account.windows
730
+ .filter((each) => usedOf(each) !== null)
731
+ .map((each) => {
732
+ const pct = usedOf(each) as number;
733
+ const reset = each.resetsInSec === null ? "" : ` ${theme.fg("dim", `(${shortDuration(each.resetsInSec)})`.padEnd(5))}`;
734
+ return `${theme.fg("dim", WINDOW_TAG[each.id] ?? each.id)} ${paint(bandFor(pct).color, `${Math.round(pct)}%`.padStart(4))}${reset}`;
735
+ });
736
+ const today = Number(stats?.byAccount?.[account.account]?.todayTokens) || 0;
737
+ const tokens = today > 0 ? ` · ${formatTokens(today)} today` : "";
738
+ lines.push(` ${label} ${paint(band.color, band.glyph)} ${cells.join(theme.fg("dim", " · "))}${theme.fg("dim", tokens)}`);
610
739
  }
611
740
  return lines;
612
741
  }
@@ -617,11 +746,15 @@ function renderPanel(report: QuotaReport, theme: Theme): string[] {
617
746
  const unconfigured = report.providers.filter((provider) => provider.notConfigured === true);
618
747
  const renderedFamilies = new Set<string>();
619
748
 
749
+ const partialScan = report.localStatsMeta?.partial === true;
620
750
  for (const provider of active) {
751
+ const localStats = report.localStats?.[provider.family];
752
+ const labelWidth = Math.max(6, ...provider.windows.map((window) => String(window.label ?? "").length));
621
753
  if (isMultiAccount(active, provider)) {
622
754
  if (renderedFamilies.has(provider.family)) continue;
623
755
  renderedFamilies.add(provider.family);
624
- lines.push(...renderAccountRows(active.filter((other) => other.family === provider.family), theme));
756
+ lines.push(...renderAccountRows(active.filter((other) => other.family === provider.family), theme, localStats));
757
+ lines.push(...renderTokenRows(localStats, labelWidth, theme, partialScan));
625
758
  continue;
626
759
  }
627
760
  const name = paint(BRAND[provider.family] ?? "#8B949E", provider.label);
@@ -644,30 +777,31 @@ function renderPanel(report: QuotaReport, theme: Theme): string[] {
644
777
  const color = kind === "transient" || kind === "throttle" ? "warning" : kind === "expired" || kind === "auth" ? "error" : "dim";
645
778
  lines.push(` ${theme.fg(color, glyph)} ${theme.fg("warning", provider.note ?? errorCaption(provider.error))}`);
646
779
  if (isRecoverableError(provider.error) && provider.note) lines.push(` ${theme.fg("warning", provider.note)}`);
647
- continue;
648
- }
649
- if (provider.note) lines.push(` ${theme.fg("warning", `~ ${provider.note}`)}`);
650
- if (provider.windows.length === 0) {
780
+ } else if (provider.windows.length === 0) {
781
+ if (provider.note) lines.push(` ${theme.fg("warning", `~ ${provider.note}`)}`);
651
782
  lines.push(` ${theme.fg("dim", "no rate-limit windows reported")}`);
652
- continue;
653
- }
654
-
655
- const width = Math.max(...provider.windows.map((window) => window.label.length));
656
- for (const window of provider.windows) {
657
- const used = usedOf(window);
658
- const label = theme.fg("dim", window.label.padEnd(width));
659
- if (used === null) {
660
- lines.push(` ${label} ${theme.fg("dim", window.note ?? "no percentage reported")}`);
661
- continue;
783
+ } else {
784
+ if (provider.note) lines.push(` ${theme.fg("warning", `~ ${provider.note}`)}`);
785
+ for (const window of provider.windows) {
786
+ const used = usedOf(window);
787
+ const label = theme.fg("dim", window.label.padEnd(labelWidth));
788
+ if (used === null) {
789
+ lines.push(` ${label} ${theme.fg("dim", window.note ?? "no percentage reported")}`);
790
+ continue;
791
+ }
792
+ const band = bandFor(used);
793
+ const reset = window.resetsInSec === null ? "" : theme.fg("dim", ` · reset in ${humanDuration(window.resetsInSec)}`);
794
+ lines.push(
795
+ ` ${label} ${paint(band.color, band.glyph)} ${paint(band.color, bar(used))} ` +
796
+ `${paint(band.color, `${used.toFixed(used < 10 ? 1 : 0)}%`.padStart(5))} ` +
797
+ `${theme.fg("dim", "used")}${reset}`,
798
+ );
662
799
  }
663
- const band = bandFor(used);
664
- const reset = window.resetsInSec === null ? "" : theme.fg("dim", ` · reset in ${humanDuration(window.resetsInSec)}`);
665
- lines.push(
666
- ` ${label} ${paint(band.color, band.glyph)} ${paint(band.color, bar(used))} ` +
667
- `${paint(band.color, `${used.toFixed(used < 10 ? 1 : 0)}%`.padStart(5))} ` +
668
- `${theme.fg("dim", "used")}${reset}`,
669
- );
670
800
  }
801
+
802
+ // Local token stats survive a failed quota probe: the credential may be
803
+ // rejected while the tokens already burned are still a fact on disk.
804
+ lines.push(...renderTokenRows(localStats, labelWidth, theme, partialScan));
671
805
  }
672
806
  lines.push(theme.fg("dim", `used % · read-only · ${report.generatedAt}`));
673
807
  if (unconfigured.length > 0) {
@@ -686,6 +820,11 @@ export default function quotaPanelExtension(pi: ExtensionAPI): void {
686
820
  let timer: ReturnType<typeof setInterval> | null = null;
687
821
  let agentEndTimer: ReturnType<typeof setTimeout> | null = null;
688
822
  let panelVisible = false;
823
+ /**
824
+ * The panel bare `/usage` opens is a glance, not a pin: it goes away when the
825
+ * next prompt starts or when `/usage` is run again. `/usage panel` pins it.
826
+ */
827
+ let transientPanel = false;
689
828
  /**
690
829
  * Nothing is pinned by default. A box or line above the editor is a persistent
691
830
  * surface, and a quota command should not claim a row until asked.
@@ -933,7 +1072,17 @@ export default function quotaPanelExtension(pi: ExtensionAPI): void {
933
1072
  paintUi(ui);
934
1073
  });
935
1074
 
936
- pi.on("agent_start", () => {
1075
+ function dismissTransientPanel(ui: UiContext): void {
1076
+ panelVisible = false;
1077
+ transientPanel = false;
1078
+ if (!surfaceActive()) stopPolling();
1079
+ paintUi(ui);
1080
+ }
1081
+
1082
+ pi.on("agent_start", (_event, ctx) => {
1083
+ // SAFETY: Pi's agent_start context implements UiContext when hasUI is true.
1084
+ const ui = ctx as unknown as UiContext;
1085
+ if (transientPanel && panelVisible && ui?.hasUI && !disposed) dismissTransientPanel(ui);
937
1086
  if (agentEndTimer) {
938
1087
  clearTimeout(agentEndTimer);
939
1088
  agentEndTimer = null;
@@ -1002,6 +1151,7 @@ export default function quotaPanelExtension(pi: ExtensionAPI): void {
1002
1151
  return;
1003
1152
  case "panel":
1004
1153
  panelVisible = true;
1154
+ transientPanel = false;
1005
1155
  lineVisible = false;
1006
1156
  boxVisible = false;
1007
1157
  explicitBox = false;
@@ -1048,8 +1198,15 @@ export default function quotaPanelExtension(pi: ExtensionAPI): void {
1048
1198
  break;
1049
1199
  }
1050
1200
 
1201
+ // Bare /usage again while its glance is open closes it.
1202
+ if (panelVisible && transientPanel) {
1203
+ dismissTransientPanel(ctx);
1204
+ return;
1205
+ }
1206
+
1051
1207
  // Bare /usage: one on-demand refresh, show the panel, report the percentages.
1052
1208
  panelVisible = true;
1209
+ transientPanel = true;
1053
1210
  lineVisible = false;
1054
1211
  boxVisible = false;
1055
1212
  await refresh(ctx, true);
@@ -0,0 +1,33 @@
1
+ import QtQuick
2
+ import Quickshell.Io
3
+
4
+ // One usage record, read straight off its data file. Carried over unchanged
5
+ // from Omarchy's built-in Agents plugin (plugins/agents/Agent.qml, MIT): a
6
+ // record that appears in the usage directory is a provider, whoever wrote it.
7
+ Item {
8
+ id: root
9
+ visible: false
10
+
11
+ property string agentId: ""
12
+ property string path: ""
13
+ property var record: null
14
+
15
+ FileView {
16
+ path: root.path
17
+ watchChanges: true
18
+ printErrors: false
19
+ onFileChanged: reload()
20
+ onLoaded: root.parse(text())
21
+ onLoadFailed: root.record = null
22
+ }
23
+
24
+ function parse(content) {
25
+ try {
26
+ var parsed = JSON.parse(String(content || ""))
27
+ root.record = parsed && typeof parsed === "object" ? parsed : null
28
+ } catch (e) {
29
+ console.warn("cad0p.pi-usage", "Ignoring bad usage record", root.path, e)
30
+ root.record = null
31
+ }
32
+ }
33
+ }
@@ -0,0 +1,333 @@
1
+ import QtQuick
2
+ import Quickshell
3
+ import Quickshell.Io
4
+ import "UsageLogic.js" as Logic
5
+
6
+ // The data side of the AI Usage panel: discover every usage record in
7
+ // Omarchy's usage directory (pi-usage's pi-*.json and Omarchy's own
8
+ // claude/codex/fireworks records alike), watch them, keep the per-section
9
+ // expansion state, and trigger the publishers on demand.
10
+ //
11
+ // Discovery and the per-record watcher follow Omarchy's built-in Agents plugin
12
+ // (plugins/agents/Main.qml, MIT, by Omarchy).
13
+ Item {
14
+ id: root
15
+ visible: false
16
+
17
+ property var settings: ({})
18
+ // Live bar, used only to see whether the built-in Agents widget is also on
19
+ // it (see collectorsWanted).
20
+ property QtObject bar: null
21
+ // file:// URL of the plugin directory, so the panel can find the pi-usage
22
+ // CLI of the package it ships in.
23
+ property string pluginDir: ""
24
+
25
+ readonly property string home: Quickshell.env("HOME") || ""
26
+ readonly property string stateHome: Quickshell.env("XDG_STATE_HOME") || (home + "/.local/state")
27
+ // PI_QUOTA_OMARCHY_DIR is the writer's own override; honoring it here keeps
28
+ // the two ends pointed at the same directory.
29
+ readonly property string usageDir: Quickshell.env("PI_QUOTA_OMARCHY_DIR") || (stateHome + "/omarchy/agents/usage")
30
+ readonly property string uiStateDir: Logic.uiStateDirectory(stateHome, Quickshell.env("PI_USAGE_PANEL_STATE_DIR"))
31
+ readonly property string uiStatePath: uiStateDir + "/cad0p.pi-usage.json"
32
+
33
+ function setting(name, fallback) {
34
+ var value = settings ? settings[name] : undefined
35
+ return value === undefined || value === null ? fallback : value
36
+ }
37
+
38
+ // ------------------------------------------------------------- discovery
39
+
40
+ property var recordIds: []
41
+ property var agents: []
42
+ property int dataRevision: 0
43
+
44
+ Process {
45
+ id: listProcess
46
+ running: false
47
+ command: ["find", root.usageDir, "-maxdepth", "1", "-name", "*.json", "-printf", "%f\n"]
48
+
49
+ stdout: StdioCollector {
50
+ waitForEnd: true
51
+ onStreamFinished: root.applyListing(text)
52
+ }
53
+ }
54
+
55
+ // New records are found by listing the directory again on a slow timer and
56
+ // after every publisher run; each record's content is watched by its own
57
+ // FileView, which is what actually moves the numbers.
58
+ Timer {
59
+ interval: 60000
60
+ running: true
61
+ repeat: true
62
+ triggeredOnStart: true
63
+ onTriggered: root.rescan()
64
+ }
65
+
66
+ function rescan() {
67
+ if (!listProcess.running) listProcess.running = true
68
+ }
69
+
70
+ function applyListing(output) {
71
+ var ids = []
72
+ var lines = String(output || "").split("\n")
73
+ for (var i = 0; i < lines.length; i++) {
74
+ var name = lines[i].trim()
75
+ if (name.slice(-5) === ".json") ids.push(name.slice(0, -5))
76
+ }
77
+ ids.sort()
78
+ if (JSON.stringify(ids) !== JSON.stringify(recordIds)) recordIds = ids
79
+ }
80
+
81
+ Instantiator {
82
+ id: agentInstantiator
83
+ model: root.recordIds
84
+
85
+ delegate: Agent {
86
+ required property var modelData
87
+ agentId: modelData
88
+ path: root.usageDir + "/" + modelData + ".json"
89
+ onRecordChanged: root.recordsChanged()
90
+ }
91
+
92
+ onObjectAdded: (index, object) => root.rebuildAgents()
93
+ onObjectRemoved: (index, object) => root.rebuildAgents()
94
+ }
95
+
96
+ function rebuildAgents() {
97
+ var result = []
98
+ for (var i = 0; i < agentInstantiator.count; i++) {
99
+ var agent = agentInstantiator.objectAt(i)
100
+ if (agent) result.push(agent)
101
+ }
102
+ agents = result
103
+ recordsChanged()
104
+ }
105
+
106
+ function recordsChanged() {
107
+ dataRevision++
108
+ scheduleLimitsRetry()
109
+ }
110
+
111
+ property var retryAgentIds: []
112
+ property string pendingUpdateKind: ""
113
+ property bool pendingPublisher: false
114
+
115
+ Timer {
116
+ id: limitsRetry
117
+ interval: 30000
118
+ repeat: false
119
+ // A retry is a collector run like any other: the same switch that gates the
120
+ // interval timer gates this one, even if it flips while the retry waits.
121
+ onTriggered: if (root.collectorsWanted) root.runCollectors("limits", root.retryAgentIds)
122
+ }
123
+
124
+ function scheduleLimitsRetry() {
125
+ var records = []
126
+ for (var i = 0; i < agents.length; i++) {
127
+ var record = agents[i] ? agents[i].record : null
128
+ if (record) records.push(record)
129
+ }
130
+ retryAgentIds = Logic.advisedRetryIds(records, settings, collectorsWanted)
131
+ if (retryAgentIds.length > 0) limitsRetry.restart()
132
+ else limitsRetry.stop()
133
+ }
134
+
135
+ // Turning the collectors off (or putting the built-in widget back on the
136
+ // bar) cancels a retry that is already waiting.
137
+ onCollectorsWantedChanged: scheduleLimitsRetry()
138
+
139
+ readonly property var providers: {
140
+ var rev = dataRevision
141
+ var records = []
142
+ for (var i = 0; i < agents.length; i++) if (agents[i] && agents[i].record) records.push(agents[i].record)
143
+ return Logic.visibleProviders(records, settings)
144
+ }
145
+
146
+ readonly property string newestUpdate: {
147
+ var rev = dataRevision
148
+ var best = ""
149
+ for (var i = 0; i < agents.length; i++) {
150
+ var record = agents[i] ? agents[i].record : null
151
+ var at = record && record.updatedAt ? String(record.updatedAt) : ""
152
+ if (at !== "" && (best === "" || new Date(at).getTime() > new Date(best).getTime())) best = at
153
+ }
154
+ return best
155
+ }
156
+
157
+ // ------------------------------------------------------------ expansion
158
+
159
+ property var expandedOverrides: ({})
160
+
161
+ FileView {
162
+ id: uiStateFile
163
+ path: root.uiStatePath
164
+ watchChanges: false
165
+ atomicWrites: true
166
+ printErrors: false
167
+ onLoaded: root.expandedOverrides = Logic.parseUiState(text()).expanded
168
+ }
169
+
170
+ Process {
171
+ id: uiStateMkdir
172
+ running: false
173
+ command: ["mkdir", "-p", root.uiStateDir]
174
+ onExited: function(exitCode) {
175
+ if (exitCode === 0) uiStateFile.setText(Logic.serializeUiState({ expanded: root.expandedOverrides }))
176
+ }
177
+ }
178
+
179
+ Timer {
180
+ id: uiStateSave
181
+ interval: 400
182
+ repeat: false
183
+ onTriggered: if (!uiStateMkdir.running) uiStateMkdir.running = true
184
+ }
185
+
186
+ function setExpanded(id, expanded) {
187
+ expandedOverrides = Logic.withExpanded(expandedOverrides, String(id), expanded)
188
+ uiStateSave.restart()
189
+ }
190
+
191
+ // --------------------------------------------------------------- refresh
192
+ //
193
+ // pi-usage's records already have two writers: the systemd user timer
194
+ // (./install.sh --omarchy-timer) and the Pi extension on every quota
195
+ // refresh. A third periodic writer would only multiply the calls to the
196
+ // usage endpoints, so the panel never schedules piquota itself; it runs it
197
+ // when you ask for a refresh.
198
+ //
199
+ // Omarchy's own collectors are different: the built-in Agents widget is what
200
+ // runs them. "Auto" runs them here only while that widget is not on the bar,
201
+ // so replacing it keeps claude/codex/fireworks fresh and keeping both never
202
+ // doubles the work.
203
+
204
+ property string collectorsSetting: String(setting("omarchyCollectors", "Auto"))
205
+ property int refreshIntervalSec: Math.max(30, Number(setting("refreshIntervalSec", 900)))
206
+ property bool builtinOnBar: false
207
+ readonly property bool collectorsWanted: Logic.collectorsWanted(collectorsSetting, builtinOnBar)
208
+ readonly property bool refreshing: collectorProcess.running || publisherProcess.running
209
+
210
+ function updateBuiltinOnBar() {
211
+ var widgets = bar && typeof bar.moduleWidgets === "function" ? bar.moduleWidgets("omarchy.agents") : []
212
+ builtinOnBar = !!widgets && widgets.length > 0
213
+ }
214
+
215
+ Timer {
216
+ interval: root.refreshIntervalSec * 1000
217
+ running: root.collectorsSetting !== "Off"
218
+ repeat: true
219
+ triggeredOnStart: true
220
+ onTriggered: {
221
+ root.updateBuiltinOnBar()
222
+ if (root.collectorsWanted) root.runCollectors("normal")
223
+ }
224
+ }
225
+
226
+ Process {
227
+ id: collectorProcess
228
+ running: false
229
+ onExited: {
230
+ root.rescan()
231
+ if (root.pendingUpdateKind !== "") {
232
+ var kind = root.pendingUpdateKind
233
+ root.pendingUpdateKind = ""
234
+ root.runCollectors(kind)
235
+ }
236
+ }
237
+ stderr: StdioCollector {
238
+ waitForEnd: true
239
+ onStreamFinished: if (text.trim() !== "") console.warn("cad0p.pi-usage", text.trim())
240
+ }
241
+ }
242
+
243
+ function collectorCommand(kind, agentIds) {
244
+ var args = []
245
+ if (kind === "force") args.push("--force")
246
+ if (kind === "limits") args.push("--limits-only")
247
+ var providerSettings = settings && settings.providers ? settings.providers : {}
248
+ for (var id in providerSettings) {
249
+ if (id.indexOf("pi-") === 0) continue
250
+ if (providerSettings[id] && providerSettings[id].enabled === false) args.push("--except", id)
251
+ }
252
+ if (agentIds) {
253
+ for (var i = 0; i < agentIds.length; i++) args.push(agentIds[i])
254
+ }
255
+ // Absent on a machine without Omarchy's collectors: nothing to run.
256
+ return ["bash", "-c", "command -v omarchy-agent-usage-update >/dev/null 2>&1 || exit 0; exec omarchy-agent-usage-update \"$@\"", "omarchy-agent-usage-update"].concat(args)
257
+ }
258
+
259
+ function runCollectors(kind, agentIds) {
260
+ // The one choke point every collector run passes through: the interval
261
+ // timer, an explicit refresh and the retryAdvised retry.
262
+ if (!collectorsWanted) return
263
+ if (collectorProcess.running) {
264
+ pendingUpdateKind = Logic.queueUpdateKind(pendingUpdateKind, kind)
265
+ return
266
+ }
267
+ collectorProcess.command = collectorCommand(kind, agentIds)
268
+ collectorProcess.running = true
269
+ }
270
+
271
+ Process {
272
+ id: publisherProcess
273
+ running: false
274
+ onExited: {
275
+ root.rescan()
276
+ if (root.pendingPublisher) {
277
+ root.pendingPublisher = false
278
+ root.runPublisher()
279
+ }
280
+ }
281
+ stderr: StdioCollector {
282
+ waitForEnd: true
283
+ onStreamFinished: if (text.trim() !== "") console.warn("cad0p.pi-usage", text.trim())
284
+ }
285
+ }
286
+
287
+ function localPath(url) {
288
+ var text = String(url || "")
289
+ if (text.indexOf("file://") === 0) text = decodeURIComponent(text.slice(7))
290
+ return text.replace(/\/+$/, "")
291
+ }
292
+
293
+ // The user's timer unit first, so a manual refresh runs with exactly the
294
+ // environment the periodic one does (PI_QUOTA_CLAUDE_SOURCE and friends);
295
+ // then the CLI of the package this plugin ships in; then piquota on PATH.
296
+ function publisherCommand() {
297
+ var script = [
298
+ "if command -v systemctl >/dev/null 2>&1 && systemctl --user cat piquota-omarchy.service >/dev/null 2>&1; then",
299
+ " exec systemctl --user start piquota-omarchy.service",
300
+ "fi",
301
+ "cli=\"$(readlink -f \"$1\" 2>/dev/null)/../../bin/piquota.js\"",
302
+ "if [ -f \"$cli\" ] && command -v node >/dev/null 2>&1; then exec node \"$cli\" omarchy; fi",
303
+ "if command -v piquota >/dev/null 2>&1; then exec piquota omarchy; fi",
304
+ "if [ -x \"$HOME/.local/bin/piquota\" ]; then exec \"$HOME/.local/bin/piquota\" omarchy; fi",
305
+ "exit 0"
306
+ ].join("\n")
307
+ return ["bash", "-c", script, "pi-usage-refresh", localPath(pluginDir)]
308
+ }
309
+
310
+ function runPublisher() {
311
+ if (publisherProcess.running) {
312
+ pendingPublisher = true
313
+ return
314
+ }
315
+ publisherProcess.command = publisherCommand()
316
+ publisherProcess.running = true
317
+ }
318
+
319
+ // r, middle click, IPC refresh: everything, now.
320
+ function refreshAll() {
321
+ updateBuiltinOnBar()
322
+ runPublisher()
323
+ if (collectorsWanted) runCollectors("force")
324
+ }
325
+
326
+ // Opening the panel wants the numbers that go stale on the wire; Omarchy's
327
+ // collectors reuse their recent transcript scans in this mode.
328
+ function refreshOnOpen() {
329
+ updateBuiltinOnBar()
330
+ rescan()
331
+ if (collectorsWanted) runCollectors("limits")
332
+ }
333
+ }