Build beautiful terminal UIs in F# with zero ceremony.
Elm Architecture • SIMD rendering • 2,983 tests • < 32 B/frame on idle • Core package has zero external dependencies
📦 NuGet • 📖 Getting Started • 🖼️ Widget Gallery • 🔄 Migration Guide
|
System Monitor — tabs, live metrics, scrolling process list |
Kanban Board — keyboard card navigation and multi-column moves |
|
Interactive Form — text input, dropdown selection, validation |
Hello World — the simplest possible TEA app |
Run any sample yourself:
dotnet run --project samples/09-SystemMonitor
dotnet add package SageTUIdotnet new install SageTUI.Templates
dotnet new sagetui -n MyApp
cd MyApp && dotnet runSageTUI.Html is a separate package/project for HTML parsing and rendering. It is not part of the core SageTUI package.
If you want the fastest path, use the template:
dotnet new install SageTUI.Templates
dotnet new sagetui -n MyApp
cd MyApp
dotnet runIf you want to start from an empty F# console app, paste this complete Program.fs:
open SageTUI
type Msg =
| Increment
| Decrement
| Quit
let init () = 0, Cmd.none
let update msg count =
match msg with
| Increment -> count + 1, Cmd.none
| Decrement -> max 0 (count - 1), Cmd.none
| Quit -> count, Cmd.quit
let view count =
El.column [
El.text "Hello, SageTUI!"
|> El.bold
|> El.fg (Color.Rgb(255uy, 200uy, 50uy))
El.text ""
El.text (sprintf "Count: %d" count) |> El.bold
El.text ""
El.text "[j] increment [k] decrement [q] quit" |> El.dim
]
|> El.padAll 1
|> El.bordered Rounded
let keyBindings =
Keys.bind [
Key.Char 'j', Increment
Key.Char 'k', Decrement
Key.Char 'q', Quit
Key.Escape, Quit
]
let program : Program<int, Msg> =
{ Init = init
Update = update
View = view
Subscribe = fun _ -> [ keyBindings ] }
[<EntryPoint>]
let main _ = App.run program; 0Press q or Esc to quit.
For one-off screens and demos:
open SageTUI
App.display (fun () ->
El.text "Hello from SageTUI!"
|> El.bold
|> El.fg (Color.Named(Cyan, Bright))
|> El.bordered Rounded
|> El.padAll 1)App.display is the smallest API surface; App.run is the full Elm Architecture entry point.
open SageTUI
type Msg = Increment | Quit
let init () = 0, Cmd.none
let update msg count =
match msg with
| Increment -> count + 1, Cmd.none
| Quit -> count, Cmd.quit
let view count =
El.column [
El.text (sprintf "Count: %d" count) |> El.bold
El.text "[j] increment [q] quit" |> El.dim
] |> El.padAll 1 |> El.bordered Rounded
let keyBindings =
Keys.bind [
Key.Char 'j', Increment
Key.Char 'q', Quit
Key.Escape, Quit
]
let program : Program<int, Msg> =
{ Init = init
Update = update
View = view
Subscribe = fun _ -> [ keyBindings ] }
[<EntryPoint>]
let main _ = App.run program; 0App.run auto-detects your terminal capabilities and handles setup, rendering, and cleanup.
| Category | What You Get |
|---|---|
| Architecture | Elm Architecture (init/update/view/subscribe), pure state management |
| Layout | Row, Column, Fill, Percentage, Min/Max, padding, borders, alignment, gap, flex-shrink |
| Elements | Text, Row, Column, Overlay, Constrained, Bordered, Padded, Keyed, Canvas, Scroll (viewport clipping), Hyperlink (OSC 8 terminal hyperlinks) |
| Borders | 6 border styles (Rounded, Light, Heavy, Double, ASCII, None) with optional titled borders (El.borderedWithTitle) |
| Rendering | Arena-allocated zero-GC frame loop, SIMD-accelerated diff, 24-bit TrueColor |
| Widgets | TextInput, Select, Table, Tabs, Modal, TreeView, ProgressBar, Checkbox, Toggle, RadioGroup, SpinnerWidget, Toast, Form, FuzzyFinder, TextEditor, SplitPane, VirtualList, VirtualTable, OrderableList, OrderableVirtualList, DiffView |
| Program combinators | withDebugger (F12 overlay), withLogging (transparent sink), withPersistence (JSON save/restore), withHistory (undo/redo), withErrorBanner (visible crash recovery) |
| Data utilities | Diff.compute (LCS structural diff), DiffView.view (colored +/− diff rendering), OrderableList<'a> (pure reorderable list), Cmd.computeWhen (memoized async), NavigationStack (push/pop routing) |
| Command algebra | Cmd.bind (monadic sequencing), Cmd.andThen (sequential chaining), Cmd.sequence (ordered list of commands) |
| Focus | FocusRing<'F> with next/prev/isFocusedAt — tab cycling with no allocations, works with any type including [<NoEquality>] via index |
| Scrolling | ScrollState, VirtualList with scroll indicators, El.scroll element for viewport clipping |
| Canvas | HalfBlock (▀/▄) and Braille (⠿) pixel modes |
| Transitions | Runtime support for Fade, Wipe, Dissolve, and ColorMorph, with additional transition shapes modeled in the API |
| Themes | 5 built-in themes (dark, light, nord, dracula, catppuccin) |
| HTML Bridge | Optional SageTUI.Html package for parsing HTML fragments into Element trees |
| Mouse | Click subscriptions, hit-testing with Z-order, focus cycling |
| Safety | Restores the terminal on unhandled managed exceptions |
- F#-native TEA — immutable model, explicit messages, pure view functions
- Terminal-native layout — rows, columns, fill, percentages, borders, alignment, gap
- Fast rendering path — packed cells, arena lowering, and diffing that avoids repainting unchanged output
- Testable by design — integration and snapshot tests can render real programs without a live terminal
- Small mental model — toolkit, not framework ceremony
Terminal-native flexbox with CSS vocabulary:
// Rows and columns
El.row [ El.text "Left" |> El.fill; El.text "Right" |> El.width 20 ]
// Box model
El.text "Content" |> El.padAll 1 |> El.bordered Rounded
// Alignment
El.text "Centered" |> El.center
// Gap between children
El.column [ El.text "A"; El.text "B"; El.text "C" ] |> El.gap 1
// Percentage sizing
El.row [
El.text "Sidebar" |> El.percentage 30
El.text "Main" |> El.fill
]TextInput.view focused model.Input
ProgressBar.view { ProgressBar.defaults with Percent = 0.75; Width = 40 }
Tabs.view {
Items = ["Home"; "Settings"; "Help"]
ActiveIndex = activeTab
ToString = id
ActiveColor = Some (Color.Named(Cyan, Bright))
InactiveColor = None
}
Table.view columns rows (Some selectedRow)
Modal.view { Modal.defaults with BorderStyle = Rounded; MaxWidth = Some 40 } content
TreeView.view id focused nodes treeState
Form.view fields focusedKey modelThese are low-level building blocks. Most real apps compose them inside view functions rather than relying on a heavyweight retained widget tree.
let themed = Theme.dark // or: light, nord, dracula, catppuccin
Theme.heading themed "Styled heading"
Theme.panel themed "My Panel" (El.column [...])Call any .NET library from your update function:
let update msg model =
match msg with
| FetchData ->
model, Cmd.ofTask
(fun () -> httpClient.GetStringAsync("https://api.example.com/data"))
(fun result -> DataReceived result)
| DataReceived data -> { model with Data = data }, Cmd.noneSageTUI.Html is an optional companion package/project for rendering HTML into SageTUI elements.
open SageTUI.Html
let html = """<div>
<h1 style="color: cyan">Dashboard</h1>
<table>
<tr><th>Service</th><th>Status</th></tr>
<tr><td>API</td><td style="color: green">● Online</td></tr>
</table>
</div>"""
let element = HtmlString.parseFragment htmlBenchmarked with BenchmarkDotNet on .NET 10.0, i7-11800H:
| Benchmark | Mean | Allocated |
|---|---|---|
| Buffer.diff identical (80×24) | 596 ns | 32 B |
| Buffer.diff 10% changed (80×24) | 2.85 μs | 2.2 KB |
| Arena render dashboard (80×24) | 15.3 μs | 3.4 KB |
| Ref render dashboard (80×24) | 14.1 μs | 6.6 KB |
| Arena 50-item column (80×50) | 29.9 μs | 22.5 KB |
| Arena nested 3-level (80×100) | 48.2 μs | 11.4 KB |
Headline: The arena render path allocates < 32 bytes per frame on idle screens — a 97% reduction from the original 3,640 B/frame reference implementation. SIMD-accelerated diff skips 16-cell chunks with a single Span.SequenceEqual, so unchanged frames cost < 600 ns.
Run benchmarks yourself: dotnet run -c Release --project SageTUI.Benchmarks
┌─────────────────────────────────────────┐
│ Your Application │
│ init → update → view → subscribe │
├─────────────────────────────────────────┤
│ Element Tree │
│ Text · Row · Column · Styled · ... │
├─────────────────────────────────────────┤
│ Arena Rendering (zero-GC) │
│ Pre-allocated nodes, single pass │
├─────────────────────────────────────────┤
│ SIMD Diff (hardware accel) │
│ Only changed cells hit the terminal │
├─────────────────────────────────────────┤
│ Terminal Backend │
│ Auto-detect, TrueColor, mouse, raw │
└─────────────────────────────────────────┘
For a deep-dive into the render pipeline, element cases, arena design, and SIMD diff, see ARCHITECTURE.md.
The sample suite is tiered so the strongest experiences lead the front door:
| Tier | Sample | What It Shows |
|---|---|---|
| Flagship | 09 SystemMonitor | Dense operator console: tabs, scrolling, live updates, themes, telemetry |
| Showcase | 06 Kanban | Keyboard-driven board navigation and multi-column state |
| Showcase | 08 Sparklines | Canvas-based telemetry and compact data visualization |
| Showcase | 04 InteractiveForm | Keyboard-first form flow, TextInput, Select, validation |
| Supporting | 01 HelloWorld | Smallest possible TEA app with borders and key bindings |
| Supporting | 05 ColorPalette | Theme semantics, color modes, text styles |
| Supporting | 07 Transitions | Keyed transitions and animated state changes |
| Supporting | 02 Dashboard | Secondary overview sample; useful for layout ideas, not the hero |
| Experimental | 03 HtmlRenderer | HTML→Element bridge and document rendering lab |
If you only run one sample, start here:
dotnet run --project samples/09-SystemMonitorSageTUI uses the Elm Architecture (TEA):
- Model — your app's state (an immutable F# type)
- Msg — a discriminated union of everything that can happen
- init — returns
(model, Cmd)— your starting state - update —
msg → model → (model, Cmd)— handle events, return new state - view —
model → Element— render state as a terminal UI tree - subscribe —
model → Sub list— declare ongoing subscriptions (keyboard, timers, resize) - Cmd — side effects (async tasks, quit signal).
Cmd.nonemeans "no side effect" - Sub — ongoing event sources.
Keys.bindfor keyboard,TimerSubfor polling
SageTUI ships a first-class testing module (Testing.fs) so you can unit-test your programs without a real terminal.
open SageTUI.Testing
let app =
TestHarness.init myProgram 80 24
|> TestHarness.pressKey Key.Enter
|> TestHarness.pressKey (Key.Char 'j')
|> TestHarness.typeText "hello"
// Assert on model
app |> TuiExpect.modelSatisfies "item selected" (fun m -> m.Selected = 1)
// Assert on rendered output
app |> TuiExpect.viewContains "label visible" "hello"
app |> TuiExpect.viewNotContains "error gone" "Error"TuiExpect prints a framed box on failure for immediate readability:
╔══════════════════════════════════════╗
║ viewContains: label visible ║
║ Expected substring: "hello" ║
║ Rendered output (80×24): ║
║ > Hello, World! ║
╚══════════════════════════════════════╝
let lines = TestHarness.renderElement 40 3 (El.text "Hello" |> El.bordered Rounded)
lines.[0] |> Expect.stringContains "top border" "╭"TestHarness.advanceTime fully drains causal delay chains:
let app =
TestHarness.init timerProgram 80 24
|> TestHarness.advanceTime 1000 // fires all Cmd.Delay(≤1000ms, ...) chains
app |> TuiExpect.viewContains "tick fired" "Tick: 1"let app =
TestHarness.init formProgram 80 24
|> TestHarness.typeText "Alice"
|> TestHarness.focusNext
|> TestHarness.typeText "30"
|> TestHarness.sendMsg Submit
app |> TuiExpect.modelSatisfies "name captured" (fun m -> m.Name = "Alice")let app =
TestHarness.init counterProgram 80 24
|> TestHarness.clickAt 5 3 // col 5, row 3 — resolves via arena hit map
app |> TuiExpect.modelSatisfies "button clicked" (fun m -> m.Count = 1)Program combinators transform a Program<'model,'msg> into a richer one. They compose with |>:
let program =
{ Init = init; Update = update; View = view; Subscribe = fun _ -> [keyBindings] }
|> Program.withLogging (fun msg model -> printfn "[%A] -> %A" msg model)
|> Program.withPersistence {
SavePath = "myapp.json"
Serialize = Json.serialize
Deserialize = Json.deserialize
}
|> Program.withDebugger DebuggerConfig.defaults| Combinator | What it adds | Type change? |
|---|---|---|
withLogging sink |
Logs every message + model to sink |
No — same Program<'m,'msg> |
withPersistence config |
Auto-save/restore on every update | No — same Program<'m,'msg> |
withHistory config |
Undo/redo with configurable depth | Yes — Program<HistoryModel<'m>, HistoryMsg<'msg>> |
withErrorBanner |
Renders visible error banner on crash | No — same Program<'m,'msg> |
withDebugger config |
F12-toggleable live model inspector | Yes — Program<DebuggerModel<'m>, DebuggerMsg<'msg>> |
withTransition config |
Animated keyed-element transitions | No — same Program<'m,'msg> |
- Package version: 0.9.3
- Target framework: .NET 10.0
- API status: pre-1.0; expect iterative changes while the surface area settles
- Core package:
SageTUI - Template package:
SageTUI.Templates - Optional companion project:
SageTUI.Html - Experimental areas: the HTML bridge and some sample/demo surfaces are still evolving
For testing or custom terminal implementations:
let profile = Detect.fromEnvironment readEnv getTerminalSize
let backend = Backend.create profile
App.runWithBackend backend programWhere readEnv is a string -> string option function and getTerminalSize returns the current terminal size.
- .NET 10.0+
- A terminal with ANSI escape sequence support (all modern terminals)
MIT



