diff options
| author | Igor Soarez <igor@soarez.org> | 2026-11-01 10:22:01 +0200 |
|---|---|---|
| committer | Igor Soarez <igor@soarez.org> | 2026-11-01 10:22:01 +0200 |
| commit | 8146d1db419e8bdacd75920da6f3761abf724853 (patch) | |
| tree | 921a1d609d96bb2129f7ac2d80635fd3c0c41691 /menubar | |
| parent | 959f0d46cac1a09d0821da599d8dff54d1147892 (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.swift | 10 | ||||
| -rw-r--r-- | menubar/model.swift | 47 | ||||
| -rw-r--r-- | menubar/panel.swift | 11 |
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" |
