Swift · SwiftUI · Shellby

One SwiftUI Codebase, Three Different Hands: Shellby's Multi-Session Adaptation

A Shellby postmortem: command broadcast, split view, ⌘W — one codebase serving the Mac's keyboard, the iPad's multitasking, the iPhone's one-handed use.

Shellby runs on iPhone, iPad, and macOS from one SwiftUI codebase. “One codebase, three platforms” sounds like SwiftUI’s selling point, but in interaction-dense areas like multi-session, split view, and shortcuts, the three platforms’ differences force you to fork several paths inside the same code. This post covers three concrete adaptations: command broadcast, split view, and a deceptively simple ⌘W.

Command broadcast: push it down into the input path

First, a small design decision. Command broadcast — one input sent to multiple sessions at once, to batch-run a command on multiple machines — has an obvious implementation: at the UI layer, “iterate all panes and send the input to each.”

Shellby doesn’t do that; it pushes broadcast down into the session object’s own input path. A session’s sendInput method first writes the input to itself, then checks for an active broadcast group, and if there is one, forwards to the other members.

Why is this better? Because all input sources go through this one sendInput chokepoint — SwiftTerm keystrokes, iOS soft-keyboard special keys, the symbol bar, all of them. Putting broadcast logic at this single entry point makes it naturally apply to every input source, without wiring broadcast into each UI component separately. The data structure is minimal too: a broadcast group holds only “is active” and “member list,” with sessions weakly referencing back to the group.

Broadcast deliberately does no state alignment — it doesn’t care whether each session’s current cwd matches or whether they’re in the same shell state, it’s pure byte broadcast. That’s a tradeoff: “smart alignment” introduces endless edge cases, and the ops scenario of “send the same command to several homogeneous machines” already assumes their states are close. Misfire protection isn’t about alignment, it’s about strong visibility: the broadcast toggle is highlighted orange, and a persistent orange “Broadcasting” label sits on the right — you can’t miss that you’re sending commands to multiple machines.

Split view: the draggable divider killed by reflow

Power users on the Mac want multiple terminals on screen at once — split view. The first version used a draggable HSplitView/VSplitView, the kind with a draggable line in the middle that resizes both sides live.

That approach was forced out by a terminal-rendering trap.

The problem is that SwiftUI’s SplitView does multiple trial layout passes on its subviews during layout — it tries several sizes before settling on the final split position. And every trial pass makes the terminal view (SwiftTerm) reflow once (rewrap the text), and these intermediate reflows leak into the scrollback. The result: the top pane’s content gets corrupted by these trial reflows.

The fix is to drop draggable and switch to an equal-split VStack/HStack — one pass fixes the sizes, each pane reflows only once. The cost is losing the ability to drag the ratio, in exchange for uncorrupted content. To compensate, there’s an auto-arrangement: among all possible column counts, pick the one that makes each cell’s aspect ratio closest to 1.3 (terminals read better slightly wide) with the fewest empty cells. Panes can be dragged to reorder (only the title bar is the drag handle, to avoid conflicting with word selection in the terminal), and reordering only changes the array order, not the session instances, so content isn’t lost.

A “theoretically better interaction” (draggable divider) lost to “the reality of the underlying component” (reflow corrupting the buffer) — this happens a lot in engineering, and accepting a degraded fallback that minimizes the loss beats forcing a pretty interaction that corrupts content.

⌘W: one shortcut, three implementations

The best illustration of how un-frugal “one codebase, three platforms” can be is ⌘W — “close.” In a session context it’s ambiguous: close the current session tab, or close the whole window? Three platforms, three different handling paths:

  • macOS: SwiftUI’s menu command can’t grab ⌘W — the system’s key equivalent has higher priority and triggers “close window.” The fix is to install a local key monitor with NSEvent.addLocalMonitorForEvents and intercept ⌘W: if there’s an active session, close the session and swallow the event; otherwise let it through to close the window.
  • Real iPad: the reverse — you have to remove the system close command when building the menu, ceding ⌘W to “close session,” or the duplicate with system close triggers a UIKeyCommand dedup crash.
  • iOS-on-Mac (“Designed for iPad” running on a Mac): close is injected by the Mac bridge layer and removal is unreliable, so “close session” gets remapped to ⇧⌘W.

The same “press ⌘W to close the current session” intent got three implementations, because the three platforms’ system menu mechanisms differ. This isn’t a lack of unity; it’s that each platform’s keyboard/menu model is inherently different, and for one codebase to “feel right” on all three, you have to fork at exactly these points.

Some multi-window adaptations are “free”: each window holds its own session manager for window isolation, ⌘N for a new window, and the iPad’s multi-Scene / Stage Manager come from WindowGroup automatically. But some need active handling — on macOS, the system’s automatic window tabbing is deliberately turned off (allowsAutomaticWindowTabbing = false), because system tabs open independent windows (each with its own session manager), overlapping in meaning with “session tabs within a window,” and mixing the two baffles users.

Reuse the terminal view, don’t tie it to SwiftUI identity

There’s one design running throughout that deserves its own mention: the terminal view is held and reused by the session object, not tied to SwiftUI’s view identity.

SwiftTerm’s terminal view is UIKit/AppKit, older than Swift concurrency. If you let it follow view identity the usual SwiftUI way, it would be destroyed and recreated when switching tabs or entering/leaving split view, and the terminal content would be gone. So the reverse: the session object holds that terminal view, and when switching tabs/split, the same view is remounted (detached from the old parent, attached to the new), and content persists. Switching between tabs of a single session goes further, using ZStack + opacity to show/hide — all terminals stay resident at full size, only opacity changes, nothing unmounts, nothing resizes, so no reflow, no content corruption.

Takeaways

“One codebase, three platforms” really does save a lot, but it saves “most,” not “all.” A few lessons:

  • Put shared logic at a single entry point: broadcast pushed down to sendInput, applying to all input sources in one place, not wired into each UI component;
  • The theoretically optimal interaction must yield to underlying reality: the draggable divider lost to SwiftTerm’s reflow trap, degraded to equal-split + auto-arrangement, accepting the loss for correctness;
  • Platform differences concentrate at the interaction layer: ⌘W is three implementations, not disunity but different keyboard/menu models — frame the differences at these points and the core logic stays one codebase;
  • Don’t let heavy objects follow SwiftUI identity: the terminal view is held and reused by the session, switching only remounts, content isn’t lost.

One codebase across three platforms — the real work isn’t in the 90% you share, it’s in cleanly forking the remaining 10% of platform differences so each platform “feels native.”

Comments

  • Loading…

Comments are reviewed before publishing; email is visible only to me.