From eb7bf7b8c8410826a6b1dfe8aa17a7137800c457 Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Mon, 15 Jun 2026 18:28:40 -0700 Subject: [PATCH] =?UTF-8?q?slab:=20go=20dark/light=20across=20tailscale=20?= =?UTF-8?q?macs=20(menubar=20+=20=E2=8C=83=E2=8C=A5=E2=8C=98A=20hotkey=20+?= =?UTF-8?q?=20go.fish)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - go.fish: 'go dark'/'go light' flips macOS Dark Mode on neo + reachable tailscale macs (panda/chicken/blueberry) over ssh, passthrough to real go - menubar: Appearance submenu (All Macs → Dark/Light, Toggle) - global ⌃⌥⌘A toggles appearance based on this host's current mode --- dotfiles/fish/functions/go.fish | 30 +++ .../Sources/SlabMenubar/AppDelegate.swift | 244 +++++++++++++++++- .../Sources/SlabMenubar/MenuBuilder.swift | 96 ++++++- 3 files changed, 366 insertions(+), 4 deletions(-) create mode 100644 dotfiles/fish/functions/go.fish diff --git a/dotfiles/fish/functions/go.fish b/dotfiles/fish/functions/go.fish new file mode 100644 index 0000000000..bbc3c6063f --- /dev/null +++ b/dotfiles/fish/functions/go.fish @@ -0,0 +1,30 @@ +function go --description 'go dark|light → flip macOS appearance on neo + reachable tailscale macs; anything else → real go' + switch "$argv[1]" + case dark light + set -l mode $argv[1] + set -l val true + test "$mode" = light; and set val false + + # AppleScript that flips Dark Mode for the logged-in GUI session. + set -l osa "tell application \"System Events\" to tell appearance preferences to set dark mode to $val" + + # Local host (this machine, neo). + osascript -e "$osa" >/dev/null 2>&1 + printf ' %-9s → %s\n' (hostname -s) $mode + + # Remote macs over ssh, flipped in parallel; unreachable ones skipped. + for h in panda chicken blueberry + begin + if ssh -o ConnectTimeout=4 -o BatchMode=yes $h "osascript -e '$osa'" >/dev/null 2>&1 + printf ' %-9s → %s\n' $h $mode + else + printf ' %-9s — unreachable\n' $h + end + end & + end + wait + return 0 + case '*' + command go $argv + end +end diff --git a/slab/menubar-swift/Sources/SlabMenubar/AppDelegate.swift b/slab/menubar-swift/Sources/SlabMenubar/AppDelegate.swift index c02a1e2103..0bd1cfd0fe 100644 --- a/slab/menubar-swift/Sources/SlabMenubar/AppDelegate.swift +++ b/slab/menubar-swift/Sources/SlabMenubar/AppDelegate.swift @@ -75,6 +75,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { /// System-wide ⌘⌥T → re-tile claude terminals. Kept alive for the app's /// lifetime; unregistered in `applicationWillTerminate`. private var tileHotkey: GlobalHotkey? + /// System-wide ⌃⌥⌘A → toggle Dark Mode across this host + tailscale macs. + private var appearanceHotkey: GlobalHotkey? + + /// Macs (beyond this host) to flip when going dark/light. ssh aliases that + /// resolve on the LAN/tailnet; unreachable ones are skipped silently. + private static let appearanceHosts = ["panda", "chicken", "blueberry"] func applicationDidFinishLaunching(_ notification: Notification) { statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) @@ -100,6 +106,22 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { refreshTimer = timer RunLoop.main.add(timer, forMode: .common) + // Title-component hygiene for the Slab-* Terminal profiles: the + // working-dir and active-process checkboxes are NOT scriptable and + // a running Terminal serves profiles from memory (and clobbers + // external plist writes on its next save). The only safe window is + // while Terminal is down — patch at launch if it isn't running, + // and again every time it quits. + NSWorkspace.shared.notificationCenter.addObserver( + self, selector: #selector(appDidTerminate(_:)), + name: NSWorkspace.didTerminateApplicationNotification, object: nil) + if NSRunningApplication.runningApplications( + withBundleIdentifier: "com.apple.Terminal").isEmpty { + DispatchQueue.global(qos: .utility).async { + Self.patchTerminalTitleComponents() + } + } + // Global ⌘⌥T re-tiles claude terminals — same payload as the menu's // "Tile now" item, no need to open the dropdown. let hotkey = GlobalHotkey { [weak self] in self?.tileNow() } @@ -108,6 +130,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { tileHotkey = hotkey } + // Global ⌃⌥⌘A toggles Dark Mode on this host + the tailscale macs. + // Distinct id so it can't collide with the tiling hotkey's slot. + let appHotkey = GlobalHotkey(id: 2) { [weak self] in self?.toggleAppearance() } + if appHotkey.register(keyCode: UInt32(kVK_ANSI_A), + modifiers: UInt32(controlKey | optionKey | cmdKey)) { + appearanceHotkey = appHotkey + } + // setDesktopImageURL only writes the wallpaper on the active Space of // each screen — macOS gives every Space its own wallpaper slot. To // keep all Spaces synced to the current tint we re-apply on each @@ -122,6 +152,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { func applicationWillTerminate(_ notification: Notification) { tileHotkey?.unregister() + appearanceHotkey?.unregister() passphraseServer.stop() NSWorkspace.shared.notificationCenter.removeObserver(self) } @@ -150,6 +181,26 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { // cache probes + fire-and-forget gen kicks, so the main thread // never waits on node/network (see slab-menubar-perf). snapshot.claudeSessions = self.resolveWallpapers(snapshot.claudeSessions) + // Sticky per-session title emoji — assigned here (off-main, + // serialized by the `gathering` guard) so decor + menu read one + // consistent mark. + snapshot.claudeSessions = TitleEmoji.assign(snapshot.claudeSessions) + // RENDERING overlay — a session whose turn is done but whose + // launched render (a ~/.ac-pop-renders heartbeat tagged with its + // sessionId) is still running shows pink `rendering` instead of + // idle slate, so a backgrounded build never reads as "not + // working on anything". Attention states (awaiting/interrupted) + // still win; a genuinely working session stays green. + let renderSids = Set(snapshot.popRenders.map(\.sessionId).filter { !$0.isEmpty }) + if !renderSids.isEmpty { + snapshot.claudeSessions = snapshot.claudeSessions.map { s in + var s = s + if s.state == .complete, renderSids.contains(s.sessionId) { + s.state = .rendering + } + return s + } + } DispatchQueue.main.async { self.gathering = false self.state = snapshot @@ -187,6 +238,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { } // No menu rebuild here — it's lazy via menuNeedsUpdate(_:). + // Consume any "open this PDF / video" asks (tiny main-thread + // stats; see PdfViewer.swift / VideoViewer.swift contracts). + // The lookup hands each viewer chip the sticky TitleEmoji of + // the Claude session that asked (state is main-thread here). + let emojiFor: (String) -> String = { sid in + self.state.claudeSessions.first(where: { $0.sessionId == sid })?.emoji ?? "" + } + PdfViewer.shared.consumeRequests(emojiFor: emojiFor) + VideoViewer.shared.consumeRequests(emojiFor: emojiFor) self.applyTerminalDecor() self.applyDesktopTint() } @@ -824,6 +884,35 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { ShellRunner.runAsync("/usr/bin/osascript", args: ["-e", script]) } + /// Menu items "All Macs → Dark/Light" — representedObject is "dark"/"light". + @objc func setAppearance(_ sender: NSMenuItem) { + guard let mode = sender.representedObject as? String else { return } + applyAppearance(mode) + } + + /// Global ⌃⌥⌘A — flip to the opposite of this host's current mode. + @objc func toggleAppearance() { + let isDark = UserDefaults.standard.string(forKey: "AppleInterfaceStyle") == "Dark" + applyAppearance(isDark ? "light" : "dark") + } + + /// Set macOS Dark Mode on this host and each reachable tailscale mac. Every + /// call is dispatched async (no local shell), so the menu never blocks and + /// an offline/asleep host just times out its ssh quietly. + private func applyAppearance(_ mode: String) { + let val = (mode == "dark") ? "true" : "false" + let osa = "tell application \"System Events\" to tell appearance preferences to set dark mode to \(val)" + // Local host (neo). + ShellRunner.runAsync("/usr/bin/osascript", args: ["-e", osa]) + // Remotes: pass the remote command as a single ssh arg (no local shell), + // so the AppleScript's double quotes survive untouched on the far end. + for host in Self.appearanceHosts { + ShellRunner.runAsync("/usr/bin/ssh", + args: ["-o", "ConnectTimeout=4", "-o", "BatchMode=yes", + host, "osascript -e '\(osa)'"]) + } + } + @objc func sshPeer(_ sender: NSMenuItem) { guard let host = sender.representedObject as? String else { return } let safeHost = host.replacingOccurrences(of: "\\", with: "\\\\") @@ -1028,6 +1117,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { switch s { case .blank: return "blank" case .working: return "working" + case .rendering: return "rendering" case .complete: return "complete" case .awaiting: return "awaiting" case .interrupted: return "interrupted" @@ -1117,6 +1207,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { bold: (56000, 65535, 60000), cursor: (24000, 56000, 34000)), "● working") : (Palette(bg: (55000, 65535, 58000), text: (2500, 20000, 8000), bold: (1000, 13000, 4000), cursor: (4000, 42000, 15000)), "● working") + // Rendering = pink (turn done, but a launched render is still + // cooking — between working-green and awaiting-amber). Light: bright + // rose page, deep magenta ink; dark: deep rose page, bright pink ink. + case .rendering: + return dark + ? (Palette(bg: (9000, 1800, 5800), text: (62000, 44000, 54000), + bold: (65535, 56000, 62000), cursor: (58000, 18000, 40000)), "◐ rendering") + : (Palette(bg: (65535, 54500, 60000), text: (26000, 2500, 15000), + bold: (17000, 1000, 9500), cursor: (54000, 6000, 31000)), "◐ rendering") // Complete = slate (turn done — "look when ready"). Blink: a small, // same-hue nudge so the window breathes rather than flashes. Direction // flips by appearance — dark mode lifts toward brighter slate, bright @@ -1188,6 +1287,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { switch state { case .blank: s = "blank" case .working: s = "working" + case .rendering: s = "rendering" case .complete: s = "complete" case .awaiting: s = "awaiting" // No dedicated Terminal.app settings set for interrupted — reuse the @@ -1275,8 +1375,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { } // Blank windows get an empty custom title so Terminal shows just // its default tty/process line — no "● working · …" badge while - // the page is meant to look blank. - let title = (s.state == .blank) ? "" : "\(glyph) · \(s.titleString)" + // the page is meant to look blank. Every other window leads with + // its sticky session emoji — the anchor that survives retiles. + let emojiPrefix = s.emoji.isEmpty ? "" : "\(s.emoji) " + let title = (s.state == .blank) ? "" : "\(emojiPrefix)\(glyph) · \(s.titleString)" // Terminal.app profile name. The "-msg" suffix gives the "she // texted" magenta-tinted palette its own settings set so Terminal // windows show the accent too (their colors come from the profile, @@ -1426,6 +1528,19 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { tm.append(" try") tm.append(" set clean commands of slabSS to {\"screen\", \"tmux\", \"less\", \"more\", \"view\", \"mandoc\", \"tail\", \"log\", \"top\", \"htop\", \"bash\", \"zsh\", \"sh\", \"fish\", \"node\", \"npm\", \"pnpm\", \"yarn\", \"bun\", \"deno\", \"turbo\", \"vite\", \"tsx\", \"ts-node\", \"nodemon\", \"esbuild\", \"git\", \"ssh\", \"python\", \"python3\", \"ruby\", \"claude\"}") tm.append(" end try") + // A fresh `make new settings set` inherits Terminal's FACTORY + // title components (working dir + process + size all on), not + // the user's default profile — so a themed window read + // "aesthetic-computer — — claude — 80×24". Turn off + // every component AppleScript can reach; the two it can't + // (working dir + active process) are plist-only and handled by + // `patchTerminalTitleComponents()` when Terminal next quits. + tm.append(" try") + tm.append(" set title displays window size of slabSS to false") + tm.append(" set title displays device name of slabSS to false") + tm.append(" set title displays shell path of slabSS to false") + tm.append(" set title displays settings name of slabSS to false") + tm.append(" end try") if let bg = pal.bg { tm.append(" try") tm.append(" set background color of slabSS to \(rgbStr(bg))") @@ -1857,9 +1972,85 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { /// slab-terminal-font-zoom). It steals focus for ~0.35 s/window, so the /// frequent auto-retile-after-close path passes false (fresh/unchanged /// windows already render their profile font). + /// + /// Fast path: windows snap into the grid via the in-process AX API + /// (see AXTiler — milliseconds, no focus steal); the Terminal font pass + /// is an async catch-up that only runs when the grid font actually + /// changed (or on an explicit resetZoom tile). Falls back to the legacy + /// osascript path when Accessibility trust is missing. func tileNowImpl(resetZoom: Bool) { guard let geom = Self.screenGeom() else { return } let textSize = state.textSize + guard AXTiler.trusted else { + tileNowLegacy(resetZoom: resetZoom, geom: geom, textSize: textSize) + return + } + let prevFont = lastTiledFontSize + DispatchQueue.global(qos: .userInitiated).async { [weak self] in + guard let pass = Self.axTilePass(geom: geom, textSize: textSize) else { return } + DispatchQueue.main.async { + self?.lastTiledFontSize = pass.fontSize + // Reset decor memo so the next refresh re-themes every + // window from scratch (a re-pack invalidates prior placement). + self?.lastTerminalDecor.removeAll() + } + // Geometry is already done — the grid snapped above. Terminal + // text size catches up asynchronously, and only when needed: + // the profile-font write + Default-Font-Size menu dance is the + // slow, focus-stealing part of the old tiler. + guard pass.nTerm > 0, resetZoom || prevFont != pass.fontSize else { return } + var lines: [String] = [ + "tell application \"Terminal\"", + " set _slabIds to id of (every window whose miniaturized is false)", + " repeat with _wid in _slabIds", + " try", + " set font size of current settings of (first window whose id is (contents of _wid)) to \(pass.fontSize)", + " end try", + " end repeat", + "end tell", + ] + if resetZoom { + lines.append("tell application \"Terminal\" to activate") + lines.append("repeat with _wid in _slabIds") + lines.append(" try") + lines.append(" tell application \"Terminal\" to set index of (first window whose id is (contents of _wid)) to 1") + lines.append(" delay 0.25") + lines.append(" tell application \"System Events\" to tell process \"Terminal\" to click menu item \"Default Font Size\" of menu 1 of menu bar item \"View\" of menu bar 1") + lines.append(" delay 0.1") + lines.append(" end try") + lines.append("end repeat") + } + ShellRunner.runAsync("/usr/bin/osascript", args: ["-e", lines.joined(separator: "\n")]) { + // The profile switch / zoom reset can reflow window frames — + // re-pin the grid (instant, AX again). + Self.axTilePass(geom: geom, textSize: textSize) + } + } + } + + /// One AX tile sweep: enumerate both terminals' tileable windows + /// (iTerm2 fills the grid first, matching the legacy order), compute + /// the layout, and pin every frame. Returns nil when nothing is open. + private struct AXPass { let nIterm: Int; let nTerm: Int; let fontSize: Int } + @discardableResult + private static func axTilePass(geom: ScreenGeom, textSize: TextSize) -> AXPass? { + let iterm = AXTiler.windows(bundleId: "com.googlecode.iterm2") + let term = AXTiler.windows(bundleId: "com.apple.Terminal") + let all = iterm + term + guard !all.isEmpty, + let layout = computeTileLayout(count: all.count, geom: geom, size: textSize) + else { return nil } + for (i, w) in all.enumerated() { + let cell = layout.cellAt(index: i).bounds + AXTiler.setFrame(w, left: cell.left, top: cell.top, + right: cell.right, bottom: cell.bottom) + } + return AXPass(nIterm: iterm.count, nTerm: term.count, fontSize: layout.fontSize) + } + + /// The pre-AX tiler, kept verbatim as the no-Accessibility fallback: + /// three osascript spawns (two count probes + one bounds script). + private func tileNowLegacy(resetZoom: Bool, geom: ScreenGeom, textSize: TextSize) { DispatchQueue.global(qos: .userInitiated).async { // Size the grid to everything on screen across both apps. The // `is running` guard never launches a quit Terminal.app, so a @@ -1958,6 +2149,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { } private static func windowCount(app: String) -> Int { + // Fast path: count in-process via AX (no osascript spawn, no Apple + // Events round-trip). NSRunningApplication returning nothing for a + // quit app keeps the never-launches guarantee. + if AXTiler.trusted { + let bid = app == "iTerm2" ? "com.googlecode.iterm2" : "com.apple.Terminal" + return AXTiler.windows(bundleId: bid).count + } let spec = appSpecifier(app) // Terminal exposes `miniaturized`, so minimized windows are excluded // from the tile grid (they shouldn't consume a cell). iTerm2 doesn't @@ -2000,6 +2198,48 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { } } + /// Terminal quit → patch the Slab profiles' unscriptable title keys. + /// Delayed a beat so Terminal's own final prefs flush lands first + /// (whoever writes last wins, and we want it to be us). + @objc private func appDidTerminate(_ note: Notification) { + guard let app = note.userInfo?[NSWorkspace.applicationUserInfoKey] + as? NSRunningApplication, + app.bundleIdentifier == "com.apple.Terminal" else { return } + DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + 2) { + Self.patchTerminalTitleComponents() + } + } + + /// Force the title bar of every Slab-* Terminal profile down to just + /// the custom title: the "Working directory or document", "Active + /// process name", and "Window size" components have no AppleScript + /// terms, so they can only be cleared in the prefs plist — and only + /// while Terminal is NOT running (it serves profiles from memory and + /// rewrites the whole domain on save, clobbering external edits). + /// Idempotent; no-ops when Terminal is up or nothing needs changing. + private static func patchTerminalTitleComponents() { + guard NSRunningApplication.runningApplications( + withBundleIdentifier: "com.apple.Terminal").isEmpty else { return } + let domain = "com.apple.Terminal" as CFString + guard var ws = CFPreferencesCopyAppValue("Window Settings" as CFString, domain) + as? [String: Any] else { return } + var dirty = false + for (name, value) in ws where name.hasPrefix("Slab") { + guard var prof = value as? [String: Any] else { continue } + for key in ["ShowRepresentedURLInTitle", + "ShowActiveProcessInTitle", + "ShowDimensionsInTitle", + "ShowTTYNameInTitle"] where (prof[key] as? Bool) != false { + prof[key] = false + dirty = true + } + ws[name] = prof + } + guard dirty else { return } + CFPreferencesSetAppValue("Window Settings" as CFString, ws as CFDictionary, domain) + CFPreferencesAppSynchronize(domain) + } + /// True when the system is currently in Dark mode. Reads NSApp's /// effectiveAppearance so live appearance flips (Auto / sunset switch / /// manual toggle) propagate without an explicit observer — the next diff --git a/slab/menubar-swift/Sources/SlabMenubar/MenuBuilder.swift b/slab/menubar-swift/Sources/SlabMenubar/MenuBuilder.swift index 63a83de4ca..07d0e22b77 100644 --- a/slab/menubar-swift/Sources/SlabMenubar/MenuBuilder.swift +++ b/slab/menubar-swift/Sources/SlabMenubar/MenuBuilder.swift @@ -52,6 +52,8 @@ enum MenuBuilder { menu.addItem(buildImsg(status: imsgStatus, configured: imsgConfigured, target: target)) menu.addItem(buildAsana(state: asana, target: target)) appendOvertime(to: menu, target: target) + menu.addItem(buildPdf(target: target)) + menu.addItem(buildVideo(target: target)) if state.deskflow.configured { menu.addItem(buildDeskflow(state: state, target: target)) } @@ -100,6 +102,8 @@ enum MenuBuilder { saver.toolTip = "Launch the currently-selected screen saver now (Slab Status if chosen in System Settings)." menu.addItem(saver) + menu.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) @@ -277,6 +281,34 @@ enum MenuBuilder { return "\(type)\(bar) \(pctText) \(countCell)\(memCell)\(r.label)" } + /// "Appearance" submenu — flip macOS Dark Mode on this host plus the + /// reachable tailscale macs in one click. Mirrors the global ⌃⌥⌘A toggle. + private static func buildAppearance(target: AppDelegate) -> NSMenuItem { + let parent = NSMenuItem(title: "Appearance", action: nil, keyEquivalent: "") + let sub = NSMenu() + + let dark = NSMenuItem(title: "All Macs → Dark", + action: #selector(AppDelegate.setAppearance(_:)), keyEquivalent: "") + dark.target = target + dark.representedObject = "dark" + sub.addItem(dark) + + let light = NSMenuItem(title: "All Macs → Light", + action: #selector(AppDelegate.setAppearance(_:)), keyEquivalent: "") + light.target = target + light.representedObject = "light" + sub.addItem(light) + + sub.addItem(.separator()) + let toggle = NSMenuItem(title: "Toggle (⌃⌥⌘A)", + action: #selector(AppDelegate.toggleAppearance), keyEquivalent: "") + toggle.target = target + sub.addItem(toggle) + + parent.submenu = sub + return parent + } + private static func buildTailnet(state: StateSnapshot, target: AppDelegate) -> NSMenuItem { let online = state.tailnetPeers.filter { $0.online }.count let total = state.tailnetPeers.count @@ -344,6 +376,7 @@ enum MenuBuilder { switch s.state { case .awaiting: dot = "◉" case .working: dot = "●" + case .rendering: dot = "◐" // turn done, its render still cooking case .complete: dot = "✓" case .interrupted: dot = "✕" // Esc'd, idle at the prompt case .blank: dot = "·" // tiny midpoint — fresh, nothing yet @@ -358,8 +391,11 @@ enum MenuBuilder { if !s2.isEmpty { return s2 } return s.state == .blank ? "(new)" : s2 }() + // The session's sticky emoji rides between dot and subject so a + // menu row and its window wear the same mark (see TitleEmoji). + let emojiPrefix = s.emoji.isEmpty ? "" : "\(s.emoji) " let entry = NSMenuItem( - title: "\(dot) \(subjectText)\(tail)", + title: "\(dot) \(emojiPrefix)\(subjectText)\(tail)", action: #selector(AppDelegate.focusClaudeSession(_:)), keyEquivalent: "" ) @@ -367,7 +403,7 @@ enum MenuBuilder { entry.representedObject = s.tty entry.toolTip = sessionTooltip(s) entry.isEnabled = !s.tty.isEmpty - entry.attributedTitle = coloredTitle(for: s, dot: dot, subject: subjectText, tail: tail) + entry.attributedTitle = coloredTitle(for: s, dot: dot, subject: "\(emojiPrefix)\(subjectText)", tail: tail) menu.addItem(entry) } } @@ -462,6 +498,7 @@ enum MenuBuilder { // awaiting = warm amber (needs you), blank = quiet gray (fresh, no // input yet), stale = gray. case .working: dotColor = NSColor(deviceHue: 0.33, saturation: 0.70, brightness: 0.78, alpha: 1.0) + case .rendering: dotColor = NSColor(deviceHue: 0.92, saturation: 0.62, brightness: 0.96, alpha: 1.0) // pink — between green and amber case .complete: dotColor = NSColor(deviceHue: 0.58, saturation: 0.30, brightness: 0.70, alpha: 1.0) case .awaiting: dotColor = NSColor(deviceHue: 0.10, saturation: 0.95, brightness: 0.95, alpha: 1.0) case .interrupted: dotColor = NSColor(deviceHue: 0.78, saturation: 0.55, brightness: 0.80, alpha: 1.0) // violet @@ -488,6 +525,8 @@ enum MenuBuilder { parts.append("turn complete (idle)") case .working: parts.append("working") + case .rendering: + parts.append("rendering (a launched render is still running)") case .interrupted: parts.append("interrupted (Esc'd, idle at prompt)") case .blank: @@ -571,6 +610,59 @@ enum MenuBuilder { /// iMessage submenu — mirrors Mail. The parent title is whatever the /// helper reported (the contact's display name lives only in the /// untracked config, never here), so nothing personal is in tracked code. + /// PDF viewer submenu — slab's minimal scroller (PdfViewer.swift). Lists + /// every open document (click → bring forward), plus open/close-all. The + /// title carries the count so glancing tells you what slab is holding. + private static func buildPdf(target: AppDelegate) -> NSMenuItem { + let open = PdfViewer.shared.openPaths + let parent = NSMenuItem( + title: open.isEmpty ? "PDF viewer" : "PDF viewer: \(open.count)", + action: nil, keyEquivalent: "") + let sub = NSMenu() + sub.autoenablesItems = false + sub.addItem(item("Open PDF…", selector: #selector(AppDelegate.openPdfFromPanel), target: target)) + if !open.isEmpty { + sub.addItem(.separator()) + for path in open { + let row = item((path as NSString).lastPathComponent, + selector: #selector(AppDelegate.focusPdf(_:)), target: target) + row.representedObject = path + row.toolTip = path + sub.addItem(row) + } + sub.addItem(.separator()) + sub.addItem(item("Close all", selector: #selector(AppDelegate.closeAllPdfs), target: target)) + } + parent.submenu = sub + return parent + } + + /// Video viewer submenu — slab's minimal player (VideoViewer.swift), so + /// rendered videos preview without QuickTime. Same shape as buildPdf. + private static func buildVideo(target: AppDelegate) -> NSMenuItem { + let open = VideoViewer.shared.openPaths + let parent = NSMenuItem( + title: open.isEmpty ? "Video viewer" : "Video viewer: \(open.count)", + action: nil, keyEquivalent: "") + let sub = NSMenu() + sub.autoenablesItems = false + sub.addItem(item("Open video…", selector: #selector(AppDelegate.openVideoFromPanel), target: target)) + if !open.isEmpty { + sub.addItem(.separator()) + for path in open { + let row = item((path as NSString).lastPathComponent, + selector: #selector(AppDelegate.focusVideo(_:)), target: target) + row.representedObject = path + row.toolTip = path + sub.addItem(row) + } + sub.addItem(.separator()) + sub.addItem(item("Close all", selector: #selector(AppDelegate.closeAllVideos), target: target)) + } + parent.submenu = sub + return parent + } + private static func buildImsg(status: String, configured: Bool, target: AppDelegate) -> NSMenuItem { let parent = NSMenuItem(title: status, action: nil, keyEquivalent: "") let sub = NSMenu() -- 2.51.2