aboutsummaryrefslogtreecommitdiff
path: root/menubar/main.swift
blob: e186f9393820165c1da70ff22a9bbbc9c0000324 (plain)
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()