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.
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_heightInlineConfig::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
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
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
- Cursor restored after every present. The
InlineRenderersaves/restores around every log write and every UI present; the user’s typed text is never disturbed. - Terminal modes restored on drop. Guaranteed by
TerminalSession’s RAII; the inline renderer only adds the scroll-region reset if it was set. - No full-screen clear in inline mode. The renderer never emits
CSI 2J— that would nuke scrollback. - One writer owns the terminal (the one-writer rule, see
operations). Passing stdout into
InlineRenderer::newtakes 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
| Mode | Use when |
|---|---|
| Alt-screen | Full-focus apps (editors, file managers, dashboards). User exits, shell is pristine. |
Inline + ScrollRegion | Tools in a pipeline context on a capable terminal. Maximum throughput. |
Inline + Hybrid | Ambiguous environment, modern terminal without sync-output (e.g. Alacritty pre-0.13). |
Inline + OverlayRedraw | Inside any multiplexer, or unknown TERM. Universal fallback. |
Cross-references
- Concepts: screen modes — the conceptual deep-dive covering the trade-off matrix, mixed-strategy flows, and why each invariant matters.
- Terminal session — how
alternate_screenis turned on and off. - Capabilities — the flags driving the selector.
- Synchronized output — DEC 2026 and flicker avoidance.
- One-writer rule — why only one writer may own the terminal stream.
- Model trait — where the runtime integrates inline rendering.