Terminal apps sit in a unique position: they must render text fast, handle brutal amounts of output, and stay responsive while a dozen processes stream into them. Getting that wrong is easy — and very visible. A dropped keystroke or a stuttering scroll while a test suite is printing thousands of lines is the kind of detail users feel before they can name it.
This post is the long version of how LayoutCLI is built: the platform decision, how state flows through the app, and the specific engineering choices that keep the window smooth when the output gets heavy.
Why Native Instead of Electron
The obvious way to ship a desktop app in 2026 is a web view — one codebase, one rendering model, and a huge ecosystem. We chose Swift 5.9 and SwiftUI instead, and it was not a cosmetic decision. A terminal has three jobs that punish a browser runtime:
- It renders text constantly. A single
npm installor a watch-mode build can push tens of thousands of lines through the view layer in a minute. That work wants to happen close to the GPU, not behind a JavaScript bridge. - It runs long-lived processes. Every pane is a real shell that stays alive for hours. The app must be a good citizen about memory and file descriptors for the whole session, not just at launch.
- It is always in the foreground of your attention. Latency that a user ignores in a settings screen is unbearable in the thing they type into all day.
Native buys us three things we could not fake:
- Memory. The whole app idles at a fraction of what an Electron shell needs, which matters because your terminals are already the hungry part of your stack.
- Startup. A native binary launches instantly — there is no bundle to parse, no runtime to boot, and no first-paint negotiation before your sessions restore.
- Fidelity. Real AppKit text rendering, native fonts, system menu conventions, and full support for the macOS accessibility stack come with the platform rather than being reimplemented on top of it.
An Electron terminal is a browser pretending to be a terminal, and that gap shows up exactly when you need the app most: full-screen programs, huge scrollback, and heavy color output.
Architecture at a Glance
The app is roughly four layers, and keeping them separate is the reason one pane misbehaving does not take down the window:
- Process layer — one PTY per pane, owned by a session actor that never touches the UI thread.
- Terminal emulation — a parser that turns the byte stream into a grid of styled cells and scrollback lines, running off the main thread.
- State layer — observable stores that hold panes, layouts, and widgets, and publish only what actually changed.
- View layer — SwiftUI views that read slices of that state and render them.
Almost every performance problem we have fixed came down to the same question: which layer is doing work that belongs in another one?
State Management with @Observable
On macOS 14 we use the Observation framework’s @Observable macro instead
of hand-rolled ObservableObject plumbing. Views track only the properties
they actually read, so updating one pane does not redraw the whole window:
@MainActor
@Observable
final class SessionStore {
var panes: [Pane] = []
var activeLayout: Layout = .grid4
func focus(_ pane: Pane) {
// only views reading `panes` re-render
panes = panes.map { $0.id == pane.id
? $0.markedFocused() : $0 }
}
}
With the old observable-object pattern, a single @Published change
invalidated every view observing the object, even the ones that only cared about an
unrelated field. In a window that may hold dozens of panes, that is the difference
between a calm UI and one that repaints the world every time you move focus.
The pattern we settled on is simple: keep frequently-mutating state at the leaf. The high-frequency data — the terminal cell grid, the stats sampler — lives in objects observed by exactly the view that draws it. The slower, structural state — which panes exist, what the layout is — lives in the store. When those two scopes get mixed, you get accidental whole-window invalidation.
Keeping the Main Thread Free
SwiftUI is a main-thread framework, and the main thread is also responsible for handling your keystrokes. Any work we do there is work that competes with typing. Three rules keep it clear:
- PTY reads never touch the main actor. Shell output is read on a dedicated queue and handed to the parser there.
- ANSI parsing happens off-thread. Escape sequences are decoded into styled runs on a background executor, so thousands of colored lines streaming in do not block the window.
- Frames are coalesced. Even after parsing, we do not apply every burst of output as its own view update. Output is batched into display frames so that a process printing faster than the screen can refresh does not translate into faster-than-screen invalidations.
The mental model that keeps us honest: the main thread should never wait for a process that has nothing to say to the user.
Managing Memory: Dynamic Scrollback
Scrollback is the classic terminal memory leak. Users love it — it is how you find the line that explains the failure — and it grows without bound by default. A single process left running overnight can accumulate hundreds of megabytes of history.
We cap scrollback dynamically rather than with a fixed number. Under normal conditions you get a generous buffer. As memory pressure rises, the limit shrinks and the oldest lines are dropped first, so a runaway process cannot take the app down with it. The trade-off is explicit: we would rather lose the very start of a very long log than lose the window it was printing into.
One Sampler for Every Widget
The CPU and memory widgets could each run their own timer. That is the obvious implementation and it is wrong: you end up with several polling loops querying the same system statistics, duplicating work and waking the machine more often than necessary.
Instead, a single stats sampler owns the polling cadence and publishes values to whichever widgets are on screen. Add a second widget and the cost does not double — it stays flat. Fewer timers also means fewer opportunities for the UI to hitch, and lower idle energy use over a workday.
What We Measured
Anecdotes about performance are worth little, so we benchmark the things users actually complain about:
- Typing latency under load — keystroke-to-glyph time while a pane is printing thousands of lines per second. This is the number we guard hardest.
- Cold launch — from click to a usable shell with sessions restoring.
- Idle footprint — resident memory with several panes open and nothing running, because that is the app’s baseline all day.
- Sustained throughput — how much output the parser can absorb before frames start dropping.
The pattern across all four was the same: our best wins came from doing less on the main thread, not from micro-optimizing the renderer.
Every performance fix that stuck was an architecture fix: move the work off the thread that owns the keyboard.
Built for Developers Who Care About Footprint
If you notice your fan spinning because a text editor opened a log file, this app is for you. LayoutCLI stays out of the way: fast to launch, light on memory, and smooth when the output gets heavy. The goal is an app you forget is running until the moment you need a fifth terminal — and then it is simply there. Grab it and benchmark it yourself.