From f42bf2d441b70fd2c82b7fcd41f7a2d0538b1fe7 Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Fri, 17 Jul 2026 16:52:38 -0700 Subject: [PATCH] slab: refine the menu hierarchy and about window --- slab/menubar-swift/README.md | 29 +- .../Sources/SlabMenubar/AppDelegate.swift | 118 +++++++- .../Sources/SlabMenubar/MenuBuilder.swift | 284 +++++++++++------- .../Sources/SlabMenubar/Paths.swift | 2 + .../Sources/SlabMenubar/SlabAboutWindow.swift | 137 +++++++++ .../Sources/SlabMenubar/StateSnapshot.swift | 4 + 6 files changed, 432 insertions(+), 142 deletions(-) create mode 100644 slab/menubar-swift/Sources/SlabMenubar/SlabAboutWindow.swift diff --git a/slab/menubar-swift/README.md b/slab/menubar-swift/README.md index 5e7aa05674..ffe2c27694 100644 --- a/slab/menubar-swift/README.md +++ b/slab/menubar-swift/README.md @@ -14,22 +14,19 @@ Native macOS menu-bar app for the slab workflow — replaces `slab/bin/slab-menu The status-item icon changes by state: -| State | SF Symbol | -| -------------------------------------------------- | ----------------------------------- | -| idle (no work) | `square.stack.3d.up` (outline) | -| N active, lid open | `square.stack.3d.up.fill` + `N` | -| N active, lid closed, sleep disabled (working) | `moon.zzz.fill` + `N` | -| N active, lid closed, sleep NOT disabled (⚠ drift) | `exclamationmark.triangle.fill` + N | -| ambient synth running | `waveform.path.ecg` | - -Menu contents are parity with the Python version: - -- status line + active-prompt / active-subagent counts -- Tailnet submenu (click peer → opens `ssh ` in Terminal.app) -- Mail submenu (`mbsync` + `mu index`, open sync log) -- Stay-awake toggle / Sleep now -- Open daemon log / Open sounds folder -- Reload daemon / Quit menu bar +| State | Menu-bar picture | +| --- | --- | +| idle (no work) | slow-spinning rainbow line | +| N agent sessions | N-sided status polygon (one line / two bars / triangle…) | +| input waiting, no agent sessions | `message.fill` + un-ingested count | +| input waiting with agent sessions | message dot over the session polygon | +| ambient synth running | `waveform.path.ecg` | +| lid closed with unrepresented work | `moon.zzz.fill` or warning triangle | + +The root menu stays deliberately short and stable: Agents, Inbox, Work, +Fleet & System, Media & Capture, Mac, and Slab. Each owns its details in a +submenu; layout and terminal styling no longer crowd the Restore Threads menu. +The footer contains a native About Slab window and Quit Slab. Refresh cadence: 2 s. Mail unread count refreshes every 30 s (15 ticks). diff --git a/slab/menubar-swift/Sources/SlabMenubar/AppDelegate.swift b/slab/menubar-swift/Sources/SlabMenubar/AppDelegate.swift index 900a5be9dc..e68cb67ae5 100644 --- a/slab/menubar-swift/Sources/SlabMenubar/AppDelegate.swift +++ b/slab/menubar-swift/Sources/SlabMenubar/AppDelegate.swift @@ -95,6 +95,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { private var imsgStatus = "—" private var imsgConfigured = false private var imsgUnread = 0 + private var signalPending = false + private var signalStatus = "Signal: —" + private var signalConfigured = false + private var signalUnread = 0 /// Keeps a just-arrived message visible to Slab even if Messages marks it /// read before our next snapshot. Unread messages keep the signal alive; /// this short edge pulse covers the actual arrival moment. @@ -169,8 +173,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { // Contact awareness has its own lightweight clock instead of riding // the fleet refresh. Poll once at launch, then every three seconds. refreshImsgCount() + refreshSignalCount() let contactTimer = Timer.scheduledTimer(withTimeInterval: 3.0, repeats: true) { - [weak self] _ in self?.refreshImsgCount() + [weak self] _ in + self?.refreshImsgCount() + self?.refreshSignalCount() } imsgTimer = contactTimer RunLoop.main.add(contactTimer, forMode: .common) @@ -353,8 +360,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { // the menubar should notice an arrival even on an unthemed // wall. Unread keeps it present; the edge pulse catches a // message that another device marks read almost immediately. - self.state.messageWaiting = self.imsgUnread > 0 - || Date() < self.imsgArrivalVisibleUntil + self.applyInputNotificationState() self.updateIcon() self.updateAnimTimer() @@ -444,6 +450,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { mailStatus: mailStatus, imsgStatus: imsgStatus, imsgConfigured: imsgConfigured, + signalStatus: signalStatus, + signalConfigured: signalConfigured, asana: asanaState, deploy: deployState, target: self @@ -486,16 +494,18 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { // recolor non-template images on macOS 10.14+. button.contentTintColor = nil button.image = IconRenderer.image(for: state, phase: rainbowPhase, rotation: rotationPhase) - // When the polygon icon is showing, the edge count communicates the - // number — only show a numeric tail for subagents (which the polygon - // doesn't represent) or the legacy fallback states. + // The red message signal is an event; its numeric tail says how many + // inputs remain un-ingested. Agent counts stay separate with a `+`. + var tails: [String] = [] + if state.inputNotificationCount > 0 { + tails.append(String(state.inputNotificationCount)) + } if state.claudeSessions.isEmpty && state.totalActive > 0 { - button.title = " \(state.totalActive)" + tails.append("+\(state.totalActive)") } else if state.activeSubagents > 0 { - button.title = " +\(state.activeSubagents)" - } else { - button.title = "" + tails.append("+\(state.activeSubagents)") } + button.title = tails.isEmpty ? "" : " " + tails.joined(separator: " ") } private func updateAnimTimer() { @@ -633,8 +643,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { self.imsgArrivalVisibleUntil = Date().addingTimeInterval(15) self.bumpBoundProx(displayLabel: label, message: lastText) } - self.state.messageWaiting = unread > 0 - || Date() < self.imsgArrivalVisibleUntil + self.applyInputNotificationState() self.imsgPending = false self.updateIcon() self.updateAnimTimer() @@ -645,6 +654,66 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { } } + /// Signal mirrors the iMessage poll, but keeps its own ingestion cursor. + /// The icon folds both counts together while the menu preserves the + /// per-channel breakdown. + private func refreshSignalCount() { + let helper = Paths.signalHelper + guard FileManager.default.isExecutableFile(atPath: helper) else { + signalStatus = "Signal: helper missing" + signalConfigured = false + signalUnread = 0 + applyInputNotificationState() + return + } + guard !signalPending else { return } + signalPending = true + DispatchQueue.global(qos: .utility).async { [weak self] in + let out = ShellRunner.run(helper, args: ["status"], timeout: 8).output + let line = out.split(separator: "\n").last.map(String.init) ?? "" + var label = "Signal: —" + var configured = false + var unread = 0 + var newSinceLast = false + var helperError: String? + if let data = line.data(using: .utf8), + let obj = try? JSONSerialization.jsonObject(with: data) + as? [String: Any] { + label = (obj["label"] as? String) ?? label + configured = (obj["configured"] as? Bool) ?? false + unread = (obj["unread"] as? Int) ?? 0 + newSinceLast = (obj["newSinceLast"] as? Bool) ?? false + helperError = obj["error"] as? String + } + DispatchQueue.main.async { + guard let self = self else { return } + if let helperError = helperError { + NSLog("💬 [signal] watcher error: \(helperError)") + } + let wasWaiting = self.state.messageWaiting + self.signalStatus = label + self.signalConfigured = configured + self.signalUnread = unread + if newSinceLast { + self.imsgArrivalVisibleUntil = Date().addingTimeInterval(15) + } + self.applyInputNotificationState() + self.signalPending = false + self.updateIcon() + self.updateAnimTimer() + if wasWaiting != self.state.messageWaiting { + self.applyTerminalDecor() + } + } + } + } + + private func applyInputNotificationState() { + state.inputNotificationCount = imsgUnread + signalUnread + state.messageWaiting = state.inputNotificationCount > 0 + || Date() < imsgArrivalVisibleUntil + } + /// Poke the prox explicitly assigned to iMessage awareness and optionally /// submit a small steering prompt to its live TTY. This is deliberately /// opt-in via an untracked binding file; Slab never guesses which agent to @@ -933,6 +1002,27 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { ShellRunner.run("/usr/bin/open", args: ["-t", Paths.imsgConfig]) } + @objc func acknowledgeImsg() { + ShellRunner.runAsync(Paths.imsgHelper, args: ["ack"]) { [weak self] in + DispatchQueue.main.async { self?.refreshImsgCount() } + } + } + + @objc func openSignal() { + ShellRunner.runAsync(Paths.signalHelper, args: ["open"]) + } + + @objc func openSignalConfig() { + ShellRunner.run(Paths.signalHelper, args: ["config"]) + ShellRunner.run("/usr/bin/open", args: ["-t", Paths.signalConfig]) + } + + @objc func acknowledgeSignal() { + ShellRunner.runAsync(Paths.signalHelper, args: ["ack"]) { [weak self] in + DispatchQueue.main.async { self?.refreshSignalCount() } + } + } + // MARK: - Deskflow KVM /// Read the launchd agent label from the untracked deskflow config. @@ -1133,6 +1223,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { } } + @objc func showAboutSlab() { + SlabAboutWindow.show() + } + @objc func quitMenubar() { DispatchQueue.global(qos: .userInitiated).async { ShellRunner.run("/bin/launchctl", args: ["unload", Paths.menubarPlist]) diff --git a/slab/menubar-swift/Sources/SlabMenubar/MenuBuilder.swift b/slab/menubar-swift/Sources/SlabMenubar/MenuBuilder.swift index 0d7c2e6625..3942615f1c 100644 --- a/slab/menubar-swift/Sources/SlabMenubar/MenuBuilder.swift +++ b/slab/menubar-swift/Sources/SlabMenubar/MenuBuilder.swift @@ -6,6 +6,8 @@ enum MenuBuilder { mailStatus: String, imsgStatus: String, imsgConfigured: Bool, + signalStatus: String, + signalConfigured: Bool, asana: AsanaState, deploy: DeployStatusState, target: AppDelegate @@ -17,6 +19,8 @@ enum MenuBuilder { mailStatus: mailStatus, imsgStatus: imsgStatus, imsgConfigured: imsgConfigured, + signalStatus: signalStatus, + signalConfigured: signalConfigured, asana: asana, deploy: deploy, target: target @@ -34,6 +38,8 @@ enum MenuBuilder { mailStatus: String, imsgStatus: String, imsgConfigured: Bool, + signalStatus: String, + signalConfigured: Bool, asana: AsanaState, deploy: DeployStatusState, target: AppDelegate @@ -41,27 +47,54 @@ enum MenuBuilder { menu.removeAllItems() menu.autoenablesItems = false - menu.addItem(info("Status: \(state.statusLine)")) - menu.addItem(buildSystem(state: state, target: target)) + let statusSuffix = state.inputNotificationCount > 0 + ? " · \(state.inputNotificationCount) waiting" + : "" + menu.addItem(info("Slab · \(state.statusLine)\(statusSuffix)")) menu.addItem(.separator()) - appendPopRenders(to: menu, state: state) - appendClaude(to: menu, state: state, target: target) - menu.addItem(info("Subagents in flight: \(state.activeSubagents)")) - menu.addItem(.separator()) - - menu.addItem(buildTailnet(state: state, target: target)) - menu.addItem(buildMail(status: mailStatus, target: target)) - menu.addItem(buildImsg(status: imsgStatus, configured: imsgConfigured, target: target)) - menu.addItem(buildAsana(state: asana, target: target)) - menu.addItem(buildDeploy(state: deploy, target: target)) - appendOvertime(to: menu, target: target) - menu.addItem(buildPdf(target: target)) - menu.addItem(buildVideo(target: target)) - menu.addItem(buildAudio(target: target)) + let agents = NSMenu() + appendPopRenders(to: agents, state: state) + appendClaude(to: agents, state: state, target: target) + agents.addItem(info("Subagents in flight: \(state.activeSubagents)")) + agents.addItem(.separator()) + agents.addItem(buildWindowLayout(state: state, target: target)) + agents.addItem(buildAgentAppearance(state: state, target: target)) + let closeTerm = item("Close front terminal window", + selector: #selector(AppDelegate.closeFrontTerminal), + target: target) + closeTerm.toolTip = "Close the frontmost Terminal or iTerm2 window. If auto-tile is on, the remaining windows re-tile." + agents.addItem(closeTerm) + menu.addItem(section(agentTitle(state), symbol: "person.2.fill", submenu: agents)) + + let inbox = NSMenu() + inbox.addItem(info("iMessage + Signal · \(state.inputNotificationCount) un-ingested")) + inbox.addItem(buildMail(status: mailStatus, target: target)) + inbox.addItem(buildImsg(status: imsgStatus, configured: imsgConfigured, target: target)) + inbox.addItem(buildSignal(status: signalStatus, configured: signalConfigured, target: target)) + let inboxTitle = state.inputNotificationCount > 0 + ? "Inbox: \(state.inputNotificationCount) waiting" + : "Inbox: clear" + menu.addItem(section(inboxTitle, symbol: "tray.full.fill", submenu: inbox)) + + let work = NSMenu() + work.addItem(buildAsana(state: asana, target: target)) + work.addItem(buildDeploy(state: deploy, target: target)) + appendOvertime(to: work, target: target) + menu.addItem(section("Work", symbol: "checkmark.circle.fill", submenu: work)) + + let fleet = NSMenu() + fleet.addItem(buildSystem(state: state, target: target)) + fleet.addItem(buildTailnet(state: state, target: target)) if state.deskflow.configured { - menu.addItem(buildDeskflow(state: state, target: target)) + fleet.addItem(buildDeskflow(state: state, target: target)) } + menu.addItem(section("Fleet & System", symbol: "network", submenu: fleet)) + + let media = NSMenu() + media.addItem(buildPdf(target: target)) + media.addItem(buildVideo(target: target)) + media.addItem(buildAudio(target: target)) // Request for Audio — sing a /pop melody, one note at a time. The // wizard plays the pitch + shows the word per note, records, then @@ -70,56 +103,51 @@ enum MenuBuilder { selector: #selector(AppDelegate.requestForAudio(_:)), target: target) rfa.representedObject = "hum" rfa.toolTip = "Open the RFA wizard in iTerm2 — sing the 'hum' melody note by note, then hear it recompiled." - menu.addItem(rfa) + media.addItem(rfa) // Call recording — captures mic (+ system audio if an aggregate // device is wired) to a WAV in ~/Documents/Shelf/meetings/. On stop // the WAV is handed off to meetings/cli.mjs which transcribes, // detects whistlepops, and builds an arxiv-style PDF. - menu.addItem(buildCall(state: state, target: target)) - menu.addItem(.separator()) - - // Close the frontmost Terminal / iTerm2 window from the menubar. - // Useful for closing Claude sessions cleanly without reaching for - // the keyboard, and a faster path than ⌘W when the menu is already - // open. Auto-re-tiles the remaining windows when auto-tile is on. - let closeTerm = item("Close front terminal window", - selector: #selector(AppDelegate.closeFrontTerminal), - target: target) - closeTerm.toolTip = "Close the frontmost Terminal or iTerm2 window. If auto-tile is on, the remaining windows re-tile." - menu.addItem(closeTerm) + media.addItem(buildCall(state: state, target: target)) + menu.addItem(section("Media & Capture", symbol: "play.rectangle.fill", submenu: media)) + let mac = NSMenu() // Tidy a scattered desktop of Stickies notes into a tight collapsed // column in the top-left via Stickies' own "Arrange By" command. let stackSticky = item("Arrange Stickies (collapse + top-left)", selector: #selector(AppDelegate.stackStickies), target: target) stackSticky.toolTip = "Run Stickies' built-in Arrange By (Color) with Collapse All — packs all notes into a collapsed column in the top-left corner." - menu.addItem(stackSticky) - menu.addItem(.separator()) + mac.addItem(stackSticky) + mac.addItem(.separator()) let stayAwake = item("Stay awake (lid closed)", selector: #selector(AppDelegate.toggleStayAwake), target: target) stayAwake.state = state.sleepDisabled ? .on : .off - menu.addItem(stayAwake) - menu.addItem(item("Sleep now", selector: #selector(AppDelegate.sleepNow), target: target)) + mac.addItem(stayAwake) + mac.addItem(item("Sleep now", selector: #selector(AppDelegate.sleepNow), target: target)) let saver = item("Start screensaver", selector: #selector(AppDelegate.startScreensaver), target: target) saver.toolTip = "Launch the currently-selected screen saver now (Slab Status if chosen in System Settings)." - menu.addItem(saver) + mac.addItem(saver) - menu.addItem(buildAppearance(target: target)) + mac.addItem(buildAppearance(target: target)) let mute = item("Mute ambient sonification", selector: #selector(AppDelegate.toggleMute), target: target) mute.state = state.muted ? .on : .off - menu.addItem(mute) - menu.addItem(.separator()) + mac.addItem(mute) + menu.addItem(section("Mac", symbol: "desktopcomputer", submenu: mac)) - menu.addItem(item("Open daemon log", selector: #selector(AppDelegate.openDaemonLog), target: target)) - menu.addItem(item("Open sounds folder", selector: #selector(AppDelegate.openSoundsFolder), target: target)) - menu.addItem(.separator()) + let slab = NSMenu() + slab.addItem(item("Open daemon log", selector: #selector(AppDelegate.openDaemonLog), target: target)) + slab.addItem(item("Open sounds folder", selector: #selector(AppDelegate.openSoundsFolder), target: target)) + slab.addItem(.separator()) + slab.addItem(item("Reload daemon", selector: #selector(AppDelegate.reloadDaemon), target: target)) + menu.addItem(section("Slab", symbol: "square.stack.3d.up.fill", submenu: slab)) - menu.addItem(item("Reload daemon", selector: #selector(AppDelegate.reloadDaemon), target: target)) - menu.addItem(item("Quit menu bar", selector: #selector(AppDelegate.quitMenubar), target: target)) + menu.addItem(.separator()) + menu.addItem(item("About Slab", selector: #selector(AppDelegate.showAboutSlab), target: target)) + menu.addItem(item("Quit Slab", selector: #selector(AppDelegate.quitMenubar), target: target)) } private static func info(_ title: String) -> NSMenuItem { @@ -135,6 +163,35 @@ enum MenuBuilder { return it } + /// Consistent first-level navigation: every subsystem is represented by + /// one titled, symbol-bearing parent instead of spilling its controls into + /// the root menu. Deeper builders keep ownership of their own details. + private static func section(_ title: String, symbol: String, submenu: NSMenu) -> NSMenuItem { + submenu.autoenablesItems = false + let parent = NSMenuItem(title: title, action: nil, keyEquivalent: "") + if let image = NSImage(systemSymbolName: symbol, accessibilityDescription: title) { + image.isTemplate = true + parent.image = image.withSymbolConfiguration( + NSImage.SymbolConfiguration(pointSize: 13, weight: .regular) + ) ?? image + } + parent.submenu = submenu + return parent + } + + private static func agentTitle(_ state: StateSnapshot) -> String { + let sessions = state.claudeSessions.count + let subagents = state.activeSubagents + if sessions == 0 { + return subagents > 0 ? "Agents: \(subagents) subagents" : "Agents: idle" + } + let subagentSuffix = subagents > 0 ? " · +\(subagents) subagents" : "" + if state.anyAwaiting { + return "Agents: \(state.awaitingCount) awaiting · \(sessions) active\(subagentSuffix)" + } + return "Agents: \(sessions) active\(subagentSuffix)" + } + /// Extra mail accounts loaded from untracked `~/.config/slab/mail-accounts.json` /// so client identities never appear in tracked code. Returns [] if absent /// or malformed. @@ -235,6 +292,9 @@ enum MenuBuilder { var byID: [String: PopRender] = [:] for r in state.popRenders { byID[r.id] = r } for item in menu.items { + if let submenu = item.submenu { + updatePopRenders(in: submenu, state: state) + } guard let raw = item.identifier?.rawValue, raw.hasPrefix(popRenderItemIDPrefix) else { continue } let id = String(raw.dropFirst(popRenderItemIDPrefix.count)) @@ -346,30 +406,12 @@ enum MenuBuilder { return parent } - /// Inline the Claude session list straight into the main menu so the - /// user can see (and click into) every thread without drilling through - /// a submenu. Each row is colored with the same palette as its icon + /// List Claude/Codex sessions inside the Agents section. Each row is + /// colored with the same palette as its icon /// edge — cyan for working, red for awaiting, gray for stale — so the /// menubar polygon and the dropdown read as the same picture. private static func appendClaude(to menu: NSMenu, state: StateSnapshot, target: AppDelegate) { let sessions = state.claudeSessions - // Label by the agent(s) present: a single agent names itself - // ("Claude" / "Codex"); a mix reads as "Agents". - let agentTypes = Set(sessions.map { $0.agentType }) - let label: String = { - guard let only = agentTypes.first, agentTypes.count == 1 else { return "Agents" } - return only.isEmpty ? "Claude" : only.prefix(1).uppercased() + only.dropFirst() - }() - let header: String - if sessions.isEmpty { - header = "Agents: idle" - } else if state.anyAwaiting { - header = "\(label): \(state.awaitingCount) awaiting · \(sessions.count) active" - } else { - header = "\(label): \(sessions.count) active" - } - menu.addItem(info(header)) - menu.addItem(buildRestoreSubmenu(state: state, target: target)) if !sessions.isEmpty { @@ -431,94 +473,82 @@ enum MenuBuilder { let parent = NSMenuItem(title: "Restore threads", action: nil, keyEquivalent: "") let sub = NSMenu() + for n in 1...10 { + let entry = NSMenuItem( + title: "Restore last \(n)", + action: #selector(AppDelegate.restoreRecentThreads(_:)), + keyEquivalent: "" + ) + entry.target = target + entry.representedObject = n + entry.isEnabled = true + sub.addItem(entry) + } + parent.submenu = sub + return parent + } + + private static func buildWindowLayout(state: StateSnapshot, target: AppDelegate) -> NSMenuItem { + let parent = NSMenuItem(title: "Window layout", action: nil, keyEquivalent: "") + let sub = NSMenu() let tile = item("Auto tile windows", selector: #selector(AppDelegate.toggleAutoTile), target: target) tile.state = state.autoTile ? .on : .off sub.addItem(tile) + let tileNow = item("Tile now", selector: #selector(AppDelegate.tileNow), target: target) tileNow.keyEquivalent = "t" tileNow.keyEquivalentModifierMask = [.command, .option] sub.addItem(tileNow) - // Scatter — the inverse of Tile: shrink every session into tiny confetti - // windows jittered across the desktop (non-overlapping, lots of desktop - // showing through) with super-tiny text. let scatterNow = item("Scatter now", selector: #selector(AppDelegate.scatterNow), target: target) scatterNow.keyEquivalent = "s" scatterNow.keyEquivalentModifierMask = [.command, .option] - scatterNow.toolTip = "Opposite of Tile — shrink each session to a tiny window with super-small text and scatter them across the desktop without overlapping, so most of the desktop shows through." + scatterNow.toolTip = "Shrink each session into a tiny window and scatter them across the desktop." sub.addItem(scatterNow) + sub.addItem(.separator()) - // Text size — radio trio so the active mode is visible at a glance. - // Far = auto-fit "suitable", Near = denser, Tiny = edge-of-legibility. let textParent = NSMenuItem(title: "Text size", action: nil, keyEquivalent: "") let textSub = NSMenu() - let farItem = NSMenuItem(title: "Far (suitable)", action: #selector(AppDelegate.setTextFar), keyEquivalent: "") - farItem.target = target - farItem.state = state.textSize == .far ? .on : .off - textSub.addItem(farItem) - let nearItem = NSMenuItem(title: "Near (small)", action: #selector(AppDelegate.setTextNear), keyEquivalent: "") - nearItem.target = target - nearItem.state = state.textSize == .near ? .on : .off - textSub.addItem(nearItem) - let tinyItem = NSMenuItem(title: "Tiny (tightest)", action: #selector(AppDelegate.setTextTiny), keyEquivalent: "") - tinyItem.target = target - tinyItem.state = state.textSize == .tiny ? .on : .off - textSub.addItem(tinyItem) + let sizes: [(String, Selector, TextSize)] = [ + ("Far (suitable)", #selector(AppDelegate.setTextFar), .far), + ("Near (small)", #selector(AppDelegate.setTextNear), .near), + ("Tiny (tightest)", #selector(AppDelegate.setTextTiny), .tiny), + ] + for (title, selector, size) in sizes { + let entry = item(title, selector: selector, target: target) + entry.state = state.textSize == size ? .on : .off + textSub.addItem(entry) + } textParent.submenu = textSub sub.addItem(textParent) + parent.submenu = sub + return parent + } + + private static func buildAgentAppearance(state: StateSnapshot, target: AppDelegate) -> NSMenuItem { + let parent = NSMenuItem(title: "Terminal experience", action: nil, keyEquivalent: "") + let sub = NSMenu() let theme = item("Theme by status", selector: #selector(AppDelegate.toggleThemeByStatus), target: target) theme.state = state.themeByStatus ? .on : .off - theme.toolTip = "Re-skin Terminal windows by Claude state — Ocean for working, Red Sands for awaiting, custom title shows the subject" sub.addItem(theme) - let bright = item("Bright (sunlight)", selector: #selector(AppDelegate.toggleForceBright), target: target) bright.state = state.forceBright ? .on : .off - bright.toolTip = "Force the bright, sunlight-readable status palettes regardless of the macOS Auto dark/light schedule — for working outdoors" sub.addItem(bright) - let sigils = item("PromptRocks", selector: #selector(AppDelegate.togglePromptSigils), target: target) sigils.state = state.promptSigils ? .on : .off - sigils.toolTip = "Pin a PromptRock to each session's terminal top-right: its pet name stays fixed for the thread while its prompt-shaped form can evolve; colour follows status, and hovering or clicking reveals its subject summary" sub.addItem(sigils) - let lens = item("Zoom lens (⌃⌃)", selector: #selector(AppDelegate.toggleZoomLens), target: target) lens.state = state.zoomLens ? .on : .off - lens.toolTip = "Tap ⌃ twice for a zoom-and-flame special move on the window under the pointer. Move or drag onto another window to ease across, refit, recenter, and fire it again. ⌃⌃ zooms back out." sub.addItem(lens) - let preferIterm = item("Spawn in iTerm2", selector: #selector(AppDelegate.togglePreferIterm), target: target) preferIterm.state = state.preferIterm ? .on : .off - preferIterm.toolTip = "Restore-threads and restart-all open sessions in iTerm2 instead of Terminal.app — the only terminal that shows the tiled, per-session topic wallpapers." sub.addItem(preferIterm) - sub.addItem(.separator()) - // Experimental: tint overlay over the focused Terminal window. Toggles - // on/off for the front window. Mostly here to prove the - // overlay-window-tracks-Terminal approach works before we sink time - // into the real plist-rewrite-for-bg-image path. let overlay = item("Tint overlay on front window", selector: #selector(AppDelegate.toggleBackgroundOverlay), target: target) - overlay.toolTip = "Park a translucent NSWindow over the front Terminal — proof of concept for bg image / transparency. Click again on the same window to remove." sub.addItem(overlay) - - let clearOverlays = item("Clear all overlays", selector: #selector(AppDelegate.clearBackgroundOverlays), target: target) - clearOverlays.toolTip = "Remove every active tint overlay across all Terminal windows." - sub.addItem(clearOverlays) - - sub.addItem(.separator()) - - for n in 1...10 { - let entry = NSMenuItem( - title: "Restore last \(n)", - action: #selector(AppDelegate.restoreRecentThreads(_:)), - keyEquivalent: "" - ) - entry.target = target - entry.representedObject = n - entry.isEnabled = true - sub.addItem(entry) - } + sub.addItem(item("Clear all overlays", selector: #selector(AppDelegate.clearBackgroundOverlays), target: target)) parent.submenu = sub return parent } @@ -736,6 +766,12 @@ enum MenuBuilder { sub.addItem(item("Live tail in terminal", selector: #selector(AppDelegate.openImsgTail), target: target)) sub.addItem(item("Open in Messages", selector: #selector(AppDelegate.openImsg), target: target)) sub.addItem(.separator()) + let acknowledge = item("Mark as ingested", + selector: #selector(AppDelegate.acknowledgeImsg), + target: target) + acknowledge.toolTip = "Clear Slab's pending-input count for this conversation. This is separate from Messages.app's read badge." + sub.addItem(acknowledge) + sub.addItem(.separator()) } else { sub.addItem(info("Not set up — fill in the contact config:")) } @@ -744,6 +780,26 @@ enum MenuBuilder { return parent } + private static func buildSignal(status: String, configured: Bool, target: AppDelegate) -> NSMenuItem { + let parent = NSMenuItem(title: status, action: nil, keyEquivalent: "") + let sub = NSMenu() + if configured { + sub.addItem(item("Open Signal", selector: #selector(AppDelegate.openSignal), target: target)) + sub.addItem(.separator()) + let acknowledge = item("Mark as ingested", + selector: #selector(AppDelegate.acknowledgeSignal), + target: target) + acknowledge.toolTip = "Clear Slab's pending-input count for the watched Signal conversation." + sub.addItem(acknowledge) + sub.addItem(.separator()) + } else { + sub.addItem(info("Not set up — fill in the Signal contact config:")) + } + sub.addItem(item("Edit contact config", selector: #selector(AppDelegate.openSignalConfig), target: target)) + parent.submenu = sub + return parent + } + /// Asana submenu — assigned, incomplete tasks grouped by project. The /// parent title is the helper's "Asana: N" count; the submenu lists each /// project as a disabled header followed by its task items (click → open diff --git a/slab/menubar-swift/Sources/SlabMenubar/Paths.swift b/slab/menubar-swift/Sources/SlabMenubar/Paths.swift index 7073609102..e11528de57 100644 --- a/slab/menubar-swift/Sources/SlabMenubar/Paths.swift +++ b/slab/menubar-swift/Sources/SlabMenubar/Paths.swift @@ -31,6 +31,8 @@ enum Paths { /// never in tracked code). Mirrors the slab-wallpaper wrapper convention. static var imsgHelper: String { "\(slabBin)/imsg" } static var imsgConfig: String { "\(home)/.config/slab/imsg.json" } + static var signalHelper: String { "\(slabBin)/signal" } + static var signalConfig: String { "\(home)/.config/slab/signal.json" } /// Optional prox binding written by `prox_bind_notification`. It contains /// only a stable local session id and wake flag; contact identity remains /// in the separate private iMessage config. diff --git a/slab/menubar-swift/Sources/SlabMenubar/SlabAboutWindow.swift b/slab/menubar-swift/Sources/SlabMenubar/SlabAboutWindow.swift new file mode 100644 index 0000000000..abe2da6731 --- /dev/null +++ b/slab/menubar-swift/Sources/SlabMenubar/SlabAboutWindow.swift @@ -0,0 +1,137 @@ +import AppKit + +/// A small native About window shared by every Slab host. It deliberately +/// mirrors MacPal's calm masthead layout while using the installed Slab app +/// icon, so the status item finally has a visible identity outside the menu. +final class SlabAboutWindow: NSObject, NSWindowDelegate { + private static var shared: SlabAboutWindow? + + private let window: NSWindow + + static func show() { + let controller = shared ?? SlabAboutWindow() + shared = controller + NSApp.activate(ignoringOtherApps: true) + controller.window.center() + controller.window.makeKeyAndOrderFront(nil) + } + + private override init() { + window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 320, height: 330), + styleMask: [.titled, .closable, .fullSizeContentView], + backing: .buffered, + defer: false + ) + super.init() + window.titlebarAppearsTransparent = true + window.titleVisibility = .hidden + window.isMovableByWindowBackground = true + window.isReleasedWhenClosed = false + window.level = .normal + window.delegate = self + buildContent() + } + + func windowWillClose(_ notification: Notification) { + SlabAboutWindow.shared = nil + } + + private func buildContent() { + let content = NSView() + window.contentView = content + + let stack = NSStackView() + stack.orientation = .vertical + stack.alignment = .centerX + stack.spacing = 8 + stack.edgeInsets = NSEdgeInsets(top: 24, left: 32, bottom: 24, right: 32) + stack.translatesAutoresizingMaskIntoConstraints = false + content.addSubview(stack) + NSLayoutConstraint.activate([ + stack.leadingAnchor.constraint(equalTo: content.leadingAnchor), + stack.trailingAnchor.constraint(equalTo: content.trailingAnchor), + stack.topAnchor.constraint(equalTo: content.topAnchor), + stack.bottomAnchor.constraint(equalTo: content.bottomAnchor), + ]) + + let iconView = NSImageView() + iconView.image = slabIcon() + iconView.imageScaling = .scaleProportionallyUpOrDown + iconView.translatesAutoresizingMaskIntoConstraints = false + iconView.widthAnchor.constraint(equalToConstant: 104).isActive = true + iconView.heightAnchor.constraint(equalToConstant: 104).isActive = true + stack.addArrangedSubview(iconView) + stack.setCustomSpacing(12, after: iconView) + + let name = NSTextField(labelWithString: "Slab") + name.font = NSFont.systemFont(ofSize: 22, weight: .bold) + name.alignment = .center + stack.addArrangedSubview(name) + + let tagline = NSTextField(labelWithString: "the living workstation behind Aesthetic Computer") + tagline.font = NSFont.systemFont(ofSize: 12) + tagline.textColor = .secondaryLabelColor + tagline.alignment = .center + stack.addArrangedSubview(tagline) + stack.setCustomSpacing(16, after: tagline) + + let detail = NSTextField(wrappingLabelWithString: + "Prompt sessions, fleet awareness, desktop choreography, and local tools — held together in one quiet place.") + detail.font = NSFont.systemFont(ofSize: 13) + detail.alignment = .center + detail.maximumNumberOfLines = 3 + detail.preferredMaxLayoutWidth = 256 + detail.translatesAutoresizingMaskIntoConstraints = false + detail.widthAnchor.constraint(equalToConstant: 256).isActive = true + stack.addArrangedSubview(detail) + stack.setCustomSpacing(16, after: detail) + + let link = NSButton(title: "aesthetic.computer", target: self, action: #selector(openAestheticComputer)) + link.isBordered = false + link.font = NSFont.systemFont(ofSize: 12, weight: .semibold) + link.contentTintColor = .controlAccentColor + stack.addArrangedSubview(link) + stack.setCustomSpacing(12, after: link) + + let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String + let build = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String + let versionText: String + if let version, let build { + versionText = "Version \(version) (\(build))" + } else { + versionText = "Development build" + } + let versionLabel = NSTextField(labelWithString: versionText) + versionLabel.font = NSFont.systemFont(ofSize: 11) + versionLabel.textColor = .secondaryLabelColor + versionLabel.alignment = .center + stack.addArrangedSubview(versionLabel) + + let copyright = NSTextField(labelWithString: "© 2026 Aesthetic, Inc.") + copyright.font = NSFont.systemFont(ofSize: 11) + copyright.textColor = .tertiaryLabelColor + copyright.alignment = .center + stack.addArrangedSubview(copyright) + + content.layoutSubtreeIfNeeded() + window.setContentSize(NSSize(width: 320, height: ceil(content.fittingSize.height))) + } + + private func slabIcon() -> NSImage { + if let path = Bundle.main.path(forResource: "AppIcon", ofType: "icns"), + let icon = NSImage(contentsOfFile: path) { + return icon + } + if let icon = NSApp.applicationIconImage, icon.isValid { + return icon + } + return NSImage(systemSymbolName: "square.stack.3d.up.fill", + accessibilityDescription: "Slab") ?? NSImage() + } + + @objc private func openAestheticComputer() { + guard let url = URL(string: "https://aesthetic.computer") else { return } + NSWorkspace.shared.open(url) + } +} diff --git a/slab/menubar-swift/Sources/SlabMenubar/StateSnapshot.swift b/slab/menubar-swift/Sources/SlabMenubar/StateSnapshot.swift index ee34f1c009..7ec86acaa8 100644 --- a/slab/menubar-swift/Sources/SlabMenubar/StateSnapshot.swift +++ b/slab/menubar-swift/Sources/SlabMenubar/StateSnapshot.swift @@ -148,6 +148,10 @@ struct StateSnapshot { /// menubar always carries the signal, while themed terminals also receive /// the shared message accent. This is passive awareness only. var messageWaiting: Bool = false + /// Total messages not yet ingested by Slab across the watched input + /// channels. Unlike each desktop app's read badge, this clears when the + /// agent checks, reads, or replies through the bridge. + var inputNotificationCount: Int = 0 /// Deskflow KVM — present only on machines with a deskflow.json; the /// menu shows a status line + Start/Stop/Restart for the LaunchAgent. var deskflow: DeskflowState = DeskflowState() -- 2.51.2