Imported from xavier-castro/bubbles (
AGENTS.md). Install upstream withnpx skills add xavier-castro/bubbles. Copyright stays with the author.
Repository Guidelines
Project Overview
Bubbles is a small macOS menu-bar utility that turns any real OS windows into
browser-style tabs — a "tmux for mac apps." Hold ⌘ while dragging one window onto
another to form a session: the two windows share one frame and one floating
strip. Switch tabs with ⌥+1…n, cycle with ⌥+Tab. Bindings are configurable
in ~/.config/bubbles.toml (written on first launch).
The app runs as an accessory (menu-bar, LSUIElement) process, has no main window,
and requires the Accessibility permission (System Settings → Privacy & Security)
to read and move other apps' windows.
This is a single-context codebase: one CONTEXT.md glossary at the root plus
docs/adr/. Read both before working in an unfamiliar area, and use the glossary's
vocabulary (session, tab, active tab, hidden tab, strip, compact mode, focused
session) rather than the terms it lists under "Avoid".
Architecture & Data Flow
The design centers on one coordinator (SessionController) that wires together
input capture, a window-server abstraction, and the strip UI.
⌘-drag gesture
└─> DragMonitor (global CGEventTap) ──┐
⌥+key chord │ (both push events to)
└─> KeyboardShortcutMonitor (CGEventTap) ──> SessionController
│
┌───────────────────────────┬───────────────┤
▼ ▼ ▼
WindowEngine FocusTracker Session(s)
(AXWindowEngine + (sole owner of (session state,
SpacesService) "which session" strip frames)
read/move/raise/activate) via push events)
Data flow for the core gesture:
DragMonitordetects a ⌘-drag, hands the draggedManagedWindowto the controller.WindowEngine.window(at:)hit-tests the drop point against the live window-server snapshot (WindowServerSnapshot/CGWindowListCopyWindowInfo).SessionController.formSessioncreates/joins aSession;SessionController+SessionOperationsresizes the member windows onto the sharedcontentFrame, parks hidden tabs offscreen, and shows the strip (TabBarPanel).AXObserverBridge/AXAppObserverBridgepush move/resize/destroy/focus events back into the controller;SessionController+AXSyncdebounces window movement and resizes the strip.FocusTrackeranswers "which session does the user mean?" for keyboard shortcuts, strip z-order, and auto-add targeting.
Key architectural rule (see docs/adr/0001-focus-tracker.md): focus is owned
exclusively by FocusTracker. Deriving focus from NSWorkspace or live AX at a call
site is a regression. Resolution order is: frontmost app's focused tab → window-server
z-order (non-session windows never win) → most-recent → none. The tracker's interface
takes value snapshots (window ids, pids), never live AXUIElements — this is what
keeps it unit-testable without a window server.
The rebrand from Tabbed to Bubbles and the group → session vocabulary re-mint are
recorded in docs/adr/0002-rename-to-bubbles.md.
Key Directories
Sources/App/— entry point,AppDelegate(status item, AX-permission check, boots the controller),AppSettings(UserDefaults UI toggles).Sources/Group/— the core (directory name kept; contents are session logic).SessionController(coordinator, split across+SessionOperations,+AXSync,+PanelZOrder,+WindowCreation,+Keyboard,+Workspaceextensions),Session(session state + strip-frame math),FocusTracker,SessionViewModel(CombineObservableObject),PanelZOrder(z-order policy).Sources/Accessibility/— window-system boundary:WindowEngine(protocol) +AXWindowEngine,SpacesService(private CGS APIs),AXObserverBridge,AXAppObserverBridge,WindowServerSnapshot,CoordinateConverter, andBubbles-Bridging-Header.h(declares the private_CGS*/_AXUIElementGetWindowsymbols).Sources/Drag/—DragMonitor, the ⌘-drag event tap.Sources/Input/—KeyboardShortcutMonitor, the keyDown event tap.Sources/UI/—TabBarPanel(NSPanel hosting a SwiftUITabStripView),DropIndicatorPanel,TabItemView,Theme.Sources/Model/—TabModels.swift(TabDescriptor).Sources/Config/—BubblesConfig(loads~/.config/bubbles.toml) andTOML.swift(a minimal TOML-subset parser: tables, strings, booleans).Tests/— XCTest suites (see Testing & QA).tools/— standalone Swift diagnostic scripts, not part of any build target.docs/adr/,docs/agents/— ADRs and agent-facing conventions..scratch/— local Markdown issue tracker (not committed by default).
Development Commands
Build is XcodeGen-driven. Bubbles.xcodeproj/ is generated and gitignored —
regenerate it after changing project.yml, never edit the .pbxproj by hand.
# 1. Regenerate the Xcode project (required after any project.yml change)
xcodegen generate
# 2. Build (Debug, in place)
xcodebuild -project Bubbles.xcodeproj -scheme Bubbles -configuration Debug \
-derivedDataPath .build/DerivedData build
# 3. Run tests (tests run hosted inside the built app via TEST_HOST)
xcodebuild -project Bubbles.xcodeproj -scheme Bubbles \
-destination 'platform=macOS' test
# 4. Release build (as CI does) — signing overrides come from env/secrets in CI
xcodebuild -project Bubbles.xcodeproj -scheme Bubbles -configuration Release \
-derivedDataPath .build/DerivedData \
DEVELOPMENT_TEAM="$APPLE_TEAM_ID" CODE_SIGN_IDENTITY="$APPLE_SIGNING_IDENTITY" \
CODE_SIGN_STYLE=Manual build
Local development is manual signing (team W9C2P3N7Q2, Apple Development
identity, hardened runtime on). Running the app requires an interactive macOS session
with the Accessibility permission granted — you cannot drive the real window
manipulation headlessly in CI.
Code Conventions & Common Patterns
- Everything is
@MainActor. Nearly every class is annotated@MainActor. Global event-tap callbacks (C function pointers inDragMonitor,KeyboardShortcutMonitor) hop onto the main actor viaMainActor.assumeIsolated { … }before touching state. New code should follow the same isolation; there is no free-threaded state. - No DI framework. The
WindowEngineprotocol is the main seam:AppDelegateconstructs the concreteAXWindowEngineand injects it intoSessionController.FocusTrackeris constructed with closures for its inputs (sessions: { … },zOrderedWindowIDs: { … }) — this is the established testability pattern; prefer it for new stateful modules. - Delegates are
weak.DragMonitorDelegate,KeyboardShortcutMonitorDelegateand theirdelegateproperties areweak var. Timers and async closures capture[weak self]. - Error handling is lightweight. Failure paths
NSLog("[Bubbles] …")and return (e.g. a failedCGEventTapcreation logs and bails). Config parsing is lenient by design: unknown/malformed keys are ignored so a partial file still applies the values it does set. There is nothrow/error-type machinery in the app paths. - No Combine beyond the view model.
SessionViewModeluses@Published; the rest of the app is imperative AppKit. Strip animations and z-order sync are driven byTimer.scheduledTimerpolling (120 Hz / 60 Hz ticks) that hop to the main actor, and byDispatchQueue.main.async/asyncAfterfor debouncing — not Combine pipelines or async/await. - Naming. Controllers/managers use the noun form (
SessionController,KeyboardShortcutMonitor,WindowEngine,SpacesService). Controller behavior is split into+extensions by concern. The glossary terms inCONTEXT.mdare the canonical names for domain concepts in identifiers, comments, and test names: the unit of tabbing is a session (formerly "tab group"); its members stay tabs. - Private APIs (
_CGS*,_AXUIElementGetWindow) are declared inSources/Accessibility/Bubbles-Bridging-Header.hand called fromSpacesService/AXWindowEngine. Keep new private-CGS declarations there. - Config vs. preferences. Keyboard bindings live in the TOML file
(
~/.config/bubbles.toml, overridable via theBUBBLES_CONFIGenv var;BUBBLES_FORCE_COMPACTforces compact mode); UI toggles (compact mode, auto-add) live in UserDefaults viaAppSettings.
Important Files
project.yml— source of truth for targets, signing, and versions. Two targets:Bubbles(app) andBubblesTests. Edit this, thenxcodegen generate.Sources/App/main.swift— entry point; setsNSApplicationto.accessory.Sources/App/AppDelegate.swift— lifecycle, status-bar menu, AX-permission check, instantiates and startsSessionController.Sources/Group/SessionController.swift(+ its+extensions) — the orchestrator; start here when changing any behavior.Sources/Group/FocusTracker.swift— focus resolution; readdocs/adr/0001-focus-tracker.mdfirst.Sources/Accessibility/WindowEngine.swift/AXWindowEngine.swift— window-system boundary; the seam to test against.Sources/Accessibility/Bubbles-Bridging-Header.h— private CGS/AX declarations.Sources/Config/BubblesConfig.swift+TOML.swift— user-facing configuration.Resources/Info.plist,Resources/Bubbles.entitlements— bundle metadata; notecom.apple.security.app-sandbox = false(sandbox disabled, required for window and AX manipulation — do not re-enable).CONTEXT.md— domain glossary.docs/adr/— architectural decisions.tools/b1-probe.swift— dump live window-server state:swift tools/b1-probe.swift [--all](all vs. on-screen). Handy for debugging drag/hit-test and z-order.tools/b1-loop.swift— regression loop that simulates⌥+keyand samples window frames:swift tools/b1-loop.swift --key N --iterations k --stale s --settle s --hz f. EmitsREDif a window is observed "in transit" (flying out) during a tab switch — use it to validate tab-switch smoothness.
Runtime / Tooling Preferences
- macOS 14.0+, Swift 5. Pure AppKit + a thin SwiftUI strip view. No third-party
dependencies; the TOML parser is vendored in-repo (
Sources/Config/TOML.swift). - AppKit is the real UI layer.
TabBarPanelis anNSPanelthat hosts a SwiftUITabStripView. Treat AppKit (panels, event taps, AX) as primary; SwiftUI is presentation-only. - Accessibility + private CGS APIs are load-bearing. The app cannot function
without the Accessibility permission, and Spaces handling depends on private
CGS*symbols declared in the bridging header. These APIs are undocumented and can change across OS versions — guard calls and test the affected path manually. - CI is release-only.
.github/workflows/release.ymlruns onv*.*.*tag push:xcodegen generate→xcodebuildRelease →codesign --verify→gh release→ update the Homebrew tap cask. There is no PR/commit CI or test gate — tests are run locally.runs/(agent telemetry) and.build/are gitignored.
Testing & QA
- Framework: XCTest, in the
BubblesTeststarget. Tests@testable import Bubblesand run hosted inside the built app (TEST_HOST/BUNDLE_LOADERinproject.yml), so they execute against the real app binary. - Running:
xcodebuild -project Bubbles.xcodeproj -scheme Bubbles -destination 'platform=macOS' test. - Suites (one file per concern in
Tests/):FocusTrackerTests— focus resolution policy, incl. the attention-steal regression.PanelZOrderPolicyTests— strip visibility/z-order rules.CoordinateConverterTests— CG↔AppKit coordinate round-trips.BubblesConfigTests— TOML parsing, modifier aliases, and default-fallback behavior.
- Conventions:
- Test methods are snake_case
test_(e.g.test_whenAnotherAppGrabsAttention_focusedSessionIsTheVisuallyFrontmostOne), not the default SwifttestFoostyle. setUp()resets state inline; there are no shared fixtures or test doubles beyond the injected closures.- The idiomatic pattern is to make a stateful module take injected closures
(
FocusTracker'ssessions/zOrderedWindowIDs) so its behavior is asserted synchronously without a live window server. Prefer this for new testable logic.
- Test methods are snake_case
- Coverage expectations: there is no coverage gate. The window-manipulation paths
(real AX/CGS, drag, Spaces) are not unit-testable in isolation — validate them with
the
tools/scripts and a real interactive session, not XCTest.
Agent Conventions (this repo)
- Domain: read
CONTEXT.mdand the relevantdocs/adr/before exploring. Use the glossary's terms and flag (don't silently override) any contradiction with an ADR. - Issue tracker: issues/specs live as Markdown under
.scratch/<feature-slug>/(aspec.mdplusissues/NN-<slug>.md, numbered from01). Seedocs/agents/issue-tracker.md. - Triage: the five canonical roles map 1:1 to
needs-triage,needs-info,ready-for-agent,ready-for-human,wontfix(seedocs/agents/triage-labels.md).