// 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)) /// 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) // 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: 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 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) } /// 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") private let home = NSHomeDirectory() /// 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}", "#{pane_current_path}", ].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 >= 10, !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, path: f[9].hasPrefix(home) ? "~" + f[9].dropFirst(home.count) : f[9]) } 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) { let result = runReporting(dinggerBinary, ["focus", "--pane", paneId]) if !result.ok { log("jump: dingger focus --pane \(paneId) 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 } /// 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, 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])) let labelColor = row.name.isEmpty ? secondary : primary text.append(NSAttributedString( string: row.label, attributes: [.font: font, .foregroundColor: labelColor])) return text }