SageTUI 0.9.10
dotnet add package SageTUI --version 0.9.10
NuGet\Install-Package SageTUI -Version 0.9.10
<PackageReference Include="SageTUI" Version="0.9.10" />
<PackageVersion Include="SageTUI" Version="0.9.10" />
<PackageReference Include="SageTUI" />
paket add SageTUI --version 0.9.10
#r "nuget: SageTUI, 0.9.10"
#:package SageTUI@0.9.10
#addin nuget:?package=SageTUI&version=0.9.10
#tool nuget:?package=SageTUI&version=0.9.10
SageTUI
Build beautiful terminal UIs in F# with zero ceremony.
Elm Architecture • SIMD rendering • 2,990 tests • < 32 B/frame on idle • Core package has zero external dependencies
📦 NuGet • 📖 Getting Started • 🖼️ Widget Gallery • 🔄 Migration Guide
See It In Action
<table> <tr> <td align="center" width="50%">
System Monitor — tabs, live metrics, scrolling process list
</td> <td align="center" width="50%">
Kanban Board — keyboard card navigation and multi-column moves
</td> </tr> <tr> <td align="center" width="50%">
Interactive Form — text input, dropdown selection, validation
</td> <td align="center" width="50%">
Hello World — the simplest possible TEA app
</td> </tr> </table>
Run any sample yourself:
dotnet run --project samples/09-SystemMonitor
Install
Core library
dotnet add package SageTUI
Project template
dotnet new install SageTUI.Templates
dotnet new sagetui -n MyApp
cd MyApp && dotnet run
Optional HTML bridge
SageTUI.Html is a separate package/project for HTML parsing and rendering. It is not part of the core SageTUI package.
60-Second Quickstart
If you want the fastest path, use the template:
dotnet new install SageTUI.Templates
dotnet new sagetui -n MyApp
cd MyApp
dotnet run
If 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; 0
Press q or Esc to quit.
Smallest Possible Static View
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.
Interactive App (Elm Architecture)
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; 0
App.run auto-detects your terminal capabilities and handles setup, rendering, and cleanup.
Features
| 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, PasswordInput, 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 |
Why SageTUI
- 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
Layout Engine
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
]
Widgets
TextInput.view focused model.Input
PasswordInput.view focused model.Password
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 model
PasswordInput is a thin wrapper over TextInput: editing, cursoring, selection, and paste behavior are shared. Reveal mode is controlled by your app state via PasswordInput.toggleReveal or PasswordInput.setRevealed; the widget does not add a built-in "show password" button.
These are low-level building blocks. Most real apps compose them inside view functions rather than relying on a heavyweight retained widget tree.
Themes
let themed = Theme.dark // or: light, nord, dracula, catppuccin
Theme.heading themed "Styled heading"
Theme.panel themed "My Panel" (El.column [...])
.NET Interop
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.none
HTML Rendering
SageTUI.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 html
Performance
Benchmarked 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
Architecture
┌─────────────────────────────────────────┐
│ 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.
Samples
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-SystemMonitor
Concepts
SageTUI 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
Testing
SageTUI ships a first-class testing module (Testing.fs) so you can unit-test your programs without a real terminal.
Simulate keystrokes and inspect model/render
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"
Framed failure output
TuiExpect prints a framed box on failure for immediate readability:
╔══════════════════════════════════════╗
║ viewContains: label visible ║
║ Expected substring: "hello" ║
║ Rendered output (80×24): ║
║ > Hello, World! ║
╚══════════════════════════════════════╝
Direct element rendering (no program needed)
let lines = TestHarness.renderElement 40 3 (El.text "Hello" |> El.bordered Rounded)
lines.[0] |> Expect.stringContains "top border" "╭"
Virtual time — no Thread.Sleep
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"
Type text, focus, send messages
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")
Click hit-testing via arena hit map
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
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> |
Support & Stability
- 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
Advanced: Custom Backend
For testing or custom terminal implementations:
let profile = Detect.fromEnvironment readEnv getTerminalSize
let backend = Backend.create profile
App.runWithBackend backend program
Where readEnv is a string -> string option function and getTerminalSize returns the current terminal size.
Requirements
- .NET 10.0+
- A terminal with ANSI escape sequence support (all modern terminals)
License
MIT
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net8.0 is compatible. net8.0-android was computed. net8.0-browser was computed. net8.0-ios was computed. net8.0-maccatalyst was computed. net8.0-macos was computed. net8.0-tvos was computed. net8.0-windows was computed. net9.0 is compatible. net9.0-android was computed. net9.0-browser was computed. net9.0-ios was computed. net9.0-maccatalyst was computed. net9.0-macos was computed. net9.0-tvos was computed. net9.0-windows was computed. net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net10.0
- FSharp.Core (>= 9.0.303)
-
net8.0
- FSharp.Core (>= 9.0.303)
-
net9.0
- FSharp.Core (>= 9.0.303)
NuGet packages (1)
Showing the top 1 NuGet packages that depend on SageTUI:
| Package | Downloads |
|---|---|
|
SageTUI.Html
HTML/CSS layout engine add-on for SageTUI. Parse HTML markup and render it as terminal UI elements using SageTUI's flexbox layout engine. Depends on AngleSharp and Falco.Markup. |
GitHub repositories
This package is not used by any popular GitHub repositories.
0.9.8: Eliminate ghost echo on Unix raw input. Console.In.Read() causes the .NET runtime's StdInReader to echo each typed character to stdout itself (a managed write, independent of the termios ECHO flag), ghosting keystrokes onto the alternate screen in WSL2/terminals. The Unix input loop now reads via raw read(2) with incremental UTF-8 decoding, so typed characters never echo and multi-byte runes survive buffer boundaries. Windows path unchanged. 2,988 tests. 0.9.7: Fix bracketed-paste parsing so payloads enter paste mode at ESC[200~, preserve embedded escape fragments, and enforce a bounded transport limit. 0.9.5: Latest Harmony integration fixes — Cmd.withTimeout race hardening, Shift+Tab backtab handling, README test count refresh, and installer compatibility updates. 0.9.1: Sprint 71 additions — Sub.interval/intervalMs, Cmd.withTimeout, Program.withUpdateInterceptor, RemoteData.mapError, Deferred.viewString, ToastQueue.overlay. CI NETSDK1005 fix for multi-TFM restore. 2,764 tests. Previous: 0.9.0: Complete Elm Architecture (TEA/MVU) with 17+ widgets, SIMD-accelerated double-buffered rendering, flexbox layout engine, ErrorPolicy DU (CrashOnError|LogAndContinue|RecoverWith), TestHarness, Keys.bind, Cmd.fromAsync/fromTask, VirtualList multi-select, TextInput, FuzzyFinder, Viewport, Chart, Toast/ToastQueue, Tabs, Select, theme system, template package.