Native macOS terminal multiplexer built with SwiftUI (macOS 14+, Swift 6.0). Uses libghostty (via GhosttyKit.xcframework) for terminal rendering and PTY ownership. A forged daemon holds PTY master fds across app launches so sessions survive a quit/relaunch.
Feature-based architecture with a shared domain kernel. See CONTEXT.md for domain glossary, docs/adr/ for architectural decisions.
swift build # compile check — always run before claiming done
swift test # domain logic tests (ForgeTests)
make dev # debug build + launch app
make run # release build + launch app
tail -20 /tmp/forge.log # log categories: [app] [ghostty] [daemon] [attention] [debug]Sources/
Core/ # Shared kernel (SPM: ForgeCore). Models, Ports, pure logic.
Models/ # Workspace > Project > Tab > Pane, SplitNode,
# AttentionQueue, AttentionEvent, AttentionTimestamps
Ports/ # AttentionPort, NotificationPort, PaneActivityPort, ProcessPort
ContentDetector.swift # Pattern matching for interactive prompts
CloseConfirmation.swift # Pure decision logic for close operations
MoveTabConfirmation.swift # Pure decision logic for move-tab confirmation
TabReordering.swift # Pure index math for tab reorder swap targets
StackOrdering.swift # Stack mode queue ordering
CommandNameResolver.swift # Foreground-process name resolution
AppCommand.swift # Typed command enum for cross-view dispatch
Features/ # Vertical slices — each feature owns its own layers
Attention/ # AttentionManager, PaneActivityWatcher, MacNotificationAdapter,
# NotificationPanel, NotificationCenterRow
Sidebar/ # SidebarProjectList, SidebarProjectRow, SidebarTabRow, TruncatingText, IconButton, StatusDot
Stack/ # StackView, StackToolbar, StackEmptyState
Terminal/ # TerminalArea, ProjectDetailView, PaneSplitView, PaneTerminalView,
# PaneDivider, WindowTabBar, WindowTab
CommandPalette/ # CommandPalette, CommandRegistry
ProjectPicker/ # ProjectPickerView, PickerProjectRow
Settings/ # SettingsView + panes (General, ListMode, StackMode, Theme, Font, Terminal, Shortcuts, About)
TitleBar/ # TitleBarManager, TitleBarOverlay (chrome, fullscreen)
Shared/ # Cross-feature: MainView, AppState, ModalOverlays, ModalContainer,
# Tooltip, InlineRenameField, ReorderableStack, NotificationToast, ModifierKeyMonitor
Infrastructure/ # Cross-feature adapters
Terminal/ # GhosttyRenderer (libghostty bridge), GhosttyNSView, GhosttyCallbackContext
Process/ # DaemonAdapter (forged RPC), DaemonActivityAdapter
Git/ # GitBranchPoller
Config/ # ForgeConfig, ForgeConfigStore, UIStatePersistence, KeyboardShortcuts
Theme/ # ThemeParser, ThemeDefinition, ThemeDefinition+SwiftUI, FontResolver
Debug/ # DebugServer, DebugServer+Responses
Logging/ # ForgeLog
Daemon/ # forged executable — PTY fd custodian
Forged.swift, FDSocket.swift
WorkspaceController.swift # Thin orchestrator: owns Workspace, lifecycle, attention/git wiring
WorkspaceController+Actions.swift # Command methods: project/tab/pane CRUD, split, rename, etc.
WorkspaceController+Rendering.swift # Renderer creation, daemon registration, focus/update
MenuCommands.swift # Menu bar commands
ForgeApp.swift # Composition root + AppDelegate
- Core/ is the shared kernel. Features depend on it, not on each other.
- Features/ are vertical slices. Each feature materializes only the layers it needs.
- Infrastructure/ holds adapters that serve multiple features. Feature-specific adapters live inside the feature.
- Port protocols live in
Core/Ports/. Cross-feature port implementations live inInfrastructure/. Feature-specific implementations live in the feature. - No cross-imports between features. Communication goes through Core or the orchestrator.
- Core/ — Pure functions on domain models, decision logic, value types. Zero framework imports (no AppKit, no SwiftUI). If it has no framework imports, it's probably Core.
- Features/ — Views, feature-specific controllers, feature-specific adapters. Each feature is a self-contained vertical slice.
- Infrastructure/ — Adapters that serve multiple features: I/O, processes, system APIs, persistence.
- Tested domain logic lives in Core/ (the
ForgeCoreSPM target). When a feature's domain grows large enough to warrant its own test target, extract a new SPM target.
- WorkspaceController — Thin orchestrator. Owns Workspace model, runs
connect()lifecycle (load workspace.json → pre-fetch daemon fds → start watcher + git poller). Uses(any AttentionPort)?— not the concrete AttentionManager. - PaneActivityWatcher (
Features/Attention/) — Event-driven attention detector. HooksGhosttyRenderer.onOutputfor BEL + content-match scanning; pollsPaneActivityPortevery 2s for command-completion (active→inactive transitions). EmitsAttentionEvents that the controller routes to AttentionManager + notifications. - AttentionManager (
Features/Attention/) — Owns the attention queue. Conforms toAttentionPort. Receives events from PaneActivityWatcher via the controller. Also owns MacNotificationAdapter. NotificationPanel and NotificationCenterRow live here too. - GitBranchPoller (
Infrastructure/Git/) — Pollsgit rev-parse --abbrev-ref HEADevery 5s for the active project. PostsforgeWindowTitleChangedon change. - DaemonAdapter (
Infrastructure/Process/) — Unix-domain socket client for theforgeddaemon. Sends/receives PTY master fds via SCM_RIGHTS. Ops: store, retrieve, list, release, is_active. - GhosttyRenderer (
Infrastructure/Terminal/) — libghostty surface bridge. Two init paths: EXEC (Ghostty forks the shell) and EXTERNAL_FD (reconnect to a PTY fd from the daemon). ExposesonOutput(PTY bytes),onInput(keyboard), andsetFocused. - AppState (
Features/Shared/) —@Observablecross-feature UI state: modals, sidebar, rename state, stack actions. DispatchesAppCommands. OwnsrenameTextand rename lifecycle helpers. Uses(any AttentionPort)?andonModeChangedclosure — no direct refs to feature implementations. - UIStatePersistence (
Infrastructure/Config/) — Save active project+tab selection, sidebar state. - TitleBarManager (
Features/TitleBar/) — All title bar visual management: overlay installation, chrome stripping, fullscreen handling, appearance sync. - ForgeConfigStore —
@Observableconfig store with injectedthemeLoaderclosure (decoupled from ThemeParser). Injected via@Environmentin views, constructor in non-view types..sharedonly referenced at the composition root (AppDelegate).
- AttentionPort — Queue management: handleEvent, markDone, hide, moveToBack, unhide, removeTab, pruneStaleHiddenEntries, promoteToFront, seedQueue, pruneResolved. Properties: currentTabUUID, queueCount, timestamps.
- NotificationPort — System notification delivery.
- PaneActivityPort —
query(paneIds:) -> [PaneActivity]. Reports which panes have a foreground process other than the controlling shell. Implementations fail open. Today's adapter:DaemonActivityAdapter(queries forged'sis_activeop). - ProcessPort / PersistencePort (
Core/Ports/ProcessPort.swift) — Pane creation and PTY-fd persistence contracts. DaemonAdapter implements PersistencePort.
- One pattern per problem. If the codebase already solves something, use that solution — don't invent a parallel one.
- One dispatch mechanism per class of operation. Don't mix NotificationCenter, direct method calls, and closures for the same kind of action.
- Event-driven attention: PTY output →
onOutputcallback → ContentDetector + BEL scan → AttentionEvent → AttentionManager. - forged daemon owns fds; Forge quits with
_exit(0)to skip AppKit teardown and avoid SIGHUP to child shells.
- ThemeColor — Platform-agnostic
(red: Double, green: Double, blue: Double)struct inThemeDefinition.swift. No SwiftUI import. - ThemeDefinition+SwiftUI.swift — Extension adding
.colorcomputed property that convertsThemeColortoSwiftUI.Color. - ThemeParser — Imports Foundation only. Returns
ThemeColorvalues.loadTheme(id:)searches Ghostty theme paths. - ForgeConfigStore — Receives a
themeLoader: (String) -> ThemeDefinition?closure at init (wired toThemeParser.loadThemeat the composition root). Never imports ThemeParser directly. - FontResolver (
Infrastructure/Theme/) — Resolves terminal fonts: config family → Ghostty config → Nerd Font fallbacks → system monospaced. - Views access theme colors via
configStore.resolvedTheme?.background.color(the.coloraccessor converts ThemeColor → SwiftUI.Color).
- Hard limit: 300 lines per file. Files over 300 lines must be split before merging. Find a natural seam — don't just chop arbitrarily.
- One type per file, named to match the type. Extensions in
Type+Category.swiftfiles are fine.
- No "Forge" prefix in function, method, or type names.
- Project = top-level sidebar item. Domain model:
Project. Has a working directory; owns its tabs. - Tab = item nested inside a project. Domain model:
Tab. Owns its panes and split tree. - Pane = a single terminal surface. Backed by a PTY fd held by the daemon and a libghostty surface for rendering.
WorkspaceControlleris the single@Observableobject that owns workspace state. All views consume it via@Environment.AppStateowns cross-feature UI state (modals, sidebar visibility, rename state). Injected via@Environment.@Stateis only for local, view-scoped UI state (hover, animation). Never domain state.- Domain models (
Workspace,Project,Tab,Pane) are@Observable @MainActor. - No
.sharedsingletons consumed by views or non-view types. Inject via@Environment(views) or constructor (non-view types)..sharedis permitted only at the composition root (AppDelegate).
@MainActorfor all observable state, ports, and controllers.- Structured concurrency (
async/await,Task {},TaskGroup) for async work. DispatchQueueonly in adapter internals where required by underlying APIs (Ghostty I/O thread, EXTERNAL_FD read thread).
- Request-response adapter calls (e.g., DaemonAdapter ops): propagate failure to the caller. Show a toast on user-visible failure.
- Fail-open for activity polling: a wedged daemon must never block the close path.
DaemonActivityAdapterhas a 200ms timeout and reports all-idle on timeout. - Optimistic UI updates: permitted only for drag interactions (reorder, swap) where latency matters.
- Core: TDD with Swift Testing (
@Test,#expect). Pure logic, no side effects. - Infrastructure: Integration tests when behavior depends on external processes.
- UI: Visual verification via debug server screenshots.
- No speculative code — every line serves a current requirement.
- No unused abstractions — delete code that has no caller.
- No premature helpers — three similar lines are better than a premature abstraction.
- Backgrounds load from Ghostty theme files (
resolvedTheme.background.color). Default theme:ghostty-seti. - UI surfaces (sidebar, toolbars, title bar spacers) layer
Color.white.opacity(0.06)on top of the theme background for subtle depth. - Fallback when no theme:
Color(red: 0.1, green: 0.1, blue: 0.1). - Color hierarchy:
.primary/.labelColorfor active elements,.secondary/.secondaryLabelColorfor inactive,.tertiaryfor minor hints (chevrons). - Accent color (
Color.accentColor) for active indicators, attention dots, and selection highlights. - Window appearance (light/dark) is derived from theme background luminance — set automatically in
TitleBarManager.syncAppearance().
- Tooltips: Custom tooltip system (
Tooltip.swift), never native.toolTipor.help(). Format: label on first line, keyboard shortcut on second line (no parens). Use.tooltip(Shortcut)or.tooltip(label, shortcut:)for SwiftUI views;.setForgeTooltip()for AppKit views. - Icon buttons: SwiftUI icons use
IconButton(hover: secondary -> primary). AppKitNSButtonicons in the title bar must usehoverTint: trueinsetForgeTooltip()to match. When touching any icon button, verify it has both a tooltip and correct hover behavior. - Truncation tooltips: Sidebar text (project names, tab names) uses
TruncatingTextwhich shows a tooltip only when ellipsized. - Inline rename:
InlineRenameField(inFeatures/Shared/) used by both Sidebar and TabBar. Rename state (renameText,renamingTabId,renamingProjectId) lives inAppState— not drilled via params. - Modal overlays:
ModalOverlaysViewModifier handles CommandPalette, ProjectPicker, and NotificationPanel presentation. MainView applies it — individual modals don't manage their own visibility.
- Window created programmatically in
AppDelegate.createMainWindow()— not via SwiftUIWindowscene. Title bar managed byTitleBarManager(Features/TitleBar/). titlebarAppearsTransparent = true,titleVisibility = .hidden,titlebarSeparatorStyle = .none._NSTitlebarDecorationViewandNSVisualEffectViewinside the title bar are hidden (not removed) — more resilient to macOS re-adding them during layout passes.- Stripping runs on a 100ms repeating timer for 3s at launch, plus on
didBecomeKeyNotificationand after fullscreen exit. - A custom overlay NSView is installed on the title bar containing: path label, branch label, mode toggle button, and split buttons.
- Title bar background matches sidebar color (theme background + white overlay) for visual continuity.
- On
willEnterFullScreen: hide split icons, disabletitlebarAppearsTransparenton macOS 15.3+ (workaround for OS bug). - On
didExitFullScreen: re-measure title bar height, restoretitlebarAppearsTransparent, re-strip chrome, reinstall overlay. Fullscreen exit resets title bar properties — everything must be reapplied. - SwiftUI views also observe fullscreen notifications to toggle titlebar spacer visibility.
- List mode: sidebar visible (120-400px, default 160px), tab bar at top, split buttons in title bar, mode toggle button hidden.
- Stack mode: sidebar hidden,
StackToolbarwith action buttons above or below terminal (configurable viaconfig.stackView.toolbarPosition, default "bottom"), mode toggle button visible in title bar at 82px from left. - Title bar overlay constraints shift between modes — path label leading changes to accommodate mode toggle button.
- Spring for interactive feedback: drag reorder (
response: 0.25, dampingFraction: 0.85), toast show (duration: 0.4, bounce: 0.2). - EaseInOut for structural changes: sidebar toggle, chevron rotation (
0.2s). - EaseIn for dismiss: stack card flyout (
0.35s, scale 0.85 + offset -800 + fade). - EaseOut for fade-away: toast dismiss (
0.3s), tooltip fade-out (0.1s). - Tooltip show delay:
0.5s, fade-in:0.15s.
- Sidebar row hover: 4px. Tooltip pill: 6px. Modals/toasts: 12px. Inline rename field: 3px. Tab active indicator: 1px.
curl localhost:7654/ping # check if app is running
curl localhost:7654/screenshot > /tmp/forge-screenshot.png # screenshot (then Read to inspect)
curl localhost:7654/state # workspace state as JSON
curl localhost:7654/logs # last 50 log lines
curl -X POST localhost:7654/action -d '{"action":"selectProject","args":{"name":"my-project"}}'
curl -X POST localhost:7654/action -d '{"action":"selectTab","args":{"index":0}}'Available actions: selectProject, selectTab, addProject, removeProject, addTab, splitPane.
Before claiming work is done:
swift buildsucceedsswift testpasses- If UI was changed:
make dev, wait for launch, thencurl localhost:7654/screenshot > /tmp/forge-screenshot.pngandRead /tmp/forge-screenshot.pngto visually inspect - Check
tail -20 /tmp/forge.logfor errors after launch
make bump-patch # 0.1.0 -> 0.1.1; auto-commits "chore(release): bump version to 0.1.1"
make bump-minor # 0.1.0 -> 0.2.0
make bump-major # 0.1.0 -> 1.0.0
make release # build + Developer-ID sign + notarize + staple + git push + gh release create
make release-patch # bump-patch + release
make release-minor # bump-minor + release
make release-major # bump-major + releaseAPPLE_ID, APPLE_TEAM_ID, and SIGNING_IDENTITY are hardcoded in scripts/release.sh (admin@serendipityapps.com / 9CD626Q2L2 / Developer ID Application: Serendipity Apps LLC (TN) (9CD626Q2L2) — same Apple Developer team as the tutor repo). None are secret. Only APPLE_APP_SPECIFIC_PASSWORD lives in .env (gitignored) — see .env.example.
Conventional Commits — lowercase prefix + colon + short imperative summary in lowercase:
feat:— user-visible featurefix:— bug fixrefactor:— internal restructure, no behavior changedocs:— documentation onlychore:— tooling, build, deps, version bumps (the bump targets writechore(release): bump version to X.Y.Z)test:— test-only changes- Append
!(e.g.feat!:) or includeBREAKING CHANGE:in the body for breaking changes
When the user says "release a new version" without specifying patch/minor/major, decide from the commits since the last tag:
LAST_TAG=$(gh release view --json tagName -q .tagName 2>/dev/null || echo "")
git log ${LAST_TAG:+${LAST_TAG}..}HEAD --oneline| Commits since last tag include | Bump |
|---|---|
feat!: or BREAKING CHANGE: (post-1.0 only) |
major |
any feat: |
minor |
only fix:, chore:, docs:, refactor:, test: |
patch |
Pre-1.0 (0.X.Y): never bump to major without explicit user confirmation — treat feat!: as minor. The first 1.0.0 is an intentional stability commitment, not a code-driven decision.
State the chosen level and a one-line rationale ("3 feat: commits, 2 fix: commits — minor") to the user before running make release-{level}.
Default: gh release create --generate-notes (GitHub auto-summarizes from commits + PRs). To override with AI-curated notes, write them to a file and set RELEASE_NOTES_FILE:
RELEASE_NOTES_FILE=/tmp/forge-notes.md make releaseCurated-notes structure — group by category, omit empty sections, skip chore(release): bump version commits:
### Features
- One bullet per feat:, terse, user-perspective (not "added X" — describe the new capability).
### Fixes
- One bullet per fix:, describing what no longer breaks.
### Other
- Notable refactor/docs/chore/test items only if user-visible or operationally relevant.- Get the last tag and the commits since:
LAST_TAG=$(gh release view --json tagName -q .tagName 2>/dev/null || echo "") git log ${LAST_TAG:+${LAST_TAG}..}HEAD --oneline
- Classify commits via the table above; pick the bump level.
- State the plan to the user (level + commit summary). Wait for OK if anything is ambiguous (e.g. unclear whether a
feat:is breaking, or whether to cut a 1.0). - Either:
make release-{level}for default auto-generated notes, or- Draft curated notes to
/tmp/forge-notes.md, thenmake bump-{level} && RELEASE_NOTES_FILE=/tmp/forge-notes.md make release.
- Report the release URL from the script's final output.