Skip to content

Latest commit

 

History

History
220 lines (190 loc) · 94.4 KB

File metadata and controls

220 lines (190 loc) · 94.4 KB

Umber

What This Is

A native macOS terminal written in Swift 6 / AppKit, deliberately with no AI features — built to host agent-afk's REPL properly. The agent lives in the terminal; the terminal is a fast, correct, native window that gets out of the way. The bar is "beautiful and user-friendly as hell" before clever. Rendering is SwiftTerm (vendored, v1.15.0 + six patches); everything else is 42 small AppKit files, none over 350 LOC. Personal project, private repo griffinwork40/umber, single main branch.

Naming: the app was renamed from MacTerminal to Umber on 2026-07-27 (commit faf1291). Code, scripts, bundle id, env var, and config path are all umber/Umber/UMBER_. The checkout directory was renamed from mac-terminal to umber on 2026-08-18. Remaining MacTerminal/MT_DIAG strings in .afk/ are historical records, not stale code; MacTerminalView in KeyBindings.swift is SwiftTerm's own upstream type name.

Commands

cd app
./Scripts/make-app-bundle.sh           # debug bundle → build/Umber.app
./Scripts/make-app-bundle.sh release   # optimised
open build/Umber.app

swift build                            # compile only
swift run Umber                        # fast iteration loop (no Dock icon / Spotlight)
UMBER_DIAG=1 swift run Umber           # + dump resolved font/theme/scrollback to stderr

./Scripts/check-keybindings.sh         # headless: compiles shipped KeyBindings.swift, runs a 17-case truth table
./Scripts/check-space-restore.sh       # headless: compiles shipped Defaults.swift, 12-case truth table for OpenSpaceRoots
./Scripts/check-reflow.sh              # headless: 8-case truth table over the vendored emulator — does narrowing corrupt scrollback? (#494)
./Scripts/check-altbuffer-resize.sh    # headless: 4-case truth table over the vendored emulator — does an alt-buffer resize resurrect stale cells? (the tmux bleed)
./Scripts/check-metal-renderer.sh      # offscreen GUI: does the GPU renderer SHIP (shader in both bundles) and COME UP? 6 cases incl. a falsification
./Scripts/check-renderer-config.sh     # headless: compiles shipped Renderer.swift, 13-case mapping table — does a `renderer` string reach the renderer it names?
./Scripts/check-ghostty-pane.sh         # offscreen GUI: does a REAL shell come up in a REAL GhosttyPane? the kill criterion, an OSC 7 assertion, + a silence control
./Scripts/check-cursor-style.sh         # headless: compiles shipped CursorStyle.swift, 15 spellings + 6 DECSCUSR codes — did the enum survive leaving SwiftTerm?
./Scripts/check-engine-config.sh        # headless: compiles shipped TerminalEngine.swift, 18-case mapping table — does an `engine` string reach the core it names?
./Scripts/check-command-outcome.sh      # headless: compiles shipped CommandOutcome.swift, 17 cases — does a finished command mark its tab correctly (and mostly NOT)?
./Scripts/check-light-theme.sh          # headless: compiles shipped ThemeValues.swift + ThemeContrast.swift, 7 cases — are the palettes well-formed, is `afk-light` light enough to flip the window chrome, and does every OTHER preset stay dark?
./Scripts/check-pane-teardown.sh        # offscreen GUI: does closing a document FREE it? ghostty surface + BOTH engines' shells + idempotency + a control
./Scripts/verify-vendor.sh             # is vendor/SwiftTerm the pinned revision, WITH all six local patches?
./Scripts/check-keys-e2e.sh            # real NSEvents → real pty bytes; LAUNCHES the app, steals focus
./Scripts/check-file-size.sh           # headless: enforces the 350-LOC ceiling on Sources/ + Scripts/
./Scripts/check-find-menu.sh           # do Undo/Redo/Find-and-Replace reach anything? GUI session, offscreen, no focus theft
./Scripts/check-cwd-follow.sh           # headless: 15-case truth table over ShellDirectory — does the sidebar follow the shell, and does `cd Here` quote safely?
./Scripts/check-theme-contrast.sh       # headless: compiles shipped ThemeValues.swift + ThemeContrast.swift + SyntaxPalette.swift (+ registry/repair/reference halves), 345 assertions — are measured palettes legible, are the editor's syntax roles readable apart from one documented comment exception, are runtime selection pairs readable in every palette, and are both strict enough to reject known-bad controls?
./Scripts/check-git-status.sh           # headless: 58-case truth table over GitStatus + GitStatusReader — real repos, real renames, a real conflict, a real worktree, a real repo nested inside another
./Scripts/check-sidebar-toggle.sh       # offscreen GUI: is the titlebar button INSTALLED, does its own action resolve to a live responder, and does clicking it actually move the sidebar? + a control

There is no test target and no CI. The nineteen check-*.sh scripts (plus verify-vendor.sh) and UMBER_DIAG are the whole verification surface — see app/README.md ("Checks") for why. check-keys-e2e.sh needs Accessibility permission for the invoking terminal and exits 2 without it, 3 if the app never becomes frontmost. Prefer check-keybindings.sh for routine work; it is fast and headless. check-find-menu.sh guards the three Edit-menu items that have no Umber code behind them (Undo, Redo, Find and Replace are AppKit responder actions with target = nil), because an item wired to an unanswered selector or an unrecognised tag renders, clicks, and silently does nothing; it sends each find tag in its own process (sharing one text view let the bar keep the prior action's state, so a dead tag passed) and exits 2 for "no window server" vs 1 for "tag 1 worked, tag 12 did nothing" so a real dead item cannot hide behind an environment excuse. check-space-restore.sh is headless too and runs against a temp dir and its own defaults domain, so it never touches your real remembered Spaces — it asserts that isolation as its last case — as a delta against a snapshot taken before the harness runs, not as absence of the key, which is what it wrongly asserted until 2026-07-28 (it therefore failed on every machine where Umber had actually been used). check-reflow.sh is the odd one out: its unit under test is the vendored emulator, not a file in Sources/Umber, so it links .build/out/Products/Debug/SwiftTerm.o and needs swift build first (it runs it itself if needed). It exits 1 for a real assertion failure and 2 for anything environmental — missing vendor/, no toolchain, a harness that would not compile, or a case whose own scrollback-capacity precondition failed — so a broken environment can never read as a green gate. It was validated by falsification: reverting patch 0002 makes it fail 6 of 8 cases, which is the only reason to believe the 8 passes mean anything. check-altbuffer-resize.sh is its sibling and shares that shape — same vendored subject, same SwiftTerm.o link, same exit-code split, and reverting patch 0003 makes it fail 2 of its 4 cases. What it adds is a control case: it runs the identical narrow-then-widen sequence against the NORMAL buffer, where the trim always ran, so a run where cases 1–2 fail and the control also fails means the harness is measuring the wrong thing and its verdict must be discarded. That is precisely the check the retired ReflowGateTests never had, and it is why the two remaining cases are worth believing. check-cwd-follow.sh is the most explicit about its own blindness: it compiles the shipped ShellDirectory.swift standalone (that file imports only Foundation/Darwin so that it can), spawns a real child shell into a temp directory, and reads its cwd back through the same two syscalls the app uses — then states in its own comments the two things it CANNOT test. The tcgetpgrp branch is one: Darwin requires an explicit TIOCSCTTY that a posix_spawned child cannot issue and Swift marks fork() unavailable, so that branch is only reached for real inside SwiftTerm's forkpty (LocalProcess.swift:513) — the gate asserts the fallback path it does exercise and names it as such, rather than letting a case titled "foreground" quietly test something else. setRoot(_:) is the other, being an AppKit type it cannot link. It earned its keep before it ever went green: it caught the URL-vs-.path equality trap now documented in FileTreeViewController.setRoot(_:), where URL equality carries a directory marker that resolvingSymlinksInPath() drops on a symlink-terminated path — which would have rebuilt the file tree twice a second and destroyed the user's expansion state. Three gates landed on 2026-07-31 — check-git-status.sh, check-metal-renderer.sh and check-renderer-config.sh — within minutes of each other, so none of them is meaningfully "the newest"; each is unusual in a different way. check-git-status.sh is the only gate that is two files: the shell half builds fixtures, and the assertions live in Scripts/check-git-status-harness.swift, which it copies to main.swift and compiles. That split is forced, not chosen — inline the two halves were 358 lines, over the ceiling, and the rule for that is to find the seam rather than shave comments. It follows check-cwd-follow.sh's strong exit contract (compile wrapped in if ! swiftc ...; then exit 2; fi) and adds a git --version guard, because "git too old for --porcelain=v2" is a real environmental case. Every fixture is built by real git: real commits, a real git mv, a real merge conflict from two diverging branches, a real bare remote, a real detached HEAD, a real git worktree add. It was validated by falsification like the reflow pair, but the counterexample had to be manufactured rather than reverted — there is no upstream bug in first-party code — so a deliberately naive parser was written first and the gate observed to fail 4 of its cases while its control case stayed green; the transcript is kept in .afk/research/git-sidebar-falsification-2026-07-31.md. What it cannot reach is stated in its own header: the poller's cadence, the badge glyph, colour legibility inside the selection pill, and every line of AppKit wiring — those import AppKit and cannot be compiled alone, so they are UMBER_DIAG and daily-drive territory. check-metal-renderer.sh is the one whose subject is a silent lie rather than a bug: a config saying "renderer": "metal" while the app quietly draws with Core Text, which was this project's real state for its whole life until patch 0001 was rewritten. Two static cases (the shader is in the SwiftPM resource bundle and in Umber.app/Contents/ResourcescandidateBundles() resolves those through different lookups, so one proves nothing about the other), three behavioural, and one falsification: it hides Shaders.metal, re-runs, and requires the request to FAIL — if Metal still comes up with the shader gone, the gate says it is BLIND and exits 1, because its other cases would then be passing for some reason other than our packaging. Same 1 vs 2 split as its siblings, and a dead harness is 2 rather than a wrong-renderer 1, because a process that crashed is telling you about the machine. Of the three behavioural cases only the third is a real control: coretext on a fresh view passes without exercising anything, because useMetalRenderer is already false and setUseMetal early-returns (Mac/MacTerminalView.swift:379-381) — so it falsifies "the harness ignores its argument" and nothing more. The metal-then-coretext case is what actually runs the teardown, and it is the path a ⌘R takes after renderer is edited back. check-renderer-config.sh is its necessary sibling, not a duplicate: every behavioural case here calls SwiftTerm's setUseMetal directly and therefore bypasses Renderer.named completely, so before that script a typo in the mapping table shipped with this gate still green — and the symptom would be the quietest possible one, a config line that reads correctly, parses without a warning, and does nothing. It compiles the shipped Renderer.swift alone (Foundation-only by design, the same trick check-keybindings.sh and check-cwd-follow.sh play) and needs no GPU, no window server and no swift build, which is why it is a separate script: folding it in would make the cheap check unavailable on exactly the machines where the expensive one cannot run. check-theme-contrast.sh (2026-08-03) started as the split-harness kind, like check-git-status.sh: assertions lived in Scripts/check-theme-contrast-harness.swift, compiled against the shipped ThemeValues.swift + ThemeContrast.swift. What makes it unusual is that its subject is normally considered a matter of opinion — every claim a theme makes about itself is a number, and a number nobody computed is a taste claim wearing a lab coat. It ends in a falsification case: the same thresholds must reject xterm's #0000EE on black, the canonical unreadable blue, because if that passed the other assertions would be measuring nothing. Two things it got right the hard way. Its verdict is the harness's exit code, not a stdout substring — the bug check-ghostty-pane.sh shipped, where any status was forgiven if ALL-OK appeared anywhere. And its reachability check strips comments before grepping for a case line, because the obvious version (grep the bare quoted preset name) was blind: every preset name also appears in a doc comment, so deleting the real switch case still passed. Found by deliberately deleting it — the lesson is that a grep-based assertion must match the construct, not the vocabulary. What it cannot reach is everything needing AppKit, and, more importantly, taste: four automated search passes each satisfied more constraints than the last while producing, in order, noise, pastels, neon, and a green that measured well and read as mint. It guards the floor, not the bar. Hardened 2026-08-03 after a devil's-advocate pass found its one structural blind spot: all ten APCA.lc call sites passed a dark background, so the opposite-polarity branch (normBG/normTXT) was never executed and a transposition there was undetectable. It now carries external reference fixtures — Ottosson's published sRGB→Oklab values (which pin all nine matrix coefficients against a third party, matching to four decimals), APCA's canonical extremes in both polarities, and structural dichromacy invariants (a grey must survive unchanged; red/green must collapse; blue/yellow must not). Validated by six deliberate mutations of ThemeContrast.swift — a 1% coefficient perturbation, two transposed OKLab coefficients, both APCA constant swaps, and both CVD projection swaps — all six caught, where the APCA light-background swap previously was not. Classic Repaired (#32) pushed it to 255 assertions and split off Scripts/check-theme-contrast-registry.swift (the palette-registry and config-string-resolver assertions) the first time the shared harness crossed the 350-LOC ceiling. Extended 2026-08-03 (#26) to cover the editor's syntax roles (§5k) and selection pairing (§5l) for every shipped palette, in a third split file, Scripts/check-theme-contrast-syntax.swift, compiled against SyntaxPalette.swift too — validated by six more mutations of that file (two roles sharing a slot, a role reading plain text, a clamp that returns its input, the old 0.55 alpha, keyword moved to the magenta afk-dark cannot render, and a readableFloor lowered to 10 — all six caught, the last because what catches it is the gate noticing its own threshold has gone too loose to mean anything), plus a consumer guard: two greps assert that Theme still builds .selection from the palette and that the editor still sets selectedTextAttributes, because the failure this repo actually shipped once was not a bad number but a measured number reaching nothing. Reconciling #26 with Classic Repaired surfaced a real syntax conflict and a false selection conflict: the deliberately quiet terminal foreground (#8A8A8A, Lc 39.6 against black) cannot lift its syntax comment past 45, so that gap remains PINNED. The selection gate originally measured that same terminal foreground, but production paints the brighter chromeForeground (#CBCCCD), whose selection pair clears the Lc 60 floor at 71.1; the stale exception was deleted once the gate measured the consumer's actual value. Repainting the palette to fit the comment rule would still be a taste call the merge should not make unasked, and the reconciliation itself pushed the shared harness 5 lines over the ceiling, so it gained a fifth split file, Scripts/check-theme-contrast-reference.swift (the external reference-value assertions moved out verbatim, same pattern as the registry split). 345 assertions. check-sidebar-toggle.sh (2026-08-05) is check-find-menu.sh's sibling one level down: same hazard class — a control with target = nil whose action is answered only by the responder chain, which "renders, clicks, and silently does nothing" if the chain stops answering — but the subject is a titlebar button, which is less visible than a menu item, not more. It follows check-pane-teardown.sh's shape (offscreen @testable import Umber, a real SpaceWindowController, pump() for the animated collapse, verdict-is-the-exit-code, 1 real vs 2 environmental) and asserts five things: the accessory is installed exactly once, the button's shape (nil target, toggleSidebar:, accessibility label, tooltip, image), the button's action resolving to a live responder, isCollapsed flipping in both directions, and a bogus selector resolving to nil as a control. What makes it worth reading is that its first falsification was too easy on it, and the second one found two assertions in the gate that could not fail. The author broke it by deleting the addTitlebarAccessoryViewController call, which fails case 1 and short-circuits the rest — proving only the case already in mind. Breaking it a different way instead, by swapping button.action to a nonsense selector, exposed both: case 3 resolved the hardcoded expectedSel rather than button.action, so it printed ok ... NSSplitView while the shipped button was inert — the same "a gate measured a value the app never draws" defect this file records for check-theme-contrast.sh's preset grep — and case 4's back-flip test was after2 == before1 alone, which is satisfied trivially when the first click never moved anything, so it cheerfully reported "FLIPPED IT BACK (false -> false)". Both are fixed (case 3 resolves the consumer's own selector; case 4 requires a real transition off after1 and a return to before1), and the same break now fails 4 cases instead of 2. The transferable lesson is narrower than "falsify your gates": falsify them with a break the author did not already have in mind, because a counterexample chosen by the person who wrote the assertions tends to exercise the assertion they were proudest of.

There is no .xcodeproj by design — the project stays all-text, diffable, and scriptable. Do not add one.

That convention was narrowed on 2026-07-31, and the narrowing is a real loss rather than a clarification. Phase 2 of the libghostty swap added libghostty-spm .exact("1.3.2") to app/Package.swift: a 51.8 MB prebuilt GhosttyKit.xcframework, plus two transitive dependencies (msdisplaylink, swift-argument-parser). It is MIT-licensed but it is not auditable text, and the probe plan records this as §7 risk 3 — "A real convention loss with no mitigation" — with the explicit instruction that the rule be amended honestly rather than left standing while the code quietly violates it. So, precisely: first-party source stays all-text and diffable, and that part is not negotiable; the emulator dependency no longer is. Everything in app/Sources/Umber/ remains readable, greppable, and under the 350-LOC ceiling. One consequence landed on 2026-08-01: app/Package.resolved is now committed (a !app/Package.resolved negation in .gitignore), because .exact("1.3.2") pins one edge while both transitive deps declare floating from: constraints — without the lockfile a fresh clone resolves them to whatever is newest, which is the drift the .exact existed to prevent. Ignoring it was right while every dependency was a local path; a versioned remote one changed that.

What that costs is concrete, not theoretical. Three of the six patches/swiftterm/ patches fix upstream defects found by reading vendored source0002 (#494 scrollback reflow), 0003 (the alt-buffer tmux bleed) and 0004 (an ungated release-build abort()). None of those three could have been found, let alone fixed locally, against an opaque binary; the equivalent under libghostty is filing an issue and waiting. The counter-argument is equally real and is why D was still chosen: those are SwiftTerm's defects, and the auditability was only ever valuable because the dependency needed auditing. Ghostty is actively maintained and ships none of these bugs. The trade is "I can fix it myself" for "it needs fixing less often, but when it does I am blocked upstream" — a change in risk profile, not a free win, and worth revisiting if libghostty ever goes unmaintained.

Architecture

Path Purpose
app/ The application: SwiftPM package (swift-tools-version: 6.0, macOS 14+), 54 Swift files, none over 350 LOC
app/Sources/Umber/ All Swift source. Flat — no subdirectories, because SwiftPM globs the whole path and a flat list of well-named files is cheaper to scan than a tree to walk
app/Scripts/ Bundle assembly + the verification scripts + icon generation
vendor/SwiftTerm Gitignored. Upstream v1.15.0 + six local patches: 0001 (Metal shader shipped as a .copy resource — rewritten 2026-07-31; it used to exclude: the shader, which made the vendored GPU renderer permanently unreachable), 0002 (the #494 isWrapped fix — required; a tree missing it silently corrupts scrollback), 0003 (alt-buffer line trim — required; a tree missing it bleeds stale cells across tmux panes) and 0004 (gates an ungated upstream debug assertion behind #if DEBUG — it walked the whole scrollback per resize and called abort() in release), 0005 (DCS Ptmux passthrough — lets apps inside tmux reach the outer terminal), and 0006 (gates linefeed selection.selectNone() on mouseMode != .off — without it, every LF clears the selection before ⌘C can copy). 0002/0003/0004 touch the same file, so the pin carries one combined Buffer.swift hash and a partly-patched tree lands in verify-vendor.sh's exit-3 "unknown" branch by design. 0005 adds a new file (PtmuxDcsHandler.swift); 0006 patches MacTerminalView.swift. Recreation documented in app/README.md ("Dependency note") — needs chmod -R u+w, SPM checkouts are read-only
libghostty-spm (SPM, .exact("1.3.2")) The candidate replacement emulator core, added 2026-07-31 (Phase 2). A 51.8 MB prebuilt GhosttyKit.xcframework, MIT, checksum-verified upstream, pulling one transitive dep (msdisplaylink) — swift-argument-parser comes from the vendored SwiftTerm and predates it, which Package.swift's comment claimed wrongly until 2026-08-01. Pinned .exact and never from: — the ABI is pre-stable and the spike's floating from: "1.2.0" is named in probe plan §8.1 as the mistake not to repeat. Backs GhosttyPane; both engines are linked on purpose until Step 2 deletes the loser
.afk/plans/native-swift-terminal-afk-host.md Plan of record — approach, cited evidence, rejected alternatives, Step 0 gate results (§11). Read before any structural change
.afk/research/naming-decision-2026-07-27.md Naming rationale and availability checks
.afk/verification/ Per-feature manual checklists, for the residue a gate cannot reach. One file so far (sidebar-toggle-accessory-checklist.md). A checklist here is an admission, not a substitute: anything on it that turns out to be mechanically checkable belongs in a check-*.sh instead, and should be moved there and ticked with the gate named

Files are grouped by concern below. A Type+Concern.swift name always means "an extension of Type carrying one whole concern" — the split is by what the code does, never by line count, so the file you want is the one whose name matches your question. Every file opens with a header comment stating what it owns and why it is separate; read that before the code.

Source file LOC Owns
Bootstrap & app-level
main.swift 19 Manual NSApplication bootstrap — not @main. Sets .regular activation policy, assigns delegate, runs the loop
AppDelegate.swift 291 Lifecycle (launch/terminate), Space management, config reload/open, app-wide zoom fan-out over [SpaceDocument]
AppDelegate+EditorActions.swift ~100 Wave 2+3: editor @objc actions routed from menu items — goToLine, toggleWordWrap, sendPathToTerminal, runInTerminal, selectNextOccurrence, showCommandPalette. Extracted from AppDelegate.swift at the 359-LOC ceiling
AppMenu.swift 323 The NSMenu tree (buildMenu()) minus the Navigate submenu (extracted to +Navigate). Every find item must keep its tag — see "Search is SwiftTerm's" below. Undo/Redo/Find-and-Replace here are pure AppKit responder actions with no implementation in this repo; check-find-menu.sh is what keeps them honest. View menu carries the tmux-inspired split and focus items: ⌘⇧\ (Split Right), ⌘⇧- (Split Down), ⌘⇧H/J/K/L (focus pane left/down/up/right)
AppMenu+Navigate.swift 64 The Navigate submenu — Command Palette (⌘⇧P), Jump to Symbol (⌘⇧O), Next/Previous Tab (⌘⌥→/←), ⌘1–⌘9. Extracted from AppMenu.swift when the View menu's split and pane-focus items pushed it past the 350-LOC ceiling
SpaceRestore.swift 93 restoreSpaces() and the written contract for what it deliberately does NOT restore. Verified by check-space-restore.sh
StarterConfig.swift 57 The commented config.json template ⌘, writes on first run
Space = one window = one project root
SpaceWindowController.swift 313 One window == one native macOS tab == one Space: tabbingIdentifier, addTabbedWindow, per-root frame autosave, the static open-Space registry and persisting it for restore, title/subtitle. Owns no PTY and no documents
SidebarToggleAccessory.swift 120 The titlebar's borderless sidebar.leading button. Pins .leftnot because .leading is invalid, which an earlier comment here claimed and which NSTitlebarAccessoryViewController.h:27 contradicts, but because .left stops auto-flipping for a 10.12+ app and Umber ships no localization. Sends toggleSidebar(_:) through the responder chain, so NSSplitViewItem.isCollapsed remains the only sidebar state. Gated by check-sidebar-toggle.sh
SpaceViewController.swift 334 The container — NSSplitViewController with a sidebar item (file tree) and the document area. Owns the document list and activation, fans config/zoom out, and owns syncDocumentChrome() — the single funnel from document state to the tab strip and the window's unsaved marker. Generalized over SpaceDocument; do not reintroduce a concrete-type assumption. add(document:beforeActivating:) is the polymorphic entry point. Also owns splitPeers (the split-peer dictionary) because Swift extensions cannot add stored properties — all the methods that write it live in +Splits.swift
SpaceViewController+DocumentConstruction.swift 114 The ⌘T path, and the only file in the app that names both engines: addTerminalDocument switches on config.engine to build a TerminalPane or a GhosttyPane, then hands it to the container as a plain SpaceDocument. Also declares ShellStarting, the two-member seam that lets it call start() without a second switch. Phase 6 deletes the loser by editing one case. Also named by +Splits.swift for the same engine switch used when building a split peer
SpaceViewController+Closing.swift 71 Closing a whole Space: spaceShouldClose() (veto-only) and tearDownAllDocuments() (the commit); also closeActiveDocument() — ⌘W routing that collapses a split rather than closing the tab when a split peer exists. closeDocument(at:) deliberately stays in the container — it writes `documents*, and that setter is file-private on purpose
SpaceViewController+Splits.swift 195 NEW (splits v1) — the model half of split panes. Tracks splitPeers (keyed by primary document's ObjectIdentifier, NOT in documents[]). splitHorizontal: (⌘⇧\) creates a peer via the same engine switch as addTerminalDocument. splitVertical: (⌘⇧-) is wired but the stub is empty until vertical splits are built. moveFocusLeft/Right/Up/Down: (⌘⇧H/J/K/L, tmux-inspired) toggle keyboard focus between primary and peer using the isDescendant first-responder walk from ShellHosting.swift. closeSplitPane() collapses the split (always closes the peer, v1 behavior). teardownSplit(for:) runs when a tab closes. terminateSplitPeer(_:) handles a peer's shell exiting. Peers are not tabs — the strip stays 1:1 with documents[]
SpaceViewController+Delegates.swift 185 The four inbound delegate conformances (file viewer, tab strip, document, file tree) — one concern: "something the user did elsewhere arrives here". Updated to route a split peer's shell-exit through terminateSplitPeer(_:) when firstIndex finds nothing in documents[]
SpaceViewController+MenuValidation.swift 37 validateUserInterfaceItem(_:) — gates the split and focus menu items so AppKit does not auto-enable them when the action would silently no-op. Extracted from the container at the 350-line ceiling (2026-08-31)
SplitContainerView.swift 245 NEW (splits v1) — a manual frame-based split pane container: 1 or 2 child views separated by a draggable 1px divider. No Auto Layout, no NSSplitView — same frame-based discipline as the rest of the app. setPrimary(_:), addSplit(_:direction:), removeSplit() are the three write operations; layout() distributes frames; mouse tracking provides drag-to-resize with cursor change. Pure layout concern: knows nothing about documents, terminals, or the tab strip
DocumentAreaViewController.swift 108 The right-hand region: strip visibility + manual layout of strip over the active document. Updated: container is now SplitContainerView (was NSView); present(documentView:) delegates to container.setPrimary; new presentSplit(primaryView:splitView:direction:) and dismissSplit() are the split-presentation API for SpaceViewController+Splits.swift
Documents — the heterogeneous-tab seam
SpaceDocument.swift 342 The SpaceDocument protocol (the seam heterogeneous tabs plug into), the DocumentStatus type, the SpaceDocumentDelegate a document reports to and the optional SpaceDocumentReporting slot it reports through, + TerminalPane conformance. Owns the documentShouldClose() / documentWillClose() pair — one asks, one commits. ⚠ 8 lines from the ceiling
DocumentTabStrip.swift 276 The hand-rolled in-window strip: the view, its owner-facing surface, and all geometry. Metrics live here and nowhere else
DocumentTabStrip+Drawing.swift 275 Every mark painted. Reads geometry, never writes strip state
DocumentTabStrip+Accessibility.swift 226 The AX tree and its element proxies — VoiceOver reaches tabs that overflow off-screen
DocumentTabStrip+Mouse.swift 170 Tracking, hover, clicks, middle-click close, the overflow menu
DocumentStatus+Presentation.swift 55 The status dot's colour. Separate because it extends DocumentStatus, a type owned by SpaceDocument.swift
Sidebar file tree
FileTreeViewController.swift 250 View assembly (a stack: git header over the outline), identity-preserving refresh() on window-became-key, setRoot(_:) (the tree's displayed root moves; the Space's identity root never does), double-click routing. Holds the git poller's one stored property and nothing else of it
FileTreeViewController+ContextMenu.swift 114 The row's right-click menu, incl. cd Here. Pulled out of the controller to make room for the git header
FileTreeViewController+OutlineView.swift 298 Both NSOutlineView conformances + the cell machinery only they build — including the staged chip and the row tooltip, the two channels that carry what a one-letter badge cannot
FileNode.swift 110 The lazily-populated tree model. NSOutlineView identifies rows by object identity, so nodes are updated, never replaced. Deliberately knows nothing about git — status is looked up from a snapshot at draw time, never cached here
Git in the sidebar (read-only, on purpose)
GitStatus.swift 312 Pure and view-free (Foundation only, so check-git-status.sh can compile it alone): the git status --porcelain=v2 -z grammar and the snapshot the tree decorates from. Holds hasUpstream separately from ahead/behind because git emits those two headers under different conditions — see its doc comment
GitStatusReader.swift 153 Also pure: finds the repository via git rev-parse (never by stat-ing .git, which is a file in a worktree) and spawns status with GIT_OPTIONAL_LOCKS=0. Reads the pipe before waiting, or a big untracked tree deadlocks
GitStatus+Presentation.swift 76 Status → system colour, and only that. Narrowed 2026-08-02: the wording moved to +Phrasing because it needed no AppKit and this file's import AppKit was the sole reason it went ungated
GitStatus+Phrasing.swift 91 Pure and view-free (Foundation only, and compiled by the gate): every word a row says — GitFileStatus.accessibilityDescription, plus GitFileEntry.statusPhrase/.tooltip, which is where isStaged and originalPath finally surface. Note originalPath is nil for every other record, not every other status: an RM row is .modified and still names its origin
GitStatus+Rollup.swift 85 Pure and view-free (Foundation only, and compiled by the gate): the whole "what does a folder inherit" concern, which used to be spread across two types in GitStatus.swiftrollupPrecedence, propagatesToParent, and the rollUp walk that is their only caller
FileTreeViewController+Git.swift 341 ⚠ The whole git concern for the tree: the 2s poller (GitStatusFollow), its key/resign lifecycle, the off-main-thread spawn, and the per-row lookup (status(for:) for any row, entry(for:) for files only — a directory roll-up has no single entry to describe). ⚠ 9 lines from the ceiling — the next concern here gets its own file. Nothing is cached on FileNode — see its header on why a second copy is the stale-decoration bug class. The discovered repository is cached, keyed on the root it was answered for and never on containment — see repositoryRootPath on why those differ
GitBranchHeaderView.swift 110 The ambient branch + ahead/behind line above the tree. Hides itself entirely outside a repo, and an NSStackView drops it from the layout when it does
File viewer (second SpaceDocument)
FileViewerPane.swift 296 Type + delegate declarations, view construction, and the read path
FileViewerPane+Editing.swift 143 The write path — dirty tracking, encoding, the confirm alerts, NSTextViewDelegate. textDidChange triggers incremental re-highlighting via highlightEditedParagraph()
FileViewerPane+Document.swift 180 The SpaceDocument conformance, self-contained. Adding a document kind must never touch the container
FileViewerPane+Highlighting.swift 232 Colour resolution from the theme's ANSI palette via SyntaxPalette (syntaxColours()), and the two application paths — highlightSyntax() (full file) and highlightEditedParagraph() (paragraph-scoped, Wave 2 block-comment expansion). The tokeniser and block-comment helpers have their own files
SyntaxTokeniser.swift ~175 Regex tokeniser: the Token model, tokenise(_:family:keywords:) (five priority phases: comments → strings → numbers → keywords → types), findAll(), and regexCache. Extracted from +Highlighting when it hit 480 LOC. No tree-sitter, no grammar bundles
FileViewerPane+BlockReHighlight.swift ~105 Wave 2: block-comment boundary heuristics — hasBlockComments, containsBlockDelimiter, mightBeInsideBlockComment. Drive the paragraph-expansion logic in highlightEditedParagraph
SyntaxLanguage.swift 224 Pure and view-free (Foundation only): file-extension → (LanguageFamily, keywords) mapping. Wave 2 added Lua, PHP, Dart, Elixir, Perl, Haskell, Scala, R, and R Markdown
FileViewerPane+Folding.swift 333 Wave 2: indent-based code folding (⌘⌥[ / ⌘⌥]), gutter triangle support, showSymbolOutline(_:) (⌘⇧O)
SymbolOutline.swift 177 Wave 2: pure-Foundation symbol extraction — Symbol, SymbolKind, extractSymbols(from:family:). Covers all LanguageFamily cases
SymbolOutlinePanel.swift 275 Wave 2: AppKit floating ⌘⇧O panel — search field, table view, keyboard navigation, row rendering. Extracted from SymbolOutline.swift at the 350-LOC ceiling
FileViewerPane+MultiSelect.swift ~200 Wave 3: ⌘D select-next-occurrence, multi-cursor selection
CommandPalette.swift 287 Wave 3: ⌘⇧P floating command palette — panel construction, show/hide, fuzzy filtering, command execution
CommandPalette+Commands.swift 65 Wave 3: the static allCommands list. Editing this file is the single-source-of-truth for adding a palette command
CommandPalette+UI.swift ~130 Wave 3: NSTableView/Search/Window delegate conformances and PaletteRowView. Extracted from CommandPalette.swift at the 400-LOC ceiling
StickyScrollView.swift ~208 Wave 3: sticky-scroll overlay that pins the enclosing scope header at the top of the editor
EditorTextView.swift 234 Wave 3: NSTextView subclass — current-line highlight, column guide, indent rainbow, trailing-whitespace dots. Extracted from FileViewerPane+Chrome.swift at the 392-LOC ceiling
Terminal
TerminalPane.swift 346 The shell process + SwiftTerm view binding: startProcess($SHELL, ["-l"]), apply(config:), font clamping/zoom, DECSCUSR cursor style, LocalProcessTerminalViewDelegate callbacks. ⚠ 4 lines from the ceiling — split it before adding to it; the renderer concern already went to its own file for exactly this reason
TerminalPane+Renderer.swift 117 Asking SwiftTerm for a drawing back end and refusing to lie about which one it gave: applyRenderer(_:), liveRenderer (read back from isUsingMetalRenderer, never remembered — the view rebuilds its MTKView when it changes window), and the unconditional stderr line on a silent downgrade
Renderer.swift 86 Pure and view-free (Foundation only): the Renderer enum, its generous config-string parsing, and the argument for why .coreText is still the default. The per-file citations for both draw paths live in its doc comments
Terminal — the libghostty probe (second engine, live alongside)
GhosttyPane.swift 279 The pane proper: the surface, the per-pane TerminalController, start(), and the working-directory guard. The assignment order in start() is load-bearing — configuration first, controller second, or a shell spawns in the wrong directory before anyone asked. start() is idempotent, because fontSize is part of TerminalSurfaceOptions and a second call after any zoom would respawn the shell. Its header accounts for the three parity gaps (shell, scrollback, renderer) and the OSC 7 measurement artifact. Teardown — the one thing Phase 4 had to add before a pane could be constructed — now exists: documentWillClose() in +Document.swift frees the surface and the shell with it, called from closeDocument(at:) and tearDownAllDocuments() (SpaceViewController+Closing.swift) and gated by check-pane-teardown.sh
GhosttyPane+Appearance.swift 182 AppConfig → libghostty's config vocabulary, live zoom through the controller (never through TerminalSurfaceOptions.fontSize, which rebuilds the surface and discards scrollback), and the UMBER_DIAG report naming every UNVERIFIED .custom key it sent
GhosttyPane+Document.swift 262 SpaceDocument + ShellHosting + all five surface delegates, plus documentWillClose() — the one line (view.controller = nil) that frees the surface. One delegate property matched by runtime cast, where TerminalPane needed a processDelegate, a bespoke bellDelegate slot and a subclass override — and still could not reach OSC 7 or OSC 133
GhosttyTerminalView.swift 68 AppTerminalView subclass carrying the same ⌘-chord table as UmberTerminalView. Known gap: no kitty-keyboard guard, because libghostty exposes no equivalent query — acceptable for a probe, not for a default
ShellHosting.swift 239 The ShellHosting protocol — two members, send(text:) and currentDirectory — plus TerminalPane's conformance and the container's shellHosts / focusedShellHost queries. Updated: focusedShellHost is now split-aware — checks if the first responder lives in the peer's view before falling back to the active document. isDescendant(_:of:) helper walks the NSResponder chain to match either engine's internal first-responder view. FileViewerPane must never conform
ShellIntegration.swift 201 Pure and view-free (Foundation only, so check-shell-integration.sh can compile it alone): OSC 133 A/C/D state machine, parseExitCode, register(on:callback:) wiring, the OscRegistering protocol, and parseOsc7Directory — the extracted Foundation-only URL parser that handles file://hostname/path and percent-decoding for OSC 7. Gated by check-shell-integration.sh
TerminalPane+ShellIntegration.swift 192 AppKit-side OSC 7 + OSC 133 wiring for the SwiftTerm engine: appendShellIntegrationEnv (sets UMBER_INTEGRATION), registerShellIntegration (hooks OSC 133), handleOsc7Directory (stores parsed path to _reportedDirectory), applyCommandOutcome, and associated-object backing for _reportedDirectory. Also app/Resources/shell-integration.zsh (88 lines) — the zsh script that emits OSC 7 on every precmd and OSC 133 A/C/D around commands
ShellDirectory.swift 162 Pure and view-free (Foundation/Darwin only, so check-cwd-follow.sh can compile it alone): reads a live shell's cwd via tcgetpgrp + proc_pidinfo(PROC_PIDVNODEPATHINFO), and builds the single-quoted cd line that moves it. Its header documents at length why this is not OSC 7
SpaceViewController+DirectoryFollow.swift 191 The whole cwd-follow concern: the 750ms poller (DirectoryFollow), its key/resign lifecycle, and followDirectory(_:) — the only place in the app that moves the tree
UmberTerminalView.swift 108 LocalProcessTerminalView subclass whose only job is performKeyEquivalent(with:) interception (SwiftTerm declares keyDown public, not open)
KeyBindings.swift 65 Pure, view-free MacLineEditing.controlBytes(keyCode:modifiers:) — the single ⌘-chord → control-byte table
Configuration & appearance
LiquidGlass.swift 89 macOS 26+ Liquid Glass adoption — the single place all glass-specific code lives. configureWindow(_:) removes the titlebar separator for clean glass edges; makeGlassContainer(frame:cornerRadius:) creates NSGlassEffectView for floating panels. Everything guarded by @available(macOS 26, *). fullSizeContentView was tried and reverted — NSSplitViewController does not propagate safe area insets to the document area's manual frame layout. Terminal and editor content areas are never touched — the ANSI colour contract and the contrast gate require opaque backgrounds
Config.swift 349 Private ConfigFile Decodable, AppConfig, defaults(), fail-soft load(). Shed the theme concern (2026-08-03), derived-chrome (2026-08-17, Config+Chrome.swift), and font resolution (2026-08-31, Config+Font.swift) each time it hit the 350-line ceiling, rather than shaving comments
Config+Theme.swift 96 The one question "given some optional strings a user typed, which palette do we install?" — per-field, fail-soft, never throwing, never returning a half-built palette. Takes primitives rather than the spec so ConfigFile can stay private. Owns the decision that a typo'd preset falls back to umber (the measured palette) rather than afk-dark (the one the gate exempts)
Config+Editor.swift 78 The editor-behaviour resolver — applyEditor(tabWidth:softTabs:wordWrap:indentRainbow:columnGuide:showTrailingWhitespace:stickyScroll:). Wave 2+3 added indentRainbow, columnGuide, showTrailingWhitespace, and stickyScroll config fields
Config+Font.swift 119 Font resolution — defaultFontSize/minFontSize/maxFontSize constants, resized(_:to:), preferredMonoFont(family:size:), and applyFont(family:requestedSize:fontThicken:lineHeight:ligatures:). Also owns the font-adjacent terminal settings (fontThicken, lineHeight, ligatures) because they share the same fail-soft shape and the same "how text is rendered" concern. Extracted from Config.swift at the 350-line ceiling (2026-08-31)
Config+Chrome.swift 64 Chrome derived from the resolved theme: effectiveBackground, effectiveForeground, and the window appearance — the seam that makes the sidebar match the terminal. An AppConfig extension so call sites are unchanged; the one in-app caller of WCAG.lightChromeCutoff. Split from Config.swift at the 350-line ceiling (2026-08-17)
Defaults.swift 183 The UserDefaults stores — FontZoom / LastSpaceRoot / OpenSpaceRoots — + the isUsableSpaceRoot check they share. This is the unit check-space-restore.sh compiles
CommandOutcome.swift 79 Pure and view-free (Foundation only): the OSC 133 policy — what a finished command means for its tab, and the three separate reasons it usually means nothing. Separate from DocumentStatus because that type lives beside NSView-typed protocol members and cannot compile headless; this can, so check-command-outcome.sh gates the policy while the wiring stays daily-drive territory
TerminalEngine.swift 114 Pure and view-free (Foundation only): which emulator core backs a terminal document, the generous config-string mapping onto it, and the argument for why .swiftTerm is still the default — the incumbent stays until Phase 5's reflow gate, the kitty-keyboard guard and the soak are answered. Holds two name lists on purpose: configNames (canonical, one per case, derived from allCases — what check-engine-config.sh pins) and acceptedConfigNames (every alias named(_:) takes — what a warning must show someone who just mistyped). Acting on the choice is SpaceViewController+DocumentConstruction.swift; this file only decides, which is what makes it gateable headlessly
CursorStyle.swift 105 Pure and view-free (Foundation only): the six caret cases, the config spellings, the DECSCUSR codes, and the shape/blink split libghostty wants. Was SwiftTerm's enum until 2026-07-31 — the one symbol that made Config.swift import the emulator. Gated by check-cursor-style.sh
Theme.swift 201 The AppKit bridge: hex parsing, luminance/SwiftTerm colour conversion, and the five presets built from ThemeValues. Holds no hex of its own and no name mapping either — preset(named:) delegates to ThemePalette.named so the mapping stays gateable. It imports AppKit and SwiftTerm, so anything living here is unreachable by any gate
ThemeValues.swift 226 Pure and view-free (Foundation only, and compiled by every theme gate): the five shipped palettes as hex strings, each colour annotated with the OKLCH triple it was derived from, plus the config-string resolver (named(_:)/configNames) that used to be an ungated switch in Theme.swift. umber is the one designed and measured for this app rather than ported into it; afk-light is a verbatim GitHub Light Default port and the only preset above lightChromeCutoff; classic-repaired is Umber's own repair of Terminal.app Basic (readable blue, quiet body text) rather than a port, and the one palette whose quiet terminal foreground leaves its syntax comment below §5k's universal floor — its brighter chromeForeground keeps the editor selection readable; see ThemeContrast.swift
ThemeContrast.swift 285 Pure and view-free (Foundation only, and compiled by every theme gate): lightChromeCutoff — the number AppConfig.appearance branches on, kept here beside the formula it is compared against so a gate can link the real symbol rather than a copy of its value — WCAG 2.1, APCA 0.98G-4g, OKLab distance, Viénot–Brettel–Mollon dichromacy simulation, and SelectionPairing — the Lc 60 runtime floor (stricter than a merely-legible glyph's 45) FileViewerPane reads to decide whether a theme's selection pair is safe to install at all, or should fall back to the system pair. Both contrast models ship on purpose — WCAG materially overstates contrast on near-black backgrounds, which is every background this app has, so thresholds are written against APCA and WCAG is reported alongside
SyntaxPalette.swift 157 Pure and view-free (Foundation only, and compiled by the gate): which ANSI slot each syntax role reads from, and the one place a palette gets overridden — a comment whose ANSI 8 is illegible falls back to the foreground at commentAlpha. Holds the DECISIONS because painting the ranges needs AppKit and is therefore ungateable. The app consumer is FileViewerPane+Highlighting.swift; the gate (check-theme-contrast-harness.swift §5k) exercises this file independently

There are two tab levels, on purpose (plan §12.3, branch (C) — settled 2026-07-27).

  • Spaces = native macOS window tabs. One NSWindow per project root. Because these are real system tabs, ⌘⇧[ / ⌘⇧] cycling, the tab overview, drag-to-reorder, drag-out-to-detach and Merge All Windows work without being implemented. Do not replace this level with a custom tab bar — it is load-bearing, and it is why (C) was chosen over a full in-window strip.
  • Documents = a hand-rolled strip (DocumentTabStrip) inside each Space. This level must be custom: a system window tab is an NSWindow, so a mixed terminal/editor strip would give every document its own contentView and there could be no shared, full-height sidebar (plan §12.2). The strip is hidden at a single document so one-terminal Umber still just looks like a terminal.

Adding a new document kind (editor, diff, observer panel) means writing a SpaceDocument conformer — not touching the container. That is the whole payoff of the restructure.

Keymap: ⌘N new Space · ⌘⇧N new Space in a new window · ⌘O open folder as a Space · ⌘T new document · ⌘W close document (falls through to the Space when it is the last; collapses a split first when one exists) · ⌘⇧W close Space · ⌘⌥← / ⌘⌥→ cycle documents · ⌘1–⌘9 select document · ⌘B toggle sidebar · ⌘0 Actual Size (predates the above) · ⌘⇧\ split right · ⌘⇧- split down (tmux-inspired: | and -) · ⌘⇧H/J/K/L focus pane left/down/up/right (tmux-inspired vim nav) · ⌘Z / ⌘⇧Z undo & redo (editor only; answered by NSWindow's undo manager, not by us) · ⌘F find, ⌘G / ⌘⇧G next & previous, ⌘E use selection · ⌥⌘F find and replace (editor only — SwiftTerm's validateUserInterfaceItem greys it out over a terminal, which is correct: scrollback is a transcript) · ⌃⌘F full screen (moved off ⌘F when search landed — ⌃⌘F is the macOS default anyway). ⌘⌥arrows are safe because MacLineEditing.controlBytes guards on intent == [.command]exact equality — and check-keybindings.sh case 13 asserts ⌘⌥← passes through; the same exact-equality guard is why ⌘F reaches the menu untouched. ⌘⇧H/J/K/L are safe for the same reason: the guard requires intent == [.command] exactly, and adding Shift puts them outside that set.

Search is SwiftTerm's, not ours. performFindPanelAction is @objc open (Mac/MacTerminalView.swift:2169) and dispatches NSFindPanelAction tags to showFindBar / performFind(next:); validateUserInterfaceItem (:2119) already whitelists showFindPanel/next/previous. So the Edit-menu items in buildMenu() are the whole implementation — and every one of them must carry a tag, because performFindPanelAction early-returns unless the sender is an NSMenuItem and reads menuItem.tag. Known gap: one match at a time (selection-based), no all-match highlighting.

Configuration

  • On disk: ~/.config/umber/config.json. Does not exist until created; ⌘, writes a commented starter file and opens it, ⌘R reloads live. The app never rewrites an existing file.
  • UserDefaults: Umber.fontSizeOverride (live zoom), Umber.lastSpaceRoot (the last root opened with ⌘O), Umber.openSpaceRoots (the roots of every open Space, restored on launch — capped at 12, checked on read). AppKit frame autosave is per project root, UmberSpace:<path>; the old single UmberWindow key is still read once as a seed and never written again. Bundle id com.griffinlong.umber.
  • Relaunch reopens every Space that was open at quit, as one native tab group — not its documents. A shell has no resumable state, so recreating terminals would be theatre; see AppDelegate.restoreSpaces(), which argues it in full. Nothing to restore (first launch, or every remembered root since deleted) degrades to one default Space.
  • renderer: coretext (default) or metal. SwiftTerm ships a complete GPU renderer and Umber can now reach it — Renderer.swift documents what the two paths actually cost. Opt-in, because upstream calls the GPU path experimental and its speedup here is UNMEASURED; a wrong guess is one config line and ⌘R from reverted. It self-downgrades to Core Text if it cannot initialise and prints one line to stderr, and check-metal-renderer.sh is what proves that downgrade is not happening silently. Flipping the default should follow real use, not this note.
  • engine: swiftterm (default) or ghostty. Which emulator core backs a new document — TerminalEngine.swift argues both sides. Read once at construction and never re-read, so editing it and hitting ⌘R affects the next ⌘T rather than the tab in front of you; a live pane cannot change core without discarding its scrollback and its shell. That is also the feature: both engines can be open in one window at once, which is what comparing them on the same work requires. Still opt-in because the Phase 5 reflow gate is unwritten. Note renderer above applies to swiftterm only — libghostty draws with its own.
  • theme: omitted (default), or {"preset": "umber" | "classic-repaired" | "afk-dark" | "afk-light" | "tokyo-night" | "classic"}. afk-light (GitHub Light Default, verbatim) is the only light one, and it needs no second setting: AppConfig.appearance derives the window chrome from the background's luminance, so a light background gives a light sidebar — that seam existed before the preset did. Following System Settings is deliberately NOT built; it needs an effectiveAppearance observer and reopens the deliberate pin at SpaceWindowController.swift:164-166, so it is its own PR. classic-repaired is Umber's own repair of Terminal.app Basic's rough edges (readable blue, quiet body text) rather than a verbatim port; its deliberately quiet terminal foreground leaves the editor comment below §5k's universal floor, so that one measured gap is PINNED in check-theme-contrast-syntax.swift rather than silently loosened. Its editor selection is readable because the editor uses the palette's brighter chromeForeground, the same value production paints. umber was added 2026-08-03 and is the only palette here designed for the app rather than transcribed into it — warm umber-black base, legible dim text, a bright ring genuinely distinct from the normal one, and tier-1 red/green separation under dichromacy. Gated by check-theme-contrast.sh (345 assertions incl. falsification cases); derivation, tooling and four documented wrong answers in .afk/research/theme-design-2026-08-03/README.md. umber became the DEFAULT on 2026-08-03 — omitting theme now gives you it, and "preset": "classic" is the only way to install no colours at all.
  • The reason this repo gave for shipping no palette was STALE, and was corrected 2026-08-03. The claim — repeated in AFK.md, Config.swift, StarterConfig.swift, README.md and GitStatus+Presentation.swift — was that "any theme regenerates ANSI 16–255 by interpolating bg/fg, replacing the standard 256-colour cube". That is true of SwiftTerm's library default (TerminalOptions.swift:92 is .base16Lab) and false of Umber: TerminalPane.swift:165 pins ansi256PaletteStrategy = .xterm before any colour, Terminal.swift:523,538 gate the rebuild on != .xterm, and installPalette under .xterm routes to generateXtermPalette (Colors.swift:169-190), which appends the literal standard cube and never reads bg/fg. A custom palette has been safe for some time. Note the knock-on: GitStatus+Presentation.swift:40-42 cites this claim as one of two reasons the sidebar refuses to source git tints from the ANSI palette — that reason is void, but the refusal is still correct on the surviving ones (at theme == nil there is no palette to read; system colours adapt to Increase Contrast and to the window's appearance). It is right for fewer reasons than it says.
  • Full field reference (font, cursor, scrollback, shell, optionAsMeta, renderer, engine, theme) is in app/README.md — including why no palette is installed by default. Leave that default alone unless the change is deliberate.

Conventions

  • Hard ceiling: no source file over 350 LOC. Set 2026-07-28, enforced by ./Scripts/check-file-size.sh (exit 1 on violation, warning band at 315). This is a maintainability rule, not an aesthetic one: with no test target and no CI, correctness here depends on a reader — human or agent — holding a whole file in context before editing it, and a 550-line AppKit file spends most of an agent's working context establishing what is safe to touch. The failure mode is silent, because the agent then edits from a partial read. When a file approaches the ceiling, find its seam and pull one whole concern out (a delegate conformance, a model type, a drawing routine, a UserDefaults store); do not raise LIMIT. Every new file gets a header comment naming what it owns, and the file table above gets updated in the same commit — a stale map costs an agent the same context the split just bought back. Prose is exempt: .afk/plans/*.md are single arguments meant to be read end to end.
  • Prefer one concern per file, and a seam over a flag. New behaviour that plugs into an existing type should arrive as a new file conforming to an existing protocol (SpaceDocument is the model — see FileViewerPane, added without the container learning anything about it), not as another branch inside a type that already has a job.
  • Comments explain why, and cite their evidence — vendor source lines (Terminal.swift:725), upstream issue numbers (SwiftTerm #494), cross-repo files, plan sections (plan §4.1). Match this density: nearly every non-obvious line carries a rationale. A patch with no why comment on a non-obvious line is under-written for this repo.
  • Config parsing fails soft, per field. AppConfig.load() never throws: a bad value degrades to the default and appends a warning printed to stderr. Six bad fields ⇒ six warnings and a working terminal. Preserve this — do not introduce a throwing config path.
  • One source of truth for defaults: AppConfig.defaults() in Config.swift and the defaultFontSize / minFontSize / maxFontSize statics in Config+Font.swift. Read them; never re-hardcode.
  • Keybindings live in one table (KeyBindings.swift), not scattered switches — deliberately, so check-keybindings.sh can compile the shipped file directly. Adding a chord means editing the table and the truth table.
  • @MainActor on every type; @preconcurrency on the SwiftTerm conformance with an inline note why. No Sendable annotations.
  • Force-unwraps are confined to compile-time-known constants (literal UTF-8, literal hex palettes). No try! — filesystem writes use try?. Keep it that way.
  • Umber is the only prefix in code; the diagnostic env var is UMBER_DIAG (any non-nil value).
  • Commit style: conventional-ish, lowercase, scope in parens, em-dash rationale — feat(app): make font size actually configurable — 14pt default, zoom that sticks.

Known Risks

  • SwiftTerm #494 is still OPEN UPSTREAM, now MITIGATED LOCALLY (2026-07-29) — "buffer reflow produces duplicate/orphan lines when narrowing terminal". This is the one bug that would invalidate the project's premise. It is fixed here by patches/swiftterm/0002-index-iswrapped-buffer-absolute.patch and gated by app/Scripts/check-reflow.sh; upstream has not merged its own fix, so a SwiftTerm upgrade will reintroduce the defect unless 0002 is re-applied — which is what verify-vendor.sh's hard failure and the dual Buffer.swift hashes in the pin exist to catch. The history of how this entry was wrong is kept deliberately: earlier wording claimed three deterministic reflow tests "could not reproduce it, so it is reduced, not closed" — that was wrong, and was corrected 2026-07-27. The defect only manifests when the scrollback offset is non-zero, and git show 1e84dd0~1:spike/Tests/SpikeGatesTests/ReflowGateTests.swift | grep -c yBase returns 0: two of the three cases ran at _yBase == 0 where the defect is structurally inexpressible, and the third asserted only duplicate tokens, never missing ones. That was absence of evidence, not risk reduction. The deeper reason that test could never have worked is now understood and encoded in the new gate: the faulty line sits in the else of if _y >= _scrollBottom, so feeding line\r\n repeatedly pins the cursor to the bottom row where wrapping goes through scroll(true) instead — _yBase > 0 and the faulty branch were mutually exclusive under that input. Reaching the bug needs absolute CUP to a row above the last one, after scrollback has accumulated.
  • Reflow defect of record — PATCHED 2026-07-29, and the patch is what needs watching now. The defect (confirmed by direct read 2026-07-27): Buffer.swift:1171 and :1211 set _lines[_y].isWrapped = true using a screen-relative index, while the adjacent content writes at :1176 and :1224 correctly use the buffer-absolute _lines[_y + _yBase] (_y is screen-relative per Buffer.swift:22,42-43). With non-empty scrollback the wrapped-line flag landed yBase rows off target, so reflow joined a falsely-wrapped scrollback line to an unrelated neighbour and split a genuinely-wrapped one — silent mangling of history, not a visual glitch. Both sites are now buffer-absolute via patches/swiftterm/0002-index-iswrapped-buffer-absolute.patch. What keeps it fixed: app/Scripts/check-reflow.sh (8 cases over the full normal buffer including scrollback, read back through getBufferAsDataTerminal.swift:5947, whose loop for row in 0..<b.lines.count at :5953 is what makes it see scrollback at all, unlike getLine(row:) which returns nil for row >= rows at :759), plus verify-vendor.sh exiting 2 on an unpatched Buffer.swift. The gate was validated by falsification, not by passing: unpatched it fails 6 of 8 cases, e.g. scrollback row 5 reading |T0006sss| before narrowing and || after — a blank row spliced into history, with every token still present exactly once, which is why token counting alone was never enough. The fix is LOCAL, not upstream: upstream's partial fix (protectedLines) is still on unmerged branch reflow @ 0619254f and #494 is still open, so re-vendoring drops the fix silently unless 0002 is re-applied. The plan rejected local patching because core buffer surgery in a repo with zero tests buys correctness that cannot be verified (.afk/plans/emulator-foundation-probe-and-vendor-integrity.md §5–6) — that objection was answered by making the gate, not the 2-line diff, the deliverable. The closing line of this entry used to read "instrument TerminalPane.sizeChanged if anything reflow-shaped is ever seen again"; something reflow-shaped was seen again, hours later, and it was a different defect in the same function — see the next entry.
  • Alt-buffer resize defect — FOUND AND PATCHED 2026-07-29 (0003), and it is the reason to distrust "reflow is handled". Symptom as reported: running tmux inside Umber, content bled across pane boundaries and survived tmux window switches — narrow ribbons of stale, wrapped text sitting inside the wrong pane. Cause: isReflowEnabled is nothing but hasScrollback (Buffer.swift:419-421) and the alt buffer is built scrollback: nil (Terminal.swift:694), yet upstream keeps the "trim each line to the new width" loop inside if isReflowEnabled (Buffer.swift:522-531). So narrowing the alt buffer shrank cols while every BufferLine kept its old dataSize; those cells stayed invisible because the renderer clamps to min(terminal.cols-1, line.count-1) (Apple/AppleTerminalView.swift:887) — until the next widening, when BufferLine.resize took its shrink branch and copied data[0..<cols] verbatim (BufferLine.swift:197), pulling pre-narrowing cells back into the visible grid. Ghost band = columns [oldNarrowCols, min(newCols, originalWideCols)). tmux is the victim because it lives in the alt buffer and repaints only the deltas its own model says changed, so nothing ever reconciles the divergence and a window switch redraws from the same stale model. Every size change triggers it: processSizeChange (Apple/AppleTerminalView.swift:233 — live window drag, ⌘B, full screen) and resetFont (:151 — ⌘+/⌘-/⌘0, ⌘R), which is why a day of use deposits several bands at several widths. Three things worth carrying forward: (1) this is upstream SwiftTerm, reproducible with zero Umber code and unrelated to 0002, which touches neither resize nor isReflowEnabled — the two defects were adjacent, not the same; (2) check-reflow.sh passing was never evidence about this, because it only ever exercised the normal buffer, so "the reflow gate is green" meant less than it looked like — the new gate covers the alt buffer and Terminal.resize's refresh(startRow:0,endRow:rows-1) at Terminal.swift:5541 is a view refresh that redraws from the corrupted grid rather than asking the child for anything; (3) the full diagnosis, the reproduction, and the still-unchecked leads (glyph-width divergence in UnicodeWidthData.swift, and any erase routine bounded by line.count instead of cols) are in .afk/research/tmux-bleed-altbuffer-resize-2026-07-29.md. Upstream this when convenient; #494 is still open, so a re-vendor drops 0003 too.
  • Vendored SwiftTerm discards chdir's result when starting a shell. vendor/SwiftTerm/Sources/SwiftTerm/Pty.swift:103 is _ = chdir(cCurrentDirectory), inside the forked child (between forkpty and execve) where there is nothing to report back to — so a working directory that has been deleted, renamed, or made unreadable since the Space opened starts the shell in the app process's cwd, silently and with exit 0. TerminalPane.resolvedWorkingDirectory() works around it by validating the path up front and warning under UMBER_DIAG; it does not cover a directory that exists but lacks +x, where fileExists says yes and chdir still fails with no diagnostic. Not patched in the vendor because patches/ is a deliberate, auditable surface and this is moot post-libghostty (whose .exec backend honours workingDirectory itself — probe plan §6.2).
  • The git sidebar's DATA is gated; its RENDERING is not, and the brief refuses to pretend otherwise. check-git-status.sh covers discovery, the spawn and the grammar — 58 cases, falsification-validated. It cannot reach the poller's 2s cadence, the badge glyph, whether an orange filename stays legible inside the blue selection pill, or any line of the AppKit wiring, because those import AppKit and this repo's only verification surface is the single-file swiftc harness (.afk/research/git-sidebar-decision-2026-07-31.md §5, Risk 5). That is the same accepted-unverified category as the tree's file icons. Two mitigations exist instead of a gate: UMBER_DIAG=1 prints one [umber] git: line per changed snapshot (repo root, branch, entry and directory counts) so "no decorations appeared" cannot stay ambiguous between not a repo, wrong repo and tint failed to draw; and the one silent bug class — a cell recycled from a dirty row keeping its badge — is written against explicitly in applyGitDecoration, which always writes the nil case. Verified once by hand on 2026-07-31, against this worktree (the .git-is-a-file case): discovery found the worktree root, the branch parsed, ahead/behind were correctly absent rather than zero, and a row dump confirmed app rolled up as M while clean siblings drew no badge. That is a one-off observation, not a standing check. And the ungated half bit within the day: the poller cached its discovered repository behind a containment test (repository.relativePath(for: newRoot) != nil), which stays true when the tree root moves into a repository nested inside the one already known — precisely this project's .afk-worktrees/* layout, which is gitignored in the outer checkout and therefore reports nothing about its own contents. cd-ing from the main checkout into a worktree pinned the sidebar to the outer repo, naming the wrong branch over a tree where every file drew no badge, and it never recovered because nothing asked again. Now keyed on the root it was answered for (repositoryRootPath). The lesson is narrower than "add a gate": discovery was never wrong, and no test of discovery could have caught this — the defect was a containment test standing in for an identity test in AppKit code the harness cannot compile. What the gate can hold is the property underneath, so it now asserts that a nested worktree resolves to itself, that the outer repo still contains it, and that the outer repo reports no status for its dirty files. Falsified by probe rather than by the gate: modelling both cache tests against real nested fixtures returns outer/master/no-decoration for containment and inner-wt/probe-nested/M for identity.
  • The sidebar toggle's BEHAVIOUR is gated; its PLACEMENT is not — the same split as the git sidebar above, and worth stating separately because the two halves fail so differently. check-sidebar-toggle.sh proves the accessory is installed, that the button's own action resolves to a live responder, and that clicking it moves isCollapsed both ways; a dead button is now impossible to ship silently, which was the entire risk. What no gate in this repo can see is whether the thing is drawn where a person expects — beside the traffic lights rather than overlapping them, one per frontmost tab rather than stacked, and re-tinting correctly across umber/afk-light/classic. Those stay in .afk/verification/sidebar-toggle-accessory-checklist.md as items (a), (e), (f) and (g), unticked on purpose. Two known limits that are not defects: the glyph is static and never distinguishes collapsed from expanded (Finder does the same), and there is no NSToolbar anywhere in the app, so a .left accessory lives in the titlebar strip and vanishes with it in full screen (⌃⌘F) — the affordance is absent exactly where the menu bar is hidden too. One correction worth keeping: the original commit justified .left by asserting that .leading is invalid and asserts. It is not. NSTitlebarAccessoryViewController.h:27 grants Leading/Trailing to anything linked on 10.12+, this app links macOS 14, and .leading was observed attaching and pumping a run loop cleanly at -target arm64-apple-macos14 while a genuinely unsupported attribute (.width) aborted. .left is still right for this app, for the opposite reason to the one first written down — it is the non-mirroring choice, and there is no localization to mirror.
  • The git poller is a plain 2s timer, not FSEvents, and that is a deliberate first cut. The research recommends FSEvents as the primary signal with a slow poll as backstop, which is what VS Code does. Shipped here is the poll alone: it matches what the tree already does (refresh() is window-became-key driven and says so), and its worst case is latency rather than wrongness. Cost is measured — ~6.6ms on this checkout, ~92ms on a real 60k-file repo, scaling with tree size rather than dirty-file count — which is why the interval is 2s and why it stops dead on resign-key. Upgrading means watching both GitRepository.gitDirectory and .commonDirectory (they differ in a worktree; one alone is half-blind) plus the worktree itself, since untracked files never touch .git at all.
  • Staging, committing and discarding are REFUSED, not deferred. The plan of record listed "destructive git operations in a GUI" among the things to refuse regardless (.afk/plans/emulator-foundation-probe-and-vendor-integrity.md:208-215) before the question was asked, and VS Code's Source Control view ranks 6th of 7 on value-per-complexity for a terminal because its entire purpose is routing a GUI-only user around git add. Anyone tempted to "finish the feature" should read .afk/research/git-sidebar-decision-2026-07-31.md §1 first: VS Code itself never puts staging controls in the file tree. Also worth knowing the falsifier the brief set for its own premise — if a week of use still ends with git status typed in the terminal, the correct response is to cut the decorations, not to extend them toward a commit box.
  • G6, the multi-hour soak, is not closed. It retires only through real daily use, never a test.
  • Scrollback above ~3,500 lines pins SwiftTerm's scrollbar thumb at its 1% floor and makes Buffer.resize walk every line twice per window resize. Default is 1000 for that reason. It was three walks until 2026-07-31: patch 0004 removed the third, an upstream // DEBUG: Post-condition block that was never wrapped in #if DEBUG — it re-walked the viewport plus the entire scrollback to re-prove what the two loops above it had just established, and ended in abort(), i.e. a release-build crash raised from inside a live window drag. Confirmed as upstream's rather than ours by fetching the pristine v1.15.0 file and hash-matching it against the pin's own upstream_buffer_swift. Worth reporting upstream.
  • The Metal renderer works, but its benefit is UNMEASURED — and that asymmetry is the risk. As of 2026-07-31 "renderer": "metal" genuinely reaches SwiftTerm's GPU path, proven end to end by check-metal-renderer.sh including a falsification case. What is not proven is that it is faster here: no before/after frame or throughput number exists, and the argument is entirely structural (the Core Text path rebuilds an attributed string and a CTLine per visible row per frame with no macOS row cache — Apple/AppleTerminalView.swift:1364, :1400, and the per-line dirty-rect skip is compiled out under #if false at :1352; the GPU path caches per-row vertex data and rebuilds only dirty rows — Mac/MacTerminalView.swift:245). Upstream labels it "Experimental GPU path… image caching is basic; GPU path is still evolving" (:217), and its glyph coverage, ligatures, wide/CJK cells and selection rendering have never been reviewed here. So the default stays coretext, and the honest next step is the offscreen throughput harness .afk/research/performance-audit-2026-07-31.md §1 describes — measuring is what would let the default flip.
  • A gate that folded two signals into one || produced a confident false negative, and it was nearly written into the record as a correction. check-ghostty-pane.sh's kill criterion used to pump the run loop until title || OSC 7 and then report both. pump returns the moment its condition holds and a zsh sends the title first, so the harness stopped a beat before the OSC 7 sequence landed and printed pwd=- on every run. PR #20 read that as evidence, and shipped a paragraph withdrawing the claim that libghostty gives OSC 7 for free — "wired but not re-confirmed, inherited from the spike". Splitting the case in two (5a title, required; 5b OSC 7, required, and its path asserted against the pane's root) showed OSC 7 arriving every run, three runs in a row. The overclaim was correct; the correction was the artifact. Two lessons worth more than the fix: a disjunction in a wait condition silently truncates the slower signal, so waits should be conjunctions with the assertions split out after; and a gate that cannot notice good news cannot notice the bad news sharing its channel. The same commit made the harness's exit code the verdict — it was exit(0) unconditionally with the verdict travelling only as a stdout substring, and the shell half forgave a nonzero status whenever ALL-OK appeared anywhere in the output, so a crash after the verdict printed read as a green gate. Falsified rather than assumed: a harness patched to SIGABRT after printing ALL-OK exits 2 under the new logic and would have exited 0 under the old.
  • Closing a document now frees what it holds — and the entry this replaces was wrong in both directions. RESOLVED 2026-08-01 by documentWillClose(), a required member on SpaceDocument (documentShouldClose() asks, this one commits), called from closeDocument(at:) and from the new tearDownAllDocuments() on the ⌘⇧W path — which previously had no document loop at all, since windowShouldClose only vetoed and windowWillClose only persisted roots. Gated by check-pane-teardown.sh. Two corrections are worth keeping, because between them they are the whole lesson. (1) The old claim overstated the ghostty leak. It said "nothing frees the surface", reasoning from libghostty having removed TerminalSurface's deinit safety net (Surface/TerminalSurface.swift:405-410). True of TerminalSurface, false one layer up: TerminalSurfaceCoordinator has a deinit that calls the same tearDownSurface (:299-309), and the delegate back-reference is weak at both levels — so a released pane would eventually free its surface. Explicit teardown still earns its place, for better reasons: determinism (the shell dies when the user closes the tab, not whenever the last reference drops), and because that deinit reaches main-actor state via MainActor.assumeIsolated, which traps rather than degrades if the final release ever lands off the main actor. (2) The far worse bug was on the SwiftTerm side, and nobody suspected it. The gate was written expecting view.terminate() to end a SwiftTerm shell; it failed on the first run, with the shell still alive 25 seconds later — so every SwiftTerm tab ever closed in this app leaked its shell until quit. Both halves of SwiftTerm's terminate() miss an interactive login zsh: kill(shellPid, SIGTERM) (LocalProcess.swift:568) is ignored by design (measured under a pty.fork(): Ss+, alive), and io?.close() (:563) defers the real close(fd) to a DispatchIO cleanup handler that waits behind a never-finishing streaming read, so the shell never sees EOF. The fix is SIGHUP — the signal a closing terminal is actually supposed to send, and the one the same measurement showed does kill it. The generalisable point: the gate found a real pre-existing bug in the incumbent engine while being written to check the newcomer. A risk register is not a substitute for an instrument, and the engine you trust is the one nobody instruments.
  • RESOLVED 2026-08-03: the old default was the worst-measured palette in the repo, and the reasoning that kept it was wrong. A devil's-advocate pass on 2026-08-03 established this and it inverts the original framing. theme == nil is still the default, and it is not a neutral baseline: it installs SwiftTerm's Color.terminalAppColors (Colors.swift:91-108) on black, which fails 7 of the 8 slots check-theme-contrast.sh has floors for — ANSI 4 blue #492EE1 at APCA Lc 16.9, ANSI 1 red at 25.8, ANSI 8 (comments) at 36.0 against a floor of 48, and a normal→bright ring separation of 0.054 against a floor of 0.085. For scale, the gate's own falsification case asserts that xterm's #0000EE at Lc 14.0 must be REJECTED as the canonical unreadable blue; the default's blue is 2.9 points from it. So "keep the default and let daily use decide" was reasoning about nil as though it were conservative. It is not conservative, it is unmeasured — and "daily-drive first" is the right bar for choosing between two good palettes, not for retaining a measurably bad one. Config.swift's defaults() now installs .umber, and check-theme-contrast.sh asserts that it does — so the gate's floors now describe what the app actually ships rather than an opt-in nobody selected. "preset": "classic" still selects nil for anyone who wants the emulator's raw colours. The malformed-preset fallback was changed at the same time: a typo used to land on afk-dark, the one palette the gate deliberately exempts, and now lands on umber.
  • The tab strip's derived chrome IS now gated, and gating it immediately found a defect. The strip does not use theme colours directly: railBackground is the terminal background lifted 7% toward white, and tab labels are the theme foreground drawn at an alpha. That looked unreachable because DocumentTabStrip+Drawing.swift imports AppKit — but alpha compositing over an opaque backdrop is arithmetic, so RGB.blended/RGB.composited in ThemeContrast.swift reproduce it exactly and the harness measures the result for all three palettes (it is app chrome, so a failure is the strip's fault, not a palette's). The first run failed: inactive tab text at the shipped 0.55 alpha measured APCA Lc 37.3 under umber, 33.0 under afk-dark and 32.1 under tokyo-night — below APCA's Lc 45 floor for text readable at any size, making these 11pt labels the least legible text in the app, and wrong for every theme rather than for one. Fixed by 0.55 → 0.72, which clears 45 for all three (umber 53.9) while keeping a 23–27 Lc gap to the active label. Because the harness holds its own copies of the three constants, the gate greps the shipped file to confirm they still match — with comments stripped first, since the rationale comment beside textAlpha names the old value and a naive grep would match prose. Falsified: reverting the alpha, changing the rail lift, or reverting the default each fail the gate. That last gap partly closed on 2026-08-03: ThemeContrast.swift used to have zero call sites in the app — 204 lines of design instrument compiled into the shipping binary whose every caller was the harness — and it now has two real ones: SyntaxPalette.commentColour, calling APCA.lc to decide whether a palette's ANSI 8 is legible enough to be its comment colour, and SelectionPairing.isReadable, which decides at runtime whether the editor may install theme.selection at all — added 2026-08-03 after review found that a user-configured light background inherits a preset's dark selection and renders selected text at APCA Lc 0.0, invisible, because selection is the one ThemePalette field with no config override (#29). That is a narrow instance of exactly the runtime minimum-contrast clamp described below, so the shape was right; note the honest limit, which is that SyntaxPalette's OWN consumer is still the gate until the highlighter lands, and WCAG./OKLab./CVD. still have no caller outside it.
  • The un-taken option worth revisiting: a runtime minimum-contrast clamp (iTerm2's "Minimum Contrast" pattern), applied once in Config+Chrome.swift to whatever Theme a session resolves to. It was the strongest alternative raised against shipping a designed palette, and it is deferred rather than refused — though flipping the default to umber removed its biggest beneficiary, since the unmeasured nil path is no longer what a fresh install gets. What remains for it: the two verbatim presets, "classic", and any third-party hex a user pastes, plus giving ThemeContrast.swift a caller inside the app. It buys zero beauty, needs gamut-aware nudging not yet written in Swift, and risks hue drift if built naively.
  • A theme's LEGIBILITY is now gated; its BEAUTY is not, and that asymmetry is deliberate. check-theme-contrast.sh holds umber to 345 assertions and is falsification-validated, so the palette cannot silently regress into the failure modes the nine reference themes all exhibit. It says nothing about whether the result is good. That is not an oversight to close with more assertions: during design, four automated passes met progressively more constraints while producing noise, then pastels, then neon, then a green that measured well and rendered as mint — an optimiser exploits whichever axis you forgot to bound, and "tasteful" is not expressible as a threshold. The rendered previews in .afk/research/theme-design-2026-08-03/ are the record of the human gate, and the honest open item is that nobody has daily-driven umber for long — which is the risk it carries AS the default, not a reason to have withheld it. This sentence said the opposite until 2026-08-03 and was simply stale: the default flipped in the same PR that wrote it, because the incumbent (SwiftTerm's raw colours) failed 7 of the 8 measured floors, and a measured fact beats an absence of field time. ThemePalette.selection now reaches the editor as well as the harness (FileViewerPane+Document.swift sets selectedTextAttributes, falling back to the complete system selection pair under "preset": "classic", where there is no measured value to install, or whenever SelectionPairing.isReadable rejects the pair) but still neither terminal engine (SwiftTerm defaults selectedTextBackgroundColor to a hardcoded teal #00A6B2; libghostty exposes .selectionBackground). And Config.lightChromeCutoff is now exercisedafk-light (background #FFFFFF) arrived with PR #24 and sits above the 0.179 cutoff, where every other preset sits far below it, so the light-chrome path is no longer structurally untested and check-light-theme.sh asserts BOTH sides of the cutoff against the same symbol production reads. Reconciling the editor's syntax roles and selection pairing (#26) with Classic Repaired (#32) found one real gap and one gate bug: classic-repaired's quiet terminal foreground cannot lift its comment past the Lc 45 floor, so that gap stays PINNED; the selection gate had incorrectly measured that terminal foreground instead of the brighter chromeForeground production paints. The runtime pair measures Lc 71.1 and now clears the universal selection floor without an exception. Repainting the palette to fit the comment rule would still be a taste call the merge should not make unasked. Two caveats kept on purpose: afk-light is a verbatim GitHub Light Default port gated on WCAG ratios and not measured to umber's APCA standard, so it exercises that path without meeting that bar; and no gate here can see a pixel, so whether .aqua chrome actually looks right against #FFFFFF — or whether classic-repaired's pinned gap is actually fine to live with — stays daily-drive and human-judgment territory.
  • libghostty's 256-colour fill is UNVERIFIED, not confirmed. GhosttyPane+Appearance.swift:69-76 sends only indices 0–15 and asserts in a comment that ghostty generates 16–255 as the standard xterm cube rather than interpolating them. The Swift wrapper under app/.build/checkouts/libghostty-spm contains no palette-generation logic at all — it renders a palette = N=#hex config line and hands it to the C core, which lives inside the prebuilt 51.8 MB GhosttyKit.xcframework. So that is a claim about upstream behaviour, not something demonstrable from this checkout. This is precisely the auditability the vendored-SwiftTerm trade gave up: the equivalent SwiftTerm question was settled in one afternoon by reading Colors.swift:169-190.
  • Liquid Glass is ADOPTED on macOS 26+. Sidebar glass is automatic via NSSplitViewItem. Terminal and editor content areas stay opaque — the ANSI colour contract, the contrast gate, and SwiftTerm's opaque layer all require it. The Command Palette uses NSGlassEffectView on macOS 26+, keeping NSVisualEffectView(.hudWindow) as the pre-26 fallback. SymbolOutlinePanel is a .titled + .fullSizeContentView panel whose glass comes automatically from the window chrome — no explicit NSGlassEffectView needed. fullSizeContentView was tried and REVERTED (2026-08-20): NSSplitViewController does NOT propagate safeAreaInsets.top to a child view controller that uses manual frame layout (DocumentAreaViewController.viewDidLayout), so the terminal's top rows were clipped behind the glass titlebar — the exact same bug documented at SpaceWindowController.swift:132-141, now confirmed on macOS 26. Glass does not require fullSizeContentView: the sidebar gets its floating glass pane automatically from NSSplitViewItem, and the titlebar gets glass from the SDK linkage (via DTSDKName in Info.plist, injected by make-app-bundle.sh). The sdk field in the Mach-O LC_BUILD_VERSION load command is LOAD-BEARING — macOS reads it (not Info.plist DTSDKName) to decide whether to apply glass. SwiftPM sets both minos AND sdk to the deployment target (14.0), which puts the app in compatibility mode even when compiled against a modern SDK. make-app-bundle.sh passes -platform_version macos 14.0 <actual-sdk> to the linker to correct this, verified by otool -l. The Info.plist DT* keys are cosmetic provenance, not the functional mechanism.
  • The plan doc still contains 3 absolute /Users/griffinlong paths (one naming an unrelated fork). Scrub them before any gh repo edit --visibility public.

Not Built Yet

Terminal splits — BUILT (v1, 2026-08-17; tmux keybindings 2026-08-18). ⌘⇧\ (pipe) splits right, ⌘⇧- splits down (stub — SplitContainerView handles .vertical layout but the splitVertical: body is empty until vertical splits are enabled). ⌘⇧H/J/K/L moves focus between panes (tmux-inspired vim navigation). ⌘W collapses the split (closes the peer) on the first press; second ⌘W closes the tab. The new pane opens in the focused shell's cwd. Architecture: SplitContainerView (manual frame layout, draggable 1px divider), +Splits.swift (peer lifecycle + focus movement), DocumentAreaViewController updated to delegate to the container. focusedShellHost is now split-aware. v1 limits: 1 split per tab, horizontal only; no vertical split; no recursive splits; split state not persisted across launches; SpaceViewController.swift is at 334 LOC (menu validation extracted to +MenuValidation.swift, 2026-08-31). · **preferences UI (config is a JSON file) · shell integration (OSC 7 + OSC 133) — NOW BUILT for the SwiftTerm engine. TerminalPane.hostCurrentDirectoryUpdate is now filled by TerminalPane+ShellIntegration.swift. shell-integration.zsh (in app/Resources/, declared as a .copy SwiftPM resource so Bundle.main.path(forResource:ofType:) returns it) is sourced automatically when UMBER_INTEGRATION is set in the shell environment, emitting OSC 7 on every precmd and OSC 133 A/C/D around commands. OSC 7 populates _reportedDirectory; the 750ms kernel poll (ShellDirectory.swift) is the fallback for shells without the script. OSC 133 is wired to DocumentStatus via ShellIntegration.swift's state machine — a background tab whose command failed shows a red dot, a background tab whose 10s-or-longer command succeeded shows a green one. The policy is CommandOutcome.swift, gated by check-command-outcome.sh; OSC 133 parsing is gated by check-shell-integration.sh. Both engine paths now handle OSC 7 and OSC 133. .running remains unwired on both engines · URL clicking · profiles · editor — FileViewerPane reads, saves, and syntax-highlights files. SpaceDocument now has two conformers, so the seam is proven, not theoretical. Syntax highlighting shipped 2026-08-11 as a regex-based tokenizer (FileViewerPane+Highlighting.swift + SyntaxLanguage.swift) painting five roles onto the ANSI colours SyntaxPalette maps. No tree-sitter, no grammar bundles — auditable Swift regex patterns covering Swift, JS/TS, Python, Ruby, C/C++, Rust, Go, Java/Kotlin, CSS, JSON, shell, YAML, HTML/XML and Markdown. The colour decisions remain gated by check-theme-contrast.sh §5k (345 assertions); the rendering is daily-drive territory. Known limits: raw strings, nested block comments, and multi-line string boundaries within a single edited paragraph may mis-colour until the next full re-highlight (reload or ⌘R). The un-taken alternative (CodeEditSourceEditor, per plan §5) would have made the tokenizer permanently ungateable here because its grammars ship as a prebuilt binary XCFramework — the same trade libghostty already cost.

Git: the read-only half is BUILT, the other half is refused. Shipped 2026-07-31 — per-file status badges and tints in the tree with directory roll-up, plus an ambient branch + ahead/behind line above it. Deliberately absent, and not a backlog item: resource groups, staging checkboxes, a commit box, and discard (see "Known Risks"). Genuinely deferred rather than refused: a changed-files list (needs container surgery, and the industry keeps declining to build exactly that panel) and gutter dirty-diff / inline blame, which need a real editor first. A GitDiffPane: SpaceDocument — a tab, not sidebar UI — is the sanctioned next step if diff is ever wanted, and the seam already anticipates it.

Next step is no longer Step 3. See .afk/plans/emulator-foundation-probe-and-vendor-integrity.md: the plan of record's Step 3/Step 4 ordering was reopened after research found (a) the reflow gate that cleared SwiftTerm was a blind test, and (b) ~27 macOS-native agent-workspace terminals shipped on libghostty in the preceding months, most of them matching the shape Umber was heading for. The next commitment is a timeboxed 2-day probe writing GhosttyPane: SpaceDocument alongside TerminalPane — the seam makes the foundation choice reversible instead of a bet. Search and the app icon are done (243fcfb).