aboutsummaryrefslogtreecommitdiff
path: root/menubar
diff options
context:
space:
mode:
authorIgor Soarez <igor@soarez.org>2026-11-01 10:22:01 +0200
committerIgor Soarez <igor@soarez.org>2026-11-01 10:22:01 +0200
commit8146d1db419e8bdacd75920da6f3761abf724853 (patch)
tree921a1d609d96bb2129f7ac2d80635fd3c0c41691 /menubar
parent959f0d46cac1a09d0821da599d8dff54d1147892 (diff)
show what pi agents cost today, this week and this month, counting forked replies once
Diffstat (limited to 'menubar')
-rw-r--r--menubar/main.swift10
-rw-r--r--menubar/model.swift47
-rw-r--r--menubar/panel.swift11
3 files changed, 63 insertions, 5 deletions
diff --git a/menubar/main.swift b/menubar/main.swift
index 82931ab..77e2b5e 100644
--- a/menubar/main.swift
+++ b/menubar/main.swift
@@ -284,6 +284,14 @@ final class Menubar: NSObject, NSApplicationDelegate, NSMenuDelegate {
menu.removeAllItems()
rows = fetchRows()
restoreOffer = fetchRestoreOffer()
+ let spend = fetchSpend()
+ if !spend.isEmpty {
+ let item = NSMenuItem(title: spend.line, action: nil, keyEquivalent: "")
+ item.isEnabled = false
+ item.toolTip = "What pi agents spent, closed ones included; days start at 00:00 UTC"
+ menu.addItem(item)
+ menu.addItem(.separator())
+ }
if let offer = restoreOffer {
let item = NSMenuItem(title: restoring ? "Restoring tmux Sessions…" : "↺ \(offer.title)",
action: nil, keyEquivalent: "")
@@ -463,6 +471,8 @@ if arguments.contains("--selftest") {
if arguments.contains("--print") {
let rows = fetchRows()
print("title: \(plainTitle(for: rows))")
+ let spend = fetchSpend()
+ if !spend.isEmpty { print("$ \(spend.line)") }
let outdated = fetchOutdated()
if !outdated.isEmpty {
print("⟳ \(outdated.title)\t\(outdated.detail)\(outdated.canReload ? "" : "\t(disabled)")")
diff --git a/menubar/model.swift b/menubar/model.swift
index 3ed1a7e..d0bbd7e 100644
--- a/menubar/model.swift
+++ b/menubar/model.swift
@@ -97,6 +97,8 @@ struct Row: Equatable {
var draft: String = ""
/// The user looks at this pane: its window is active in an attached session.
var attended: Bool = false
+ /// What its session spent today (UTC), in dollars (`@dingger_cost`).
+ var cost: Double = 0
var location: String { "\(session) · \(window)" }
/// A draft left behind: where the user looks, they may be typing it.
@@ -116,10 +118,11 @@ struct Row: Equatable {
var markedLabel: String { (importance == .high ? "! " : "") + (forgottenDraft ? "✎ " : "") + label }
/// Dim second line: where it runs, which harness, whether it is low,
- /// and how long it waits.
+ /// what it spent today, and how long it waits.
var subtitle: String {
var parts = [location, agent]
if importance == .low { parts.append("low") }
+ if cost >= 0.005 { parts.append(dollars(cost)) }
if waiting, since != Int.max {
parts.append("\(status.rawValue) \(elapsed(sinceMs: since))")
}
@@ -127,6 +130,39 @@ struct Row: Equatable {
}
}
+/// "$0.42", "$19.85"; whole dollars from $100: "$1,967".
+func dollars(_ amount: Double) -> String {
+ if amount < 99.995 { return String(format: "$%.2f", amount) }
+ let formatter = NumberFormatter()
+ formatter.numberStyle = .decimal
+ formatter.locale = Locale(identifier: "en_US")
+ formatter.maximumFractionDigits = 0
+ return "$" + (formatter.string(from: NSNumber(value: amount.rounded())) ?? String(Int(amount.rounded())))
+}
+
+/// What pi agents spent today, this week, and this month (UTC), closed
+/// agents included, as the daemon counts it (`@dingger_cost_today`, ...).
+struct Spend: Equatable {
+ let today: Double
+ let week: Double
+ let month: Double
+
+ var isEmpty: Bool { today < 0.005 && week < 0.005 && month < 0.005 }
+
+ /// "$8.86 today · $457 this week · $8.86 this month"
+ var line: String {
+ "\(dollars(today)) today · \(dollars(week)) this week · \(dollars(month)) this month"
+ }
+}
+
+func fetchSpend() -> Spend {
+ let amount = { (name: String) in
+ Double(tmux(["show-options", "-gv", name])?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "") ?? 0
+ }
+ return Spend(today: amount("@dingger_cost_today"), week: amount("@dingger_cost_week"),
+ month: amount("@dingger_cost_month"))
+}
+
/// Compact age: "now", "4m", "2h", "3d".
func elapsed(sinceMs: Int) -> String {
let seconds = max(0, Int(Date().timeIntervalSince1970) - sinceMs / 1000)
@@ -290,26 +326,27 @@ func fetchRows() -> [Row] {
let format = [
"#{pane_id}", "#{@agent}", "#{@agent_state}", "#{@dingger_since}", "#{@agent_name}",
"#{session_name}", "#{window_name}", "#{window_panes}", "#{pane_index}", "#{pane_current_path}",
- "#{@dingger_importance}", "#{window_active}", "#{session_attached}", "#{@dingger_draft}",
+ "#{@dingger_importance}", "#{window_active}", "#{session_attached}", "#{@dingger_draft}", "#{@dingger_cost}",
].joined(separator: "\t")
guard let out = tmux(["list-panes", "-a", "-F", format]) else { return [] }
let lines = out.split(separator: "\n").map {
$0.split(separator: "\t", omittingEmptySubsequences: false).map(String.init)
}
// Looked at in any of its sessions, as the daemon counts it.
- let attended = Set(lines.filter { $0.count >= 14 && $0[11] == "1" && $0[12] != "0" }.map { $0[0] })
+ let attended = Set(lines.filter { $0.count >= 15 && $0[11] == "1" && $0[12] != "0" }.map { $0[0] })
// A pane in several sessions (a session group, a linked window) is
// listed once, under its first session, as the daemon counts it.
var listed = Set<String>()
let rows: [Row] = lines.compactMap { f in
- guard f.count >= 14, !f[1].isEmpty, listed.insert(f[0]).inserted else { return nil }
+ guard f.count >= 15, !f[1].isEmpty, listed.insert(f[0]).inserted else { return nil }
let status = Status(rawValue: f[2]) ?? .unknown
guard status != .unknown else { return nil }
let window = (Int(f[7]) ?? 1) > 1 ? "\(f[6]).\(f[8])" : f[6]
return Row(paneId: f[0], agent: f[1], name: f[4], status: status,
session: f[5], window: window, since: Int(f[3]) ?? Int.max,
path: f[9].hasPrefix(home) ? "~" + f[9].dropFirst(home.count) : f[9],
- importance: Importance(option: f[10]), draft: f[13], attended: attended.contains(f[0]))
+ importance: Importance(option: f[10]), draft: f[13], attended: attended.contains(f[0]),
+ cost: Double(f[14]) ?? 0)
}
return rows.sorted(by: listedBefore)
}
diff --git a/menubar/panel.swift b/menubar/panel.swift
index 5362d32..21b4644 100644
--- a/menubar/panel.swift
+++ b/menubar/panel.swift
@@ -718,6 +718,17 @@ func panelSelftest() -> [String] {
check(full.plainText.hasSuffix("[Show less ▴]") && !compact.plainText.contains("Show"),
"toggle offered only when allowed, labelled by layout")
+ // Spend.
+ check(dollars(0.42) == "$0.42" && dollars(19.849) == "$19.85" && dollars(99.994) == "$99.99"
+ && dollars(99.996) == "$100" && dollars(1967.14) == "$1,967", "spend: cents below $100, then whole dollars")
+ var spender = row("s", .done)
+ spender.cost = 19.85
+ check(spender.subtitle.hasPrefix("s · s · pi · $19.85 · done "), "spend: today's in the subtitle, before the wait")
+ spender.cost = 0.004
+ check(!spender.subtitle.contains("$"), "spend: nothing spent, nothing shown")
+ check(Spend(today: 8.86, week: 456.87, month: 8.86).line == "$8.86 today · $457 this week · $8.86 this month"
+ && Spend(today: 0, week: 0, month: 0).isEmpty, "spend: the menu's line")
+
// Forgotten drafts: a draft the user is not looking at.
var drafty = [row("d", .done), row("w", .working), row("i1", .idle), row("i2", .idle), row("i3", .idle)]
drafty[0].draft = "x"; drafty[1].draft = "x"; drafty[3].draft = "x"; drafty[4].draft = "x"