Ratatui
Skill vinsonconsulting/claude-skill-foundry/skills/tui/ratatui
Use this skill for any task involving a Rust terminal/text user interface (TUI) built with Ratatui (or crossterm), whether creating one or debugging, fixing, testing, or extending an existing one. Covers: scaffolding the initial `main`/render loop and terminal setup; fixing teardown so a panic or crash doesn't leave the terminal in raw mode / broken; laying out panels, sidebars, status bars, dashboards, and popups with Layout/Constraint; wiring widgets like List, Table, Gauge, Chart, Scrollbar (including selection/scroll state that won't move); styling, text wrapping, and Unicode width issues; streaming LLM/async output into a terminal; and unit-testing rendered output without a real terminal. Triggers on "rust + terminal app/UI/dashboard", ratatui, crossterm. Writes current Ratatui 0.30+ code, not stale tui-rs patterns. Not for: Go TUIs (Bubble Tea), Python TUIs (Textual), non-interactive CLI output or progress bars, web/browser UIs, image-to-ASCII art (ascii/textmode), or tmux/agent-session orchestration.From its SKILL.md
npx -y skills add vinsonconsulting/claude-skill-foundry --skill ratatuiAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
One thing to look at
- 1 stars1 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.
SKILL.md
14.8 KB, ~3.1k tokens by cl100k_base, as published. Nobody here has run it
Ratatui
Write current, compiling Ratatui code (pinned to 0.30.x, Rust 2024) and refuse the
stale tui-rs patterns the model remembers from training. The body is the load-bearing
20%: one mental model and one verified example per concept. Everything enumerable —
the full widget catalog, every constraint, the streaming model, testing, migration —
lives in references/. Open the matching reference before writing nontrivial code in
that area.
Mental model
Ratatui is immediate mode: there are no retained widget objects. Every frame you
build the whole UI from your own application state and throw it away. A render loop
runs terminal.draw(|frame| …); inside, you slice the area into rectangles and draw
widgets into them. Ratatui keeps two Buffers and diffs them, writing only the
changed cells to the terminal — so redrawing the entire screen every frame is cheap and
correct. You own the loop: read an event, update state, draw, repeat.
Four nouns carry everything:
- Terminal — owns the backend (crossterm by default) and the double buffers; gives you a
Frame. - Frame — one frame's drawing surface;
frame.area()is the fullRect,frame.render_widget(w, rect)draws. - Buffer — the grid of styled cells widgets write into. Dimensions are
u16(max 65,535 per side). - Rect — an
x/y/width/heightregion inu16. Layout is just splitting oneRectinto moreRects.
Lifecycle: never hand-roll the terminal
Ratatui sets up raw mode, the alternate screen, and a panic hook for you. Do not call
enable_raw_mode, EnterAlternateScreen, or any manual teardown — if you do, a panic
or early return leaves the user's terminal wrecked.
fn main() -> color_eyre::Result<()> {
color_eyre::install()?; // BEFORE init(): ratatui's panic hook must be outermost,
let terminal = ratatui::init(); // so it restores the screen before color-eyre prints.
let result = App::default().run(terminal);
ratatui::restore(); // always runs; restore BEFORE surfacing the error
result
}
ratatui::run(|terminal| { … }) is the same thing inlined: it is literally
init() → your closure → restore(), returning whatever the closure returns. Use
init()/restore() when the terminal must outlive one closure (e.g. stored in your
App); use run for throwaway examples. Drop to Terminal::new(CrosstermBackend::new(…))
only when you need a non-default backend or writer. init() enables raw mode + alternate
screen and installs a restoring panic hook; restore() reverses it and never panics.
App loop: state + message + update + draw
Model the app as state plus a message/action enum; events become messages, messages mutate state, then you redraw. This keeps event handling testable and draw code pure.
#[derive(Default)]
struct App { count: i64, running: bool }
enum Msg { Inc, Dec, Quit }
impl App {
fn run(mut self, mut terminal: ratatui::DefaultTerminal) -> color_eyre::Result<()> {
self.running = true;
while self.running {
terminal.draw(|frame| self.draw(frame))?;
if let Some(msg) = self.next_event()? { self.update(msg); }
}
Ok(())
}
fn update(&mut self, msg: Msg) {
match msg {
Msg::Inc => self.count += 1,
Msg::Dec => self.count -= 1,
Msg::Quit => self.running = false,
}
}
fn draw(&self, frame: &mut ratatui::Frame) {
frame.render_widget(format!("count: {}", self.count), frame.area());
}
fn next_event(&self) -> color_eyre::Result<Option<Msg>> {
use ratatui::crossterm::event::{self, Event, KeyCode, KeyEventKind};
if !event::poll(std::time::Duration::from_millis(100))? { return Ok(None); }
let Event::Key(key) = event::read()? else { return Ok(None) };
if key.kind != KeyEventKind::Press { return Ok(None); } // ignore Windows key-repeat/release
Ok(match key.code {
KeyCode::Char('q') => Some(Msg::Quit),
KeyCode::Up => Some(Msg::Inc),
KeyCode::Down => Some(Msg::Dec),
_ => None,
})
}
}
If you want this architecture off the shelf (component tree, subscriptions, focus),
reach for tui-realm rather than reinventing it — see references/ecosystem.md.
Layout
Split a Rect with Layout::vertical/Layout::horizontal plus a list of Constraints,
then destructure with .areas::<N>() (compile-time count) or .split(area) (dynamic,
returns Rc<[Rect]>). The six constraints are Length, Percentage, Ratio, Min,
Max, Fill — note Fill, not the removed Proportional. When constraints
compete, priority is Min > Max > Length > Percentage > Ratio > Fill. The default
Flex is Flex::Start (no stretching); change it with .flex(Flex::Center | SpaceBetween | …).
use ratatui::layout::{Constraint, Layout};
// 3-line header, filling body, 1-line status bar
let [header, body, status] = Layout::vertical([
Constraint::Length(3), Constraint::Fill(1), Constraint::Length(1),
]).areas(frame.area());
// body: fixed 30-cell sidebar + filling main
let [sidebar, main] =
Layout::horizontal([Constraint::Length(30), Constraint::Fill(1)]).areas(body);
Popups: compute a centered region, then always render_widget(Clear, area) first
so the popup isn't transparent over the content beneath it:
use ratatui::layout::Constraint;
use ratatui::widgets::{Block, Clear};
let area = frame.area().centered(Constraint::Percentage(60), Constraint::Length(10));
frame.render_widget(Clear, area);
frame.render_widget(Block::bordered().title("Confirm"), area);
Constraint nuances, Flex modes, spacing/margin/inner, nested layouts, and the
layout cache are in references/layout.md.
Widgets
Stateless widgets (Block, Paragraph, Gauge, …) are constructed and rendered each
frame. Stateful widgets (List, Table, Scrollbar) keep their selection/scroll in
a *State you own in your App and pass via render_stateful_widget — never store it in
the widget, which is rebuilt every frame.
use ratatui::widgets::{List, ListState};
// in App: list: ListState (call list.select_next() / select_previous() on j/k)
let items = List::new(["alpha", "beta", "gamma"]).highlight_symbol("> ");
frame.render_stateful_widget(items, area, &mut self.list);
Custom widgets: implement Widget for a reference so the caller keeps ownership
(every built-in does this since 0.26). Use impl Widget for &mut T if you must mutate
during render; StatefulWidget when the widget needs external state.
use ratatui::{buffer::Buffer, layout::Rect, text::Line, widgets::Widget};
struct Bar { pct: u16 }
impl Widget for &Bar {
fn render(self, area: Rect, buf: &mut Buffer) {
Line::from(format!("{}%", self.pct)).render(area, buf); // write via Line/Span/set_*, not escapes
}
}
// frame.render_widget(&bar, area);
WidgetRef/StatefulWidgetRef exist but are unstable — gated behind the
unstable-widget-ref feature (the trait is private without it). Don't reach for them by
default. Full catalog and per-widget notes: references/widgets.md.
Styling
Style + Modifier carry color and attributes, but reach for the Stylize trait's
shorthands. Text nests Span ⊂ Line ⊂ Text, and styles cascade down (a Line style is
a default its Spans patch).
use ratatui::style::{Stylize, palette::tailwind};
use ratatui::text::{Line, Span};
let line = Line::from(vec![
Span::raw("status: "),
"online".fg(tailwind::GREEN.c400).bold(),
]);
let _ = "error".red().on_black().bold();
The line!/span!/text! macros ship in the umbrella via the default macros feature
(use ratatui::macros::*). More in references/widgets.md.
Text width & wrapping — the streaming-chat footgun
The single most common bug in terminal chat UIs: assuming one char (or one byte) is one
column. It isn't. CJK ideographs and many emoji are two columns wide; combining
marks are zero; a flag emoji is several chars but two columns. Ratatui measures and
clips by display width (via unicode-width), so trust its widgets — but the moment
you do column math (truncating a title, aligning a gutter, computing a wrap point),
measure width, never length:
use unicode_width::UnicodeWidthStr;
let cols = title.width(); // display columns — NOT title.len() (bytes) or .chars().count()
Paragraph::new(text).wrap(Wrap { trim: true }) wraps at word boundaries by display
width and trims leading whitespace on wrapped lines (trim: false preserves indentation);
.scroll((y, x)) offsets the view. For grapheme-correct truncation with an ellipsis, work
in graphemes (unicode-segmentation) and stop by accumulated width. references/text-and-unicode.md
has the wrapping rules, alignment under wide chars, and a width-aware truncate helper.
Async render loop
A synchronous event::poll(timeout) loop (shown above) is fine and lighter — use it
unless you block on network/DB I/O. When you do, move to tokio: multiplex input, your
data stream, and a render tick with select!, and redraw on the tick, not per
message, so a fast stream can't pin the CPU repainting.
use tokio::sync::mpsc;
enum Action { Delta(String), Render, Quit }
// loop { tokio::select! {
// _ = render_tick.tick() => terminal.draw(|f| app.draw(f))?, // ~30–60 fps cap
// Some(a) = actions.recv() => app.apply(a), // stream deltas, input
// } }
Streaming LLM output specifically (own-your-scrollback VecDeque<Line>, auto-follow vs.
freeze-on-scroll, ScrollbarState, the u16 buffer ceiling, insert_before): see
references/agent-ui.md.
Stale patterns to reject
If you catch yourself writing any of these, stop — they are tui-rs / pre-0.26 and won't compile on 0.30:
| Reject | Use instead |
|---|---|
enable_raw_mode() / EnterAlternateScreen / manual teardown | ratatui::init() / run() (handles it + panic hook) |
Constraint::Proportional(n) | Constraint::Fill(n) |
Flex::StretchLast, SegmentSize | Flex (default Start); the type is gone |
the cassowary crate | first-party kasuari (transitive; you don't name it) |
bare Alignment for layout intent | HorizontalAlignment (Alignment is a kept alias) |
Widget::render(self, …) consuming a value you still need | impl Widget for &T; render_widget(&w, area) |
wrap/truncation by .len() / .chars().count() | display width via unicode-width |
Confirmed against ratatui 0.30.1: Proportional, StretchLast, and SegmentSize do not
exist; cassowary is absent (kasuari is the solver).
Reference map
references/layout.md— constraints in depth, priority,Flex, spacing/margin/inner,.splitvs.areas, centering, layout cache.references/widgets.md— full built-in catalog, stateful patterns, custom-widget traits,Stylize/macros, the unstableWidgetReffeature.references/text-and-unicode.md— display width vs char/byte,Wrapsemantics, grapheme-safe truncation, alignment under wide chars.references/agent-ui.md— streaming LLM scrollback (Ratatui-only); orchestration is out of scope.references/ecosystem.md— tui-realm, ratzilla, tachyonfx, backends, and current ratatui-version compatibility per crate.references/testing.md—TestBackendbuffer assertions, snapshot tests, logging without corrupting the screen.references/versioning.md— the 0.30 modular workspace, MSRV/edition, feature flags, and the tui-rs → 0.30 migration map.
What ships with it: 18 files
70.0 KB alongside SKILL.md, 2 of them executable
evals/
- evals.json4.0 KB
- functional/graders.pyruns7.0 KB
- functional/run_grader.pyruns1.8 KB
- functional/tasks.json3.7 KB
- triggering.jsonl3.3 KB
references/
- agent-ui.md5.8 KB
- ecosystem.md3.5 KB
- layout.md4.5 KB
- testing.md3.0 KB
- text-and-unicode.md3.3 KB
- versioning.md3.8 KB
- widgets.md6.3 KB
- card.authored.yaml450 B
- card.json4.9 KB
- card-review.md907 B
- README.md5.1 KB
- scan.json2.9 KB
- skill-card.md5.7 KB