Skip to content

Latest commit

 

History

History
94 lines (64 loc) · 21 KB

File metadata and controls

94 lines (64 loc) · 21 KB

Architecture

agterm is two modules: a host-free model package and an app target that adds the SwiftUI shell and the libghostty bridge.

Module split

agtermCore (Swift package)

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 are id, customName, currentCwd, gitStatus, initialCwd, and the surface slot. displayName returns a non-blank customName (trimmed, matching renameSession), otherwise the basename of currentCwd ?? initialCwd. Basename pins: root / stays /, a trailing slash is ignored (/a/b/b), and an empty path shows the home shorthand ~. gitStatus is an observed GitStatus? set by the app's GitStatusService; 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 reads git status --porcelain=v2 --branch header lines (branch.head, branch.oid, branch.upstream, branch.ab with the sign stripped so behind is non-negative) and counts entry lines (leading 1/2/u/?) into dirty. A literal (detached) head sets detachedSHA; an (initial) oid (unborn branch) does not. The worktree name comes from a …/worktrees/<name> git-dir path (trailing-segment match). compact formats the sidebar tokens (↑N ↓N *N, where *N is the conventional git dirty marker plus the changed-file count; empty when clean and in sync); branchDisplay formats 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), not parse's.
  • GitRefreshPolicy (enum, Sendable): the pure throttle/coalesce predicate, lifted out of the Process side 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 within minInterval, 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 a Session. static decide(ranCwd:currentCwd:succeeded:parsed:existing:) returns .reEnqueue when the cwd moved on (stale-result race), .keepExisting on 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) with ClipboardAccess/ClipboardDecision (enum): the pure per-direction allow/deny/prompt decision for OSC 52 clipboard access, lifted out of the app-side ClipboardPromptController (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 stable id, a name, and an ordered array of Session references.
  • AppStore (@Observable @MainActor final class): the workspace tree plus a single selectedSessionID. Owns the mutations (addWorkspace, addSession, selectSession, renameSession, renameWorkspace, closeSession, moveSession) and the persistence hooks (snapshot, restore, save). Every structural mutation — including selectSession (so a sidebar click persists immediately) — calls save(), as does app quit. A live cd does not: the PWD report updates currentCwd without 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. AppStore is the per-window unit — one tree + one selection is exactly one window's content (see WindowLibrary).
  • 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. WindowLibrary owns the ordered window metadata (WindowInfo {id, name}), the live per-window stores: [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's AppStore keeps its own PersistenceStore pointed at windows/<id>.json (today's Snapshot shape; PersistenceStore gained an optional fileName: init param for this), with a thin windows.json index (WindowsIndex carries its own version). On init it runs migration/recovery (never throws): legacy workspaces.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 what ContentView, AppActions, and ControlServer resolve a window's store through. See CLAUDE.md for the scene/restoration mechanism, the quit-flush, and the window.* control additions.
  • TerminalSurface (@MainActor protocol, AnyObject): the minimal surface contract (teardown()) that Session owns. The concrete conformer lives in the app target, which keeps agtermCore free of GhosttyKit.
  • Snapshot and friends (Codable, Equatable, Sendable value 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.

App target (XcodeGen project)

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 NSWindowTitleProbeView 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.

Control API (split across both modules)

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:

  • agtermCore holds 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). The agtermCore library target stays dependency-free.
  • agtermctlKit + agtermctl are separate targets in the same SwiftPM package. agtermctlKit is the testable library (the swift-argument-parser ParsableCommand tree plus the socket client); agtermctl is the thin executable. Only these CLI targets link ArgumentParser, so they build and test with swift build/swift test and 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 existing AppActions/AppStore seam (and GhosttySurfaceView.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 injected ControlTargetResolver.

See CLAUDE.md for the socket lifecycle, addressing, command catalog, and the keep-in-sync convention.

Sidebar (NSOutlineView)

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, so NSOutlineView keeps expansion and selection state. updateNSView reads the observed store (tree + selectedSessionID), so model changes reload the outline.
  • Selection. Only session rows are selectable; selecting one routes through AppStore.selectSession, and store.selectedSessionID is reflected back into the outline (guarded against re-entry).
  • Rename. Double-click or the Rename menu makes the row's NSTextField editable and first-responder; commit on end-editing, Escape cancels.
  • Drag-and-drop. Session rows are draggable (pasteboardWriterForItem writes the session UUID); a drop onto a different workspace validates as .move and calls AppStore.moveSession, preserving the same Session instance.
  • 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 the agtermUITests XCUITests, which drive the real app for rename, move, close, drag, and add.

libghostty integration

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.

Surface ownership

The surface lifecycle is the rule that keeps the C interop safe.

  • Session owns its GhosttySurfaceView through Session.surface, marked @ObservationIgnored so assigning the lazily-created view never churns observation. customName, currentCwd, and gitStatus are 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 TerminalView stays mounted in a ZStack (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 the Session, and only the active pane holds first responder.
  • dismantleNSView is a no-op. The surface is freed in exactly one place: destroySurface(), reached through TerminalSurface.teardown() when AppStore.closeSession removes the session. This single-owner, single-free rule is what makes passing the view as unretained userdata to libghostty safe.

Git status

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 (@MainActor orchestrator). Owned by agtermApp. All model and throttle state is main-actor isolated: the per-session in-flight Set<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 a Task.detached worker calling the nonisolated static runGit(cwd:) — not a bare nonisolated func … async, which under Xcode 26 NonisolatedNonsendingByDefault would run on the caller's (main) executor and block the UI. The worker takes only a cwd: String and returns only a Sendable GitStatus?; it never captures Session, AppStore, or Process.
  • Two cheap git calls per refresh. git -C <cwd> status --porcelain=v2 --branch (branch, upstream, ahead/behind, dirty entries) and git -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-Sendable Process/Pipe are created, run, and consumed inside runProcess. stdout is drained by a dedicated reader Thread that blocks on readDataToEndOfFile() and signals a DispatchSemaphore when it returns; the owning thread enforces the deadline with wait(timeout:) and, on timeout, terminate()s the child (which makes the reader's blocking read hit EOF and return) and reaps it with waitUntilExit() 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 a readabilityHandler) 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 @MainActor and look the session up by id (via AppStore.session(withID:), shared with the sidebar). The guards are the pure GitApplyDecision.decide(...) from agtermCore; the service only acts on its result. (1) Stale-result: if the session's current currentCwd no longer equals the cwd the worker ran for (a cd a; cd b race), discard and re-enqueue for the latest cwd. (2) Equality-gate: only write session.gitStatus when the value changed — an @Observable write 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.applyPwd sets currentCwd and then calls its onCwdChange closure (wired in agtermApp.makeSurface to service.requestRefresh), so the surface never learns about the service. The debounce is GitRefreshPolicy on the main-actor service — a prompt redraw re-reporting the same cwd does not spawn git, and cd a; cd b coalesces to one run against the latest cwd. The active session is also polled by a Task { @MainActor … } Task.sleep(~3 s) loop (not a Timer, to avoid a second assumeIsolated site), cancelled on NSApplication.didResignActiveNotification and recreated with one immediate refresh on didBecomeActiveNotification, 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 token NSTextField holding gitStatus?.compact as an NSAttributedString in secondaryLabelColor (dimmed, matching the arrows — no accent color); the name truncates first and the token collapses when empty. A gitStatus-only change re-invokes updateNSView (the read folds each session's gitStatus into the observed tuple) and reloads just the affected rows, skipping while a rename is committing. WindowContentView drives NSWindow.title to the active session's display name via a small WindowAccessor (which also deminiaturizes and orders the window front on launch, so a restored-minimized window still surfaces) and hosts the GitStatusPill toolbar item on the split view (present in both detail branches). Both surfaces expose accessibility values (git-compact on the cell, git-pill on the pill) for a future stretch XCUITest.

Concurrency contract at the C boundary

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 a final class marked @unchecked Sendable and 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 the GhosttyApp.shared singleton.
  • Every @MainActor state touch from a callback hops through DispatchQueue.main.async. Any C string is copied into a Swift String value before the hop, because the char* is only valid for the synchronous callback duration. For example, the PWD callback builds String(cString:) in the nonisolated context, then dispatches view.applyPwd(pwd) onto the main actor.
  • MainActor.assumeIsolated is used in exactly one place: the RunLoop.main Timer closure 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 in action_cb, wakeup_cb, or close_surface_cb, which are not guaranteed main-thread and would crash.
  • The surface handle and the strdup buffer array on GhosttySurfaceView are nonisolated(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 userdata is valid only while the surface-free ordering holds: the Session retains the view until destroySurface(), which is the only place ghostty_surface_free runs. The close_surface_cb therefore only recovers the view and dispatches to the main actor; it never closes or frees synchronously.
  • Session and AppStore are never made Sendable/actor explicitly; a @MainActor class is already implicitly Sendable via isolation. The Snapshot value types are Sendable, built on the main actor and handed to the file writer as a value.

Load-bearing fragile points

These are the points where a small deviation produces a blank surface, broken keys, or a crash.

  1. terminfo sibling-dir layout. libghostty derives TERMINFO as dirname(GHOSTTY_RESOURCES_DIR)/terminfo at shell spawn. GHOSTTY_RESOURCES_DIR points at Contents/Resources/ghostty, so the compiled terminfo database must sit as a direct sibling at Contents/Resources/terminfo. If broken, TERM=xterm-ghostty fails to resolve and keys break. GhosttyResources sets only GHOSTTY_RESOURCES_DIR; it never sets TERMINFO, because libghostty overwrites it at spawn.
  2. strdup buffer lifetime. The working_directory (and later initial_input) const char* config fields are backed by heap buffers that must outlive ghostty_surface_new, since libghostty consumes initial_input asynchronously after the child spawns. They are retained on the instance in a nonisolated(unsafe) array and freed only in destroySurface().
  3. xcframework embed: false. GhosttyKit.xcframework is linked, not embedded. Embedding breaks the signature on non-Developer-ID builds.
  4. 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 viewDidMoveToWindow fires with a zero-size backing, a pendingSurfaceCreation flag defers creation until setFrameSize reports a real size. This is the most common blank-surface bug.
  5. Guard ghostty_config_new() nil. The config constructor returns nil on allocation failure; the init fails loudly rather than proceeding. On ghostty_app_new failure, the config is freed before bailing.