diff --git a/.gitignore b/.gitignore index ee98377..1c97b0e 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,9 @@ icon/claude-src/ icon/AppIcon.iconset/ +# Session handoff notes — local only, never committed +CHECKPOINT.md + # Local reference material (e.g. nested checkouts of other projects) — not # part of this project, never meant to be committed. .scratch/ diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 07c2cd4..08b6342 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -9,6 +9,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate, LiveSt let liveState = LiveState() private var currentInfos: [ProfileInfo] = [] private var collisionAlertShown = false + private var windowOverlays: [String: WindowOverlay] = [:] + private var pendingWindowOverlays: Set = [] func applicationDidFinishLaunching(_ notification: Notification) { menu.delegate = self @@ -50,6 +52,33 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate, LiveSt currentInfos = infos applyTitle(infos) ManageWindowController.shared.update(infos) + updateWindowOverlays(infos) + } + + // Shown on every running *managed* profile — never on "default", + // which isn't a shannoncoat profile at all, just the real underlying + // Claude install. Removing an entry here deinits its WindowOverlay, + // which tears down its AXObserver and hides the tag panel. + private func updateWindowOverlays(_ infos: [ProfileInfo]) { + let managed = infos.filter { $0.running && $0.profile.name != "default" } + let managedNames = Set(managed.map(\.profile.name)) + windowOverlays = windowOverlays.filter { managedNames.contains($0.key) } + pendingWindowOverlays.formIntersection(managedNames) + + for info in managed + where windowOverlays[info.profile.name] == nil && !pendingWindowOverlays.contains(info.profile.name) { + guard let pid = info.pid else { continue } + let name = info.profile.name + pendingWindowOverlays.insert(name) + WindowOverlay.attach(profileName: name, color: ProfileColor.dotColor(for: name), pid: pid) { [weak self] overlay in + guard let self else { return } + pendingWindowOverlays.remove(name) + // The profile may have quit again while this was retrying + // for its window — don't attach a stale overlay if so. + guard currentInfos.contains(where: { $0.profile.name == name && $0.running }) else { return } + windowOverlays[name] = overlay + } + } } // Two profiles sharing a dir is a fatal misconfiguration (would diff --git a/Sources/WindowOverlay.swift b/Sources/WindowOverlay.swift new file mode 100644 index 0000000..338ed5d --- /dev/null +++ b/Sources/WindowOverlay.swift @@ -0,0 +1,231 @@ +// A small floating tag pinned to a Claude window's corner, showing which +// profile it belongs to — every profile launches the same app with the +// same icon and title, so there's otherwise no way to tell two open +// Claude windows apart at a glance. Kept in sync live via AXObserver +// notifications (move/resize/miniaturize/destroy) rather than polling — +// only a persistent process can hold a watcher like this at all, which is +// exactly the gap the old one-shot CLI script couldn't close. +import AppKit +import ApplicationServices + +// The tag's position relative to its window's top-right corner, shared +// across every open tag and persisted so a drag survives a relaunch. +// Anchored from the right/top edges (not left) because that's the corner +// that stays put under the common resize gesture (dragging the bottom-right +// handle) — an offset-from-left would drift away from a user's chosen spot +// every time the window changed width. +enum WindowOverlayPosition { + private static let rightInsetKey = "WindowOverlayRightInset" + private static let topInsetKey = "WindowOverlayTopInset" + + // Top-right by default — top-left is where every window's close/ + // minimize/zoom controls live, which the tag must never sit over. + static let defaultRightInset: CGFloat = 10 + static let defaultTopInset: CGFloat = 6 + + static var rightInset: CGFloat { + get { (UserDefaults.standard.object(forKey: rightInsetKey) as? Double).map { CGFloat($0) } ?? defaultRightInset } + set { UserDefaults.standard.set(Double(newValue), forKey: rightInsetKey) } + } + + static var topInset: CGFloat { + get { (UserDefaults.standard.object(forKey: topInsetKey) as? Double).map { CGFloat($0) } ?? defaultTopInset } + set { UserDefaults.standard.set(Double(newValue), forKey: topInsetKey) } + } +} + +final class WindowOverlay: NSObject, NSWindowDelegate { + let pid: pid_t + private let axWindow: AXUIElement + private var observer: AXObserver? + private let panel: NSPanel + + private static let tagSize = NSSize(width: 110, height: 20) + + init?(profileName: String, color: NSColor, pid: pid_t) { + let axApp = AXUIElementCreateApplication(pid) + guard let window = Self.firstWindow(of: axApp) else { return nil } + self.pid = pid + self.axWindow = window + + let panel = NSPanel( + contentRect: NSRect(origin: .zero, size: Self.tagSize), + styleMask: [.borderless, .nonactivatingPanel], + backing: .buffered, defer: false) + panel.isOpaque = false + panel.backgroundColor = .clear + panel.hasShadow = true + panel.level = .floating + // Draggable (so it can be moved off whatever it's obstructing), + // but .nonactivatingPanel keeps a click/drag from stealing focus + // away from the Claude window underneath. + panel.isMovableByWindowBackground = true + panel.collectionBehavior = [.stationary, .ignoresCycle] + + let background = NSView(frame: NSRect(origin: .zero, size: Self.tagSize)) + background.wantsLayer = true + background.layer?.backgroundColor = color.cgColor + background.layer?.cornerRadius = 5 + + let label = NSTextField(labelWithString: profileName) + label.frame = NSRect(origin: .zero, size: Self.tagSize) + label.alignment = .center + label.font = .systemFont(ofSize: 11, weight: .semibold) + label.textColor = .white + label.drawsBackground = false + label.lineBreakMode = .byTruncatingTail + background.addSubview(label) + + panel.contentView = background + self.panel = panel + super.init() + panel.delegate = self + + guard reposition() else { return nil } + startObserving() + } + + // A cold Electron launch can take a second or more between the + // process starting (which is what triggers `updateWindowOverlays`, + // via NSWorkspace's launch notification) and its first window + // actually existing — the same race ClaudeControl.focus's + // waitForWindow already handles for the same reason. `init?` can't + // retry on its own (once it returns nil, that attempt is done), so + // this polls it every quarter second for up to ~3s before giving up. + static func attach( + profileName: String, color: NSColor, pid: pid_t, attemptsRemaining: Int = 12, + completion: @escaping (WindowOverlay?) -> Void + ) { + if let overlay = WindowOverlay(profileName: profileName, color: color, pid: pid) { + completion(overlay) + } else if attemptsRemaining > 0 { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { + attach( + profileName: profileName, color: color, pid: pid, attemptsRemaining: attemptsRemaining - 1, + completion: completion) + } + } else { + completion(nil) + } + } + + deinit { + stopObserving() + panel.orderOut(nil) + } + + private static func firstWindow(of axApp: AXUIElement) -> AXUIElement? { + var value: CFTypeRef? + guard AXUIElementCopyAttributeValue(axApp, kAXWindowsAttribute as CFString, &value) == .success, + let windows = value as? [AXUIElement], let first = windows.first + else { return nil } + return first + } + + private static func frame(of window: AXUIElement) -> CGRect? { + var posValue: CFTypeRef? + var sizeValue: CFTypeRef? + guard AXUIElementCopyAttributeValue(window, kAXPositionAttribute as CFString, &posValue) == .success, + AXUIElementCopyAttributeValue(window, kAXSizeAttribute as CFString, &sizeValue) == .success, + CFGetTypeID(posValue) == AXValueGetTypeID(), CFGetTypeID(sizeValue) == AXValueGetTypeID() + else { return nil } + var point = CGPoint.zero + var size = CGSize.zero + guard AXValueGetValue(posValue as! AXValue, .cgPoint, &point), + AXValueGetValue(sizeValue as! AXValue, .cgSize, &size) + else { return nil } + return CGRect(origin: point, size: size) + } + + private static func screen(containing axFrame: CGRect) -> NSScreen? { + NSScreen.screens.first { $0.frame.minX <= axFrame.midX && axFrame.midX <= $0.frame.maxX } ?? NSScreen.main + } + + // AX coordinates are top-left-origin (screen top = y 0); AppKit screen + // coordinates are bottom-left-origin. Returns the window's frame + // translated into AppKit's space. + private static func appKitFrame(of axFrame: CGRect, on screen: NSScreen) -> NSRect { + NSRect(x: axFrame.origin.x, y: screen.frame.maxY - axFrame.origin.y - axFrame.height, + width: axFrame.width, height: axFrame.height) + } + + // Positions the tag using the persisted (or default) offset from the + // window's top-right corner. Returns false (and hides the tag) if the + // window has no readable frame right now — minimized, or Accessibility + // not granted. + @discardableResult + private func reposition() -> Bool { + guard let axFrame = Self.frame(of: axWindow), let screen = Self.screen(containing: axFrame) else { + panel.orderOut(nil) + return false + } + let windowFrame = Self.appKitFrame(of: axFrame, on: screen) + let origin = NSPoint( + x: windowFrame.maxX - WindowOverlayPosition.rightInset - Self.tagSize.width, + y: windowFrame.maxY - WindowOverlayPosition.topInset - Self.tagSize.height) + panel.setFrameOrigin(origin) + if !panel.isVisible { panel.orderFrontRegardless() } + return true + } + + // Fires for every panel move — ours (from `reposition`, when the + // tracked window itself moves/resizes) and the user's (dragging the + // tag). Either way, re-deriving the offset from the panel's actual + // current position and persisting it is correct: our own moves just + // recompute the same offset they were placed with, a no-op; a user + // drag captures the new spot they chose so it survives the next + // reposition and the next launch. Clamped to stay within the window + // so a sloppy drag can't wander off it entirely. + func windowDidMove(_ notification: Notification) { + guard let axFrame = Self.frame(of: axWindow), let screen = Self.screen(containing: axFrame) else { return } + let windowFrame = Self.appKitFrame(of: axFrame, on: screen) + let panelOrigin = panel.frame.origin + let rightInset = (windowFrame.maxX - (panelOrigin.x + Self.tagSize.width)) + .clamped(to: 0...(max(0, windowFrame.width - Self.tagSize.width))) + let topInset = (windowFrame.maxY - (panelOrigin.y + Self.tagSize.height)) + .clamped(to: 0...(max(0, windowFrame.height - Self.tagSize.height))) + WindowOverlayPosition.rightInset = rightInset + WindowOverlayPosition.topInset = topInset + } + + // MARK: - Live tracking + + private func startObserving() { + var newObserver: AXObserver? + guard AXObserverCreate(pid, WindowOverlay.axCallback, &newObserver) == .success, let newObserver + else { return } + observer = newObserver + + let refcon = Unmanaged.passUnretained(self).toOpaque() + for name in [ + kAXMovedNotification, kAXResizedNotification, kAXUIElementDestroyedNotification, + kAXWindowMiniaturizedNotification, kAXWindowDeminiaturizedNotification, + ] { + AXObserverAddNotification(newObserver, axWindow, name as CFString, refcon) + } + CFRunLoopAddSource(CFRunLoopGetCurrent(), AXObserverGetRunLoopSource(newObserver), .defaultMode) + } + + private func stopObserving() { + guard let observer else { return } + CFRunLoopRemoveSource(CFRunLoopGetCurrent(), AXObserverGetRunLoopSource(observer), .defaultMode) + self.observer = nil + } + + private static let axCallback: AXObserverCallback = { _, _, notification, refcon in + guard let refcon else { return } + let overlay = Unmanaged.fromOpaque(refcon).takeUnretainedValue() + switch notification as String { + case kAXUIElementDestroyedNotification, kAXWindowMiniaturizedNotification: + overlay.panel.orderOut(nil) + default: + overlay.reposition() + } + } +} + +extension Comparable { + func clamped(to range: ClosedRange) -> Self { + min(max(self, range.lowerBound), range.upperBound) + } +} diff --git a/VERSION b/VERSION index bcab45a..bbdeab6 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.0.3 +0.0.5