Skip to content

WillEhrendreich/SageTUI

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

229 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

SageTUI

NuGet CI

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

See It In Action

System Monitor — tabs, live metrics, scrolling process list

SystemMonitor demo

Kanban Board — keyboard card navigation and multi-column moves

Kanban demo

Interactive Form — text input, dropdown selection, validation

InteractiveForm demo

Hello World — the simplest possible TEA app

HelloWorld demo

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, 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
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

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
  • updatemsg → model → (model, Cmd) — handle events, return new state
  • viewmodel → Element — render state as a terminal UI tree
  • subscribemodel → Sub list — declare ongoing subscriptions (keyboard, timers, resize)
  • Cmd — side effects (async tasks, quit signal). Cmd.none means "no side effect"
  • Sub — ongoing event sources. Keys.bind for keyboard, TimerSub for 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

About

Build beautiful terminal UIs in F# — Elm Architecture, flexbox layout, 17+ widgets, SIMD rendering, zero dependencies.

Resources

License

Contributing

Stars

Watchers

Forks

Packages

 
 
 

Contributors

Languages