dart_tui
Elm-style terminal UI framework for Dart, inspired by Bubble Tea.
Build rich, interactive CLI applications with a clean ModelโUpdateโView architecture, a full component library, and Lipgloss-quality styling โ all in pure Dart.
๐ Documentation & component gallery โ browse every component, block and guide with live previews.

Features
- ModelโUpdateโView โ same architecture as Elm and Bubble Tea; pure, testable state
- Async commands (
Cmd) for timers, HTTP, subprocesses, and any async work - 27+ ready-made components โ spinners, progress bars, text inputs, tables, trees, multi-select, list with fuzzy filter, tabbed views, in-line cursor, and more
- One-shot helpers โ
promptSelect/promptConfirm/promptInput, plus gum-stylefilter,spin, andpager - Spring animation โ harmonica-style damped-spring easing for smooth progress, scroll, and cursor motion
- Readline editing keys โ emacs bindings in text input & area (
ctrl+a/e/b/f/w,alt-word motion,ctrl+w) - File logging โ
FileLogwrites diagnostics to a file without corrupting the rendered UI - Lipgloss-inspired styling โ true-color RGB, borders with titles, padding, word-wrap, gradients, SGR attributes
- Style utilities โ
getWidth(),getHeight(),truncate(),truncateLeft(), per-side border flags,tabWidth,marginBackground - Style inheritance โ
Style.inherit(parent)fills unset fields;CompleteColorfor per-profile color downgrade - Canvas compositing โ paint styled blocks at arbitrary (x, y) positions with z-index layering
- Cell-level diff renderer โ only changed cells are written; zero flicker
- Synchronized updates (
CSI ?2026) for terminals that support them - Auto background detection โ OSC 11 query fires at startup; your model receives
BackgroundColorMsg - Fluent
ProgramOptionfunctions โwithAltScreen(),withHideCursor(),withTickInterval(),withMouseCellMotion(),withMouseAllMotion(),withReportFocus(),withWindowSize(),withLogFile() - Fast startup โ kernel snapshots cut warm-JIT from ~1 s to ~500 ms; AOT compiles to native
Installation
# pubspec.yaml
dependencies:
dart_tui: ^2.1.0
dart pub get
Migrating from 1.x? Version 2.0 removes the
TeaModel,LegacyKeyMsg,TuiStyle, andProgramOptionscompatibility APIs. UseModel,KeyPressMsg/KeyMsg, immutableStyle, and a singleProgram(options: <ProgramOption>[...])configuration path. See the 2.0.0 changelog for the complete migration notes.
Quick start
import 'package:dart_tui/dart_tui.dart';
void main() async {
await Program(
options: [withAltScreen()],
).run(CounterModel());
}
final class CounterModel extends Model {
CounterModel({this.count = 0});
final int count;
@override
Cmd? init() => tick(const Duration(seconds: 1), (_) => _TickMsg());
@override
(Model, Cmd?) update(Msg msg) {
if (msg is _TickMsg) {
if (count >= 5) return (this, () => quit());
return (CounterModel(count: count + 1),
tick(const Duration(seconds: 1), (_) => _TickMsg()));
}
if (msg is KeyMsg && (msg.key == 'q' || msg.key == 'ctrl+c')) {
return (this, () => quit());
}
return (this, null);
}
@override
View view() => newView('Count: $count\n\nPress q to quit.');
}
final class _TickMsg extends Msg {}
Core concepts
ModelโUpdateโView
โโโโโโโโโโโโโโโโ Msg โโโโโโโโโโโโโโโโ
โ Model โ โโโโโโโถ โ update โ
โ (your state)โ โ (pure fn) โ
โโโโโโโโโโโโโโโโ โโโโโโโโฌโโโโโโโโ
โฒ โ (Model, Cmd?)
โ โผ
โ โโโโโโโโโโโโโโโโ
โโโโโ render โโโ โ view โ
โ (pure fn) โ
โโโโโโโโโโโโโโโโ
| Concept | Description |
|---|---|
Model |
Immutable state. Implement init(), update(Msg), view(). |
Msg |
Tagged event: key press, window resize, tick, custom data. |
Cmd |
FutureOr<Msg?> Function() โ async side-effect that delivers one message back. |
View |
Declared output string plus optional cursor position, mouse mode, window title. |
Program |
Owns the event loop, terminal raw mode, renderer, and signal handling. |
Returning a value (prompt-style)
abstract class OutcomeModel<T> implements Model {
T? get outcome; // non-null โ program exits and returns this value
}
final String? result = await Program().runForResult<String>(MyPromptModel());
Commands
// Built-in helpers
Msg quit()
Msg interrupt()
Cmd tick(Duration d, Msg Function(DateTime) fn) // one-shot delay
Cmd every(Duration d, Msg Function(DateTime) fn) // repeating, wall-clock aligned
Cmd? batch(List<Cmd?> cmds) // concurrent
Cmd? sequence(List<Cmd?> cmds) // sequential
Cmd execProcess(String exe, List<String> args, {...}) // external process
Cmd requestBackgroundColor() // fire OSC 11 query manually
// Terminal control
Msg enterAltScreen() // switch to alt screen buffer
Msg exitAltScreen() // return to primary screen
Msg hideCursor() // hide terminal cursor
Msg showCursor() // show terminal cursor
Cmd setWindowTitle(title) // set window/tab title via OSC
Msg clearScrollArea() // clear screen and scrollback
Cmd scrollUp([int n = 1]) // scroll viewport up n lines
Cmd scrollDown([int n = 1])// scroll viewport down n lines
Program options
Program(
options: [
withFps(60), // default 60, max 120
withCellRenderer(), // cell-level diff (less flicker on older terminals)
withAltScreen(), // enter alternate screen buffer
withHideCursor(), // hide terminal cursor (pass false to keep visible)
withTickInterval(const Duration(milliseconds: 100)), // global tick rate
withMouseCellMotion(), // enable button-event mouse tracking
withMouseAllMotion(), // enable all-motion mouse tracking
withReportFocus(), // enable focus/blur reporting (FocusMsg / BlurMsg)
withWindowSize(120, 40), // inject a fixed window size (useful in tests)
withLogFile(File('debug.log')), // append renderer output to a file
withFilter((model, msg) { // intercept / transform messages
if (msg is QuitMsg) return null; // suppress
return msg;
}),
],
).run(MyModel());
For an interactive program that explicitly owns terminal input, pass
withInput(stdin). Program restores cooked terminal mode before cancelling
that subscription during shutdown.
Styling
Inspired by Lipgloss. All styling is composable and immutable.
// True-color foreground, bold, 40-char centered block
final title = const Style(
foregroundRgb: RgbColor(203, 166, 247), // Catppuccin Mauve
isBold: true,
width: 40,
align: Align.center,
).render('Hello, dart_tui!');
// Borders + padding + title
final box = const Style(
border: Border.rounded,
borderForeground: RgbColor(137, 180, 250),
borderTitle: ' My Box ',
borderTitleAlignment: Align.center,
padding: EdgeInsets.symmetric(vertical: 0, horizontal: 1),
width: 40,
).render(content);
// Word-wrap at 40 columns
final wrapped = const Style(
foregroundRgb: RgbColor(205, 214, 244),
wordWrap: true,
width: 42,
border: Border.box,
).render(longText);
// Layout helpers
final ui = joinHorizontal(AlignVertical.top, [leftPane, rightPane]);
final mid = place(termWidth, termHeight, Align.center, AlignVertical.middle, content);
SGR text attributes
const Style(isBold: true) // bold
const Style(isDim: true) // dim / faint
const Style(isItalic: true) // italic
const Style(isUnderline: true) // underline
const Style(isStrikethrough: true) // strikethrough
const Style(isReverse: true) // swap fg/bg
const Style(isBlink: true) // blinking text
const Style(isOverline: true) // overline decoration

Style inheritance
const base = Style(
foregroundRgb: RgbColor(203, 166, 247),
isBold: true,
isItalic: true,
);
const child = Style(
foregroundRgb: RgbColor(166, 227, 161), // overrides fg
);
// child.inherit(base) โ green + bold + italic
final resolved = child.inherit(base);
Border styles
All 7 border variants plus per-character foreground/background coloring, an embedded title, and per-side visibility flags:
Border.box // โโโ โโโ โ
Border.rounded // โญโโฎ โฐโโฏ โ
Border.thick // โโโ โโโ โ
Border.double // โโโ โโโ โ
Border.normal // +--+ | (ASCII-only)
Border.hidden // space-padded (preserves geometry)
Border.none // no border
// Draw only specific sides
style.withBorderSides(top: true, bottom: true) // top + bottom only
Border.rounded.topOnly // pre-built single-side helpers
Border.rounded.sidesOnly

Style utilities
// Measure and truncate strings respecting ANSI codes and double-wide chars
getWidth('hello') // โ 5 (visible terminal columns)
getWidth('\x1b[31mhi\x1b[0m') // โ 2 (ANSI stripped before counting)
getHeight('line1\nline2') // โ 2
truncate('hello world', 5) // โ 'hello' (drop right)
truncateLeft('hello world', 5) // โ 'world' (drop left)
// Tab expansion and margin background
const Style(tabWidth: 4) // expand \t to 4 spaces (default 4)
const Style(marginBackground: RgbColor(30, 30, 46)) // tint the margin area
Word wrap
const Style(
wordWrap: true,
width: 40,
border: Border.rounded,
borderTitle: ' Notes ',
).render(longText);

Gradient text
// Per-character true-color gradient across any number of colors
final rainbow = gradientText('dart_tui', [
const RgbColor(203, 166, 247), // mauve
const RgbColor(116, 199, 236), // sky
const RgbColor(166, 227, 161), // green
]);
// Gradient background fill
final banner = gradientBackground(' Welcome! ', [
const RgbColor(30, 30, 46),
const RgbColor(49, 50, 68),
], foreground: const Style(foregroundRgb: RgbColor(205, 214, 244)));

Light / dark background detection
Program sends \x1b]11;?\x07 (OSC 11) at startup โ the response arrives automatically as BackgroundColorMsg in your model's update(). Use isDarkRgb() to branch styles:
case BackgroundColorMsg(:final rgb):
final dark = isDarkRgb(rgb);
return (MyModel(darkTheme: dark), null);
Canvas compositing
Paint styled text blocks at arbitrary (x, y) positions with z-index layering:
final canvas = Canvas(72, 22);
canvas.paint(2, 2, leftPanel.render(content), zIndex: 1);
canvas.paint(38, 2, rightPanel.render(content), zIndex: 1);
canvas.paint(18, 14, bannerStyle.render(animatedBanner), zIndex: 2);
// Higher zIndex draws on top of lower zIndex at overlapping cells.
return newView(canvas.render());

Component library
All components are in package:dart_tui/dart_tui.dart.
Spinner
Animated indeterminate activity indicator, driven by TickMsg.
SpinnerModel(style: Spinner.dot, prefix: 'Loading ')

Progress bar
Determinate progress (0.0โ1.0) with โ/โ fill and configurable width/label.
ProgressModel(progress: 0.65, width: 40, showPercent: true)

Text input
Single-line input with cursor, charLimit, EchoMode (password), validate, tab-completion suggestions.
TextInputModel(placeholder: 'Type somethingโฆ', charLimit: 80)

Text area
Multi-line editor with scroll, line-kill (Ctrl+K), and word movement.

Select list
Vertical list with keyboard cursor (โโ / jk). Embeds into parent models for menu flows.
SelectListModel(items: ['Option A', 'Option B', 'Option C'], height: 8)

List with fuzzy filter
Full-featured list component with incremental fuzzy/subsequence filtering, descriptions, status bar, and per-element styling.
ListModel(
items: [
ListItem(title: 'Apple', description: 'A crisp red fruit'),
ListItem(title: 'Banana', description: 'A yellow tropical fruit'),
],
title: 'Fruit Picker',
height: 8,
showDescription: true,
showStatusBar: true,
)
// Press / to enter filter mode, type to narrow, Esc to clear.

Tabs
Tabbed interface with customisable TabsStyles for active/inactive labels, divider, and content area.
TabsModel(tabs: [
('Home', 'Welcome content here'),
('Profile', 'Name: Alice\nEmail: alice@example.com'),
('Settings', 'Theme: Dark\nFont: 14px'),
])
// Navigate: โ / โ / h / l / Tab / Shift+Tab

Table
Scrollable data table with configurable headers, column widths, per-row/per-cell styling.
TableModel(
columns: [TableColumn('City', 20), TableColumn('Pop', 12)],
rows: data,
styles: TableStyles(
header: const Style(isBold: true, isUnderline: true),
styleFunc: (row, col) => col == 1 ? rightAlign : null,
),
)

Tree
Hierarchical expandable list with Unicode box-drawing connectors. Navigate with โโ / jk, toggle with Enter / Space, expand/collapse with โl / โh.
TreeModel(
root: TreeNode(label: 'Languages', isExpanded: true, children: [
TreeNode(label: 'Dart', children: [TreeNode(label: 'Flutter')]),
TreeNode(label: 'Go', children: [TreeNode(label: 'Bubble Tea')]),
]),
height: 20,
)

Multi-select
Scrollable checkbox list supporting multiple concurrent selections. Navigate with โโ / jk, toggle with Space or x, select all / none with a, confirm with Enter.
MultiSelectModel(
title: 'Pick your languages',
items: [
MultiSelectItem(label: 'Dart', value: 'dart'),
MultiSelectItem(label: 'Go', value: 'go'),
MultiSelectItem(label: 'Rust', value: 'rust'),
],
height: 10,
showStatusBar: true, // shows "N/Total selected"
wrap: true, // cursor wraps at list boundaries
)
// After the user presses Enter:
final values = multi.selectedValues; // ['dart', 'rust']

Cursor
In-line blinking cursor widget โ useful for building text editors, prompts, or any UI that needs a visible insertion point that isn't tied to the real terminal cursor.
CursorModel(
mode: CursorMode.block, // block โ, underline _, or bar |
blink: true, // toggles on every TickMsg
)
// Forward TickMsg to make it blink:
final (next, _) = cursor.update(tickMsg);
// Embed in view:
'hello${cursor.view().content}world' // โ 'helloโworld'

Viewport
Scrollable content pane with soft-wrap; useful for long text, logs, or file content.
ViewportModel(content: longText, height: 20, wrap: true)

Timer & Stopwatch
TimerModel(duration: Duration(minutes: 5)) // countdown; .finished, .remaining
StopwatchModel() // elapsed time; .start()/.stop()/.reset()

Paginator
Compact page indicator (dots or numeric) for multi-page flows.
PaginatorModel(totalPages: 5, activePage: 0)

Help
Compact / full keybinding reference panel built from a KeyMap.
final keyMap = KeyMap([
KeyBinding(['โ', 'k'], 'move up'),
KeyBinding(['โ', 'j'], 'move down'),
KeyBinding(['enter'], 'select'),
KeyBinding(['q'], 'quit'),
]);
HelpModel.fromKeyMap(keyMap)

File picker
Async directory browser with configurable extension filter and keyboard navigation.
FilePickerModel(
initialDirectory: Directory.current,
extensions: {'.dart', '.yaml'},
)

Spring animation
Harmonica-style damped-harmonic-oscillator for smooth, eased motion of any scalar โ a progress value, a scroll offset, a cursor position. Pure math, no terminal I/O; drive it one frame per TickMsg.
final spring = Spring(fps: 60, frequency: 6, damping: 1);
var (pos, vel) = (0.0, 0.0);
// each TickMsg, ease toward the target:
(pos, vel) = spring.update(pos, vel, target);
damping < 1 overshoots and oscillates, 1 is critical, > 1 is over-damped (no overshoot). fpsToDelta(fps) returns the per-frame delta.
One-shot helpers
Quick, self-contained flows built on Program โ no model to write. Each returns a Future and accepts an options list so it can be scripted/tested headlessly.
// prompts
final choice = await promptSelect(['apple', 'banana'], title: 'Pick one');
final ok = await promptConfirm('Continue?');
final name = await promptInput('Name');
// gum-style
final picked = await filter(['red', 'green', 'blue']); // interactive fuzzy filter
final result = await spin(fetchData(), label: 'Loadingโฆ'); // spinner while awaiting a Future
await pager(longText); // scrollable viewer (q/Esc to exit)
All prompts and filter return null when cancelled with Esc / Ctrl+C.
Readline editing keys
TextInputModel and TextAreaModel understand the common emacs/readline bindings:
| Key | Action |
|---|---|
ctrl+a / ctrl+e |
jump to start / end of line |
ctrl+b / ctrl+f |
move one character left / right |
alt+โ / alt+โ |
move one word left / right |
ctrl+w / alt+backspace |
delete the previous word |
File logging
A running Program owns stdout, so print() corrupts the UI. Write diagnostics to a file instead and tail -f it in another terminal:
final log = FileLog('debug.log');
log('got message: $msg');
// on shutdown:
await log.close();
FileLog.none() returns a logger that silently discards everything.
Forms
A composable, huh-style form: typed fields grouped into (optionally conditional) wizard pages, per-field validation with inline errors, and dynamic fields whose visibility/options depend on other fields' values. Key-based and immutable โ a Form is a Model you can embed, or run one-shot with form.run().
final form = Form([
Group([
Field.input(key: 'name', title: 'Service name',
validate: (v) => v.contains(' ') ? 'no spaces allowed' : null),
Field.select(key: 'runtime', title: 'Runtime',
options: ['Dart', 'Go', 'Node'], initial: 'Dart'),
Field.confirm(key: 'deploy', title: 'Deploy now?', initial: true),
], title: 'Basics'),
Group([
Field.multiSelect(key: 'regions', title: 'Regions',
options: ['iad', 'fra', 'sfo']),
Field.note(title: 'Review', description: 'Press enter to submit.'),
], title: 'Deploy', hidden: (v) => v.get<bool>('deploy') != true), // dynamic page
]);
final values = await form.run(); // FormValues? โ null if cancelled
final regions = values?.get<List<String>>('regions');
Field types: Field.input, .password, .text (multiline), .file, .select / .selectOf<T>, .multiSelect / .multiSelectOf<T>, .confirm, .note. Any field or group takes hidden, and titles/options can be dynamic (titleFor, optionsFor).

Examples
61 runnable examples covering every feature:
| Example | What it shows |
|---|---|
simple.dart |
Tick-driven countdown, minimal model |
textinput.dart |
Single-line text input |
textinputs.dart |
Multi-field form with Tab focus |
textarea.dart |
Multi-line editor |
readline.dart |
Readline editing keys โ ctrl+a/e/b/f/w, alt word motion |
autocomplete.dart |
Tab-completion suggestions |
list_simple.dart |
Basic SelectListModel |
list_default.dart |
List with selection state |
list_filter.dart |
ListModel with fuzzy filtering |
list_mutation.dart |
ListModel mutation API โ add / remove items live |
multi_select.dart |
MultiSelectModel โ checkbox list with toggle-all |
table.dart |
City data table |
tree.dart |
Expandable language/framework tree |
cursor_model.dart |
CursorModel โ blinking block/underline/bar cursor |
spinner.dart |
Animated spinner |
spinners.dart |
All built-in spinner styles |
progress_bar.dart |
Interactive progress bar |
progress_animated.dart |
Auto-incrementing progress |
spring.dart |
Spring โ harmonica-style eased motion |
pager.dart |
Scrollable viewport |
gum.dart |
gum helpers โ filter / spin / pager |
file_picker.dart |
Directory browser |
help.dart |
HelpModel + KeyMap |
timer.dart |
Countdown timer |
stopwatch.dart |
Elapsed-time stopwatch |
paginator.dart |
Page dot indicator |
gradient.dart |
Per-character gradient text |
canvas.dart |
Canvas compositing with z-index |
color_profile.dart |
ColorProfile + BackgroundColorMsg |
package_manager.dart |
Spinner + progress multi-step |
composable_views.dart |
Timer + spinner composition |
tabs.dart |
TabsModel tabbed interface |
border_style.dart |
All Border variants + titles + colors |
word_wrap.dart |
Style.wordWrap at multiple widths |
sgr_attrs.dart |
SGR text attributes + Style.inherit() |
mouse.dart |
Mouse click / scroll events |
exec_cmd.dart |
External editor via execProcess |
http.dart |
HTTP fetch with spinner |
file_log.dart |
FileLog โ write diagnostics to a file, not the UI |
form.dart |
Forms โ huh-style fields, validation, wizard, dynamic pages |
result.dart |
OutcomeModel returning a value |
isbn_form.dart |
Validated TextInputModel |
showcase.dart |
Full-featured gallery |
all_features.dart |
Component integration demo |
| (+ 16 more) | window_size, fullscreen, cursor_style, pipe, send_msg, realtime, prevent_quit, sequence, focus_blur, vanish, print_key, views, set_window_title, altscreen_toggle, prompts_chain, shopping_list |
Run any example:
# JIT (source, slower first run)
dart run example/simple.dart
# Kernel snapshot (~2ร faster startup)
make kernel EXAMPLE=simple
dart run tool/bin/simple.dill
Development
Prerequisites
- Dart SDK โฅ 3.11 for development (
dart_tuiconsumers remain compatible with Dart โฅ 3.5) - VHS โ only needed to re-record GIFs
Make targets use dart by default. FVM users can opt in per command with
DART='fvm dart' make <target>.
Makefile targets
make test # run all unit tests
make analyze # dart analyze across the repository
make coverage # measure lib/ line coverage, fail below FLOOR (default 90%)
make run EXAMPLE=simple # run example/simple.dart (JIT)
make kernels # compile all examples to .dill snapshots
make run-fast EXAMPLE=simple # run tool/bin/simple.dill (kernel snapshot)
make bench EXAMPLE=simple # startup benchmark (3 runs, reports median)
make gifs # build kernels then re-record all GIFs
make gif EXAMPLE=simple # re-record one GIF
make new-example NAME=my_app # scaffold example/my_app.dart from template
make clean # remove tool/bin/ build artifacts
Creating a new example
make new-example NAME=my_feature
# โ creates example/my_feature.dart with a minimal Model scaffold
make run EXAMPLE=my_feature
The generated file has everything wired up: Program, Model, key handling, and a styled view. Add your state and logic from there.
Fast startup with kernel snapshots
Pre-compile examples to skip JIT at runtime:
# Build one
bash tool/build.sh --kernel example/simple.dart
# Build all
bash tool/build.sh --kernel
# Benchmark
dart run tool/startup_bench.dart --dill tool/bin/simple.dill
Typical results (macOS arm64, Dart 3.13.1, measured with tool/bench_command.py):
| Mode | Median first visible |
|---|---|
| JIT source | 553 ms |
| Kernel snapshot | 158 ms |
| AOT executable | 11 ms |
Hot-path benchmarks
make bench-hotpath (or dart run tool/hotpath_bench.dart) prints naive Dart
vs dart_tui timings for width, cell diff, textarea, viewport, and decoder so
you can see the before/after of using the library.
Sample output from make bench-hotpath (macOS arm64, Dart 3.13.1; your
machine will differ):
| Workload | Naive | dart_tui | Ratio |
|---|---|---|---|
| getWidth plain x10000 | 30080 ยตs | 2511 ยตs | 12.0x |
| getWidth ANSI x10000 | 33792 ยตs | 3353 ยตs | 10.1x |
| textarea update+view x300 | 19733 ยตs | 3752 ยตs | 5.3x |
| viewport soft-wrap scroll+view x20 | 338586 ยตs | 17266 ยตs | 19.6x |
| decoder plain 100000 bytes | 6445149 ยตs | 3686 ยตs | 1748.5x |
make bench-hotpath
See docs/performance.md for the historical #20 vs
e00de92 audit.
For first-visible-frame startup, use make bench-startup-pty.
Re-recording GIFs
make gifs # builds all kernels, then records all 60 GIFs
make gif EXAMPLE=showcase # record one
Requires VHS and ffmpeg on your PATH (or at ~/go-packages/bin/vhs and ~/ffmpeg-local).
Architecture notes
Event loop
stdin bytes
โ
โผ
TerminalInputDecoder
โ (KeyPressMsg, WindowSizeMsg, BackgroundColorMsg, โฆ)
โผ
Queue<Msg>
โ
โผ drain all pending messages first
for msg in queue:
model = model.update(msg)
fire cmd (unawaited โ result enqueues next message)
โ
โผ render once per batch (FPS-throttled)
renderer.render(model.view())
Key properties:
- All pending messages are drained before each render โ rapid key presses never block each other
- Commands are fire-and-forget; their result arrives as the next message
- The FPS cap (default 60) only throttles screen output, not message processing
Renderers
| Renderer | Strategy | When to use |
|---|---|---|
AnsiRenderer (default) |
Line-level diff | Most terminals |
CellRenderer |
Cell-level diff (per grapheme cluster) | Terminals without ?2026 sync |
License
MIT. Inspired by Bubble Tea by Charm. See LICENSE.
Libraries
- dart_tui
- Elm-style terminal UIs for Dart, inspired by Bubble Tea.