// 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 // How the tag is drawn. A dot is for when the colour alone already // identifies the window and the name is just clutter; it's never rotated, // having no reading direction to preserve. enum WindowOverlayStyle: String { case chip, dot } // The window corner a tag is anchored to. Anchoring to the *nearest* // corner rather than always the top-right is what holds a tag still // through a resize: one parked near the bottom-left tracks that corner, // so dragging the top-right handle no longer drags the tag with it. enum WindowOverlayCorner: String { case topLeft, topRight, bottomLeft, bottomRight var isTop: Bool { self == .topLeft || self == .topRight } var isLeft: Bool { self == .topLeft || self == .bottomLeft } // Quadrant test rather than four distance comparisons — same answer, // and it stays well-defined for a tag sitting dead centre. static func nearest(to point: NSPoint, in frame: NSRect) -> WindowOverlayCorner { switch (point.y > frame.midY, point.x < frame.midX) { case (true, true): return .topLeft case (true, false): return .topRight case (false, true): return .bottomLeft case (false, false): return .bottomRight } } } // Where one profile's tag sits on its window, persisted so a drag survives // a relaunch. Stored per profile rather than once for everything: two // windows side by side are exactly the case these tags exist for, and // wanting each one's tag somewhere different is the normal outcome — one // window's sidebar is not another's. // // Offsets are stored as the user dropped them and are never written back // clamped. A window too small to honour the full offset shows the tag // pushed in as far as it fits (see `WindowOverlay.frame(window:screen:)`), but the // stored distance is left intact, so widening the window again restores // the chosen spot instead of leaving the tag stuck where a temporary // resize squeezed it. struct WindowOverlayPlacement { // Top-right by default — top-left is where every window's close/ // minimize/zoom controls live, which the tag must never sit over. var corner: WindowOverlayCorner = .topRight var insetX: CGFloat = 8 var insetY: CGFloat = 6 var isVertical: Bool = false private static func key(_ field: String, _ profile: String) -> String { "WindowOverlay\(field).\(profile)" } // From the version that kept a single shared offset. Read as a seed for // a profile with nothing stored yet, so an existing tag stays where it // already is instead of jumping on the first launch after upgrading. private static let legacyInsetXKey = "WindowOverlayRightInset" private static let legacyInsetYKey = "WindowOverlayTopInset" private static func number(_ defaults: UserDefaults, _ keys: String...) -> CGFloat? { for key in keys { if let value = defaults.object(forKey: key) as? Double { return CGFloat(value) } } return nil } static func load(for profile: String) -> WindowOverlayPlacement { let defaults = UserDefaults.standard var placement = WindowOverlayPlacement() if let raw = defaults.string(forKey: key("Corner", profile)), let corner = WindowOverlayCorner(rawValue: raw) { placement.corner = corner } if let x = number(defaults, key("InsetX", profile), legacyInsetXKey) { placement.insetX = x } if let y = number(defaults, key("InsetY", profile), legacyInsetYKey) { placement.insetY = y } placement.isVertical = defaults.bool(forKey: key("Vertical", profile)) return placement } func save(for profile: String) { let defaults = UserDefaults.standard defaults.set(corner.rawValue, forKey: Self.key("Corner", profile)) defaults.set(Double(insetX), forKey: Self.key("InsetX", profile)) defaults.set(Double(insetY), forKey: Self.key("InsetY", profile)) defaults.set(isVertical, forKey: Self.key("Vertical", profile)) } } // Chip or dot. Unlike placement this really is one setting for the whole // app — it's a single control in Settings, not something chosen per window. enum WindowOverlayPosition { private static let styleKey = "WindowOverlayStyle" static var style: WindowOverlayStyle { get { (UserDefaults.standard.string(forKey: styleKey)).flatMap(WindowOverlayStyle.init(rawValue:)) ?? .chip } set { UserDefaults.standard.set(newValue.rawValue, forKey: styleKey) } } } // Draws the tag itself. Custom drawing rather than a laid-out NSTextField // because both of the things this has to get right — centring the text on // the chip's actual midline, and rotating the whole chip when it's parked // against a side edge — are a transform and two draw calls here, versus // fighting a label's intrinsic baseline placement and its unrotatable // frame. private final class TagView: NSView { var text: String var color: NSColor var style: WindowOverlayStyle var isVertical: Bool // Which side edge a vertical chip is against, which decides the way it // reads: bottom-to-top on the left, top-to-bottom on the right, so the // text always leans into the window rather than away from it. Ignored // when horizontal. var isLeftSide: Bool static let thickness: CGFloat = 13 static let dotDiameter: CGFloat = 11 private static let horizontalPadding: CGFloat = 7 private static let maxLength: CGFloat = 140 static let font = NSFont.systemFont(ofSize: 10, weight: .semibold) init(text: String, color: NSColor, style: WindowOverlayStyle, isVertical: Bool, isLeftSide: Bool) { self.text = text self.color = color self.style = style self.isVertical = isVertical self.isLeftSide = isLeftSide super.init(frame: .zero) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } // The chip's extent along its reading direction, before any rotation. private static func chipLength(for text: String) -> CGFloat { let width = (text as NSString).size(withAttributes: [.font: font]).width return min(maxLength, (width + horizontalPadding * 2).rounded(.up)) } // Rotation is a property of the drawing, not of the panel, so the // panel's own width and height swap over for a vertical chip. static func size(text: String, style: WindowOverlayStyle, isVertical: Bool) -> NSSize { switch style { case .dot: return NSSize(width: dotDiameter, height: dotDiameter) case .chip: let length = chipLength(for: text) return isVertical ? NSSize(width: thickness, height: length) : NSSize(width: length, height: thickness) } } override func draw(_ dirtyRect: NSRect) { guard let ctx = NSGraphicsContext.current?.cgContext else { return } if style == .dot { color.setFill() NSBezierPath(ovalIn: bounds).fill() return } ctx.saveGState() defer { ctx.restoreGState() } // Lay the chip out once, horizontally, around the origin — then // rotate the whole coordinate space if it belongs on a side edge. // One layout path serves both orientations, so the vertical case // can't drift out of step with the horizontal one. ctx.translateBy(x: bounds.midX, y: bounds.midY) if isVertical { ctx.rotate(by: isLeftSide ? .pi / 2 : -.pi / 2) } let length = Self.chipLength(for: text) let rect = NSRect( x: -length / 2, y: -Self.thickness / 2, width: length, height: Self.thickness) color.setFill() NSBezierPath(roundedRect: rect, xRadius: Self.thickness / 2, yRadius: Self.thickness / 2).fill() let paragraph = NSMutableParagraphStyle() paragraph.alignment = .center paragraph.lineBreakMode = .byTruncatingTail let attributes: [NSAttributedString.Key: Any] = [ .font: Self.font, .foregroundColor: ProfileColor.contrastingTextColor(on: color), .paragraphStyle: paragraph, ] let attributed = NSAttributedString(string: text, attributes: attributes) // Drawn into a rect exactly one line tall and centred on the // chip's midline. Handing it the chip's full height instead would // top-align the text inside it — the reason the old label sat // high rather than centred. let lineHeight = attributed.size().height attributed.draw(in: NSRect( x: rect.minX + Self.horizontalPadding, y: rect.midY - lineHeight / 2, width: rect.width - Self.horizontalPadding * 2, height: lineHeight)) } } final class WindowOverlay: NSObject, NSWindowDelegate { let pid: pid_t private let axWindow: AXUIElement private var observer: AXObserver? private let panel: NSPanel private let tagView: TagView private let profileName: String private var placement: WindowOverlayPlacement // True only while `reposition` is moving the panel itself — see // `windowDidMove`, which must ignore those moves. private var isRepositioning = false private var dragSettle: DispatchWorkItem? private var isMinimized = false 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 self.profileName = profileName let placement = WindowOverlayPlacement.load(for: profileName) self.placement = placement let style = WindowOverlayPosition.style let tagView = TagView( text: profileName, color: color, style: style, isVertical: style == .chip && placement.isVertical, isLeftSide: placement.corner.isLeft) tagView.autoresizingMask = [.width, .height] self.tagView = tagView let panel = NSPanel( contentRect: NSRect(origin: .zero, size: NSSize(width: 1, height: 1)), 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] panel.contentView = tagView 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 { dragSettle?.cancel() 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) } private var currentGeometry: (window: NSRect, screen: NSScreen)? { guard let axFrame = Self.frame(of: axWindow), let screen = Self.screen(containing: axFrame) else { return nil } return (Self.appKitFrame(of: axFrame, on: screen), screen) } // The tag's frame for a given window frame: measured inward from its // anchored corner, with the offsets clamped so it always stays over // the window no matter how small that window gets. Only the *drawn* // position is clamped — see WindowOverlayPlacement. // // Then clamped again, into whatever part of the window is actually on // screen. A window dragged half off the edge takes its anchored corner // with it, and a tag that follows it out of view identifies nothing — // the whole point is telling, at a glance, which profile the window you // can still see belongs to. Clamped to the window's *visible* portion // rather than to the screen at large so the tag stays on the thing it // labels instead of drifting off onto its own. `visibleFrame` keeps it // clear of the menu bar and Dock, which would hide it just as // effectively as the screen edge. // // Both clamps are presentational only — neither is written back to // `placement`, which changes solely when the user drags the tag. So the // anchor is remembered throughout, and the tag returns to it the moment // the window is fully back on screen. private func frame(window: NSRect, screen: NSScreen) -> NSRect { let style = WindowOverlayPosition.style let size = TagView.size( text: profileName, style: style, isVertical: style == .chip && placement.isVertical) let corner = placement.corner let insetX = placement.insetX .clamped(to: 0...max(0, window.width - size.width)) let insetY = placement.insetY .clamped(to: 0...max(0, window.height - size.height)) var origin = NSPoint( x: corner.isLeft ? window.minX + insetX : window.maxX - insetX - size.width, y: corner.isTop ? window.maxY - insetY - size.height : window.minY + insetY) // A window entirely off screen leaves nothing to clamp into; fall // back to the screen so the tag stays reachable rather than being // pinned to an empty rect. let onScreen = window.intersection(screen.visibleFrame) let bounds = onScreen.isEmpty ? screen.visibleFrame : onScreen origin.x = origin.x.clamped(to: bounds.minX...max(bounds.minX, bounds.maxX - size.width)) origin.y = origin.y.clamped(to: bounds.minY...max(bounds.minY, bounds.maxY - size.height)) return NSRect(origin: origin, size: size) } // Puts the tag where the current placement says it belongs. 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 (windowFrame, screen) = currentGeometry else { panel.orderOut(nil) return false } let style = WindowOverlayPosition.style tagView.style = style tagView.isVertical = style == .chip && placement.isVertical tagView.isLeftSide = placement.corner.isLeft tagView.needsDisplay = true isRepositioning = true defer { isRepositioning = false } let tagFrame = frame(window: windowFrame, screen: screen) panel.setFrame(tagFrame, display: true) // The shadow is derived from the content's alpha, so a shape or // size change leaves a stale one behind without this. panel.invalidateShadow() // Positioned either way — only the showing is conditional, so a // hidden tag still knows where it belongs and can be revealed later // without recomputing anything. if isMinimized || !isTargetVisible(tagFrame: tagFrame) { panel.orderOut(nil) } else if !panel.isVisible { panel.orderFrontRegardless() } return true } // Re-evaluate whether the tag should currently be on screen. Called // when some app activates, which is the usual way a window ends up // buried or uncovered without moving at all. func refreshVisibility() { reposition() } // A tag is a floating panel, which puts it above every ordinary window // on the system rather than merely above the window it belongs to. Left // alone it hovers over the browser, the editor, everything — even when // its own window is buried or on another Space. // // There is no cross-process way to attach one window above another: // `addChildWindow` is same-process only, and the private ordering call // window managers use is a one-shot operation that goes stale the // moment anything else reorders, so it wouldn't avoid this work either. // So the tag is shown only when the window it labels is genuinely // visible underneath it. // // The window server returns its list strictly front-to-back, which // answers both halves in one pass: walk forward, and anything // overlapping the tag before we reach our own window is covering it. // Reaching our window with nothing in the way means the tag is showing // real estate that actually belongs to that window; never reaching it // means the window isn't on screen at all — minimized, hidden, or on // another Space — and the tag has nothing to label. private func isTargetVisible(tagFrame: NSRect) -> Bool { guard let listing = CGWindowListCopyWindowInfo( [.optionOnScreenOnly, .excludeDesktopElements], kCGNullWindowID) as? [[String: Any]] else { return true } // can't tell — better a stray tag than a missing one let tag = Self.cgRect(of: tagFrame) let ownPid = getpid() for entry in listing { guard let owner = entry[kCGWindowOwnerPID as String] as? pid_t, let boundsDict = entry[kCGWindowBounds as String], let bounds = CGRect(dictionaryRepresentation: boundsDict as! CFDictionary) else { continue } // Identified by owner alone, deliberately. Matching the exact // rect meant comparing a freshly-read AX frame against the // window server's snapshot, and mid-move the two disagree — // AX already reports the new position while the listing still // has the old one. The match then failed, the walk continued // past our own window, and the first unrelated window that // happened to overlap the tag "occluded" it. Whether that // occurred came down to what was nearby, which is why it // looked as though only certain drag directions broke it. // // A dialog of the same app landing here counts as reaching our // window, which is right: Claude covering its own window is // not a reason to disown the tag. if owner == pid, bounds.intersects(tag) { return true } // Our own tags are in this list too, and one tag sitting over // another's window must not hide it. if owner == ownPid { continue } // Only ordinary windows can bury another ordinary window. // Everything above that band — the menu bar, the Dock, // notification banners, and the invisible one-pixel markers // some utilities park in a screen corner — is permanently in // front of everything and would veto the tag forever. One such // marker at the bottom-left corner is what made a tag vanish // there, and only there: it takes a move off two edges at once // for the tag to clamp into that exact pixel. guard (entry[kCGWindowLayer as String] as? Int) == 0 else { continue } if bounds.intersects(tag) { return false } } return false } // AppKit is bottom-left-origin per screen; the window server is // top-left-origin from the primary screen (screens[0], the one with the // menu bar). private static func cgRect(of rect: NSRect) -> CGRect { let primaryMaxY = NSScreen.screens.first?.frame.maxY ?? rect.maxY return CGRect(x: rect.minX, y: primaryMaxY - rect.maxY, width: rect.width, height: rect.height) } // MARK: - User placement // Fires for every panel move — ours (from `reposition`, when the // tracked window itself moves/resizes) and the user's (dragging the // tag). Only the latter should redefine the placement, which is what // the `isRepositioning` guard filters for. // // Re-deriving the offset from our own moves too looks like it should // be a harmless no-op — recomputing the very offset we just positioned // against — but it isn't, because the two halves read the window at // different instants. AppKit posts this notification synchronously // from inside `setFrame`, and during a live window drag the AX frame // read here is already newer than the one `reposition` derived the // origin from, so each move event bakes in the few points the window // travelled in between. Those errors accumulate across the hundreds // of events one drag produces, until the offset saturates and the tag // has walked clear across its window — off-screen entirely, if that // edge of the window is. func windowDidMove(_ notification: Notification) { guard !isRepositioning else { return } scheduleDragSettle() } // A tag drag emits a continuous stream of moves, and re-anchoring on // each one would snap the tag out from under the cursor mid-gesture // (and flip its orientation repeatedly on the way past an edge). So // the placement is only committed once the moves stop, which is as // close to a "drag ended" signal as a background-movable window gets // — NSWindow has no such delegate callback, unlike live resize. private func scheduleDragSettle() { dragSettle?.cancel() let work = DispatchWorkItem { [weak self] in self?.commitUserPlacement() } dragSettle = work DispatchQueue.main.asyncAfter(deadline: .now() + 0.2, execute: work) } private func commitUserPlacement() { guard let windowFrame = currentGeometry?.window else { return } let tagFrame = panel.frame let centre = NSPoint(x: tagFrame.midX, y: tagFrame.midY) let corner = WindowOverlayCorner.nearest(to: centre, in: windowFrame) // Orientation follows whichever edge the tag ended up nearest: a // chip against a side reads vertically, so it can sit close in // without covering the window's own controls. Dots have no // reading direction, so they're never rotated. let toSide = min(centre.x - windowFrame.minX, windowFrame.maxX - centre.x) let toTopOrBottom = min(windowFrame.maxY - centre.y, centre.y - windowFrame.minY) let isVertical = WindowOverlayPosition.style == .chip && toSide < toTopOrBottom // Offsets run inward from the anchored corner. Stored unclamped at // the top end (a drag outside the window is clamped to zero, but a // long reach into a wide window is kept in full) so a later resize // can restore it. let insetX = corner.isLeft ? tagFrame.minX - windowFrame.minX : windowFrame.maxX - tagFrame.maxX let insetY = corner.isTop ? windowFrame.maxY - tagFrame.maxY : tagFrame.minY - windowFrame.minY placement = WindowOverlayPlacement( corner: corner, insetX: max(0, insetX), insetY: max(0, insetY), isVertical: isVertical) placement.save(for: profileName) // Snap into the committed placement — this is what applies a // rotation the drag just earned, and squares the tag up against // its corner. reposition() } // 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: overlay.panel.orderOut(nil) case kAXWindowMiniaturizedNotification: overlay.isMinimized = true overlay.reposition() case kAXWindowDeminiaturizedNotification: overlay.isMinimized = false overlay.reposition() default: overlay.reposition() } } } extension Comparable { func clamped(to range: ClosedRange) -> Self { min(max(self, range.lowerBound), range.upperBound) } }