Architecture

September 17, 2026 · View on GitHub

Native Swift/SwiftUI menu-bar (accessory) app. SPM executable, no external app framework.

Data

  • Models.swift, Codable Repo, Agent, RunMode, Settings, seed agents (Claude Code, Codex). Tolerant decoders so adding fields never reseeds the store. LaunchDefaults is the pure launch-resolution precedence: override → per-repo pin → last-used → agent default.
  • Store.swift, AppStore (@MainActor ObservableObject), JSON at ~/Library/Application Support/Tintpad/store.json. Frecency recording, background auto-discovery, debounced settings writes, the (tip-jar) allows(_:) entitlement check. A store that fails to load is preserved as store.corrupt-<ts>.json, never overwritten by a reseed. SingleInstance.swift flocks a sidecar so two instances can't clobber each other's writes.

Services

  • Frecency.swift, fre-style continuous half-life decay ranking.
  • FuzzyMatch.swift, how a query finds a repo. FuzzyMatch.match returns a tier (exact, prefix, word boundary, infix of three letters or more, subsequence, path) plus the matched character offsets in the name, case- and diacritic-insensitive. RepoSearch.rank orders hits by (tier, frecency index), a strict weak ordering, so an exact name outranks a frequent fork and the list never reshuffles. The palette draws the offsets as match ink, and caches the ranking against AppStore.searchRevision (bumped on any repo or half-life change).
  • GitInfo.swift, parses .git/HEAD + .git/config directly (no subprocess).
  • CommandTemplate.swift, variable substitution + login-shell binary resolution. All interpolated values are sanitized (control chars stripped) and single-quoted, the injection surface, unit-tested.
  • RepoDiscovery.swift, scans root folders 1–2 levels deep for .git.
  • ShellEnvironment.swift, resolves the interactive login-shell PATH (with a timeout, off the main thread) so a GUI-launched app finds claude/codex/… (the #1 footgun).
  • ProcessRunner.swift, the one way subprocesses run: hard timeout, concurrently drained pipes (no 64KB deadlock), SIGTERM then SIGKILL, plus spawnDetached for processes that are the thing being opened. Backs the terminal-adapter helper, osascript (its input writes the script to stdin), worktree git, and the async GitHub clone.
  • GitStatus.swift, "is this working tree dirty" via a bounded git status --porcelain (first-byte read, never takes the index lock). RepoTint.swift, every repo's stable identity hue and short name.
  • WorktreeService.swift, DispatchService.swift, GitHubService.swift, Keychain.swift, LaunchService.swift.

Terminal handoff

  • LaunchService.swift, the one launch path. No launch blocks the main thread: handOff runs the adapter's prepare on the main actor (installed, TCC trust, NSRunningApplication), then the TerminalHandoff.blocking half on a serial GCD handoffQueue (serial so two keystroke scripts never interleave, every step bounded), and answers through a main-actor completion, after recording frecency and the session on success. launchAgent, resumeLast and openInEditor have no synchronous variants. The palette holds launchInFlight until the answer, says "Still opening …" after 4s (LaunchStatusCopy), keeps Esc live, plays the launch exit on a focus loss, and routes each answer through the pure LaunchAnswerPolicy: its own drop lands it, a newer idle drop shows a failure at once, otherwise the next summon within 30s does, and Return retries it. Resumes from every surface share one in-flight guard (ResumeError.inFlight), so a double press never launches twice.
  • TerminalAdapter.swift, a Sendable protocol + 7 adapters. Ghostty / kitty / Alacritty via open CLI flags (Ghostty needs an Accessibility keystroke, single-instance limitation), WezTerm via bundled binary, iTerm2 / Terminal.app via AppleScript do script (all AppleScript runs in a child osascript through ProcessRunner, never in process, and AppleScriptRunner.classify turns its exit status and stderr into permission or launch errors), Warp via open-at-path + clipboard fallback (no command API). A missing TCC grant throws TerminalLaunchError.permissionNeeded(summary:remedy:pane:), which carries the System Settings pane (PrivacyPane) so the palette can open it on ⏎ instead of only naming it in prose. See CONTRIBUTING to add one.

UI

  • CommandPanel.swift, a borderless .nonactivatingPanel NSPanel at .statusBar level (it draws over the menu bar strip to fuse with the camera housing), forced darkAqua, returns focus on Esc / focus-loss. dock() computes the summon screen's notch geometry per show (safeAreaInsets.top) and bridges it to SwiftUI via NotchAnchor, notched screens dock flush with the screen top, plain screens just below the menu bar. The window draws no system shadow and carries no AppKit animation (animationBehavior = .none, so the order-out removes it in one transaction rather than fading it out under the drop's own scripted exit, and hide() defers NSApp.hide a runloop turn so a stranded frame can't be left composited). Dismissal is sequenced by the pure DismissSequencer (DismissSequencer.swift): blank the panel to alpha 0, order it out a runloop turn later, hide the app a turn after that, never from inside resignKey, and a summon mid-sequence cancels it. The drop casts its own shadow inside a transparent margin, and resize(toContentHeight:) still reads the real safe-area insets (zero today) so a future style change can't silently clip.
  • PaletteView.swift, the palette: the drop, a pure-black capsule that forms under the notch (bead swells → expands in place → content follows, played by StepSequencer from the DropTimeline beats, crossfade under Reduce Motion). Exits are requested through PaletteModel.requestDismiss(_:) (DismissPolicy picks the exit, a focus loss during a launch plays the launch exit), the view plays them and calls exitDidFinish(), and only then does the controller's DismissSequencer blank and order out the panel. Stark black and white: gray repo tokens, a white selection chip with black ink, and the contract as two etched instrument chips (AGENT and MODE, micro-label eyebrows, never truncated), with the permission-skipping mode red-etched and every dangerous path routed through one confirm gate (fireOrConfirm). A launch that fails on a missing TCC grant arms a pending state of the same shape (a short red line, ⏎ opens the exact System Settings pane, Esc cancels), because a permission error whose only affordance is prose reads as "nothing happened". Fully mute at rest: tokens only, the query materializes as you type. A scoped NSEvent key monitor drives navigation (.onKeyPress on a TextField swallows arrows), and ←/→ move through tokens only while the field is empty. KeyPolicy decides when Tab belongs to focus traversal instead of agent cycling.
  • DropGeometry.swift, the pure geometry of the drop, resolved per summon from NotchGeometry (housing depth and width, screen width) and the Dynamic Type scale: the 8pt gap below the housing or menu bar, the capsule height (the housing's depth clamped to 32 to 40, or 36 for the floating pill), chips 12pt shorter than the capsule, the minimum width (housing width plus one capsule height either side, or 280), width hugging in 8pt steps, the 20pt shadow margin and the window's height and width. The view holds no geometry literals.
  • StepSequencer.swift, a @MainActor runner for timed beats with an injectable scheduler and a generation counter, so a re-summon cancels every beat of the sequence it interrupts (nested asyncAfter chains could not be cancelled).
  • DropTimeline.swift, the beat tables for the drop's arrival and each exit (DismissReason: launch, escape, focus loss), plus the Reduce Motion crossfades, as pure data the sequencer plays.
  • AgentMarks.swift, the agent's brand mark (shown beside its name in the launch pill and in Settings), rasterized on demand at the exact pixel size it will be drawn, with a per-brand optical correction so an airy mark and a dense one carry the same ink. Monogram.swift assigns distinct letters across the whole agent set (AppStore.monogram(for:) is the single source of truth).
  • Tokens.swift, spacing, radii, and the scalable TypeRamp used by the resizable surfaces.
  • SettingsView.swift (+ per-pane views), native NavigationSplitView preferences. Forced darkAqua and tinted gray at the root, because SwiftUI controls take the accent from the environment without naming it, so tinting only the sidebar left toggles, links, and list glyphs painting the user's macOS accent into a monochrome window. Two controls ignore the tint and are handled explicitly: a Link keeps the system link blue until asked for .buttonStyle(.plain) (linkStyle()), and a List styles a Label's icon itself, so rows that need an ink glyph are laid out as an HStack.
  • HotkeyManager.swift, KeyboardShortcuts global summon. TintpadApp.swift, MenuBarExtra accessory app.

Tests

swift test, pure-logic unit tests (frecency incl. clock-rollback clamping, command-template sanitization/injection, launch-default precedence, git parse + dirty detection, repo tints and short names, discovery, monogram assignment, Tab policy), plus the suites for the drop and the launch path: ContractPreviewTests, LaunchGateTests, OnboardingCopyTests, DismissSequencerTests, DropGeometryTests, DropSubjectTests, StepSequencerTests, DropTimelineTests, DismissPolicyTests, PaletteKeysTests, FuzzyMatchTests, AppleScriptClassifyTests, ProcessRunnerTests, LaunchStatusCopyTests, LaunchAnswerPolicyTests and PermissionEscalationTests. TESTING.md is the journey-based synthetic user testing plan. Scripts/uitest.sh, synthetic-input GUI smoke test (local only, needs Accessibility/Automation grants). TINTPAD_SHOWCASE=1 summons the palette at launch and holds it open for screenshots, see DEMO.md.

See also: AUDIT.md (security/quality), RELEASE.md, HOMEBREW.md.