Skip to Content
Getting startedEmbedding in a CLI

Embedding in a CLI

The most common reason to reach for FrankenTUI over Ratatui is inline mode: a stable UI region that coexists with a shell’s log output and scrollback. This page shows how to add an inline FrankenTUI UI to an existing CLI without taking over the terminal.

This is a how-to, not a reference. If you want the full ScreenMode API, see screen modes. If you want to understand why inline mode is hard enough to need three strategies, see frame pipeline.

The target audience is anyone with a long-running CLI tool (build orchestrators, package managers, cluster operators, dev servers) who wants a live status bar or dashboard without destroying the user’s scrollback.

Motivation

A CLI that takes over the alternate screen makes two mistakes:

  1. It destroys scrollback. Everything that was in the terminal before the tool ran is invisible, and users hate that.
  2. It hides its own log output. Streaming progress lines that scroll naturally into the terminal’s buffer are often more useful than a fancy dashboard that evaporates on exit.

Inline mode solves both. The tool’s existing log output continues to scroll into the buffer. A small UI region at the bottom stays anchored, redraws deterministically, and disappears cleanly when the program exits.

Mental model

The DECSTBM ANSI sequence (ESC [ top ; bottom r) tells the terminal “scrolling only happens within rows top..bottom.” FrankenTUI sets this to 1..(height - ui_height). The bottom ui_height rows are untouched by terminal-side scrolling, so your UI stays put while log output scrolls above.

For terminals whose DECSTBM is unreliable, FrankenTUI falls back to an overlay-redraw strategy (save cursor, clear UI area, write new log lines, redraw UI, restore cursor — all inside a DEC 2026 sync bracket pair). The default strategy is hybrid: scroll region on the fast path, overlay redraw on detected unreliability.

The key line

Inline mode is one line:

.screen_mode(ScreenMode::Inline { ui_height: 10 })

ui_height is how many terminal rows you want reserved for the UI at the bottom. Everything above that is scrollback.

Worked example

Start from your existing CLI

Assume you have a build tool that streams log lines to stderr. Nothing in your existing logging needs to change — FrankenTUI never writes to stderr, and stderr still scrolls naturally into scrollback.

Add a FrankenTUI model

src/ui.rs
use ftui_core::event::Event; use ftui_core::geometry::Rect; use ftui_render::frame::Frame; use ftui_runtime::{App, Cmd, Model, ScreenMode}; use ftui_widgets::block::Block; use ftui_widgets::paragraph::Paragraph; pub struct BuildUi { pub tasks_done: u32, pub tasks_total: u32, pub current_target: String, } #[derive(Debug, Clone)] pub enum UiMsg { Progress { done: u32, total: u32, target: String }, Quit, } impl From<Event> for UiMsg { fn from(e: Event) -> Self { match e { Event::Key(k) if k.is_char('q') => UiMsg::Quit, // Progress events arrive via Cmd::perform, not keyboard. _ => UiMsg::Progress { done: 0, total: 0, target: String::new() }, } } } impl Model for BuildUi { type Message = UiMsg; fn update(&mut self, msg: UiMsg) -> Cmd<UiMsg> { match msg { UiMsg::Progress { done, total, target } => { self.tasks_done = done; self.tasks_total = total; self.current_target = target; Cmd::none() } UiMsg::Quit => Cmd::quit(), } } fn view(&self, frame: &mut Frame) { let height = frame.height(); let width = frame.width(); let outer = Rect::new(0, 0, width, height); let block = Block::default().title(" build ").bordered(); block.render(outer, frame); let pct = if self.tasks_total == 0 { 0 } else { (self.tasks_done * 100) / self.tasks_total }; let summary = format!( " {}/{} ({}%) — {} (q to cancel)", self.tasks_done, self.tasks_total, pct, self.current_target, ); let inner = Rect::new(1, 1, width.saturating_sub(2), 1); Paragraph::new(summary).render(inner, frame); } } pub fn run(initial: BuildUi) -> std::io::Result<()> { App::new(initial) .screen_mode(ScreenMode::Inline { ui_height: 4 }) .run() }

Continue logging normally

Your existing log lines go to stderr and scroll in the top region. The UI sits at the bottom. Nothing about your logging code needs to change. If you were using tracing, point it at stderr (or a log file):

src/main.rs
use tracing_subscriber::EnvFilter; fn main() -> std::io::Result<()> { tracing_subscriber::fmt() .with_writer(std::io::stderr) .with_env_filter(EnvFilter::from_default_env()) .init(); tracing::info!("starting build"); // ... your existing logic, emitting tracing events ... ui::run(ui::BuildUi { tasks_done: 0, tasks_total: 42, current_target: String::new(), }) }

Plumb progress into the UI

Progress events from your build logic reach the model via Cmd::perform or a subscription that watches a channel. The pattern is the same as any Elm-style app: turn async events into messages and let update reconcile.

Inline vs alt-screen checklist

QuestionIf yes, use
Does the user expect scrollback to survive?Inline
Is this a long-running background tool (build, daemon, watcher)?Inline
Will the tool be composed in pipelines?Inline
Is this a full-screen app (text editor, file manager)?Alt-screen
Does the UI need the whole viewport?Alt-screen
Is it OK to lose the user’s pre-run scrollback?Alt-screen

Most CLI-tool additions want inline. Most standalone apps want alt-screen.

Pitfalls

Do not write directly to stdout. The one-writer rule applies even more strictly in inline mode: the writer’s cursor model is the only source of truth. If you println! from your build logic, the UI region will get stomped. Route logs to stderr, or to a log file, or through tracing to a configured writer.

Sizing the UI region for narrow terminals. If the user’s terminal is smaller than ui_height rows total, inline mode can’t work. Detect this and either shrink the UI or fall back to “no UI, just logs” — don’t try to render a 10-row UI into a 6-row terminal.

Multiplexer quirks. Some tmux and screen versions have buggy DECSTBM implementations. FrankenTUI’s hybrid strategy detects this and falls back to overlay redraw, but older builds of these tools can still show subtle artifacts. If you ship a tool that must work everywhere, test inside the multiplexers you expect your users to run.

Forgetting Ctrl+C. Inline mode still needs RAII cleanup on exit. Let the App::run() return naturally (e.g. via Cmd::quit() on a keystroke). std::process::exit() bypasses drop and will leave the terminal with a stuck scroll region.

Where next