dart_tui 3.0.0
dart_tui: ^3.0.0 copied to clipboard
Elm-style terminal UI framework for Dart — Model–Update–View, async commands, 27+ components, Lipgloss-inspired styling, spring animation, and gum-style helpers.
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: ^3.0.0
dart pub get
Migrating from 2.x? Version 3.0 delivers Ctrl+C and Ctrl+Z to
updateas keys instead of quitting or suspending for you, sends one key event per Unicode scalar, validates timers and forms at construction, and removesView.setContent(assignview.content). See the 3.0.0 changelog for the complete migration notes.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.