Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Architecture at a glance

TermHerd is a replatform of an Electron session manager, and the rewrite is scoped by the defects it must fix by construction — a god object, races, silent catches, and an untestable design. Everything below is downstream of that.

The authoritative documents are docs/PRD.md and docs/ARCHITECTURE.md.

The dependency rule

A hexagonal workspace. Adapters depend on the core; the core depends on no adapter.

   app  ──────►  core  ◄──────  adapters
   (iced GUI)      │           (scan, pty)
                   ▼
                claude
             (pure codec)
CrateIsDepends on
corethe domain: headless App state machine, the tab/pane tree, the keymap, the port traitsclaude only
claudea pure codec for the Claude CLI’s own formats — path encode/derive, JSONL digest, OSC decodenothing
appthe iced GUI shell; builds the adapters in main() and injects them; owns the one effect executor and the MCP control surfacecore + adapters
scanfilesystem discovery — walks ~/.claude/projectscore
ptythe terminal adapter (portable-pty + alacritty_terminal)core
mcpthe stdio settings servercore

When adding code the question is which crate does this belong in? If the answer is “the core should call this adapter directly”, the answer is wrong — add a port trait and have the adapter implement it.

The headless core

core::App::apply(Event) -> Vec<Effect>

Elm-style, and pure: no I/O, no clock, no panic. The GUI translates user gestures into Events and performs the returned Effects. Everything testable lives behind apply — which is also why the MCP surface adds no new mutation path: every tool goes through an Event the keyboard already used.

The pane tree is pure data, exhaustively unit-testable. Its mutators return Option<()> rather than panicking when an invariant is violated — a broken invariant surfaces as None/Err, never an unwrap.

Concurrency

One tokio runtime, actor per session: each session is owned by a task holding its PTY handle and terminal grid, reachable only by channel. There is no shared &mut Session. The GUI thread owns the App and applies events single-threaded.

That structure is the fix for the session-id race the predecessor had, not a mitigation of it.

The quality bar

Non-negotiable, and CI-enforced:

  • unwrap, expect and panic are clippy-denied in core and claude. Production paths return typed errors.
  • unsafe_code is denied workspace-wide. The one sanctioned exception is the macOS AppKit FFI for the Cmd+Q quit path: a cfg-gated module with a SAFETY: note on every block.
  • No global mutable state — no static mut, no lazy_static, no require-time singletons. Dependencies are built in main() and injected.
  • One logging stack: tracing. No println! outside tests.
  • Function length is gated (clippy::too_many_lines, threshold 150). A function that exceeds it on purpose carries a local allow with a rationale.
  • Twelve gates guard a pull request — formatting, clippy with warnings denied, the test suite, dependency licensing, unused dependencies, the crate dependency rule, intra-crate module boundaries, workflow lint, markdown lint, the roadmap, and this book. Each runs only when its own file category changed, so a docs-only change skips every Rust job; they fan into one required check, which treats a skipped gate as a pass. Portable crates are additionally built and tested on Windows on every PR.

The full CI reference is docs/CI.md.

Toolchain

Pinned to Rust 1.95.0, edition 2024, via rust-toolchain.toml.