ADR-008: Terminal Backend Strategy (Replace Crossterm, Enable WASM, Unify Interfaces)
Status: Proposed Date: 2026-02-08
Context
FrankenTUI currently depends on:
- Native terminal I/O via Crossterm, wrapped by
ftui-core::terminal_session::TerminalSession. - Terminal output via ANSI emission to
stdout, coordinated byftui-runtime::TerminalWriter.
The bd-lff4p epic (“FrankenTerm.WASM”) requires a backend strategy that:
- Replaces both Crossterm (native) and xterm.js (web) with first- party components.
- Enables the same application code (
Model::update/view) to run natively and in WASM. - Preserves FrankenTUI invariants: one-writer rule, deterministic rendering,
explicit time, and safe Rust in-tree (
#![forbid(unsafe_code)]in our crates).
This ADR complements ADR-003 (Crossterm as the v1 backend) by defining the v2+ path.
Decision
1. Introduce a backend boundary at the runtime
Refactor the runtime so Program depends on a small backend interface rather
than directly constructing and owning TerminalSession + TerminalWriter.
The runtime must be able to:
- Read input as canonical
ftui_core::event::Event. - Present UI as
ftui_render::buffer::Buffer(and optionally aBufferDiff). - Toggle terminal features (mouse/paste/focus/kitty keyboard) via a backend-agnostic config struct.
- Use explicit time (monotonic) and a platform-specific scheduler/ executor (native threads vs WASM event loop).
2. Make ftui-core backend-agnostic (no Crossterm)
ftui-core must remain the home for:
- Event types (
Event,KeyEvent,MouseEvent, etc.). - Parsing/semantic normalization (input parser, coalescers, semantic events).
- Capability detection policy and overrides (including environment-based policy).
But ftui-core must not own the platform terminal lifecycle and raw
event reads long-term.
3. Add platform crates (native + web)
ftui-backend(new crate): backend traits + small shared structs/enums used by the runtime boundary.ftui-tty(new crate): native backend implementation (Unix/macOS first, Windows later).ftui-web(new crate): WASM backend implementation (DOM input + renderer bridge).
ftui-runtime will depend on ftui-backend and accept any backend
implementing the trait(s).
Crate Map (Target Dependency Shape)
ftui-core: canonicalEventtypes, parsing/semantic normalization, backend-agnostic capability policy.ftui-render→ftui-core:Cell/Buffer/Diff/Presenter(terminal-model-independent kernel).ftui-backend→ftui-core,ftui-render: backend traits + small shared structs at the runtime boundary.ftui-runtime→ftui-backend(+ftui-layout/text/style/widgetsas today):Programis backend-driven.ftui-tty→ftui-backend(+ temporary Crossterm OR first-party native backend): native lifecycle + input + ANSI output.ftui-web→ftui-backend: WASM driver (DOM input, renderer bridge, explicit clock/executor).ftui-demo-showcase→ftui-runtimeplus a concrete backend (ftui-ttyfor native;ftui-webfor WASM).
4. Isolate Crossterm during migration
During staged migration, Crossterm (if used at all) must be contained within
ftui-tty only.
This allows:
- Immediate refactors toward a backend boundary without rewriting I/O at the same time.
- Later replacement of Crossterm with a custom native backend without touching higher layers.
This is explicitly a temporary containment step, not a compatibility layer intended to live indefinitely.
5. WASM time + async effects: no threads, explicit executor
WASM cannot assume:
std::thread- blocking sleeps
- synchronous stdin/stdout
We will reshape runtime effects so that side effects are executed by an injected executor. Concretely:
- The runtime core remains a deterministic state machine over
(state, inputs, clock) -> (state, outputs, effects). - Native driver executes effects using threads/worker queues as needed.
- WASM driver executes effects using
spawn_localand browser timers (no blocking).
For WASM specifically:
- Ticks are explicit: time-based behavior is driven by injected
Event::Tickvalues. - Time is explicit: all elapsed time queries go through
BackendClock(no implicitInstant::now()in the core loop). - Sleep is non-blocking: “sleep for X” is an effect scheduled by the executor; it never blocks the UI thread.
This may require changing the current
Cmd::Task(TaskSpec, Box<dyn FnOnce() -> M + Send>) to an effect form that
can be executed on both platforms without blocking the UI.
Backend Interface Sketch
This is the intended (implementable) shape. Names are provisional; the key is the boundary.
// ftui-backend
use core::time::Duration;
use ftui_core::event::Event;
use ftui_core::terminal_capabilities::TerminalCapabilities;
use ftui_render::buffer::Buffer;
use ftui_render::diff::BufferDiff;
#[derive(Debug, Clone, Copy, Default)]
pub struct BackendFeatures {
pub mouse_capture: bool,
pub bracketed_paste: bool,
pub focus_events: bool,
pub kitty_keyboard: bool,
}
pub trait BackendClock {
fn now_mono(&self) -> Duration;
}
pub trait BackendEventSource {
type Error;
fn size(&self) -> Result<(u16, u16), Self::Error>;
fn set_features(&mut self, features: BackendFeatures) -> Result<(), Self::Error>;
fn poll_event(&mut self, timeout: Duration) -> Result<bool, Self::Error>;
fn read_event(&mut self) -> Result<Option<Event>, Self::Error>;
}
pub trait BackendPresenter {
type Error;
fn capabilities(&self) -> TerminalCapabilities;
fn write_log(&mut self, text: &str) -> Result<(), Self::Error>;
fn present_ui(
&mut self,
buf: &Buffer,
diff: Option<&BufferDiff>,
full_repaint_hint: bool,
) -> Result<(), Self::Error>;
fn gc(&mut self) {}
}
pub trait Backend {
type Error;
type Clock: BackendClock;
type Events: BackendEventSource<Error = Self::Error>;
type Presenter: BackendPresenter<Error = Self::Error>;
fn clock(&self) -> &Self::Clock;
fn events(&mut self) -> &mut Self::Events;
fn presenter(&mut self) -> &mut Self::Presenter;
}Notes:
- Inline mode is a native presenter concern (current
TerminalWriter) but should remain a configuration option at the runtime boundary. Backends that cannot support it must reject explicitly rather than silently degrading. - Capability detection remains policy-driven; the backend provides a profile and/or raw signals, but higher layers should not depend on platform quirks.
Alternatives Considered
A. Keep ftui-core::TerminalSession as-is and add a separate WASM stack
Rejected because it:
- Duplicates the runtime loop and effect semantics (determinism and golden replay diverge).
- Bakes Crossterm assumptions into “core” types long-term.
- Makes it harder to build a single golden-trace corpus usable for both native and web.
B. Rewrite the native backend first (replace Crossterm immediately)
Rejected as a first step because it couples two large changes: refactoring runtime/backend boundaries and rewriting I/O and lifecycle. This increases risk and slows progress.
C. Keep output ANSI-only and render WASM through an ANSI emulator
Rejected because the project goal is to replace xterm.js and own the renderer. ANSI emulation may exist for compatibility/testing, but it cannot be the primary path.
Consequences
Positive
- Clean platform boundary: backend details stop leaking into core/runtime.
- Enables native + WASM to share the same model/update/view code with explicit time sources.
- Improves testability: backends become mockable; golden traces can replay against the same state machine.
- Crossterm becomes an implementation detail that can be removed without refactoring the whole stack again.
Negative
- Requires refactoring
ftui-runtime::Programand potentiallyCmd/effect execution. - Adds new crates (
ftui-backend,ftui-tty,ftui-web) and some initial integration overhead.
Migration Plan (With Delete Checkpoints)
The plan is staged to keep the workspace green and avoid long-lived shims.
- Add
ftui-backendcrate with backend traits and a native adapter implementation that wraps existing code. - Refactor
ftui-runtime::Programto accept a backend instance (native constructors still exist for ergonomics). - Introduce
ftui-ttyand moveTerminalSessionand terminal lifecycle into it. At this point,ftui-coreno longer depends on Crossterm. - Add
ftui-webcrate with a driver skeleton (no threads; injected clock + executor). Must compile:cargo check --target wasm32-unknown-unknownfor relevant crates. - Replace Crossterm inside
ftui-ttywith a first-party native backend implementation. - Delete the old Crossterm-backed implementation.
File deletion requires explicit written user permission per AGENTS.md.
Test Plan / Verification
cargo check --all-targetsstays green at every stage.- Add backend mock tests for:
- Feature toggles mapping (
BackendFeatures→ platform operations). - Deterministic event delivery under resize storms.
- One-writer rule enforcement at the presenter boundary.
- Feature toggles mapping (
- Extend E2E gates:
- Native: existing
tests/e2e/scripts/*plus JSONL schema validation. - WASM: build checks + trace replay harness that asserts checksums against golden traces.
- Native: existing
Related Docs
docs/spec/frankenterm-architecture.md(see “Backend Trait: Replacing Crossterm” and “Implementation Order”).docs/spec/frankenterm-correctness.md(correctness + golden trace requirements that the backend boundary must support).