jackin'
Behind jackin' — InternalsTUI Design

TUI Architecture

Elm Architecture layers, source code locations, typed effects flow, and the boundary between TUI and non-TUI code.

Architecture — Model / Update / View

Every jackin' terminal surface uses Ratatui with The Elm Architecture: Model, Message, Update, Effect/Subscription, View, and terminal/run-loop wiring. This is a hard requirement for jackin-console, jackin-launch, jackin-capsule, and every new TUI path. It keeps the update layer testable, the render layer pure, and external work outside the visual boundary.

Each layer has a strict scope:

LayerFileOwns
Modeltui/app.rs, screen model.rsVisible terminal state: active screen, focus owner, modal stack, scroll offsets, hover/click state, picker state, animation state, render-safe snapshots
Messagetui/message.rsSemantic TUI events and results: key/mouse intent, resize, tick, background completion, dialog outcome, screen transition
Updatetui/update.rsDeterministic state transitions: apply Message to the model and return typed effects
Effect / Subscriptiontui/effect.rs, tui/subscriptions.rsTyped descriptions of non-TUI work and long-lived event sources; they describe work but do not execute external commands
Viewtui/view.rs, screen view.rsPure Ratatui frame composition from the current model; capsule-only terminal protocol reconciliation such as cursor/mode state, outer-terminal title, OSC passthrough, and pointer-shape hints stays in the backend/writer layer around the frame
Terminal / run looptui/run.rs, tui/terminal.rsTerminal lifecycle, backend adapter, event-loop wiring, and routing of completed effects back into messages
Componentstui/components/Surface-local visual components not reusable enough for jackin-tui

Messages and central update. Every state change that could be driven by a test goes through a semantic Message and an update function instead of mutating model state inline in the event handler. Event handlers translate terminal input into messages; update applies the message and returns dirty state plus typed effects. Existing console code may still use names such as ManagerMessage, ManagerState, update_manager, and UpdateResult, but new structure should use the same pattern under the owning surface's src/tui/.

Pure view. Render functions take immutable model state. The only mutations allowed before render are geometry-prep calls that run immediately before the draw and cache render geometry into the model. Nothing that runs inside terminal.draw(...) may write to the model, spawn background tasks, persist config, call Docker/git/op/gh, touch the filesystem, or perform network I/O.

External work through typed effects. Long-running work and non-visual product behavior live outside src/tui/. Update returns typed effect requests; the run loop or an effect executor calls non-TUI service/runtime code; completed work returns as typed messages. No blocking disk, socket, subprocess, Docker, git, 1Password, GitHub CLI, config persistence, or network call runs from a renderer, component, or update function.

Async event loop. Event loops should use async event streams and tokio::select! where the surface already runs inside Tokio, so background work can advance between terminal events. Animation ticks drive periodic redraws for spinners and animated components. Synchronous adapters are allowed only at the terminal/backend edge; they must still route state changes through messages.

Component library. Repeated cross-surface visual patterns live in crates/jackin-tui/src/components/. Surface-specific components live under that surface's src/tui/components/ and graduate to jackin-tui only when multiple surfaces should consume the same implementation. New screens compose existing components; they do not hand-roll a second panel, picker, confirm dialog, hint bar, or text input. Every reusable jackin-tui component has a lookbook story in crates/jackin-tui-lookbook/ visible in the terminal browser (cargo run -p jackin-tui-lookbook -- --terminal) and a generated SVG in the docs site that must never drift from the real widget.

Refactor safety. Moving code into this structure is primarily a refactor. Existing workflows must keep the same command semantics, launch flow, capsule attach behavior, saved state, keyboard/mouse contracts, error handling, and visual affordances unless a PR explicitly calls out and justifies a behavior change. Capture or verify the current behavior before structural moves and rerun the relevant checks afterward.


TUI source code location — mandatory directory convention

Each surface crate has one TUI module root plus one TUI implementation directory:

crates/<surface-crate>/src/tui.rs
crates/<surface-crate>/src/tui/

src/tui.rs is a thin module root: module declarations, intentional re-exports, and short //! orientation docs only. The implementation lives in src/tui/ and follows The Elm Architecture: Model, Message, Update, Effect/Subscription, View, and event-loop wiring. Everything outside src/tui/ is non-visual product code: workspace resolution, Docker commands, config writes, network I/O, credential handling, capsule protocol/session authority, and data models that have no terminal-interaction role.

The one rule: if it is terminal interaction state, terminal events, terminal rendering, terminal lifecycle, or local UI composition → src/tui/. If it executes external work or owns non-visual product behavior → outside src/tui/.

Rule: TUI code has exactly two homes:

  1. crates/jackin-tui/ — Shared component library. Panels, pickers, scrollable blocks, hint bars, confirm dialogs, tab strips, tokens, theme adapters, and geometry helpers. Nothing surface-specific. Consumed by every other TUI crate.

  2. crates/<surface-crate>/src/tui.rs + crates/<surface-crate>/src/tui/ — The whole TUI application layer for that surface: Model, Message, Update, Effects, Subscriptions, View, local components, terminal adapter, and event-loop wiring. Single-screen crates stay flat. Multi-screen crates add screens.rs plus screens/<screen>/ under src/tui/.

Single-screen crate layout (jackin-capsule, jackin-launch):

src/
├── lib.rs
├── domain/              ← optional pure non-visual rules
├── services/            ← optional side-effect adapters
├── tui.rs               ← thin module root
└── tui/
    ├── app.rs           ← TUI Model
    ├── message.rs       ← Message / Action vocabulary
    ├── update.rs        ← update(model, message) -> effects
    ├── effect.rs        ← typed effect requests only
    ├── subscriptions.rs
    ├── run.rs           ← event-loop wiring
    ├── terminal.rs      ← terminal lifecycle/backend
    ├── view.rs          ← top-level render composition
    ├── components.rs    ← thin local-component module root
    └── components/

Multi-screen crate layout (jackin-console):

src/
├── lib.rs
├── domain/              ← optional pure non-visual rules
├── services/            ← optional side-effect adapters
├── tui.rs               ← thin module root
└── tui/
    ├── app.rs           ← top-level TUI Model
    ├── message.rs
    ├── update.rs
    ├── effect.rs
    ├── subscriptions.rs
    ├── run.rs
    ├── terminal.rs
    ├── view.rs
    ├── components.rs    ← thin local-component module root
    ├── components/
    ├── screens.rs       ← thin screen module root
    └── screens/
        ├── workspaces/
        │   ├── mod.rs
        │   ├── model.rs
        │   ├── message.rs
        │   ├── update.rs
        │   ├── effect.rs
        │   ├── view.rs
        │   └── components/
        ├── editor/
        │   ├── model.rs
        │   ├── message.rs
        │   ├── update.rs
        │   ├── effect.rs
        │   ├── view.rs
        │   └── components/
        └── settings/
            ├── model.rs
            ├── message.rs
            ├── update.rs
            ├── effect.rs
            ├── view.rs
            └── components/

MUV contract inside each surface crate:

  • tui/app.rs and screen model.rs files — terminal interaction model only; no external I/O.
  • tui/message.rs — semantic events/results that drive the update loop.
  • tui/update.rs — state transition logic; may return effects but must not execute external commands.
  • tui/effect.rs — typed effect requests only; no Docker/git/op/gh/filesystem/network execution.
  • tui/view.rs and screen view.rs files — pure rendering from the current model.
  • tui/run.rs / tui/subscriptions.rs — event-loop wiring that maps terminal events and completed non-TUI work into messages.

domain/ and services/ are optional non-TUI homes, not mandatory boilerplate.

  • Use domain/ for pure product rules that do not know about terminals: validation, derivation, immutable transforms, durable data semantics, and decisions that can be unit-tested without Ratatui or process I/O.
  • Use services/ for side-effect adapters that execute the work requested by TUI effects: Docker/runtime calls, git/GitHub CLI/1Password commands, filesystem reads/writes, network calls, config persistence, role loading, and workspace resolution.
  • Do not add these directories just to satisfy a template. If a crate already has an established non-TUI module that owns the work clearly, keep using it. The binding rule is the boundary: TUI code describes work with typed effects; non-TUI code executes it.

What belongs in src/tui/:

  • TUI model structs and enums
  • Message / Action enums
  • Update functions that mutate only TUI state and return typed effects
  • Effect and subscription type definitions
  • Event-loop wiring and terminal lifecycle/backend adapters
  • Ratatui widget implementations; surface-specific terminal protocol reconciliation belongs in the backend/writer layer, not in widget render functions
  • Frame composition, geometry helpers, hitboxes, focus-visible rendering, hint bars, and local lookbook stories

What does NOT belong in src/tui/:

  • Docker/runtime command execution
  • Git, GitHub CLI, 1Password CLI, shell command, filesystem, and network operations
  • Config/schema loading, migration, and persistence
  • Workspace resolution, role loading, mount inspection, and durable state cleanup
  • Capsule PTY/session/control-plane authority and wire protocol definitions
  • Non-visual domain rules that do not depend on terminal interaction

Enforcement: before adding any file under src/tui/, ask: "Does this execute external work, own durable product state, or define non-visual behavior?" Yes → it does not go in src/tui/. Before adding a TUI file outside src/tui/, ask: "Is this about terminal interaction or terminal rendering?" Yes → it belongs under src/tui/.

Typed effects flow — the only allowed connection between TUI and non-TUI code:

tui/update.rs
  -> returns Effect::RefreshInstances

tui/run.rs (effect executor)
  -> calls non-TUI service/runtime code

non-TUI service code
  -> performs Docker/runtime work
  -> returns typed data or typed error

tui/message.rs
  <- Message::InstancesRefreshed(result)

tui/update.rs
  -> updates the TUI model

No renderer, component, or update function calls Docker, git, op, gh, the filesystem, the network, or config persistence directly. All of that travels through a typed Effect, executes outside src/tui/, and returns as a typed Message.


Capsule terminal model pipeline (jackin-term)

The jackin-capsule render is all Ratatui. jackin-term is the per-pane terminal model, not a separate render pipeline:

  1. Ratatui composition. The whole multiplexer frame — status bar (StatusBarWidget), pane borders and bodies (PaneBodyWidget), dialogs, selection highlight, and scrollbar — is painted by view.rs into one ratatui Buffer. This is the same Elm Architecture layer as every other jackin' TUI surface.

  2. Per-pane terminal model (jackin-term). Each pane's body comes from the agent's PTY. jackin-term parses the agent's escape bytes and maintains a cell grid for that pane — the vte → DamageGrid → GridSnapshot model in crates/jackin-term/. That model is then rendered by PaneBodyWidget, a Ratatui Widget that paints the GridSnapshot's cells into the same Ratatui buffer as everything else.

Terminal protocol bytes that are not pane cells still exist, but they are backend/writer responsibilities rather than a second widget renderer: cursor positioning/visibility, mode reconciliation, the OSC outer-terminal title, pointer-shape hints, and allowed out-of-band passthrough are emitted around the Ratatui frame by the capsule backend and ClientWriter. Chrome, pane bodies, dialogs, scrollbars, banners, and selection highlights are frame content.

Terminal model pipeline decisions:

  • Depend on vte, never rebuild the parser. vte (by doy, MIT) is the canonical VT/ANSI parser crate. The capsule feeds raw PTY bytes to a vte::Parser which calls vte::Perform methods on the DamageGrid. No second parser.
  • Own the grid. DamageGrid (crates/jackin-term/src/grid.rs) is a ring-backed RowStore terminal grid owned by jackin-term; live capsule sessions share one daemon RowArena for primary, alternate, and scrollback row buffers. It records dirty rows at mutation time (DirtyTracker). It is the capsule's sole terminal model: screen cells, alternate screen, scrollback, cursor, mouse/focus/bracketed-paste/kitty-keyboard mode state, and the typed OSC/CSI passthrough stream all live here. The capsule previously used a forked vt100::Parser; that dependency has been removed from production, test, fuzz, benchmark, and source-policy paths.
  • Render the model through Ratatui. PaneBodyWidget (crates/jackin-capsule/src/tui/components/pane.rs) is a Ratatui Widget that paints a GridSnapshot's cells into the Ratatui buffer alongside the rest of the chrome. SocketBackend (crates/jackin-capsule/src/tui/socket_backend.rs) implements the ratatui Backend trait: it diffs the buffer (Buffer::diff) and emits the minimal ANSI byte sequence over the attach socket.
  • Cell-level diffing and clearing is Ratatui's job. SocketBackend serializes only the buffer's per-cell diff, so stale cells from a previous, differently-sized frame are overwritten by the next frame. The capsule's resize path additionally emits a full-screen erase on geometry change (see Capsule resize in the chrome reference).
  • CompactString for cell contents. Cell::contents uses compact_str::CompactString which stores ≤24 bytes inline with no heap allocation. This covers all ASCII and most Unicode grapheme clusters. The public contents() -> &str API is unchanged.
  • Cutover complete. DamageGrid is unconditional in jackin-capsule — there is no feature flag and no vt100 fallback. vt100 is no longer a jackin-capsule dependency.
  • Correctness gate: conformance replay harness. tests/conformance.rs in jackin-term feeds identical byte streams to DamageGrid in one chunk and byte-by-byte, then asserts identical final grids and geometry/style/cursor invariants. Any behavior change to DamageGrid must keep the harness green.
  • dump() for acceptance testing. DamageGrid::dump() returns a GridSnapshot — an owned, serializable snapshot of the full screen state with to_text() and cell(row, col) accessors. Use it in acceptance tests to assert exact screen content without going through the ANSI emit path.

On this page