Build Rust TUI apps with SuperLightTUI v0.20 (immediate-mode terminal UI). Use this skill when the user asks to create, modify, or debug terminal UI code in this repo, or asks "how do I X in SLT / TUI / terminal", or types Korean triggers like "터미널 UI", "TUI 만들어줘", "SLT로", "ratatui 대신". Read REFERENCES.md for feature flags and doc pointers; grep `src/context/` and `src/widgets/` before inventing any API.
Build Rust TUI apps with SuperLightTUI v0.20 (immediate-mode terminal UI). Use this skill when the user asks to create, modify, or debug terminal UI code in this repo, or asks "how do I X in SLT / TUI / terminal", or types Korean triggers like "터미널 UI", "TUI 만들어줘", "SLT로", "ratatui 대신". Read REFERENCES.md for feature flags and doc pointers; grep `src/context/` and `src/widgets/` before inventing any API.
SuperLightTUI (SLT) Authoring Skill — v0.20
Mental model
SLT is immediate-mode. Your app is one closure: slt::run(|ui: &mut Context| { ... }). The closure runs every frame. State lives in plain Rust variables outside the closure — no App trait, no Model/View/Update, no retained tree. SLT handles flexbox layout, ANSI diff, and stdout flush.
Response.rect reflects the previous frame because layout runs after the closure returns. Frame 1 returns a zero Rect. Guard measurement-dependent logic with if ui.tick() > 0 { ... }. See docs/PREVIOUS_FRAME_GUIDE.md.
For larger apps write components as functions: fn render_card(ui: &mut Context, data: &Card). Share read-mostly state with ui.provide(value, |ui| ...) + ui.use_context::<T>() instead of threading &theme through every helper.
The 5 API rules (predictability anchors)
These are non-negotiable in v0.20+. When generating new code, every public widget must match all 5.
Builder for optional config. Methods on Context return a builder when ≥1 option exists. Builders chain &mut self -> &mut Self, render on Drop, expose .show() to capture a .
Removed in v0.20: gauge_w, gauge_colored, line_gauge_with, breadcrumb_sep, LineGaugeOpts, HighlightRange::single, label_owned. Do not write these — AI training data may suggest them.
Floats are f64. Public surface never takes/returns f32. 0.5 is f64 natively, so ui.gauge(0.5) just works.
≤3 positional args. When 4+ args appear, use an opts struct (<Widget>Opts) or a builder.
Return-type pattern. Methods on Context return one of two types — picking the wrong one is the most common AI-generated compile error.
&mut Self — chainable mutators of the last rendered element. Use for: text, link, styled, separator, timer_display, and the style chain (bold, dim, italic, fg, bg, wrap, truncate, align, text_center, m, mx, w, h, grow, spacer, with_if, with).
Response — interaction result of an independently-rendered widget. Use for every stateful interactive widget (button, checkbox, toggle, table, tabs, select, radio, multi_select, text_input, list, tree, file_picker, slider, calendar, command_palette, rich_log).
Container helpers split: col / row / modal → Response. line / line_wrap / screen → &mut Self (these continue an inline-text chain).
ui.button("Save").bold(); // ❌ Response has no .bold() — compile errorif ui.button("Save").clicked { … } // ✅ Response field
ui.text("Saved").bold().fg(green); // ✅ &mut Self chain on display element
Naming categories (NAMING.md micro tier)
Method names encode their category. When picking a name, match the category shape of nearby methods.
Allowed universal abbreviations: bg fg id idx len min max pos pct w h x y r g b a.
Forbidden: ctx btn lbl dbg cfg req res srv db in public API. Closure params over &mut Context are always ui, never ctx.
Hooks must be called in the same order every frame unless they are id-keyed.
Hook
Key
Safe in if/match?
Use when
ui.use_state(|| init)
call order
No
Top-level state, no conditional placement
ui.use_state_named::<T>("id")
&'static str
Yes
Conditional/branching state with compile-time id
ui.use_state_named_with("id", || init)
&'static str
Yes
Same, with explicit init fn
ui.use_state_keyed("id-{i}", || init)
runtime String
Yes
Per-row state in a list (key from data)
ui.use_state_keyed_default("id-{i}")
runtime String
Yes
Same, T: Default shortcut
ui.use_memo(&deps, |d| compute(d))
call order + deps
No
Cached compute, deps change → recompute
ui.use_effect(|d| { ... }, &deps)
call order + deps
No
Side effect on deps change
// WRONG — order-based hook in conditional drifts call order between framesif expanded { letcount = ui.use_state(|| 0); }
// RIGHT — id-keyed variant is safe inside conditionalsif expanded { letcount = ui.use_state_named::<i32>("sidebar.count"); }
// Per-list-item runtime keysforiin0..items.len() {
letcount = ui.use_state_keyed_default::<i32>(format!("counter-{i}"));
}
Context injection (provide / use_context)
Stop threading &theme, &tick, &mut toasts through every render fn. provide injects a typed value scoped to a closure; nested code reads it back with use_context.
Reserve explicit parameters for writes (&mut MyDocState). Bound is T: 'static — use &'static str for literals, String for runtime values.
Conditional styling (with_if / with)
with_if(cond, modifier) and with(modifier) collapse conditional branches into a single chain. Available on text and ContainerBuilder. Beware: text uses &mut self -> &mut Self, ContainerBuilder uses consuming Self -> Self.
// text — closure receives &mut Self
ui.text("Status").with_if(is_error, |t| { t.bold().fg(Color::Red); });
// ContainerBuilder — closure receives Self by value
ui.container().with_if(is_focused, |c| c.bg(theme.surface_hover)).col(|ui| ...);
Custom widget pattern (Layer 3)
When to use which pattern:
Function (fn render_card(ui: &mut Context, data: &CardData)): 90% of cases. Use for screens, sections, reusable layouts. Cheaper to write, no trait bounds, easier to test. Built-in widgets follow this shape internally (impl Context direct methods).
impl Widget: when the component (a) has its own state struct that the caller owns, and (b) you want ui.widget(&mut w) ergonomics matching built-ins. Required for third-party crates that export widgets through trait-bound APIs.
Release workflow (mandatory — do not skip any step)
CLAUDE.md has the full 8-step checklist. Short version:
Local PRE-CI (Core + Extended both green)
Bump Cargo.toml, update CHANGELOG.md
Branch release/vX.Y.Z, single atomic commit, push
gh pr create, wait for CI green
Merge (squash), pull main
Tag, push tag, wait for release.yml green
Verify gh release view, crates.io, docs.rs
Only now announce
Red flags that mean STOP: "Probably fine", "Just a docs change", "CI will catch it", "I'll tag now and fix later". Run the gate locally first.
Common pitfalls (AI-generated SLT code)
Inventing method names. Always grep src/context/ and src/widgets/ first.
Stale removed APIs.gauge_w, gauge_colored, line_gauge_with, breadcrumb_sep, LineGaugeOpts, HighlightRange::single, label_owned are GONE in v0.20. Use the builder forms.
Response.rect on frame 1. Zero Rect. Guard with ui.tick() > 0.
use_state() inside if/match/for. Use use_state_named (&'static str id) or use_state_keyed (runtime String).
Forgetting .show() on builders that return a response. Drop renders and discards the response. Capture with let r = ui.gauge(...).show();.
'static on ContainerBuilder::draw() closure. Raw draw is deferred; the closure must be 'static.
Mixing crossterm raw events with ui.* helpers. Prefer ui.key(), ui.key_code(), ui.key_mod(). For modal-aware shortcuts use ui.raw_key_*.
Hard-coding Color::Rgb(...) instead of ui.theme() — themes can't swap.
RichLogState::new() for unbounded. New caps at 10000; use RichLogState::new_unbounded() if you really want unlimited.
First-frame hover/click tests. Render once to warm the prev-frame hit map, then send the event in a second tb.render(...) call.
Binding only Ctrl-C as quit. macOS terminals intercept Ctrl-C as Copy. Always pair q, Esc, and Ctrl-Q.
unsafe blocks.#![forbid(unsafe_code)]. Hard compile error.
Printing to stdout/stderr from a widget. A library must not write to stdout. Lints catch this.
Reference examples (skill should reference these by file:line)