Skip to Content
OperationsOne-writer rule

One-writer rule

When FrankenTUI is active, only one entity writes to the terminal — the TerminalWriter owned by your TerminalSession. Any other code that reaches stdout or stderr during a running FrankenTUI session produces undefined behaviour.

This is not a style guide. It is a correctness invariant with teeth.

Source: crates/ftui-runtime/src/terminal_writer.rs + docs/one-writer-rule.md + docs/adr/ADR-005-one-writer-rule.md.

Why one writer

Terminals are shared mutable state with a cursor, a current SGR state, and a mode flag (raw / cooked, alt-screen on/off, mouse on/off). If two entities write concurrently, every one of those pieces of state becomes unpredictable. Concretely:

  • Cursor drift. Two writers each think they know where the cursor is; both move it; the UI draws in the wrong place.
  • Partial sequences. One writer emits half of a CSI sequence, the other writer emits a byte, and the terminal interprets the result as garbage.
  • Interleaved output. UI frames and log lines bleed into each other.
  • State corruption. Raw mode gets flipped off by a library that thinks it owns stdout; alt-screen never gets exited; the terminal is unusable after exit.

Inline mode is especially sensitive because it overlays UI on the scrollback — every stray byte lives in the user’s history forever.

What counts as terminal output

TypeExamplesStatus
Must route through FrankenTUIApplication logs, tool output, progress updates
Automatically handledFrankenTUI UI rendering, frame presentation
Undefined behaviourprintln!(), eprintln!(), raw std::io::stdout().write(), third-party logging to stdout

How it is enforced

TerminalWriter is the single gateway. Every byte that reaches the terminal during a session goes through it:

Model::view ──▶ Frame ──▶ Buffer ──▶ BufferDiff LogSink ─────────────────────────┐ ▼ PtyCapture ──────────────────────┼──▶ TerminalWriter ──▶ BackendPresenter ──▶ TTY StdioCapture (feature-gated) ────┘
  • TerminalSession (from ftui-core) owns the raw-mode + alt-screen lifecycle and constructs the single TerminalWriter.
  • TerminalWriter (from ftui-runtime) serialises all writes and coordinates with the presenter’s SGR state machine.
  • RAII cleanup. When TerminalSession drops — normal exit or panic — it restores the terminal to sane state. This is why the one-writer rule must hold: if another writer can race with the drop, cleanup gets interleaved with whatever the other writer is doing.

Approved routing patterns

There are three patterns for getting non-UI output onto the terminal. Pick based on where the bytes come from.

A. LogSink — in-process logs

For logs your own code produces.

use ftui::LogSink; use std::io::Write; fn main() -> Result<()> { let app = App::new()?; let log = app.log_sink(); writeln!(log, "Starting process…")?; writeln!(log, "Loaded {} items", count)?; app.run() }
  • LogSink implements std::io::Write.
  • Output is sanitised by default — CSI / OSC / DCS / APC sequences are stripped, only TAB / LF / CR C0 controls are kept. See ADR-006 .
  • Thread-safe: clone the sink and write from multiple threads.

B. PtyCapture — subprocess output

For external tools you spawn (cargo, git, make, a shell command).

use ftui::pty::PtyCapture; fn main() -> Result<()> { let app = App::new()?; let capture = PtyCapture::spawn(&["cargo", "build"])?; app.attach_pty(capture); app.run() }
  • The subprocess runs under a real pseudo-terminal — it can’t tell the difference, so it emits progress bars and colours normally.
  • ANSI sequences from trusted tools can be preserved with pty.set_passthrough_sgr(true). Leave it off for anything that consumes untrusted input.

C. StdioCapture — best-effort safety net

For catching accidental writes from libraries you don’t control.

use ftui::StdioCapture; fn main() -> Result<()> { let _guard = StdioCapture::enable()?; // BEFORE App::new let app = App::new()?; app.run() }
  • Feature-gated: features = ["stdio-capture"].
  • Best effort, not 100% reliable. Native code, FFI calls, or processes that dup stdout past it will still slip through.
  • Adds runtime overhead. Prefer as a safety net, not a primary strategy.

What happens if you violate the rule

ActionEffect
println!() from a Cmd callbackInterleaves with the next UI frame; cursor jumps; SGR state corrupts.
Raw stdout.write()Terminal state becomes unpredictable; alt-screen may not exit cleanly.
std::process::Command::spawn without PTYChild output bypasses FrankenTUI entirely; inline mode gets overwritten.
Third-party logger configured to stdoutSame as println! but with a nicer backtrace.
Writing during panic before RAII cleanupTerminal left in raw mode; user’s shell is broken until reset.

There is no helpful error. The terminal just misbehaves. That is why this is the first thing to audit when a user reports “weird rendering”.

Debugging output issues

Search for raw writes

rg --type rust 'println!|eprintln!|std::io::stdout|io::stdout\(\)\.write'

The hits should be confined to tests, CLI help, and code that runs before App::new.

Check subprocess spawning

rg --type rust 'Command::new\(|std::process::Command'

Every one of these should be wrapped in a PtyCapture (or documented as running before the TUI starts).

Check logging configuration

If the app uses tracing, log, or env_logger, make sure the appender is the LogSink or a file — not stdout.

Turn on StdioCapture

As a diagnostic, enable the feature and see whether the overlap stops. If it does, there’s an accidental writer somewhere; find it with rg + a debugger attached to the StdioCapture spill.

Mux and passthrough notes

Under tmux, screen, or zellij:

  1. Passthrough mode may be needed for some features (e.g. OSC 52 clipboard, synchronised output).
  2. set-clipboard on in tmux is required for clipboard shortcuts.
  3. Bracketed paste works with modern mux versions.
  4. FrankenTUI auto-detects mux environment and adjusts; you don’t need to configure this in application code.
# ~/.tmux.conf set -g set-clipboard on set -g allow-passthrough on

Quick reference

// DO — use LogSink for your logs let log = app.log_sink(); writeln!(log, "status: {}", status)?; // DO — use PTY capture for subprocesses let pty = PtyCapture::spawn(&["my-tool"])?; app.attach_pty(pty); // DON'T — direct terminal writes println!("status: {}", status); // BAD // DON'T — raw subprocess without PTY Command::new("my-tool").spawn()?; // BAD

Pitfalls

Don’t use raw passthrough for untrusted content. Passing attacker-controlled bytes through write_raw enables escape-sequence injection . Use the sanitising write path unless you own the source.

StdioCapture is not a license to sprinkle println!. Its job is to catch library writes you can’t fix; code you control should still use LogSink.

Panic hooks must cooperate with cleanup. Your panic hook must not write to stdout directly. Use LogSink or let the default RAII drop of TerminalSession run first, then print.