Terminal-program embedding
A real child terminal program as authored content: TerminalView, pty-backed sessions, and a tabbed/split-pane workspace layer.
For SwiftUI developers
If you write SwiftUI, you already write SwiftTUI: var body: some View,@State, @Binding, stacks, modifiers, focus, and animation carry over intact. What changes is deliberate: a small set of terminal-native rereadings, applied consistently, and explicitly recorded.
SwiftTUI mirrors SwiftUI's shape. Where an identical behavior does not fit a terminal experience, the default is terminal-native. The API stays SwiftUI-shaped. The framework implements the subsets of SwiftUI which map to high-value terminal use, and skips deprecated surfaces. For the APIs it does implement, semantic consistency is a hard constraint.
Geometry is integer terminal cells: frame(width:height:) takes Int, and there is no CGFloat. Two named coordinate domains keep the model honest: the CellRect grid for layout and rasterization, and continuous cell space for gestures, hover, and Canvas. padding()'s default is one cell. A border occupies a cell too and defaults to insetplacement. Use outset placement to grow the frame instead of eating content.
VStack(spacing: 12) {
Text("Deploys").font(.title2)
content
}
.padding() // platform-adaptive points
.border(.secondary) // overlay; no layout effectVStack(spacing: 1) {
Text("Deploys").bold()
content
}
.padding() // one cell default.
.border() // default inset, border occupies a content cell
.border(placement: .outset) // convenience: border grows the frame
Why?: a terminal allocates whole cells; the geometry is inherently integer based., and a border that pretended to be a zero-cost overlay would eat your content.
Navigation and presentation are strictly data-driven. There is deliberately no NavigationLink<Label, Destination> where Destination: View. Every screen stays derivable from, and mutable through, your app's data, which is what makes a terminal UI scriptable, testable, and deep-linkable.
NavigationLink("Build 9") { BuildView(id: 9) }Button("Build 9") { selectedBuild = .build(9) }Why?: a UI that can be driven, replayed, and tested is one whose every state is data; controls that carry hidden navigation side effects break that contract.
Models are @Observable classes; the Combine-era ObservableObject / @Published / @StateObject family does not exist. Strict concurrency @MainActor annotations are visible in the API to allow the framework to safely run other parts of the view lifecycle in the background.
final class DeployModel: ObservableObject {
@Published var builds: [Build] = []
}
struct Dashboard: View {
@StateObject private var model = DeployModel()
...
}@MainActor @Observable
final class DeployModel {
var builds: [Build] = []
}
struct Dashboard: View {
@State private var model = DeployModel()
...
}Why?: Strict concurrency enforced for views lets rendering leave the main actor without undefined behavior.
Focus traversal is geometry-aware and wraps: there is no surrounding native UI for focus to escape to, so the chain cycles. Key bindings are authored with keyCommand rather than keyboardShortcut, and onKeyPress is reshaped for a terminal byte stream: complete key events, no down/up/repeat phases, because a terminal has none to observe.
Why?: the keyboard is the terminal's native input; the APIs are fit to it.
Tabs are declared as Tab("My Label", value: .myValue), Table takes column metadata with positional row cells, toolbar items are ToolbarItemConfig values, and Picker displays only label text. One stance, four surfaces: where SwiftUI resolves arbitrary label view trees into pixels, terminal chrome is most often text - so the APIs emphasize those overloads.
Why?: a tab strip, a column header, and a picker row are usually a line of text; value metadata renders them predictably where a label view tree cannot.
fullScreenCover is chromeless. background fills the view's bounds without bleeding. Borders occupy an explicit cell. The List focus highlight is row-shaped, not container-wide. Alerts and confirmation have a defined FIFO queue. Where something can fail, the API says so: environment verbs like \.openLinkAction and \.resetFocus return Bool, and the runtime reports issues.
Why?Cells are finite and restrictive so defaults are tuned to match. And to help a bit further, SwiftTUI polishes some behavior SwiftUI leaves undefined — because it doesn't have to support heterogeneous AppKit and UIKit rendering under the hood.
Reduced motion changes the rendering itself: spinners go static and phase animations hold their first phase. CI=true or a non-TTY stdout selects the same stable built-in. By default, every App is also a CLI command that ships --accessible (--no-color and --reduce-motion), and --ascii configs, and an in-browser view triggered with --web and served via an in-binary http server (macOS, Linux, and iOS; on Windows the umbrella ships the terminal surface only) — all out of the box.
Why?: Many TUIs are have serious accessibility issues. SwiftTUI's declarative API allows it to provide alternative behaviors for screen readers without putting more burden on the developer.
The differences run in both directions. Alongside the SwiftUI-shaped surface, SwiftTUI ships capabilities SwiftUI has no analog for.
A real child terminal program as authored content: TerminalView, pty-backed sessions, and a tabbed/split-pane workspace layer.
toast and popoverTip presentation families, with FIFO-defined prompt queueing.
TextFigure renders FIGlet text and integrates it fully with the layout system so the banner text wraps if needed.
Terminal borders take a cell. They're chonky. SwiftTUI makes this regrettable truth a little nice by adding an API overload for per-side border styles... And even animatable perimeter gradients with BorderBlend.
SwiftTUI's style API protocols (ButtonStyle, ListStyle, PickerStyle, etc.) are public and extensible or will be soon. Even the families SwiftUI keeps closed.
Your SwiftTUI views run not only on the terminal, but also inside SwiftUI on macOS and iOS, in the browser via WASI, and on Android.
Departures from SwiftUI are listed in the framework's documentation. Some are intentional and some are known gaps to fix. Many are awaiting a good terminal usecase. If you find a surprising hole, open an issue.