aboutsummaryrefslogtreecommitdiff
path: root/menubar
diff options
context:
space:
mode:
authorIgor Soarez <igor@soarez.org>2026-11-03 13:05:57 +0200
committerIgor Soarez <igor@soarez.org>2026-11-03 13:05:57 +0200
commitbd1eb9ef9b3b89ff5825eb8bf9b2e978167c9ad0 (patch)
treeac3ea2a1f0f2243c692ba5e5fab251df6c9f077b /menubar
parent8146d1db419e8bdacd75920da6f3761abf724853 (diff)
show each pi agent's context use: a meter in the panel and search, text in the menu
Diffstat (limited to 'menubar')
-rw-r--r--menubar/model.swift44
-rw-r--r--menubar/panel.swift93
-rw-r--r--menubar/search.swift2
3 files changed, 122 insertions, 17 deletions
diff --git a/menubar/model.swift b/menubar/model.swift
index d0bbd7e..4117ba5 100644
--- a/menubar/model.swift
+++ b/menubar/model.swift
@@ -30,6 +30,9 @@ let mochaText = mocha(205, 214, 244)
let mochaSubtext = mocha(166, 173, 200)
let mochaOverlay = mocha(108, 112, 134)
let mochaBlue = mocha(137, 180, 250)
+let mochaRed = mocha(243, 139, 168)
+let mochaYellow = mocha(249, 226, 175)
+let mochaSurface = mocha(69, 71, 90)
// MARK: herdr's status vocabulary
@@ -99,6 +102,9 @@ struct Row: Equatable {
var attended: Bool = false
/// What its session spent today (UTC), in dollars (`@dingger_cost`).
var cost: Double = 0
+ /// How full its context window is, in percent (`@dingger_context`), as
+ /// pi's footer shows it; nil when unknown.
+ var context: Double? = nil
var location: String { "\(session) · \(window)" }
/// A draft left behind: where the user looks, they may be typing it.
@@ -117,17 +123,41 @@ struct Row: Equatable {
/// forgotten draft.
var markedLabel: String { (importance == .high ? "! " : "") + (forgottenDraft ? "✎ " : "") + label }
- /// Dim second line: where it runs, which harness, whether it is low,
- /// what it spent today, and how long it waits.
+ /// The menu's dim second line: where it runs, which harness, whether
+ /// it is low, how long it waits, what it spent today, and how full its
+ /// context is.
var subtitle: String {
+ var parts = [place]
+ if cost >= 0.005 { parts.append(dollars(cost)) }
+ if let context { parts.append(contextText(context)) }
+ return parts.joined(separator: " · ")
+ }
+
+ /// The panel's second line, before its cost and context columns.
+ var place: 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))")
}
return parts.joined(separator: " · ")
}
+
+ /// The panel's row as text: its columns, then cost and context.
+ var panelText: String {
+ let right = [cost >= 0.005 ? dollars(cost) : nil, context.map(contextText)].compactMap { $0 }
+ return "\(status.symbol) \(status.rawValue)\t\(markedLabel)\t\(place)"
+ + (right.isEmpty ? "" : "\t" + right.joined(separator: " "))
+ }
+}
+
+/// "10%".
+func contextText(_ value: Double) -> String { "\(Int(value.rounded()))%" }
+
+/// The context meter's color: as pi's footer, yellow above 70% and red
+/// above 90%; nil below.
+func contextWarning(_ value: Double) -> NSColor? {
+ value > 90 ? mochaRed : value > 70 ? mochaYellow : nil
}
/// "$0.42", "$19.85"; whole dollars from $100: "$1,967".
@@ -326,19 +356,19 @@ 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_cost}",
+ "#{@dingger_importance}", "#{window_active}", "#{session_attached}", "#{@dingger_draft}", "#{@dingger_cost}", "#{@dingger_context}",
].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 >= 15 && $0[11] == "1" && $0[12] != "0" }.map { $0[0] })
+ let attended = Set(lines.filter { $0.count >= 16 && $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 >= 15, !f[1].isEmpty, listed.insert(f[0]).inserted else { return nil }
+ guard f.count >= 16, !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]
@@ -346,7 +376,7 @@ func fetchRows() -> [Row] {
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]),
- cost: Double(f[14]) ?? 0)
+ cost: Double(f[14]) ?? 0, context: Double(f[15]))
}
return rows.sorted(by: listedBefore)
}
diff --git a/menubar/panel.swift b/menubar/panel.swift
index 21b4644..5347ceb 100644
--- a/menubar/panel.swift
+++ b/menubar/panel.swift
@@ -163,7 +163,7 @@ struct PanelContent {
}
for (index, row) in rows.enumerated() {
if index == separatorBefore { lines.append("---") }
- lines.append("\(row.status.symbol) \(row.status.rawValue)\t\(row.markedLabel)\t\(row.subtitle)")
+ lines.append(row.panelText)
}
if overflow > 0 { lines.append("+\(overflow) more waiting") }
if showsSummary {
@@ -291,8 +291,10 @@ final class ActionMenuItem: NSMenuItem {
@objc private func run() { handler() }
}
-/// One agent row: colored symbol and name, then a dim subtitle. Right-click
-/// offers "Mark as unread" for an idle agent.
+/// One agent row: colored symbol and name, then a dim line with where it
+/// runs, and at its right edge today's cost and a context meter, each in a
+/// fixed column so they line up down the list. Right-click offers "Mark as
+/// unread" for an idle agent.
final class PanelRowView: ClickView {
private let selected: Bool
private let contextMenu: NSMenu?
@@ -311,10 +313,35 @@ final class PanelRowView: ClickView {
self.onClick = { onClick(row.paneId) }
let title = singleLine(NSTextField(
labelWithAttributedString: titleText ?? rowTitle(row, primary: mochaText, secondary: mochaSubtext)))
- let subtitleLabel = NSTextField(labelWithString: row.subtitle)
- subtitleLabel.font = .systemFont(ofSize: 11)
- subtitleLabel.textColor = mochaSubtext
- let subtitle = singleLine(subtitleLabel)
+ let placeLabel = NSTextField(labelWithString: row.place)
+ placeLabel.font = .systemFont(ofSize: 11)
+ placeLabel.textColor = mochaSubtext
+ let place = singleLine(placeLabel)
+ let cost = NSTextField(labelWithString: row.cost >= 0.005 ? dollars(row.cost) : "")
+ cost.font = .monospacedDigitSystemFont(ofSize: 11, weight: .regular)
+ cost.textColor = mochaSubtext
+ cost.alignment = .right
+ let meter = ContextMeter(percent: row.context)
+ // The meter at the right edge, the cost before it; the place takes
+ // what is left and truncates.
+ let subtitle = NSView()
+ for view in [place, cost, meter] {
+ view.translatesAutoresizingMaskIntoConstraints = false
+ subtitle.addSubview(view)
+ }
+ NSLayoutConstraint.activate([
+ meter.trailingAnchor.constraint(equalTo: subtitle.trailingAnchor),
+ meter.centerYAnchor.constraint(equalTo: subtitle.centerYAnchor),
+ meter.widthAnchor.constraint(equalToConstant: ContextMeter.size.width),
+ meter.heightAnchor.constraint(equalToConstant: ContextMeter.size.height),
+ cost.trailingAnchor.constraint(equalTo: meter.leadingAnchor, constant: -6),
+ cost.widthAnchor.constraint(equalToConstant: 52),
+ cost.firstBaselineAnchor.constraint(equalTo: place.firstBaselineAnchor),
+ place.leadingAnchor.constraint(equalTo: subtitle.leadingAnchor),
+ place.trailingAnchor.constraint(lessThanOrEqualTo: cost.leadingAnchor, constant: -6),
+ place.topAnchor.constraint(equalTo: subtitle.topAnchor),
+ place.bottomAnchor.constraint(equalTo: subtitle.bottomAnchor),
+ ])
let stack = NSStackView(views: [title, subtitle])
stack.orientation = .vertical
stack.alignment = .leading
@@ -327,7 +354,7 @@ final class PanelRowView: ClickView {
stack.topAnchor.constraint(equalTo: topAnchor, constant: 5),
stack.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -5),
title.widthAnchor.constraint(lessThanOrEqualTo: stack.widthAnchor),
- subtitle.widthAnchor.constraint(lessThanOrEqualTo: stack.widthAnchor),
+ subtitle.widthAnchor.constraint(equalTo: stack.widthAnchor),
])
toolTip = row.name.isEmpty ? nil : row.name
}
@@ -343,6 +370,38 @@ final class PanelRowView: ClickView {
}
}
+/// How full an agent's context window is: a small bar, gray, then yellow
+/// above 70% and red above 90% (as pi's footer); nothing when unknown.
+final class ContextMeter: NSView {
+ static let size = NSSize(width: 44, height: 5)
+ let percent: Double?
+
+ init(percent: Double?) {
+ self.percent = percent
+ super.init(frame: NSRect(origin: .zero, size: Self.size))
+ translatesAutoresizingMaskIntoConstraints = false
+ toolTip = percent.map { "\(contextText($0)) of context" }
+ }
+
+ required init?(coder: NSCoder) { fatalError("not used") }
+
+ /// The filled part: at least a dot, at most the whole bar.
+ static func fill(_ percent: Double, width: CGFloat) -> CGFloat {
+ max(size.height, min(width, width * CGFloat(percent) / 100))
+ }
+
+ override func draw(_ dirtyRect: NSRect) {
+ guard let percent else { return }
+ let radius = bounds.height / 2
+ mochaSurface.setFill()
+ NSBezierPath(roundedRect: bounds, xRadius: radius, yRadius: radius).fill()
+ (contextWarning(percent) ?? mochaOverlay.blended(withFraction: 0.35, of: mochaSubtext) ?? mochaOverlay).setFill()
+ var filled = bounds
+ filled.size.width = Self.fill(percent, width: bounds.width)
+ NSBezierPath(roundedRect: filled, xRadius: radius, yRadius: radius).fill()
+ }
+}
+
/// The restore offer: "↺ Restore 9 sessions · 22 agents", what it was.
private final class OfferView: ClickView {
init(title: String, detail: String, onClick: @escaping () -> Void) {
@@ -718,12 +777,28 @@ func panelSelftest() -> [String] {
check(full.plainText.hasSuffix("[Show less ▴]") && !compact.plainText.contains("Show"),
"toggle offered only when allowed, labelled by layout")
+ // Context use.
+ var filling = row("f", .idle)
+ filling.cost = 3.12
+ filling.context = 78.4
+ check(filling.subtitle == "s · f · pi · $3.12 · 78%", "context: the menu's text, after the cost")
+ check(filling.place == "s · f · pi" && filling.panelText.hasSuffix("\ts · f · pi\t$3.12 78%"),
+ "context: the panel's columns")
+ check(contextWarning(70) == nil && contextWarning(70.5) == mochaYellow && contextWarning(90.5) == mochaRed,
+ "context: pi's colors, above 70% and 90%")
+ check(ContextMeter.fill(0, width: 44) == 5 && ContextMeter.fill(50, width: 44) == 22
+ && ContextMeter.fill(150, width: 44) == 44, "context: the bar's fill")
+ filling.context = nil
+ filling.cost = 0
+ check(filling.subtitle == "s · f · pi" && !filling.panelText.contains("%"), "context: unknown, nothing")
+
// 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")
+ check(spender.subtitle.hasPrefix("s · s · pi · done ") && spender.subtitle.hasSuffix(" · $19.85"),
+ "spend: today's in the menu's subtitle, after 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"
diff --git a/menubar/search.swift b/menubar/search.swift
index ed9adaa..d1e745e 100644
--- a/menubar/search.swift
+++ b/menubar/search.swift
@@ -180,7 +180,7 @@ struct SearchState: Equatable {
for match in visible {
let row = match.row
let marker = row.paneId == selected ? "›" : " "
- lines.append("\(marker) \(row.status.symbol) \(row.status.rawValue)\t\(row.markedLabel)\t\(row.subtitle)")
+ lines.append("\(marker) \(row.panelText)")
}
lines.append(footer)
return lines.joined(separator: "\n")