aboutsummaryrefslogtreecommitdiff
path: root/menubar/model.swift
diff options
context:
space:
mode:
Diffstat (limited to 'menubar/model.swift')
-rw-r--r--menubar/model.swift200
1 files changed, 200 insertions, 0 deletions
diff --git a/menubar/model.swift b/menubar/model.swift
new file mode 100644
index 0000000..b4a54a0
--- /dev/null
+++ b/menubar/model.swift
@@ -0,0 +1,200 @@
+// Shared model for the menu bar and the floating panel: herdr's palette and
+// status vocabulary, agent rows read from tmux, and the focus action.
+
+import AppKit
+
+// MARK: herdr's palette (catppuccin mocha on dark, latte on light)
+
+func herdrColor(dark: (CGFloat, CGFloat, CGFloat), light: (CGFloat, CGFloat, CGFloat)) -> NSColor {
+ NSColor(name: nil) { appearance in
+ let isDark = appearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua
+ let (r, g, b) = isDark ? dark : light
+ return NSColor(srgbRed: r / 255, green: g / 255, blue: b / 255, alpha: 1)
+ }
+}
+
+let herdrRed = herdrColor(dark: (243, 139, 168), light: (210, 15, 57))
+let herdrTeal = herdrColor(dark: (148, 226, 213), light: (23, 146, 153))
+let herdrYellow = herdrColor(dark: (249, 226, 175), light: (223, 142, 29))
+let herdrGreen = herdrColor(dark: (166, 227, 161), light: (64, 160, 43))
+let herdrGray = herdrColor(dark: (108, 112, 134), light: (156, 160, 176))
+
+// MARK: herdr's status vocabulary
+
+enum Status: String {
+ case blocked, done, working, idle, unknown
+
+ var symbol: String {
+ switch self {
+ case .blocked: return "×"
+ case .done: return "✓"
+ case .working: return "◐"
+ case .idle: return "○"
+ case .unknown: return "·"
+ }
+ }
+
+ var color: NSColor {
+ switch self {
+ case .blocked: return herdrRed
+ case .done: return herdrTeal
+ case .working: return herdrYellow
+ case .idle: return herdrGreen
+ case .unknown: return herdrGray
+ }
+ }
+
+ var priority: Int {
+ switch self {
+ case .blocked: return 4
+ case .done: return 3
+ case .working: return 2
+ case .idle: return 1
+ case .unknown: return 0
+ }
+ }
+}
+
+struct Row {
+ let paneId: String
+ let agent: String
+ /// What the agent works on (its session/task name), if it has one.
+ let name: String
+ let status: Status
+ let session: String
+ let window: String
+ let since: Int
+
+ var location: String { "\(session) · \(window)" }
+ var waiting: Bool { status == .blocked || status == .done }
+
+ /// The row's title text: the agent's name, or its harness when unnamed.
+ var label: String {
+ guard !name.isEmpty else { return agent }
+ return name.count > 80 ? String(name.prefix(79)) + "…" : name
+ }
+
+ /// Dim second line: where it runs, which harness, and how long it waits.
+ var subtitle: String {
+ var parts = [location, agent]
+ if waiting, since != Int.max {
+ parts.append("\(status.rawValue) \(elapsed(sinceMs: since))")
+ }
+ return parts.joined(separator: " · ")
+ }
+}
+
+/// Compact age: "now", "4m", "2h", "3d".
+func elapsed(sinceMs: Int) -> String {
+ let seconds = max(0, Int(Date().timeIntervalSince1970) - sinceMs / 1000)
+ switch seconds {
+ case ..<60: return "now"
+ case ..<3600: return "\(seconds / 60)m"
+ case ..<86400: return "\(seconds / 3600)h"
+ default: return "\(seconds / 86400)d"
+ }
+}
+
+// MARK: tmux and dingger access
+
+let environment = ProcessInfo.processInfo.environment
+
+func run(_ path: String, _ args: [String]) -> String? {
+ let process = Process()
+ process.executableURL = URL(fileURLWithPath: path)
+ process.arguments = args
+ let pipe = Pipe()
+ process.standardOutput = pipe
+ process.standardError = FileHandle.nullDevice
+ do {
+ try process.run()
+ } catch {
+ return nil
+ }
+ let data = pipe.fileHandleForReading.readDataToEndOfFile()
+ process.waitUntilExit()
+ guard process.terminationStatus == 0 else { return nil }
+ return String(data: data, encoding: .utf8)
+}
+
+func tmux(_ args: [String]) -> String? {
+ var all = ["-u"]
+ if let socket = environment["DINGGER_TMUX_SOCKET"] {
+ all += ["-L", socket]
+ }
+ return run(environment["DINGGER_TMUX"] ?? "/opt/homebrew/bin/tmux", all + args)
+}
+
+let dinggerBinary = environment["DINGGER_BIN"] ?? (NSHomeDirectory() + "/.local/bin/dingger")
+
+/// Every agent pane, in herdr's priority order, oldest waiting first.
+func fetchRows() -> [Row] {
+ let format = [
+ "#{pane_id}", "#{@agent}", "#{@agent_state}", "#{@dingger_since}", "#{@agent_name}",
+ "#{session_name}", "#{window_name}", "#{window_panes}", "#{pane_index}",
+ ].joined(separator: "\t")
+ guard let out = tmux(["list-panes", "-a", "-F", format]) else { return [] }
+ let rows: [Row] = out.split(separator: "\n").compactMap { line in
+ let f = line.split(separator: "\t", omittingEmptySubsequences: false).map(String.init)
+ guard f.count >= 9, !f[1].isEmpty 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)
+ }
+ return rows.sorted {
+ if $0.status.priority != $1.status.priority { return $0.status.priority > $1.status.priority }
+ if $0.since != $1.since { return $0.since < $1.since }
+ return $0.location < $1.location
+ }
+}
+
+/// Show the pane and mark it attended; the same code path as the tmux
+/// status-line click.
+func focus(_ paneId: String) {
+ _ = run(dinggerBinary, ["focus", "--pane", paneId])
+}
+
+// MARK: rendering
+
+/// herdr-style summary: `×1 ✓3 ◐2` in priority order; `○` when every agent
+/// is idle; `·` when there are none.
+func title(for rows: [Row]) -> NSAttributedString {
+ let font = NSFont.monospacedDigitSystemFont(ofSize: 13, weight: .medium)
+ let text = NSMutableAttributedString()
+ for status in [Status.blocked, .done, .working] {
+ let count = rows.filter { $0.status == status }.count
+ guard count > 0 else { continue }
+ if text.length > 0 {
+ text.append(NSAttributedString(string: " ", attributes: [.font: font]))
+ }
+ text.append(NSAttributedString(
+ string: "\(status.symbol)\(count)",
+ attributes: [.font: font, .foregroundColor: status.color]))
+ }
+ if text.length == 0 {
+ let status: Status = rows.isEmpty ? .unknown : .idle
+ text.append(NSAttributedString(
+ string: status.symbol, attributes: [.font: font, .foregroundColor: status.color]))
+ }
+ return text
+}
+
+func plainTitle(for rows: [Row]) -> String {
+ title(for: rows).string
+}
+
+/// Row title: the colored status symbol, then the agent's name. Unnamed
+/// agents show their harness, dimmed, so it never competes with real names.
+func rowTitle(_ row: Row) -> NSAttributedString {
+ let font = NSFont.menuFont(ofSize: 0)
+ let mono = NSFont.monospacedSystemFont(ofSize: font.pointSize, weight: .medium)
+ let text = NSMutableAttributedString()
+ text.append(NSAttributedString(
+ string: "\(row.status.symbol) ", attributes: [.font: mono, .foregroundColor: row.status.color]))
+ let labelColor = row.name.isEmpty ? NSColor.secondaryLabelColor : NSColor.labelColor
+ text.append(NSAttributedString(
+ string: row.label, attributes: [.font: font, .foregroundColor: labelColor]))
+ return text
+}