diff options
Diffstat (limited to 'menubar/model.swift')
| -rw-r--r-- | menubar/model.swift | 200 |
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 +} |
