1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
|
// dingger menu bar: live agent states from tmux pane user options
// (@agent / @agent_state), written by the dingger daemon.
import AppKit
struct PaneRow {
let target: String
let agent: String
let state: String
}
func emoji(for state: String) -> String {
switch state {
case "blocked": return "🔴"
case "working": return "🟡"
case "idle": return "🟢"
default: return "⚪️"
}
}
final class Menubar: NSObject, NSApplicationDelegate {
private let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
private let menu = NSMenu()
private var panes: [PaneRow] = []
func applicationDidFinishLaunching(_: Notification) {
NSApp.setActivationPolicy(.accessory) // no Dock icon
item.button?.title = "⚪️"
item.menu = menu
let timer = Timer(timeInterval: 2.0, repeats: true) { [weak self] _ in
self?.refresh()
}
RunLoop.main.add(timer, forMode: .common)
refresh()
}
private func refresh() {
panes = fetchPanes()
redrawTitle()
rebuildMenu()
}
private func fetchPanes() -> [PaneRow] {
let fmt = "#{pane_id}\t#{session_name}:#{window_index}.#{pane_index}\t#{@agent}\t#{@agent_state}"
guard let out = runTmux(["-u", "list-panes", "-a", "-F", fmt]) else { return [] }
return out.split(separator: "\n").compactMap { line in
let fields = line.split(separator: "\t", omittingEmptySubsequences: false)
guard fields.count >= 4 else { return nil }
let agent = String(fields[2])
let state = fields[3].isEmpty ? "unknown" : String(fields[3])
guard !agent.isEmpty || !fields[3].isEmpty else { return nil } // untracked shell
return PaneRow(target: String(fields[1]), agent: agent, state: state)
}
}
private func runTmux(_ args: [String]) -> String? {
let process = Process()
process.executableURL = URL(fileURLWithPath: "/opt/homebrew/bin/tmux")
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)
}
private func redrawTitle() {
let blocked = panes.filter { $0.state == "blocked" }.count
let working = panes.filter { $0.state == "working" }.count
let idle = panes.filter { $0.state == "idle" }.count
var parts: [String] = []
if blocked > 0 { parts.append("🔴\(blocked)") }
if working > 0 { parts.append("🟡\(working)") }
if parts.isEmpty {
item.button?.title = idle > 0 ? "🟢\(idle)" : "⚪️"
} else {
item.button?.title = parts.joined(separator: " ")
}
}
private func rebuildMenu() {
menu.removeAllItems()
menu.addItem(NSMenuItem(title: "dingger", action: nil, keyEquivalent: ""))
if panes.isEmpty {
let none = NSMenuItem(title: "no agents running", action: nil, keyEquivalent: "")
none.isEnabled = false
menu.addItem(none)
}
for pane in panes.sorted(by: { $0.target < $1.target }) {
let label = "\(emoji(for: pane.state)) \(pane.agent) \(pane.target) \(pane.state)"
let item = NSMenuItem(title: label, action: #selector(paneClicked(_:)), keyEquivalent: "")
item.target = self
item.representedObject = pane.target
menu.addItem(item)
}
menu.addItem(.separator())
menu.addItem(NSMenuItem(title: "Quit", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q"))
}
@objc private func paneClicked(_ sender: NSMenuItem) {
guard let target = sender.representedObject as? String else { return }
_ = runTmux(["switch-client", "-t", target])
for bundle in ["com.mitchellh.ghostty", "org.alacritty", "com.apple.Terminal"] {
if let app = NSRunningApplication.runningApplications(withBundleIdentifier: bundle).first {
app.activate()
break
}
}
}
}
let app = NSApplication.shared
let delegate = Menubar()
app.delegate = delegate
app.setActivationPolicy(.accessory)
app.run()
|