// 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)) /// Not a status color: marks a high-importance agent. let herdrPeach = herdrColor(dark: (250, 179, 135), light: (254, 100, 11)) /// herdr's mocha text colors, fixed (not appearance-dependent) for the /// always-dark floating panel. func mocha(_ r: CGFloat, _ g: CGFloat, _ b: CGFloat, _ a: CGFloat = 1) -> NSColor { NSColor(srgbRed: r / 255, green: g / 255, blue: b / 255, alpha: a) } 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 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 } } } /// The agent's importance (`@dingger_importance`, unset for normal): it /// orders agents within a status, and a low agent makes no sound. enum Importance: Int { case low = 0, normal = 1, high = 2 init(option: String) { switch option { case "high": self = .high case "low": self = .low default: self = .normal } } } struct Row: Equatable { 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 /// The pane's working directory, home as "~". var path: String = "" var importance: Importance = .normal /// The draft in the agent's input box, as one line (`@dingger_draft`). 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 /// 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. var forgottenDraft: Bool { !draft.isEmpty && !attended } var waiting: Bool { status == .blocked || status == .done } /// Only an agent you have seen can be marked unread (done again). var canMarkUnread: Bool { status == .idle } /// 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 } /// The title's text as plain text: `! ` marks a high agent, `✎ ` a /// forgotten draft. var markedLabel: String { (importance == .high ? "! " : "") + (forgottenDraft ? "✎ " : "") + label } /// 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 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". 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) 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) } /// Like run(), but reports why a command failed: exit status and stderr. func runReporting(_ path: String, _ args: [String]) -> (ok: Bool, output: String) { let process = Process() process.executableURL = URL(fileURLWithPath: path) process.arguments = args let out = Pipe(), err = Pipe() process.standardOutput = out process.standardError = err do { try process.run() } catch { return (false, "cannot run \(path): \(error.localizedDescription)") } let stdout = String(data: out.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" let stderr = String(data: err.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" process.waitUntilExit() let text = (stdout + stderr).trimmingCharacters(in: .whitespacesAndNewlines) return (process.terminationStatus == 0, text) } /// The tmux binary: launchd gives apps a PATH without Homebrew, so look in /// the usual places. let tmuxBinary: String = environment["DINGGER_TMUX"] ?? ["/opt/homebrew/bin/tmux", "/usr/local/bin/tmux", "/usr/bin/tmux"] .first { FileManager.default.isExecutableFile(atPath: $0) } ?? "/opt/homebrew/bin/tmux" func tmux(_ args: [String]) -> String? { var all = ["-u"] if let socket = environment["DINGGER_TMUX_SOCKET"] { all += ["-L", socket] } return run(tmuxBinary, all + args) } let dinggerBinary = environment["DINGGER_BIN"] ?? (NSHomeDirectory() + "/.local/bin/dingger") // MARK: restore offer /// tmux sessions the daemon offers to restore after tmux stopped /// (`offer.json`, written by `dingger watch`). struct RestoreOffer: Decodable, Equatable { struct Session: Decodable, Equatable { let name: String let windows: Int let panes: Int let agents: Int } let saved_at: Int let sessions: [Session] let agents: Int let interrupted: Int /// "Restore 9 sessions · 22 agents" var title: String { var text = "Restore \(sessions.count) \(sessions.count == 1 ? "session" : "sessions")" if agents > 0 { text += " · \(agents) \(agents == 1 ? "agent" : "agents")" } return text } /// "saved 13:01 · 1 was mid-turn" var detail: String { let formatter = DateFormatter() let saved = Date(timeIntervalSince1970: TimeInterval(saved_at) / 1000) formatter.dateFormat = Calendar.current.isDateInToday(saved) ? "HH:mm" : "MMM d, HH:mm" var parts = ["tmux stopped · saved \(formatter.string(from: saved))"] if interrupted > 0 { parts.append("\(interrupted) \(interrupted == 1 ? "was" : "were") mid-turn") } return parts.joined(separator: " · ") } } let stateDirectory = environment["DINGGER_STATE_DIR"] ?? (NSHomeDirectory() + "/.local/state/dingger") func fetchRestoreOffer() -> RestoreOffer? { guard let data = FileManager.default.contents(atPath: stateDirectory + "/offer.json") else { return nil } return try? JSONDecoder().decode(RestoreOffer.self, from: data) } // MARK: agents on an old extension /// pi agents on an old dingger extension, as the daemon counts them: /// `@dingger_outdated` it can reload, `@dingger_outdated_by_hand` it cannot /// (an extension from before reloads). struct OutdatedAgents: Equatable { let reloadable: Int let byHand: Int var isEmpty: Bool { reloadable == 0 && byHand == 0 } var canReload: Bool { reloadable > 0 } private func agents(_ n: Int) -> String { "\(n) \(n == 1 ? "Agent" : "Agents")" } /// "Reload 3 Agents on an Old Extension", or "2 Agents Need /reload". var title: String { canReload ? "Reload \(agents(reloadable)) on an Old Extension" : "\(agents(byHand)) \(byHand == 1 ? "Needs" : "Need") /reload" } var detail: String { guard canReload else { return "their dingger extension is too old to reload them" } let when = "each when idle and off screen" return byHand > 0 ? "\(when) · \(byHand) more need /reload" : when } } func fetchOutdated() -> OutdatedAgents { let count = { (name: String) in Int(tmux(["show-options", "-gv", name])?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "") ?? 0 } return OutdatedAgents(reloadable: count("@dingger_outdated"), byHand: count("@dingger_outdated_by_hand")) } /// Run `dingger ` through the user's login shell, the way a terminal /// starts tmux: restored panes inherit this environment (PATH, LANG), and /// launchd's is bare. DINGGER_RESTORE_DIRECT runs it directly (tests). func runDinggerInLoginShell(_ args: [String]) -> (ok: Bool, output: String) { if environment["DINGGER_RESTORE_DIRECT"] != nil { return runReporting(dinggerBinary, args) } let shell = getpwuid(getuid()).flatMap { String(validatingUTF8: $0.pointee.pw_shell) } ?? "/bin/zsh" return runReporting(shell, ["--login", "-i", "-c", "exec \"$0\" \"$@\"", dinggerBinary] + args) } private let home = NSHomeDirectory() /// Every agent pane: herdr's status priority; idle agents with a forgotten /// draft before the other idle ones; within a status, the more important /// first, then done and blocked oldest first (the queue), /// working and idle most recent first (`@dingger_since`: when the pane /// entered its status); then by place. 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_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 >= 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() let rows: [Row] = lines.compactMap { f in 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] 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]), cost: Double(f[14]) ?? 0, context: Double(f[15])) } return rows.sorted(by: listedBefore) } /// The list order (see `fetchRows`). func listedBefore(_ a: Row, _ b: Row) -> Bool { if a.status.priority != b.status.priority { return a.status.priority > b.status.priority } if a.status == .idle, a.forgottenDraft != b.forgottenDraft { return a.forgottenDraft } if a.importance != b.importance { return a.importance.rawValue > b.importance.rawValue } if a.since != b.since { // Int.max: never stamped; last either way. if a.since == Int.max || b.since == Int.max { return a.since < b.since } return a.waiting ? a.since < b.since : a.since > b.since } return a.location < b.location } /// Show the pane and mark it attended; the same code path as the tmux /// status-line click. func focus(_ paneId: String) { let result = runReporting(dinggerBinary, ["focus", "--pane", paneId]) if !result.ok { log("jump: dingger focus --pane \(paneId) failed: \(result.output)") } } /// Mark an idle agent unread: `dingger unread` returns once it shows as /// done. func markAgentUnread(_ paneId: String) { let result = runReporting(dinggerBinary, ["unread", "--pane", paneId]) log("unread: \(paneId) \(result.ok ? "marked" : "failed: \(result.output)")") } // 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 } /// The title's characters before the name: the status symbol, the /// importance column, and the draft mark. func rowTitlePrefix(_ row: Row) -> String { "\(row.status.symbol) " + importanceMark(row) + " " + draftMark(row) } /// `✎ ` before the name of an agent with a forgotten draft. private func draftMark(_ row: Row) -> String { row.forgottenDraft ? "✎ " : "" } /// The importance column: `!` for a high agent, a blank otherwise, so every /// name starts in the same place. private func importanceMark(_ row: Row) -> String { row.importance == .high ? "!" : " " } /// How much a low agent's name is dimmed: enough to recede, still legible. let lowImportanceAlpha: CGFloat = 0.5 /// Row title: the colored status symbol, the importance column (a peach `!` /// for a high agent), `✎` for a forgotten draft, then the agent's name. Unnamed agents show their /// harness, dimmed, so it never competes with real names; a low agent's name /// is dimmed. func rowTitle(_ row: Row, primary: NSColor = .labelColor, secondary: NSColor = .secondaryLabelColor) -> 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])) text.append(NSAttributedString( string: importanceMark(row) + " ", attributes: [.font: NSFont.monospacedSystemFont(ofSize: font.pointSize, weight: .heavy), .foregroundColor: herdrPeach])) if row.forgottenDraft { text.append(NSAttributedString(string: draftMark(row), attributes: [.font: font, .foregroundColor: primary])) } let labelColor = row.name.isEmpty ? secondary : row.importance == .low ? primary.withAlphaComponent(lowImportanceAlpha) : primary text.append(NSAttributedString( string: row.label, attributes: [.font: font, .foregroundColor: labelColor])) return text }