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:
- It destroys scrollback. Everything that was in the terminal before the tool ran is invisible, and users hate that.
- 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
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):
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
| Question | If 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.