agterm is two modules: a host-free model package and an app target that adds the SwiftUI shell and the libghostty bridge.
agtermCore/ is a local SwiftPM package that depends on Foundation and Observation only. It imports no GhosttyKit, AppKit, or Metal, so its tests run host-free via swift test with no app host, no TEST_HOST, and no Metal device. It holds the entire model and persistence layer:
Session(@Observable @MainActor final class): one shell. Fields areid,customName,currentCwd,gitStatus,initialCwd, and thesurfaceslot.displayNamereturns a non-blankcustomName(trimmed, matchingrenameSession), otherwise the basename ofcurrentCwd ?? initialCwd. Basename pins: root/stays/, a trailing slash is ignored (/a/b/→b), and an empty path shows the home shorthand~.gitStatusis an observedGitStatus?set by the app'sGitStatusService; nil means the cwd is not a git work tree (or has not yet been refreshed).GitStatus(struct,Equatable, Sendable): the parsed git status of a session's cwd —branch/detachedSHA(exactly one set),upstream,ahead,behind,dirty,worktree.static parse(porcelainV2:gitDir:)is pure and total (never throws, never nil): it readsgit status --porcelain=v2 --branchheader lines (branch.head,branch.oid,branch.upstream,branch.abwith the sign stripped sobehindis non-negative) and counts entry lines (leading1/2/u/?) intodirty. A literal(detached)head setsdetachedSHA; an(initial)oid (unborn branch) does not. The worktree name comes from a…/worktrees/<name>git-dir path (trailing-segment match).compactformats the sidebar tokens (↑N ↓N *N, where*Nis the conventional git dirty marker plus the changed-file count; empty when clean and in sync);branchDisplayformats the pill's branch label. No SwiftUI — rendering lives in the app target. The "is this a repo?" decision is the service's (a non-zero git exit → nil), notparse's.GitRefreshPolicy(enum,Sendable): the pure throttle/coalesce predicate, lifted out of theProcessside so it is unit-testable without git.static shouldRefresh(cwd:lastRanCwd:lastRanAt:now:minInterval:inFlight:)returns false when a refresh is in flight or when the same cwd was refreshed withinminInterval, true for a new cwd or once the interval has elapsed.GitApplyDecision(enum,Sendable): the pure completion-hop decision, also lifted out so the three result guards are unit-testable without aSession.static decide(ranCwd:currentCwd:succeeded:parsed:existing:)returns.reEnqueuewhen the cwd moved on (stale-result race),.keepExistingon a transient failure or an unchanged value (equality-gate), and.write(parsed)only when the value actually changed. The service performs the side effect.ClipboardPromptPolicy(struct,Sendable) withClipboardAccess/ClipboardDecision(enum): the pure per-direction allow/deny/prompt decision for OSC 52 clipboard access, lifted out of the app-sideClipboardPromptController(the NSAlert sheet) so it is unit-testable host-free.decision(for:)returns the remembered session choice or.prompt;remember(_:allow:)records the "don't ask again this session" choice, read and write kept independent.Workspace(struct): a stableid, aname, and an ordered array ofSessionreferences.AppStore(@Observable @MainActor final class): the workspace tree plus a singleselectedSessionID. Owns the mutations (addWorkspace,addSession,selectSession,renameSession,renameWorkspace,closeSession,moveSession) and the persistence hooks (snapshot,restore,save). Every structural mutation — includingselectSession(so a sidebar click persists immediately) — callssave(), as does app quit. A livecddoes not: the PWD report updatescurrentCwdwithout saving, because OSC 7 fires on every prompt redraw and persisting each one would thrash the disk. The new cwd rides along on the next structural save or on quit, so a crash loses only cwd changes since the last save.AppStoreis the per-window unit — one tree + one selection is exactly one window's content (seeWindowLibrary).WindowLibrary(@Observable @MainActor final class): the top-level owner above the workspace tree. A window is a named, persisted bundle of workspaces + sessions, each rendered in its own on-screen macOS window.WindowLibraryowns the ordered window metadata (WindowInfo {id, name}), the live per-windowstores: [UUID: AppStore](one store per open window, lazily loaded — a window is "open" iff its store is loaded), the frontmost id, and per-window + index persistence. Each window'sAppStorekeeps its ownPersistenceStorepointed atwindows/<id>.json(today'sSnapshotshape;PersistenceStoregained an optionalfileName:init param for this), with a thinwindows.jsonindex (WindowsIndexcarries its ownversion). On init it runs migration/recovery (never throws): legacyworkspaces.json→ one window, corrupt/absent index → seed, missing/corrupt per-window file → empty window — so the set is always valid and non-empty. The lookups (store(for:),loadStore(for:),activeStore/activeWindowID,store(forSession:),windowID(forSession:)) are whatContentView,AppActions, andControlServerresolve a window's store through. SeeCLAUDE.mdfor the scene/restoration mechanism, the quit-flush, and thewindow.*control additions.TerminalSurface(@MainActor protocol,AnyObject): the minimal surface contract (teardown()) thatSessionowns. The concrete conformer lives in the app target, which keepsagtermCorefree of GhosttyKit.Snapshotand friends (Codable, Equatable, Sendablevalue types): the persisted form of the tree.PersistenceStore: JSON load/save at~/Library/Application Support/agterm/workspaces.json, with the storage directory injectable for tests.
The app target adds the SwiftUI shell (ContentView, TerminalView), the AppKit WorkspaceSidebar (an NSOutlineView), the libghostty bridge (GhosttyApp, GhosttyCallbacks, GhosttyResources, GhosttySurfaceView), and the git layer (GitStatusService, GitStatusPill). The bridge files are adapted from macterm (MIT). The app links GhosttyKit.xcframework and depends on the agtermCore package.
Selection is a single Session.ID?. Workspace rows are non-selectable headers; only sessions are detail targets, so one id suffices and the owning workspace is derived.
Two app-side @MainActor registries bridge the host-free WindowLibrary (which carries no AppKit/SwiftUI handles) to the live UI, keyed by WindowInfo.ID. WindowRegistry (in agterm/WindowRegistry.swift) maps each open window's id to its NSWindow — TitleProbeView registers/unregisters on window attach/close, and raise(_:)/close(_:) back the dedup-by-id raise and the window.close teardown. QuickTerminalRegistry (in Views/QuickTerminal.swift) maps each open window's id to its per-window QuickTerminalController (owned by WindowContentView, no longer a singleton), so the frontmost-window call sites resolve the right controller and the settings broadcast reaches every window's quick terminal.
The programmatic control channel (agtermctl over a unix socket) follows the same core/app split as the git layer — pure protocol and decisions in agtermCore, all I/O in the app target:
agtermCoreholds the wire protocol (ControlProtocol.swift:Command,ControlArgs,ControlRequest/ControlResponse, the tree node types) and the pure resolvers (ControlResolve.swift: the target resolver and the socket-path resolver). TheagtermCorelibrary target stays dependency-free.agtermctlKit+agtermctlare separate targets in the same SwiftPM package.agtermctlKitis the testable library (theswift-argument-parserParsableCommandtree plus the socket client);agtermctlis the thin executable. Only these CLI targets linkArgumentParser, so they build and test withswift build/swift testand never touch Xcode or GhosttyKit.ControlServer(agterm/Control/ControlServer.swift) lives in the app target. It owns the POSIX unix socket, decodes each request, and dispatches it onto the existingAppActions/AppStoreseam (andGhosttySurfaceView.inject(text:)for input). It is the only piece that touches the live surfaces, keeping the protocol and the CLI host-free. The flat command dispatch is split for size across sibling extension files (ControlServer+SessionActions/+SurfaceIO/+WindowCommands), and target resolution (frontmost/cross-window store scoping, id/prefix matching, the pinned wire-error strings) lives in a small injectedControlTargetResolver.
See CLAUDE.md for the socket lifecycle, addressing, command catalog, and the keep-in-sync convention.
WorkspaceSidebar is an NSViewRepresentable wrapping an NSOutlineView (.plain style, with a custom row height and a content inset matching the terminal's ghostty padding). It replaces an earlier SwiftUI List, which could not do reliable cross-section drag-and-drop. A @MainActor Coordinator is the data source and delegate, backed by AppStore:
- Stable item identity. Outline items are reference-type
SidebarNodes cached by id and reused across reloads, soNSOutlineViewkeeps expansion and selection state.updateNSViewreads the observed store (tree +selectedSessionID), so model changes reload the outline. - Selection. Only session rows are selectable; selecting one routes through
AppStore.selectSession, andstore.selectedSessionIDis reflected back into the outline (guarded against re-entry). - Rename. Double-click or the
Renamemenu makes the row'sNSTextFieldeditable and first-responder; commit on end-editing, Escape cancels. - Drag-and-drop. Session rows are draggable (
pasteboardWriterForItemwrites the session UUID); a drop onto a different workspace validates as.moveand callsAppStore.moveSession, preserving the sameSessioninstance. - Add affordances. A bottom bar (SwiftUI, in
WindowContentView) holds a workspace-add button and a session-add menu — New Session (home directory) and Open Directory… (NSOpenPanel); the same two session actions are also on each workspace row's context menu. - Accessibility identifiers (
session-row,workspace-row,edit-field,add-session) back theagtermUITestsXCUITests, which drive the real app for rename, move, close, drag, and add.
agterm links GhosttyKit.xcframework, which scripts/setup.sh builds from upstream ghostty-org/ghostty source: a shallow checkout at a pinned commit plus zig build, using the keg-only zig@0.15 formula for the zig version ghostty pins. Building from source keeps the libghostty toolchain self-owned, with no third-party fork and no prunable daily-build download. The pin is a deliberately chosen known-good commit. The xcframework and the accompanying ghostty resources (themes, shell-integration scripts, compiled terminfo database) are gitignored and never committed; the build is one-time, cached by a present-check.
The surface lifecycle is the rule that keeps the C interop safe.
Sessionowns itsGhosttySurfaceViewthroughSession.surface, marked@ObservationIgnoredso assigning the lazily-created view never churns observation.customName,currentCwd, andgitStatusare observed, so the sidebar and title bar refresh when a rename, a PWD report, or a git-status update lands.- The detail pane is an eager deck: every session's
TerminalViewstays mounted in aZStack(so every shell spawns at startup), and switching flips visibility (opacity+isActive) instead of swapping by.id. The surface NSView is never dismantled/re-hosted on a switch, since re-hosting invalidates the Metal drawable and flickers the window. Surfaces stay owned by theSession, and only the active pane holds first responder. dismantleNSViewis a no-op. The surface is freed in exactly one place:destroySurface(), reached throughTerminalSurface.teardown()whenAppStore.closeSessionremoves the session. This single-owner, single-free rule is what makes passing the view as unretaineduserdatato libghostty safe.
The pure parsing, formatting, and throttle decision live in agtermCore (GitStatus, GitRefreshPolicy); the Process execution and the two render surfaces live in the app target. agtermCore stays git-free, so its tests feed canned --porcelain=v2 --branch strings and never spawn git.
GitStatusService(@MainActororchestrator). Owned byagtermApp. All model and throttle state is main-actor isolated: the per-session in-flightSet<UUID>, last-ran-cwd[UUID: String], and last-ran-at[UUID: Date]are read/written only on the main actor. The git work runs OFF the main actor in aTask.detachedworker calling thenonisolated staticrunGit(cwd:)— not a barenonisolated func … async, which under Xcode 26NonisolatedNonsendingByDefaultwould run on the caller's (main) executor and block the UI. The worker takes only acwd: Stringand returns only aSendable GitStatus?; it never capturesSession,AppStore, orProcess.- Two cheap git calls per refresh.
git -C <cwd> status --porcelain=v2 --branch(branch, upstream, ahead/behind, dirty entries) andgit -C <cwd> rev-parse --git-dir(the linked-worktree name). A non-zero status exit means the cwd is not a git work tree →gitStatus = nil(the "only if git controlled" gate, free). - Inline ~2 s timeout, no cross-context capture. The non-
SendableProcess/Pipeare created, run, and consumed insiderunProcess. stdout is drained by a dedicated readerThreadthat blocks onreadDataToEndOfFile()and signals aDispatchSemaphorewhen it returns; the owning thread enforces the deadline withwait(timeout:)and, on timeout,terminate()s the child (which makes the reader's blocking read hit EOF and return) and reaps it withwaitUntilExit()from that same thread. Draining on a separate thread is what makes the timeout real — a blocking read on the owning thread would hang forever if git produces no output and never exits, leaving the timeout dead.readDataToEndOfFile()(rather than areadabilityHandler) avoids the historically deadlock-/EOF-flaky handler teardown on the macOS 14 deployment target, and the reader keeps draining the pipe while git writes, so a large dirty tree whose status exceeds the 64 KB pipe buffer can't deadlock. - Three guards on the completion hop. After the worker returns, hop to
@MainActorand look the session up byid(viaAppStore.session(withID:), shared with the sidebar). The guards are the pureGitApplyDecision.decide(...)from agtermCore; the service only acts on its result. (1) Stale-result: if the session's currentcurrentCwdno longer equals the cwd the worker ran for (acd a; cd brace), discard and re-enqueue for the latest cwd. (2) Equality-gate: only writesession.gitStatuswhen the value changed — an@Observablewrite invalidates regardless of value, so an identical 3 s tick would storm the sidebar reload and toolbar re-eval. (3) Transient failure: a timeout or spawn error keeps the previous status, never clobbering a known status to nil. A completion hop that finds the session already closed drops its throttle state (forget) so the per-session dicts don't grow unbounded. - Refresh model. A cwd-change refresh fires through a named injection path:
GhosttySurfaceView.applyPwdsetscurrentCwdand then calls itsonCwdChangeclosure (wired inagtermApp.makeSurfacetoservice.requestRefresh), so the surface never learns about the service. The debounce isGitRefreshPolicyon the main-actor service — a prompt redraw re-reporting the same cwd does not spawn git, andcd a; cd bcoalesces to one run against the latest cwd. The active session is also polled by aTask { @MainActor … }Task.sleep(~3 s)loop (not aTimer, to avoid a secondassumeIsolatedsite), cancelled onNSApplication.didResignActiveNotificationand recreated with one immediate refresh ondidBecomeActiveNotification, so a backgrounded app spawns no git. Background sessions are refreshed only on cwd-change and on becoming the selected/active session — never polled. - Render surfaces. The sidebar cell (
WorkspaceSidebar) carries a trailing tokenNSTextFieldholdinggitStatus?.compactas anNSAttributedStringinsecondaryLabelColor(dimmed, matching the arrows — no accent color); the name truncates first and the token collapses when empty. AgitStatus-only change re-invokesupdateNSView(the read folds each session'sgitStatusinto the observed tuple) and reloads just the affected rows, skipping while a rename is committing.WindowContentViewdrivesNSWindow.titleto the active session's display name via a smallWindowAccessor(which also deminiaturizes and orders the window front on launch, so a restored-minimized window still surfaces) and hosts theGitStatusPilltoolbar item on the split view (present in both detail branches). Both surfaces expose accessibility values (git-compacton the cell,git-pillon the pill) for a future stretch XCUITest.
Swift 6 strict concurrency (complete) is on. The C-callback boundary is the highest-risk area and follows an explicit contract.
- The callback router,
GhosttyCallbacks, is afinal classmarked@unchecked Sendableand is deliberately not@MainActor. It holds no mutable state. The C@convention(c)closures run synchronously off whatever thread libghostty calls from; they capture nothing and reach Swift through theGhosttyApp.sharedsingleton. - Every
@MainActorstate touch from a callback hops throughDispatchQueue.main.async. Any C string is copied into a SwiftStringvalue before the hop, because thechar*is only valid for the synchronous callback duration. For example, the PWD callback buildsString(cString:)in the nonisolated context, then dispatchesview.applyPwd(pwd)onto the main actor. MainActor.assumeIsolatedis used in exactly one place: theRunLoop.mainTimerclosure that drives the 120 Hz tick. A main-RunLoop timer is proven to fire on the main thread, so the assumption is valid there. It is never used inaction_cb,wakeup_cb, orclose_surface_cb, which are not guaranteed main-thread and would crash.- The
surfacehandle and the strdup buffer array onGhosttySurfaceViewarenonisolated(unsafe). The documented invariant: they are mutated only on the main actor (create/destroy), and the C callbacks that read them are serialized by libghostty's tick model. - Passing the view as unretained
userdatais valid only while the surface-free ordering holds: theSessionretains the view untildestroySurface(), which is the only placeghostty_surface_freeruns. Theclose_surface_cbtherefore only recovers the view and dispatches to the main actor; it never closes or frees synchronously. SessionandAppStoreare never madeSendable/actorexplicitly; a@MainActorclass is already implicitlySendablevia isolation. TheSnapshotvalue types areSendable, built on the main actor and handed to the file writer as a value.
These are the points where a small deviation produces a blank surface, broken keys, or a crash.
- terminfo sibling-dir layout. libghostty derives
TERMINFOasdirname(GHOSTTY_RESOURCES_DIR)/terminfoat shell spawn.GHOSTTY_RESOURCES_DIRpoints atContents/Resources/ghostty, so the compiled terminfo database must sit as a direct sibling atContents/Resources/terminfo. If broken,TERM=xterm-ghosttyfails to resolve and keys break.GhosttyResourcessets onlyGHOSTTY_RESOURCES_DIR; it never setsTERMINFO, because libghostty overwrites it at spawn. - strdup buffer lifetime. The
working_directory(and laterinitial_input)const char*config fields are backed by heap buffers that must outliveghostty_surface_new, since libghostty consumesinitial_inputasynchronously after the child spawns. They are retained on the instance in anonisolated(unsafe)array and freed only indestroySurface(). - xcframework
embed: false.GhosttyKit.xcframeworkis linked, not embedded. Embedding breaks the signature on non-Developer-ID builds. - Non-zero backing size guard. A surface is created only once the view has a non-zero backing size; the Metal layer renders blank otherwise. If
viewDidMoveToWindowfires with a zero-size backing, apendingSurfaceCreationflag defers creation untilsetFrameSizereports a real size. This is the most common blank-surface bug. - Guard
ghostty_config_new()nil. The config constructor returns nil on allocation failure; the init fails loudly rather than proceeding. Onghostty_app_newfailure, the config is freed before bailing.