Skip to Content
ftui-coreScreen modes

Screen Modes — Inline vs. Alt-Screen

A TUI has two fundamentally different relationships with the terminal that launched it: it can take over the screen, promising to put everything back the way it found it (alt-screen mode), or it can share it, mixing a live UI region with streaming logs that remain in scrollback (inline mode). FrankenTUI supports both without special casing throughout the code path — the choice happens once at startup, and the rest of the pipeline adapts.

Alt-screen is easy: CSI ?1049h on entry, ?1049l on exit, and the entire screen is yours. Inline is harder: there is no isolation, the cursor shares a scrollback buffer with whatever the user was reading, and any glyph that escapes the UI region scribbles on their history. The payoff is ergonomic — frankensearch prints a table and the user still sees the command they ran above it.

This page documents both modes and the three inline strategies (ScrollRegion, OverlayRedraw, Hybrid) that trade portability against efficiency. The selector is a single function (InlineStrategy::select) driven by TerminalCapabilities.

Motivation

Every inline TUI eventually runs into the same failure mode: “everything works in iTerm, but in tmux the UI clones itself down the scrollback every time logs arrive.” Three independent bugs generate that symptom — DECSTBM is ignored, the cursor-save ANSI is rewritten, or synchronized output brackets are swallowed. Hard-coding one strategy ships the buggy combination to half the user base; probing and branching per-terminal is the only path to correctness.

Alt-screen mode

Set SessionOptions { alternate_screen: true, .. } and the guard writes CSI ?1049h at session start, flipping to a fresh screen buffer. On drop, ?1049l restores the original scrollback and cursor position. The presenter renders to the full (width, height) grid; no awareness of “where the logs live” is needed.

examples/alt_screen.rs
use ftui_core::terminal_session::{SessionOptions, TerminalSession}; let _session = TerminalSession::new(SessionOptions { alternate_screen: true, // full-screen takeover mouse_capture: true, bracketed_paste: true, focus_events: true, ..Default::default() })?; // Render into the whole terminal. On drop, the user's shell is restored.

Use alt-screen when the TUI is the user’s sole focus (editors, file managers, full dashboards). Never use it for tools that are part of a pipe-chain or that want to leave output visible after exit.

Inline mode

Leave alternate_screen: false and FrankenTUI enters inline mode. A bottom-anchored UI region (N rows) coexists with a log region (the remaining rows) that behaves like ordinary scrollback. The runtime renders the UI on every tick; logs are written with InlineRenderer::write_log() and flow upward as new lines push older ones off-screen.

┌───────────────────────────────────────────────┐ row 1 │ $ frankensearch index ... │ │ [info] indexing 12 files │ ← log region │ [info] indexed 12 / 12 │ (scrolls normally) │ [info] wrote 2.1MB to index.bin │ ├───────────────────────────────────────────────┤ │ Query: █ │ ← UI region │ > match 1 │ (sticky, height=4) │ > match 2 │ │ > match 3 │ └───────────────────────────────────────────────┘ row = term_height

InlineConfig::new(ui_height, term_height, term_width) sets the split; ui_top_row() and log_bottom_row() expose the 1-indexed ANSI boundaries the renderer uses.

The three strategies

InlineStrategy encodes how the UI region and log region cohabit (crates/ftui-core/src/inline_mode.rs:L70-L108). Each strategy has different portability, throughput, and flicker characteristics.

ScrollRegion — DECSTBM-based

Emit CSI top;bottom r once at startup to pin scrolling to rows [1, log_bottom_row]. Log writes then happen anywhere in that region and the terminal naturally scrolls older lines off the top. The UI region (rows below) is immune to scrolling — the DECSTBM barrier holds it in place.

Pros. Minimal ANSI traffic per log line (no cursor save/restore, no UI redraw). Terminal does the work.

Cons. DECSTBM behavior varies across tmux versions; some leak the region into the wrong pane. Not all modern terminals implement bottom-margin semantics correctly. Capability-gated to use_scroll_region() && use_sync_output() (see capabilities).

OverlayRedraw — portable, always-correct

No DECSTBM. For each log write: save the cursor (ESC 7), move to the log region’s bottom row, erase the line, write the log message, restore the cursor (ESC 8). The UI is then redrawn at the next present.

Pros. Works in every terminal, every multiplexer. No assumptions about scrolling semantics.

Cons. Higher byte traffic per log line (save + move + erase + write + restore), and the UI redraw on every present is unconditional.

Hybrid — overlay baseline with opportunistic DECSTBM

Start as overlay-redraw; opportunistically apply DECSTBM for bursts where scroll-region is known safe (modern terminal, no mux). Each individual operation falls back to overlay if any evidence suggests the region might be mishandled.

Pros. Correctness of overlay, throughput closer to scroll-region where the terminal cooperates.

Cons. More code paths; harder to reason about. Used as the default when scroll_region is detected but sync_output is not.

Strategy selection

crates/ftui-core/src/inline_mode.rs
pub fn select(caps: &TerminalCapabilities) -> Self { if caps.in_any_mux() { // Muxes may not handle DECSTBM correctly. InlineStrategy::OverlayRedraw } else if caps.use_scroll_region() && caps.use_sync_output() { // Modern terminal with full support. InlineStrategy::ScrollRegion } else if caps.use_scroll_region() { InlineStrategy::Hybrid } else { InlineStrategy::OverlayRedraw } }

Minimal inline example

examples/inline.rs
use std::io::{stdout, Stdout}; use ftui_core::inline_mode::{InlineConfig, InlineRenderer, InlineStrategy}; use ftui_core::terminal_capabilities::TerminalCapabilities; use ftui_core::terminal_session::{SessionOptions, TerminalSession}; fn main() -> std::io::Result<()> { let _session = TerminalSession::new(SessionOptions { alternate_screen: false, // inline mouse_capture: true, bracketed_paste: true, ..Default::default() })?; let caps = TerminalCapabilities::detect(); let config = InlineConfig::new(/* ui_height */ 4, /* term_h */ 30, /* term_w */ 100) .with_strategy(InlineStrategy::select(&caps)) .with_sync_output(caps.use_sync_output()); let mut inline = InlineRenderer::<Stdout>::new(stdout(), config); inline.enter()?; inline.write_log("indexing 12 files\n")?; inline.write_log("indexed 12 / 12\n")?; // UI render goes here via inline.present_ui(|frame| { ... })?; inline.exit()?; Ok(()) }

Invariants

  1. Cursor restored after every present. The InlineRenderer saves/restores around every log write and every UI present; the user’s typed text is never disturbed.
  2. Terminal modes restored on drop. Guaranteed by TerminalSession’s RAII; the inline renderer only adds the scroll-region reset if it was set.
  3. No full-screen clear in inline mode. The renderer never emits CSI 2J — that would nuke scrollback.
  4. One writer owns the terminal (the one-writer rule, see operations). Passing stdout into InlineRenderer::new takes ownership; no other thread may write.

Inline UIs must never exceed ui_height. Wide wrapping, newlines in widget output, or oversize modals can spill into the log region and scribble on the user’s scrollback. InlineRenderer sanitizes log text (sanitize_overlay_log_line, sanitize_scroll_region_log_text) to keep logs one line per write, but widgets must respect their own area bounds. The scissor stack in buffer is the enforcement mechanism.

When to pick which

ModeUse when
Alt-screenFull-focus apps (editors, file managers, dashboards). User exits, shell is pristine.
Inline + ScrollRegionTools in a pipeline context on a capable terminal. Maximum throughput.
Inline + HybridAmbiguous environment, modern terminal without sync-output (e.g. Alacritty pre-0.13).
Inline + OverlayRedrawInside any multiplexer, or unknown TERM. Universal fallback.

Cross-references

Where next