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
| Type | Examples | Status |
|---|---|---|
| Must route through FrankenTUI | Application logs, tool output, progress updates | |
| Automatically handled | FrankenTUI UI rendering, frame presentation | |
| Undefined behaviour | println!(), 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(fromftui-core) owns the raw-mode + alt-screen lifecycle and constructs the singleTerminalWriter.TerminalWriter(fromftui-runtime) serialises all writes and coordinates with the presenter’s SGR state machine.- RAII cleanup. When
TerminalSessiondrops — 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()
}LogSinkimplementsstd::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
| Action | Effect |
|---|---|
println!() from a Cmd callback | Interleaves 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 PTY | Child output bypasses FrankenTUI entirely; inline mode gets overwritten. |
| Third-party logger configured to stdout | Same as println! but with a nicer backtrace. |
| Writing during panic before RAII cleanup | Terminal 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:
- Passthrough mode may be needed for some features (e.g. OSC 52 clipboard, synchronised output).
set-clipboard onin tmux is required for clipboard shortcuts.- Bracketed paste works with modern mux versions.
- 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 onQuick 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()?; // BADPitfalls
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.