3 Commits
Author SHA1 Message Date
Claude Opus 5andbdeshi 5191a38f7a Fix the window tags: permission, drift, anchoring, and styling
Four separate defects, each reproduced before being fixed.

Accessibility was silently denied. AXIsProcessTrusted() returned false,
so every AX read failed, WindowOverlay.init? failed all twelve retries,
and attach gave up without a word — indistinguishable from the feature
being broken. The cause was outside this file: an ad-hoc-signed app has
its permission pinned to its exact cdhash, which changes on every build,
so the rebuild-and-replace workflow silently revoked the grant each time
while the app stayed listed and ticked in System Settings. Now it prompts
rather than failing mutely, and keeps rechecking — macOS never tells an
app it has just been granted Accessibility, and overlays were otherwise
only reconsidered when a Claude window launched, quit or activated, so a
grant made while running did nothing visible until the user happened to
touch a Claude window.

The tag walked off its window on every drag. windowDidMove re-derived and
persisted the corner offset for the app's own programmatic moves too, on
the assumption that recomputing an offset it had just positioned against
was a no-op. It isn't: AppKit posts that notification synchronously from
inside setFrame, and during a live drag the AX frame read there is
already newer than the one reposition used, so each event banked the few
points the window had moved in between. Observed saturating at 952,
which parks the tag at the far edge of the window or off-screen.

Placement was anchored to the top-right unconditionally, so any resize
moving that corner dragged the tag along. It now anchors to whichever
corner it was dropped nearest. Offsets are no longer written back
clamped either: a window too small to honour one shows the tag pushed in
as far as it fits but keeps the stored distance, so widening the window
restores the chosen spot.

Styling: thinner (13pt) and pill-shaped so it can sit close to an edge
without covering window controls; rotated when against a side edge,
reading bottom-to-top on the left and top-to-bottom on the right so the
text leans into the window; text colour chosen per profile colour by WCAG
relative luminance rather than fixed white, which was as low as ~1.1:1 on
a yellow chip against 4.50:1 worst-case now; and text drawn into a
one-line-tall rect centred on the chip's midline, fixing its high
seating. Settings gains a choice between the name chip and a plain
coloured dot, which is never rotated, having no reading direction.

Verified live throughout: a 400pt offset squeezed to 260 on a narrowed
window and returned to 400 on restore with the stored value untouched;
a drag committed the expected corner and orientation; and contrast was
measured across the hue wheel.

Tag placement is also stored per profile rather than once for the whole
app. Two windows side by side are 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. Previously a single shared offset made
them look independent (only the dragged tag moved at once) while every
other tag snapped to it on its next reposition.

A tag is a floating panel, which puts it above every ordinary window on
the system rather than merely above the window it labels — so it hovered
over the browser, the editor, and everything else, even when its own
window was 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 that goes
stale on the next reorder), so each tag now checks whether the window it
labels is genuinely visible beneath it. The window server returns its list
front-to-back, so one pass answers it: anything overlapping the tag before
we reach our own window is covering it, and never reaching that window
means it isn't on screen at all.

Tag position is also clamped into whatever part of the window is 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 clamp is
presentational only — the stored anchor is untouched, so the tag returns
to it once the window is fully back.

Occlusion is judged from the window server's listing, which it returns
front-to-back. Our own window is identified by its owning process rather
than by matching rectangles: AX reports a new position the instant a
window moves while the listing still holds the previous one, so a
geometric match fails almost continuously mid-drag — measured at 87 of 92
frames, during which the walk ran past our own window and mistook
whatever else overlapped the tag for something covering it.

Only ordinary windows count as occluders. Everything above that band is
permanently in front and would veto the tag forever — including the
invisible one-pixel markers some utilities park in a screen corner, which
is what made a tag vanish at the bottom-left and nowhere else: it takes a
window moved off two edges at once for the tag to clamp into that pixel.
2026-08-05 13:08:11 +06:00
Claude Opus 5andbdeshi 1d3a539edd Add the window identification overlay
Every profile launches the same Claude Desktop binary with the same icon
and title, so with two or more open there was no way to tell which window
belonged to which profile at a glance. WindowOverlay pins a small tag —
profile name, in that profile's own dot colour — to each running managed
profile's window, tracked live via AXObserver notifications rather than
polling. Only a persistent process can hold a watcher like that, which is
exactly the gap the old one-shot CLI could never close.

Shown for every running managed profile, not just when two or more are
up: the tag is an identity marker, not merely a disambiguator. Never
shown on "default", which isn't a shannoncoat profile at all.

Positioned top-right, since top-left is where a window's close/minimize/
zoom controls live. Draggable in case that still obstructs something in a
particular layout, on a .nonactivatingPanel so dragging it doesn't steal
focus from the window underneath, with the offset persisted.

Attachment retries for ~3s rather than requiring the target's first
window to already exist: a cold Electron launch can take a second or more
between the process starting — which is what triggers the update, via
NSWorkspace's launch notification — and its first window existing. Losing
that race previously meant the tag silently never appeared at all.

Also ignores CHECKPOINT.md, a local session-handoff note.
2026-08-05 11:40:56 +06:00
Claude Opus 5andbdeshi 9673dc2792 Redesign the Manage window and fold About into it as a tab
The previous layout was a fixed 460x360 window regardless of tab or
state, with a plain unbordered table and text +/- buttons — it read as a
generic cross-platform port rather than something native. Several rows
also had no real width anchor at all, so they floated: flush against the
window's left edge with no margin, and in the add form's case wide enough
to overflow past the right edge.

- The window is no longer manually resizable. Each tab reports its own
  preferredContentSize, recomputed in viewDidLayout and after every state
  change, and NSTabViewController resizes the window to match. That is
  what actually fixes the large empty areas: a tab with less content gets
  a smaller window rather than sitting inside a fixed larger one.
- Every row now anchors its width to the one element with a real absolute
  width, fixing both the edge-touching and the overflow.
- Table gains a bezel border and alternating rows; form labels get a
  fixed trailing-aligned width so fields line up; the error label wraps
  instead of truncating; tab padding is consistent.
- About moves out of a separate panel into a third tab, with the app icon
  centred and enlarged to 96px.

Verified visually with a standalone harness that drives the real window
and screenshots it, rather than simulating input — scripting clicks into
the app itself needs an Accessibility grant this environment lacks.
2026-08-04 23:54:56 +06:00
9 changed files with 1089 additions and 70 deletions
+6
View File
@@ -28,6 +28,12 @@ jobs:
- name: Build shannoncoat.app
run: ./build.sh
env:
# This workflow only ever runs against a tag (see the trigger
# comment above) — told to build.sh directly rather than having
# it re-derive that via `git describe`, which needs full tag
# refs a CI runner's checkout isn't guaranteed to have fetched.
SHANNONCOAT_RELEASE_BUILD: "1"
- name: Zip app bundle
run: ditto -c -k --sequesterRsrc --keepParent ".build/shannoncoat.app" "shannoncoat.app.zip"
+3
View File
@@ -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/
+124 -19
View File
@@ -2,9 +2,7 @@
// Reads live state from LiveState (push-based, no polling) and calls
// ProfileStore/ClaudeControl in-process no subprocess, no text parsing.
import AppKit
private let author = "bdeshi"
private let homepage = "https://github.com/bdeshi/shannoncoat"
import ApplicationServices
final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate, LiveStateDelegate {
let statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
@@ -12,6 +10,11 @@ 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<String> = []
private var accessibilityPromptShown = false
private var accessibilityRecheckScheduled = false
private var activationObserver: NSObjectProtocol?
func applicationDidFinishLaunching(_ notification: Notification) {
menu.delegate = self
@@ -22,6 +25,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate, LiveSt
liveState.delegate = self
ManageWindowController.shared.onProfilesChanged = { [weak self] in self?.liveState.reconcile() }
ManageWindowController.shared.onOverlayStyleChanged = { [weak self] in self?.rebuildWindowOverlays() }
// Every app activation, not just Claude's LiveState deliberately
// filters to the Claude binary, so on its own it never hears that
// some unrelated app came forward, which is exactly when the tags
// need to get out of the way.
activationObserver = NSWorkspace.shared.notificationCenter.addObserver(
forName: NSWorkspace.didActivateApplicationNotification, object: nil, queue: .main
) { [weak self] _ in self?.updateOverlayVisibility() }
liveState.reconcile()
// Launching this app fresh with nothing running means there's no
@@ -53,6 +65,107 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate, LiveSt
currentInfos = infos
applyTitle(infos)
ManageWindowController.shared.update(infos)
updateWindowOverlays(infos)
}
// Each tag decides for itself whether the window it labels is actually
// visible underneath it (see WindowOverlay.isTargetVisible) this just
// says "now would be a good time to look again". Activation is the
// usual way a window gets buried or uncovered without moving at all,
// which no AX notification reports.
private func updateOverlayVisibility() {
for overlay in windowOverlays.values { overlay.refreshVisibility() }
}
// macOS doesn't notify an app that it has just been granted
// Accessibility, and overlays are otherwise only reconsidered when a
// Claude window launches, quits or activates so a permission granted
// while this is running would do nothing visible until the user
// happened to touch a Claude window, or restarted the app. Polling for
// it costs a cheap local check every couple of seconds, and only while
// the permission is actually missing: the moment it lands, the guard in
// `updateWindowOverlays` passes and this stops rescheduling itself.
//
// Also covers a subtler case replacing the installed bundle and
// relaunching immediately can start the app before TCC has settled on
// the new copy, and a one-shot check there would strand the tags for
// the rest of the session over a denial that was only ever momentary.
private func scheduleAccessibilityRecheck() {
guard !accessibilityRecheckScheduled else { return }
accessibilityRecheckScheduled = true
DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in
guard let self else { return }
accessibilityRecheckScheduled = false
updateWindowOverlays(currentInfos)
}
}
// A style change rewrites the tag's shape and size, which is decided
// when its panel is built so the existing ones are dropped (deinit
// tears down each panel and its AXObserver) and rebuilt rather than
// patched in place.
private func rebuildWindowOverlays() {
windowOverlays.removeAll()
updateWindowOverlays(currentInfos)
}
// 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)
// A tag is positioned entirely from Accessibility data, so without
// that permission there is nothing to position: `attach` would
// burn its dozen retries per profile and give up without a word,
// which reads as "the feature is broken" rather than "macOS said
// no". Prompt instead the same call `ClaudeControl.focus` makes
// for the same reason.
//
// Worth knowing when this bites, because it looks impossible:
// a local build gets a fresh code identity every time build.sh
// runs, and that silently invalidates an existing grant while
// leaving the app still listed *and still ticked* under Privacy &
// Security Accessibility. Re-granting means removing that stale
// row with "" and adding the newly built app back; toggling it
// off and on again is not enough. (Note that running the binary
// straight out of the bundle from a terminal will appear to work
// regardless it inherits the terminal's grant, not the app's,
// so it's useless for testing this.)
if !managed.isEmpty, !AXIsProcessTrusted() {
if !accessibilityPromptShown {
accessibilityPromptShown = true
ClaudeControl.promptForAccessibility()
}
scheduleAccessibilityRecheck()
return
}
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
// A tag created while some other app is frontmost must not
// appear over it `attach` can complete seconds after the
// launch that triggered it, by which point focus has often
// moved on.
updateOverlayVisibility()
}
}
updateOverlayVisibility()
}
// Two profiles sharing a dir is a fatal misconfiguration (would
@@ -263,23 +376,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate, LiveSt
ManageWindowController.shared.show()
}
// About lives as a tab in the same window (see ManageWindow.swift)
// rather than the standard NSApp About panel the standard panel
// auto-appends its own "Version X (Y)" line from Info.plist's
// CFBundleVersion/CFBundleShortVersionString, which are identical in
// this build, so it was showing that duplicated alongside our own
// version line.
@objc func showAbout() {
// Accessory apps (no Dock icon) don't auto-activate for a window
// they open, so the standard About panel would appear behind
// whatever's frontmost without this.
NSApp.activate(ignoringOtherApps: true)
let credits = """
Which coat will Claude wear today? Menu bar switcher for running multiple isolated Claude Desktop profiles side by side — each paired with its own Claude Code config so the two never cross-contaminate.
Author: \(author)
Source: \(homepage)
Version: \(UpdateChecker.displayVersion)
"""
NSApp.orderFrontStandardAboutPanel(options: [
.applicationName: "shannoncoat",
.credits: NSAttributedString(string: credits),
])
ManageWindowController.shared.update(currentInfos)
ManageWindowController.shared.show(tab: .about)
}
@objc func quitClaude() {
+1 -1
View File
@@ -182,7 +182,7 @@ enum ClaudeControl {
AXUIElementSetAttributeValue(window, kAXMinimizedAttribute as CFString, minimized as CFTypeRef)
}
private static func promptForAccessibility() {
static func promptForAccessibility() {
let options = [kAXTrustedCheckOptionPrompt.takeUnretainedValue(): true] as CFDictionary
_ = AXIsProcessTrustedWithOptions(options)
}
+313 -45
View File
@@ -1,14 +1,21 @@
// One ordinary, non-modal window for everything profile-management related
// replaces what would otherwise be a string of separate popup alerts.
// Opened from the menu bar's "Manage Profiles" item or the standard
// Cmd+, shortcut.
// Cmd+, shortcut. Fixed-size (not user-resizable) each tab reports its
// own natural size via preferredContentSize, and NSTabViewController
// resizes the window to match automatically, so it's never left showing
// empty space sized for a different tab or state.
import AppKit
final class ManageWindowController: NSWindowController {
static let shared = ManageWindowController()
enum Tab: Int { case profiles = 0, settings = 1, about = 2 }
private let profilesVC = ProfilesViewController()
private let settingsVC = SettingsViewController()
private let aboutVC = AboutViewController()
private let tabs = NSTabViewController()
// Called after a profile is added/removed so the caller can trigger an
// immediate LiveState reconcile instead of waiting on the next
@@ -17,20 +24,28 @@ final class ManageWindowController: NSWindowController {
didSet { profilesVC.onChange = onProfilesChanged }
}
// Called when the window-tag style changes, so open tags can be
// rebuilt in the new style immediately rather than at the next
// launch/quit of a Claude window.
var onOverlayStyleChanged: (() -> Void)? {
didSet { settingsVC.onOverlayStyleChanged = onOverlayStyleChanged }
}
private init() {
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 460, height: 360),
styleMask: [.titled, .closable, .miniaturizable, .resizable],
contentRect: NSRect(x: 0, y: 0, width: 420, height: 260),
styleMask: [.titled, .closable, .miniaturizable],
backing: .buffered, defer: false)
window.title = "shannoncoat"
window.isReleasedWhenClosed = false
window.center()
let tabs = NSTabViewController()
profilesVC.title = "Profiles"
settingsVC.title = "Settings"
aboutVC.title = "About"
tabs.addTabViewItem(NSTabViewItem(viewController: profilesVC))
tabs.addTabViewItem(NSTabViewItem(viewController: settingsVC))
tabs.addTabViewItem(NSTabViewItem(viewController: aboutVC))
window.contentViewController = tabs
super.init(window: window)
@@ -38,8 +53,11 @@ final class ManageWindowController: NSWindowController {
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
func show() {
func show(tab: Tab? = nil) {
NSApp.activate(ignoringOtherApps: true)
if let tab {
tabs.selectedTabViewItemIndex = tab.rawValue
}
window?.makeKeyAndOrderFront(nil)
}
@@ -57,41 +75,60 @@ private final class ProfilesViewController: NSViewController, NSTableViewDataSou
private var infos: [ProfileInfo] = []
private let tableView = NSTableView()
private let removeButton = NSButton(title: "\u{2212}", target: nil, action: nil)
private let addRemoveControl = NSSegmentedControl()
private let detailStack = NSStackView()
private let detailAppValue = NSTextField(labelWithString: "")
private let detailCodeValue = NSTextField(labelWithString: "")
private let detailAppCopyItem = NSMenuItem(title: "Copy Path", action: #selector(copyDetailPath(_:)), keyEquivalent: "")
private let detailCodeCopyItem = NSMenuItem(title: "Copy Path", action: #selector(copyDetailPath(_:)), keyEquivalent: "")
private let addForm = NSStackView()
private let nameField = NSTextField()
private let codeField = NSTextField()
private let appField = NSTextField()
private let errorLabel = NSTextField(labelWithString: "")
private var outerStack: NSStackView!
private var scrollHeightConstraint: NSLayoutConstraint!
private static let margin: CGFloat = 20
private static let tableWidth: CGFloat = 340
private static let minVisibleRows = 3
private static let maxVisibleRows = 8
override func loadView() {
view = NSView(frame: NSRect(x: 0, y: 0, width: 460, height: 360))
view = NSView(frame: NSRect(x: 0, y: 0, width: 420, height: 260))
let column = NSTableColumn(identifier: .init("profile"))
column.title = "Profile"
column.width = 380
column.width = 340
tableView.addTableColumn(column)
tableView.headerView = nil
tableView.dataSource = self
tableView.delegate = self
tableView.rowHeight = 22
tableView.rowHeight = 24
tableView.usesAlternatingRowBackgroundColors = true
tableView.menu = buildContextMenu()
let scroll = NSScrollView()
scroll.documentView = tableView
scroll.hasVerticalScroller = true
scroll.borderType = .bezelBorder
scroll.translatesAutoresizingMaskIntoConstraints = false
let addButton = NSButton(title: "+", target: self, action: #selector(toggleAddForm))
removeButton.target = self
removeButton.action = #selector(removeSelected)
removeButton.isEnabled = false
let buttonRow = NSStackView(views: [addButton, removeButton, NSView()])
addRemoveControl.segmentCount = 2
addRemoveControl.trackingMode = .momentary
addRemoveControl.segmentStyle = .smallSquare
addRemoveControl.setImage(NSImage(systemSymbolName: "plus", accessibilityDescription: "Add Profile"), forSegment: 0)
addRemoveControl.setImage(
NSImage(systemSymbolName: "minus", accessibilityDescription: "Remove Profile"), forSegment: 1)
addRemoveControl.setWidth(28, forSegment: 0)
addRemoveControl.setWidth(28, forSegment: 1)
addRemoveControl.setEnabled(false, forSegment: 1)
addRemoveControl.setToolTip("Add Profile", forSegment: 0)
addRemoveControl.setToolTip("Remove Profile", forSegment: 1)
addRemoveControl.target = self
addRemoveControl.action = #selector(addRemoveClicked(_:))
let buttonRow = NSStackView(views: [addRemoveControl, NSView()])
buttonRow.orientation = .horizontal
buttonRow.spacing = 4
buttonRow.translatesAutoresizingMaskIntoConstraints = false
buildDetailStack()
@@ -102,44 +139,102 @@ private final class ProfilesViewController: NSViewController, NSTableViewDataSou
addForm.isHidden = true
addForm.translatesAutoresizingMaskIntoConstraints = false
let stack = NSStackView(views: [scroll, detailStack, buttonRow, addForm])
let stack = NSStackView(views: [scroll, buttonRow, detailStack, addForm])
stack.orientation = .vertical
stack.spacing = 8
stack.edgeInsets = NSEdgeInsets(top: 12, left: 12, bottom: 12, right: 12)
stack.spacing = 12
stack.edgeInsets = NSEdgeInsets(top: Self.margin, left: Self.margin, bottom: Self.margin, right: Self.margin)
stack.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(stack)
outerStack = stack
scrollHeightConstraint = scroll.heightAnchor.constraint(equalToConstant: heightForRows(0))
NSLayoutConstraint.activate([
stack.topAnchor.constraint(equalTo: view.topAnchor),
stack.leadingAnchor.constraint(equalTo: view.leadingAnchor),
stack.trailingAnchor.constraint(equalTo: view.trailingAnchor),
stack.bottomAnchor.constraint(equalTo: view.bottomAnchor),
scroll.heightAnchor.constraint(greaterThanOrEqualToConstant: 160),
detailStack.widthAnchor.constraint(equalTo: stack.widthAnchor),
scroll.widthAnchor.constraint(equalToConstant: Self.tableWidth),
scrollHeightConstraint,
// `scroll` is the one arranged subview with a real, absolute
// width everything else in this stack ties its width to
// *that*, not to `stack` itself (whose own frame spans
// edge-to-edge; edgeInsets only govern how the stack lays out
// its children, not its own bounds). Left unconstrained, these
// rows have no hard width anywhere in their constraint chain
// NSStackView doesn't fill them to the inset content width by
// default, so they end up sized/positioned ambiguously (in
// practice: flush against the window edge, sometimes wider
// than the window).
buttonRow.widthAnchor.constraint(equalTo: scroll.widthAnchor),
detailStack.widthAnchor.constraint(equalTo: scroll.widthAnchor),
addForm.widthAnchor.constraint(equalTo: scroll.widthAnchor),
])
updatePreferredSize()
}
// Sized to the actual row count (clamped to a sensible range) rather
// than one fixed height with only a couple of profiles that avoids a
// big block of empty striped background below the last row; with many
// it caps out and scrolls instead of taking over the window.
private func heightForRows(_ count: Int) -> CGFloat {
let rows = max(Self.minVisibleRows, min(count, Self.maxVisibleRows))
return CGFloat(rows) * tableView.rowHeight + 2
}
// NSTabViewController watches its selected child's preferredContentSize
// and resizes the window to match live so recomputing this after
// toggling the add form or the path-details row is what makes the
// window grow/shrink to fit instead of sitting at one fixed size with
// empty space in the states that don't need it.
private func updatePreferredSize() {
view.layoutSubtreeIfNeeded()
preferredContentSize = outerStack.fittingSize
}
// A second, authoritative recompute after every real layout pass
// explicit calls from action handlers (below) update it eagerly for
// responsiveness, but this is the one that can't be thrown off by
// metrics not being fully resolved yet at the point an eager call runs.
override func viewDidLayout() {
super.viewDidLayout()
preferredContentSize = outerStack.fittingSize
}
// Shows the selected profile's two dirs the thing you'd otherwise
// have to right-click "Reveal" to find out.
// have to right-click "Reveal" to find out. Not `.isSelectable` that
// makes a label click-into-edit, and the field editor that appears
// draws the *untruncated* string at its full intrinsic width, which
// can extend past the window and get clipped by it rather than
// wrapping or scrolling cleanly. A tooltip (full path) plus a
// right-click "Copy Path" menu covers the same "get the value out"
// need without that.
private func buildDetailStack() {
for value in [detailAppValue, detailCodeValue] {
value.isSelectable = true
detailAppCopyItem.target = self
detailCodeCopyItem.target = self
let appMenu = NSMenu()
appMenu.addItem(detailAppCopyItem)
let codeMenu = NSMenu()
codeMenu.addItem(detailCodeCopyItem)
for (value, menu) in [(detailAppValue, appMenu), (detailCodeValue, codeMenu)] {
value.textColor = .secondaryLabelColor
value.font = .systemFont(ofSize: NSFont.smallSystemFontSize)
value.lineBreakMode = .byTruncatingMiddle
value.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
value.menu = menu
}
let appRow = NSStackView(views: [labeled("Claude Desktop:"), detailAppValue])
appRow.orientation = .horizontal
appRow.spacing = 6
appRow.spacing = 8
let codeRow = NSStackView(views: [labeled("Claude Code:"), detailCodeValue])
codeRow.orientation = .horizontal
codeRow.spacing = 6
codeRow.spacing = 8
detailStack.orientation = .vertical
detailStack.alignment = .leading
detailStack.spacing = 2
detailStack.spacing = 4
[appRow, codeRow].forEach(detailStack.addArrangedSubview)
appRow.widthAnchor.constraint(equalTo: detailStack.widthAnchor).isActive = true
codeRow.widthAnchor.constraint(equalTo: detailStack.widthAnchor).isActive = true
@@ -149,62 +244,93 @@ private final class ProfilesViewController: NSViewController, NSTableViewDataSou
nameField.placeholderString = "Profile name"
nameField.delegate = self
for field in [codeField, appField] {
field.lineBreakMode = .byTruncatingMiddle
field.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
}
codeField.placeholderString = "~/.shannoncoat/data/<name>/code"
let codeChoose = NSButton(title: "Choose\u{2026}", target: self, action: #selector(chooseCodeDir))
let codeRow = NSStackView(views: [labeled("Claude Code:"), codeField, codeChoose])
codeRow.orientation = .horizontal
codeRow.spacing = 6
codeRow.spacing = 8
appField.placeholderString = "~/.shannoncoat/data/<name>/app"
let appChoose = NSButton(title: "Choose\u{2026}", target: self, action: #selector(chooseAppDir))
let appRow = NSStackView(views: [labeled("Claude Desktop:"), appField, appChoose])
appRow.orientation = .horizontal
appRow.spacing = 6
appRow.spacing = 8
errorLabel.textColor = .systemRed
errorLabel.font = .systemFont(ofSize: NSFont.smallSystemFontSize)
errorLabel.maximumNumberOfLines = 0
errorLabel.lineBreakMode = .byWordWrapping
let createButton = NSButton(title: "Create", target: self, action: #selector(createProfile))
createButton.keyEquivalent = "\r"
let cancelButton = NSButton(title: "Cancel", target: self, action: #selector(toggleAddForm))
let buttonRow = NSStackView(views: [NSView(), cancelButton, createButton])
buttonRow.orientation = .horizontal
buttonRow.spacing = 8
let formButtonRow = NSStackView(views: [NSView(), cancelButton, createButton])
formButtonRow.orientation = .horizontal
formButtonRow.spacing = 8
let divider = NSBox()
divider.boxType = .separator
addForm.orientation = .vertical
addForm.alignment = .leading
addForm.spacing = 6
[nameField, codeRow, appRow, errorLabel, buttonRow].forEach(addForm.addArrangedSubview)
addForm.spacing = 10
[divider, nameField, codeRow, appRow, errorLabel, formButtonRow].forEach(addForm.addArrangedSubview)
divider.widthAnchor.constraint(equalTo: addForm.widthAnchor).isActive = true
nameField.widthAnchor.constraint(equalTo: addForm.widthAnchor).isActive = true
codeRow.widthAnchor.constraint(equalTo: addForm.widthAnchor).isActive = true
appRow.widthAnchor.constraint(equalTo: addForm.widthAnchor).isActive = true
buttonRow.widthAnchor.constraint(equalTo: addForm.widthAnchor).isActive = true
formButtonRow.widthAnchor.constraint(equalTo: addForm.widthAnchor).isActive = true
}
// Fixed-width, trailing-aligned label so the fields in a two-row form
// (differing label lengths: "Claude Code:" vs "Claude Desktop:") start
// at the same x position instead of a ragged left edge.
private func labeled(_ text: String) -> NSTextField {
let field = NSTextField(labelWithString: text)
field.font = .systemFont(ofSize: NSFont.systemFontSize)
field.textColor = .secondaryLabelColor
field.setContentHuggingPriority(.required, for: .horizontal)
field.alignment = .right
field.widthAnchor.constraint(equalToConstant: 104).isActive = true
return field
}
func update(_ infos: [ProfileInfo]) {
self.infos = infos
tableView.reloadData()
scrollHeightConstraint.constant = heightForRows(infos.count)
refreshForSelection()
}
private func refreshForSelection() {
let row = tableView.selectedRow
guard infos.indices.contains(row) else {
removeButton.isEnabled = false
addRemoveControl.setEnabled(false, forSegment: 1)
detailStack.isHidden = true
updatePreferredSize()
return
}
let profile = infos[row].profile
removeButton.isEnabled = profile.name != "default"
addRemoveControl.setEnabled(profile.name != "default", forSegment: 1)
detailAppValue.stringValue = profile.appDir
detailAppValue.toolTip = profile.appDir
detailAppCopyItem.representedObject = profile.appDir
detailCodeValue.stringValue = profile.codeDir
detailCodeValue.toolTip = profile.codeDir
detailCodeCopyItem.representedObject = profile.codeDir
detailStack.isHidden = false
updatePreferredSize()
}
@objc private func copyDetailPath(_ sender: NSMenuItem) {
guard let path = sender.representedObject as? String else { return }
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(path, forType: .string)
}
// MARK: NSTableViewDataSource / Delegate
@@ -229,17 +355,26 @@ private final class ProfilesViewController: NSViewController, NSTableViewDataSou
cell.imageView = imageView
cell.textField = textField
NSLayoutConstraint.activate([
imageView.leadingAnchor.constraint(equalTo: cell.leadingAnchor, constant: 4),
imageView.leadingAnchor.constraint(equalTo: cell.leadingAnchor, constant: 6),
imageView.centerYAnchor.constraint(equalTo: cell.centerYAnchor),
imageView.widthAnchor.constraint(equalToConstant: 10),
imageView.heightAnchor.constraint(equalToConstant: 10),
textField.leadingAnchor.constraint(equalTo: imageView.trailingAnchor, constant: 6),
textField.trailingAnchor.constraint(lessThanOrEqualTo: cell.trailingAnchor, constant: -4),
textField.leadingAnchor.constraint(equalTo: imageView.trailingAnchor, constant: 8),
textField.trailingAnchor.constraint(lessThanOrEqualTo: cell.trailingAnchor, constant: -6),
textField.centerYAnchor.constraint(equalTo: cell.centerYAnchor),
])
}
cell.imageView?.image = ProfileColor.dotImage(for: info.profile.name, dimmed: !info.running)
cell.textField?.stringValue = "\(info.profile.name)\(info.running ? "running" : "stopped")"
let text = NSMutableAttributedString(
string: info.profile.name, attributes: [.font: NSFont.systemFont(ofSize: NSFont.systemFontSize)])
text.append(NSAttributedString(
string: " \(info.running ? "Running" : "Stopped")",
attributes: [
.font: NSFont.systemFont(ofSize: NSFont.smallSystemFontSize),
.foregroundColor: NSColor.secondaryLabelColor,
]))
cell.textField?.attributedStringValue = text
return cell
}
@@ -258,6 +393,14 @@ private final class ProfilesViewController: NSViewController, NSTableViewDataSou
// MARK: Actions
@objc private func addRemoveClicked(_ sender: NSSegmentedControl) {
switch sender.selectedSegment {
case 0: toggleAddForm()
case 1: removeSelected()
default: break
}
}
@objc private func toggleAddForm() {
addForm.isHidden.toggle()
if !addForm.isHidden {
@@ -267,6 +410,7 @@ private final class ProfilesViewController: NSViewController, NSTableViewDataSou
errorLabel.stringValue = ""
view.window?.makeFirstResponder(nameField)
}
updatePreferredSize()
}
@objc private func chooseCodeDir() { choose(into: codeField) }
@@ -295,6 +439,7 @@ private final class ProfilesViewController: NSViewController, NSTableViewDataSou
onChange?()
} catch {
errorLabel.stringValue = (error as? LocalizedError)?.errorDescription ?? "\(error)"
updatePreferredSize()
}
}
@@ -364,9 +509,31 @@ private final class SettingsViewController: NSViewController {
checkboxWithTitle: "Automatically check for updates", target: nil, action: nil)
private let launchAtLoginCheckbox = NSButton(checkboxWithTitle: "Launch at Login", target: nil, action: nil)
private let checkNowButton = NSButton(title: "Check for Updates\u{2026}", target: nil, action: nil)
private let tagStyleLabel = NSTextField(labelWithString: "Window tag:")
private let tagStylePopUp = NSPopUpButton(frame: .zero, pullsDown: false)
private var tagStyleRow: NSStackView!
private var stack: NSStackView!
var onOverlayStyleChanged: (() -> Void)?
// Order matches the popup's items, so the selected index maps straight
// onto a style without a title-string comparison.
private static let tagStyles: [(title: String, style: WindowOverlayStyle)] = [
("Name chip", .chip),
("Colored dot", .dot),
]
override func loadView() {
view = NSView(frame: NSRect(x: 0, y: 0, width: 460, height: 360))
view = NSView(frame: NSRect(x: 0, y: 0, width: 420, height: 150))
tagStylePopUp.addItems(withTitles: Self.tagStyles.map(\.title))
tagStylePopUp.selectItem(at: Self.tagStyles.firstIndex { $0.style == WindowOverlayPosition.style } ?? 0)
tagStylePopUp.target = self
tagStylePopUp.action = #selector(changeTagStyle)
tagStyleRow = NSStackView(views: [tagStyleLabel, tagStylePopUp])
tagStyleRow.orientation = .horizontal
tagStyleRow.spacing = 8
launchAtLoginCheckbox.target = self
launchAtLoginCheckbox.action = #selector(toggleLaunchAtLogin)
@@ -378,17 +545,55 @@ private final class SettingsViewController: NSViewController {
checkNowButton.target = self
checkNowButton.action = #selector(checkNow)
checkNowButton.bezelStyle = .rounded
let stack = NSStackView(views: [launchAtLoginCheckbox, autoUpdateCheckbox, checkNowButton])
stack = NSStackView(views: [launchAtLoginCheckbox, tagStyleRow, autoUpdateCheckbox, checkNowButton])
stack.orientation = .vertical
stack.alignment = .leading
stack.spacing = 12
stack.spacing = 14
stack.edgeInsets = NSEdgeInsets(top: 20, left: 20, bottom: 20, right: 20)
stack.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(stack)
NSLayoutConstraint.activate([
stack.topAnchor.constraint(equalTo: view.topAnchor, constant: 20),
stack.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20),
stack.topAnchor.constraint(equalTo: view.topAnchor),
stack.leadingAnchor.constraint(equalTo: view.leadingAnchor),
stack.trailingAnchor.constraint(lessThanOrEqualTo: view.trailingAnchor),
stack.bottomAnchor.constraint(equalTo: view.bottomAnchor),
])
updatePreferredSize()
}
override func viewDidLayout() {
super.viewDidLayout()
updatePreferredSize()
}
// NOT `stack.fittingSize` for the width that was measured observing
// it clip a checkbox's label by a consistent amount regardless of
// when it's read (loadView vs. viewDidLayout: same result), so this
// isn't a measure-too-early timing issue, it's `NSStackView.fittingSize`
// itself undercounting a checkbox's true rendered width. Asking each
// control directly for its own `intrinsicContentSize` instead which
// is what AutoLayout itself uses to size an unconstrained control
// sidesteps whatever the stack's own aggregation is getting wrong.
private func updatePreferredSize() {
// Same reasoning for the tag-style row: summed from its two
// controls plus the stack spacing rather than trusting the row's
// own fittingSize.
let rowWidth = tagStyleLabel.intrinsicContentSize.width
+ tagStylePopUp.intrinsicContentSize.width + tagStyleRow.spacing
let widest = [launchAtLoginCheckbox, autoUpdateCheckbox, checkNowButton]
.map(\.intrinsicContentSize.width)
.max().map { max($0, rowWidth) } ?? rowWidth
preferredContentSize = NSSize(width: widest + 40, height: stack.fittingSize.height)
}
@objc private func changeTagStyle() {
let index = tagStylePopUp.indexOfSelectedItem
guard Self.tagStyles.indices.contains(index) else { return }
WindowOverlayPosition.style = Self.tagStyles[index].style
onOverlayStyleChanged?()
}
@objc private func toggleLaunchAtLogin() {
@@ -432,3 +637,66 @@ private final class SettingsViewController: NSViewController {
}
}
}
// MARK: - About tab
private let projectAuthor = "bdeshi"
private let projectHomepage = "https://github.com/bdeshi/shannoncoat"
private final class AboutViewController: NSViewController {
private static let contentWidth: CGFloat = 340
override func loadView() {
view = NSView(frame: NSRect(x: 0, y: 0, width: 420, height: 150))
// Centered within a full-width wrapper rather than centered in the
// stack directly the stack is leading-aligned (for the text
// rows below), and its own per-arranged-subview alignment
// constraint on the icon would conflict with an added centering
// constraint on the icon itself.
let icon = NSImageView()
icon.image = NSApp.applicationIconImage ?? NSImage(named: NSImage.applicationIconName)
icon.translatesAutoresizingMaskIntoConstraints = false
let iconContainer = NSView()
iconContainer.translatesAutoresizingMaskIntoConstraints = false
iconContainer.addSubview(icon)
NSLayoutConstraint.activate([
icon.widthAnchor.constraint(equalToConstant: 96),
icon.heightAnchor.constraint(equalToConstant: 96),
icon.topAnchor.constraint(equalTo: iconContainer.topAnchor),
icon.bottomAnchor.constraint(equalTo: iconContainer.bottomAnchor),
icon.centerXAnchor.constraint(equalTo: iconContainer.centerXAnchor),
])
let description = NSTextField(wrappingLabelWithString:
"Runs multiple Claude Desktop profiles side by side, each paired with its own isolated Claude Code config.")
description.font = .systemFont(ofSize: NSFont.systemFontSize)
let authorField = NSTextField(labelWithString: "Author: \(projectAuthor)")
let sourceField = NSTextField(labelWithString: "Source: \(projectHomepage)")
let versionField = NSTextField(labelWithString: "Version: \(UpdateChecker.displayVersion)")
for field in [authorField, sourceField, versionField] {
field.textColor = .secondaryLabelColor
field.font = .systemFont(ofSize: NSFont.smallSystemFontSize)
}
let stack = NSStackView(views: [iconContainer, description, authorField, sourceField, versionField])
stack.orientation = .vertical
stack.alignment = .leading
stack.spacing = 10
stack.edgeInsets = NSEdgeInsets(top: 20, left: 20, bottom: 20, right: 20)
stack.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(stack)
NSLayoutConstraint.activate([
stack.topAnchor.constraint(equalTo: view.topAnchor),
stack.leadingAnchor.constraint(equalTo: view.leadingAnchor),
stack.bottomAnchor.constraint(equalTo: view.bottomAnchor),
description.widthAnchor.constraint(equalToConstant: Self.contentWidth),
iconContainer.widthAnchor.constraint(equalTo: description.widthAnchor),
])
view.layoutSubtreeIfNeeded()
preferredContentSize = NSSize(width: Self.contentWidth + 40, height: stack.fittingSize.height)
}
}
+24
View File
@@ -32,6 +32,30 @@ enum ProfileColor {
return NSColor(calibratedHue: hue, saturation: saturation, brightness: brightness, alpha: 1.0)
}
// Light or dark text, whichever reads better on the given background.
// Profile colours come off the full hue wheel (see `dotColor`), whose
// luminance varies enormously yellow against white text is barely
// legible where indigo against black text is worse so this can't be
// one fixed choice.
//
// Chooses on WCAG relative luminance rather than raw brightness,
// because the eye is roughly six times more sensitive to green than to
// blue and an unweighted average badly misjudges both. 0.179 is where
// contrast against white and against black come out equal, so it's the
// threshold that maximises contrast either way. Near-white and
// near-black rather than pure, which reads as less harsh at 10pt
// without measurably costing contrast.
static func contrastingTextColor(on background: NSColor) -> NSColor {
guard let rgb = background.usingColorSpace(.sRGB) else { return .white }
func linear(_ channel: CGFloat) -> CGFloat {
channel <= 0.03928 ? channel / 12.92 : pow((channel + 0.055) / 1.055, 2.4)
}
let luminance = 0.2126 * linear(rgb.redComponent)
+ 0.7152 * linear(rgb.greenComponent)
+ 0.0722 * linear(rgb.blueComponent)
return luminance > 0.179 ? NSColor(white: 0.10, alpha: 1) : NSColor(white: 0.98, alpha: 1)
}
// `dimmed` marks a profile that isn't actually running right now the
// colored dot still identifies it, but faded, so "open" vs "known but
// closed" is visible without reading a tooltip.
+604
View File
@@ -0,0 +1,604 @@
// 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<WindowOverlay>.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>) -> Self {
min(max(self, range.lowerBound), range.upperBound)
}
}
+1 -1
View File
@@ -1 +1 @@
0.0.2
0.0.7
+13 -4
View File
@@ -54,12 +54,21 @@ command -v swiftc >/dev/null || { echo "error: swiftc not found (install Xcode C
# Info.plist above.
cp "$VERSION_FILE" "$APP/Contents/Resources/VERSION"
# COMMIT is the short SHA the About panel appends to the version, so a dev
# COMMIT is the short SHA the About tab appends to the version, so one dev
# build is distinguishable from another — left empty for a real release
# build (HEAD sitting exactly on a tag, which is how release.yml checks
# this out), since the version number alone is unambiguous there.
# build, since the version number alone is unambiguous there. A release
# build is one of:
# - SHANNONCOAT_RELEASE_BUILD set by release.yml, which already knows
# for certain whether it's building a tag — trusted outright rather
# than re-derived, since `git describe` needs full tag refs that a
# CI runner's shallow checkout isn't guaranteed to have fetched.
# - a local/manual build with HEAD sitting exactly on a tag (checked
# via `git describe`, which is reliable here since a normal
# non-shallow local clone has its full tag history).
GIT_SHA=""
if git -C "$SCRIPT_DIR" rev-parse --git-dir >/dev/null 2>&1 \
if [[ -n "${SHANNONCOAT_RELEASE_BUILD:-}" ]]; then
: # release build — leave GIT_SHA empty
elif git -C "$SCRIPT_DIR" rev-parse --git-dir >/dev/null 2>&1 \
&& ! git -C "$SCRIPT_DIR" describe --tags --exact-match >/dev/null 2>&1; then
GIT_SHA="$(git -C "$SCRIPT_DIR" rev-parse --short HEAD 2>/dev/null || true)"
fi