Termio is a terminal-first environment for AI coding agents. The Mac app is
Swift + AppKit/SwiftUI on top of libghostty (Ghostty's terminal core); the
sessions themselves live in termiod, the Rust session host under
termiod/. It is a deliberately small, focused tool — prefer clarity over cleverness, keep the
surface area minimal, and don't add features nobody asked for. When in doubt,
open an issue and discuss before writing code.
- macOS 14+.
- Swift 6 (Xcode 26).
- Rust (
cargo) and Zig 0.17.0 onPATHunder that exact name, to buildtermiod. The daemon embeds libghostty-vt, whichbuild.rsbuilds with Zig;termiod/DEPLOY.mdhas the exact environment. Without them there is no session backend, so the app starts but can open no session. - You do not need
zigfor the Swift side: libghostty ships as a prebuiltGhosttyKit.xcframeworkvia the termio-sh/libghostty-swift package. Do not try to build Ghostty from source in this repo.
(cd termiod && cargo build) # the session host — sessions need it
swift build # resolves dependencies + compiles
swift run # launches the appA bare swift run binary has no bundle, so it looks for the daemon at
termiod/target/{release,debug}/termiod in the checkout it lives in. Build
termiod once and every later swift run finds it; TERMIO_TERMIOD_BIN
overrides the path.
Run from a macOS GUI session — Termio is a real foreground AppKit app
(bootstrapped by an explicit NSApplication in Sources/termio/App/App.swift,
not the SwiftUI App lifecycle).
swift run produces a bare binary with a generic Dock icon. For a proper
.app:
./scripts/build-app.sh # ad-hoc-signed release build → ./termio.app
open ./termio.appThe script builds termiod and bundles it into Contents/Resources. Without
cargo and zig a release build fails outright; a dev build warns and ships
without a daemon, which means it can open no session.
If you have a released Termio installed, build the side-by-side dev app so you don't clobber it:
TERMIO_CHANNEL=dev ./scripts/build-app.sh # → ./termio-dev.app
open ./termio-dev.appThe dev channel gets its own bundle id (sh.termio.app.dev), its own state dir
(~/.termio-dev), its own companion port (8788), a termio-dev CLI, and no
Sparkle update feed, so it can never auto-update itself onto the release channel.
The iOS app lives in ios/ (TermioMobile.xcodeproj, scheme TermioMobile).
ios/dev-run.sh builds and installs it pointed at this Mac's companion server.
Shared wire-protocol code lives in Shared/ and is used by both platforms.
Simulator builds need no signing. For a device build, put your Apple
Development team in a SharedXcodeSettings/DeveloperSettings.xcconfig next to
your clone — ios/Signing.xcconfig gives the exact path and format. Keeping it
outside the repo means nothing local ever lands in git, and one copy covers
every clone and worktree.
From AGENTS.md (the authoritative copy, also what AI coding agents read):
- Prioritize correctness and clarity over micro-optimization.
- No force-unwraps (
!) or anything that traps — useguard let/if letand surface failures instead of crashing. - Never silently discard errors: handle, log, or propagate.
- Comments explain why, not what. No summary/organizational comments.
- Full words for names, no abbreviations.
- Prefer adding to existing files over creating many small ones; a new file is for a genuinely new component.
- Termio uses the host-managed
.inMemorybackend: the surface renders bytes the app attaches to over the session protocol (Sources/termio/Terminal/Termiod/TermiodClient.swift). The PTY itself lives in the daemon (termiod/src/pty.rs), spawned with theopenpty+login_ttyshape. Do not switch the spawn toposix_spawn— that PTY shape breaks agents' resize repaint (seedocs/bug/terminal-resize-no-reflow-HANDOFF.md). - One
TerminalViewStateowns one surface;TermioStore's SurfaceCache keeps it alive across view rebuilds so shells survive session switching.
Be conservative about new SPM dependencies. In particular, never add a
library dependency that ships resources: plain swift build generates a
broken Bundle.module accessor (it only checks the .app root and a
hardcoded build-machine path), so CI-built releases crash at runtime even when
local dev builds work. This shipped as a crash twice (v0.1.0, v0.2.4). Vendor
such code instead, the way Sources/termio/Editor/Highlightr/ does: include
the upstream license in a vendor README.md, route resource lookups through
Bundle.termioResources, and list local deviations from upstream in the file
header.
Changes to libghostty itself go to the
termio-sh/libghostty-swift
fork (as rebased patch files there), not this repo; Termio then bumps the
package version in Package.swift.
main is the single trunk and the default branch. There is no dev or
release branch.
- Branch off the latest
mainfor every change, using afeat/…,fix/…, orchore/…name:git switch main && git pull --ff-only && git switch -c feat/<slug>. - Keep branches short-lived — open a PR early and merge or close it quickly. Long-lived divergent branches are exactly what this workflow exists to avoid.
- Open the PR against
main:gh pr create --base main. - For a substantial feature, a git worktree keeps it isolated:
git worktree add ../termio-worktrees/<slug> -b feat/<slug> main.
- Conventional Commits 1.0.0:
type(scope): imperative, lowercase summary— e.g.fix(pty): kill the session's whole process group and reap strays. Common scopes in history:pty,sidebar,panes,welcome,landing,runbook,ios. - No
Co-Authored-Bytrailers. - Keep each commit a single logical change; the body (when needed) explains why, not what.
- Don't rewrite pushed history on
main— the release build number isgit rev-list --count HEAD, and Sparkle treats a lower build number as older, which breaks auto-update.
- Titles: imperative, correctly capitalized, no conventional-commit prefixes, no trailing punctuation.
- Include a
Release Notes:section in the PR body.
Anything under docs/ carries YAML front matter (title, status, type,
created/updated); the front matter is the single source of truth for a
doc's status, and the index table in docs/README.md is generated from it —
don't edit that table by hand.
Releases are cut by pushing a version tag; nothing in the repo needs editing:
git tag v0.3.0
git push origin v0.3.0That fires .github/workflows/release.yml, which builds the app,
Developer-ID-signs and notarizes it, packages a DMG, signs and merges the
Sparkle appcast, uploads everything to Cloudflare R2
(downloads.termio.sh), and records a GitHub Release.
- The tag is the version:
v0.3.0→CFBundleShortVersionString = 0.3.0. - The build number is the commit count on
HEAD, stamped intoCFBundleVersion; it must only ever increase (see the history-rewrite warning above). - Existing installs pick the release up on their next Sparkle check via
https://downloads.termio.sh/appcast.xml.
Details, one-time secret setup, and the manual verification checklist live in
docs/RELEASING.md and docs/runbook/macos-release-runbook.md. To reproduce
a release-style signed bundle locally:
SIGN_IDENTITY="Developer ID Application" \
TERMIO_VERSION=0.3.0 TERMIO_BUILD=$(git rev-list --count HEAD) \
./scripts/build-app.shiOS releases run through .github/workflows/ios.yml and TestFlight and are
cut separately from the macOS tag flow.