DEV Community

Cover image for TerminaReactive TUI Framework for .NET
artydev
artydev

Posted on

TerminaReactive TUI Framework for .NET

Termina is a stupend library to create wonderfull TUI apps.

Here a llm.txt to use it:

Termina: Reactive Terminal UI Framework for .NET

Overview

Termina is a reactive terminal UI (TUI) framework for .NET that enables developers to build beautiful, interactive terminal applications with declarative layouts and reactive state management. It provides an MVVM architecture with automatic UI updates, ASP.NET Core-style routing, and seamless integration with Microsoft.Extensions.DependencyInjection and Microsoft.Extensions.Hosting.

Version: 0.15.1 (at documentation time)
License: Apache 2.0
Repository: https://github.com/Aaronontheweb/Termina
Documentation: https://aaronstannard.com/termina/
NuGet Package: https://www.nuget.org/packages/Termina

Key Features

Reactive MVVM Architecture

  • ViewModels use ReactiveProperty<T> for observable state management
  • Source-generated reactive properties with no boilerplate required
  • ReactiveProperty<T> is both a value holder and an Observable<T> for automatic UI updates
  • Full integration with R3 observables library (migrated from System.Reactive in v0.7.0)

Declarative Layouts

  • Tree-based layout system for composing complex UIs
  • Fluent API with .WithChild() for clean composition
  • Support for Vertical, Horizontal, Grid, and Stack layouts
  • Size constraints: Fixed, Fill, Auto, and Percent
  • Nesting layouts for complex UI composition
  • Responsive design with terminal resize support

Surgical Region-Based Rendering

  • Only changed regions re-render, not the entire screen
  • Enables smooth streaming updates for real-time content
  • Direct ANSI rendering with zero external rendering dependencies
  • Surgical updates for efficient terminal performance

ASP.NET Core-Style Routing

  • Type-safe route parameters with templates like /tasks/{id:int}
  • Route parameter injection via [FromRoute] attribute
  • Navigation via Navigate() and NavigateWithParams()
  • Full type constraint support for route parameters

Source Generators

  • AOT (Ahead-of-Time) compatible code generation
  • Native AOT publishing support with single-file executables
  • Zero reflection approach for performance

Streaming Support

  • Native StreamingTextNode for token-by-token content rendering
  • Ideal for LLM output, real-time logs, and live data streams
  • Character-level subscription updates for smooth rendering

Dependency Injection

  • Full integration with Microsoft.Extensions.DependencyInjection
  • Works seamlessly with Microsoft.Extensions.Hosting
  • Allows clean lifecycle management and service composition

Testing Support

  • VirtualInputSource for automated testing with scripted input
  • Test mode detection for CI/CD integration
  • Deterministic input replay for regression testing

Installation

Requirements

  • .NET 10.0 SDK or later
  • Terminal emulator with ANSI support (Windows Terminal, iTerm2, etc.)

Package Installation

dotnet add package Termina
dotnet add package Microsoft.Extensions.Hosting
Enter fullscreen mode Exit fullscreen mode

Core Architecture

The Three-Part Pattern

Termina applications follow an MVVM pattern with three key components:

1. ViewModel

  • Inherits from ReactiveViewModel
  • Manages application state using ReactiveProperty<T>
  • Handles keyboard input via Input.OfType<KeyPressed>()
  • Provides navigation and shutdown actions
  • Must dispose all ReactiveProperty<T> instances in Dispose() method
  • Implements OnActivated() for initialization logic

2. Page

  • Inherits from ReactivePage<TViewModel>
  • Builds UI layout in BuildLayout() method
  • Implements reactive bindings via .Select(...).AsLayout() pattern
  • Manages focus for modals and interactive controls
  • Optionally implements OnBound() for additional initialization

3. Host Configuration

  • Uses Microsoft.Extensions.Hosting for lifecycle management
  • Registers routes via AddTermina() and RegisterRoute<TPage, TViewModel>()
  • Supports virtual input for testing

Reactive Property Pattern

ReactiveProperty<T> is the foundation of Termina's reactivity:

  • Holds a value accessible via .Value property
  • Emits observables when value changes
  • Built-in DistinctUntilChanged behavior (only emits on actual value change)
  • Used in layout bindings: ViewModel.Count.Select(...).AsLayout()
  • Must be disposed properly to prevent memory leaks

Layout System

How Layout Works

The layout system processes node trees in two phases:

1. Measure Phase: Starting from root, each node calculates how much space it needs given available space. Container nodes recursively measure children.

2. Render Phase: Once measurements are complete, each node receives final bounds and renders to the terminal.

Layout Nodes vs. Container Nodes

Layout Nodes (render content):

  • TextNode - Styled text with word wrapping
  • PanelNode - Bordered container with title
  • SpinnerNode - Animated loading indicator
  • StreamingTextNode - Streaming content with scrolling
  • GraphNode - Live scrolling graph with gradient coloring
  • ProgressBarNode - Progress bar with gradient fill and label
  • TextInputNode - Single-line text input with cursor
  • TextAreaNode - Multi-line text input with word wrap
  • CopyableTextNode - Read-only text with clipboard support
  • SelectionListNode - Interactive list selection
  • FilePickerNode - File/folder picker

Container Nodes (arrange children):

  • VerticalLayout - Stack children top-to-bottom
  • HorizontalLayout - Stack children left-to-right
  • GridNode - 2D grid with consistent column/row sizing
  • StackLayout - Overlay children (z-stack)
  • ScrollableContainer - Scrollable content area
  • ModalNode - Modal overlay with backdrop

Size Constraints

Four constraint types control sizing:

Constraint Description Example
Fixed(n) Exactly n rows/columns .Height(3)
Fill(weight) Take remaining space .Fill() or .Fill(2)
Auto Size to content .HeightAuto()
Percent(n) n% of available space .Height(SizeConstraint.Percent(50))

Common Layout Patterns

Vertical stacking with header, content, footer:

Layouts.Vertical()
    .WithChild(header.Height(3))      // Fixed height
    .WithChild(content.Fill())        // Take remaining space
    .WithChild(footer.Height(1));
Enter fullscreen mode Exit fullscreen mode

Horizontal layout with weighted fills:

Layouts.Horizontal()
    .WithChild(menu.Width(30))
    .WithChild(main.Fill(2))          // 2x weight
    .WithChild(aside.Fill(1));        // 1x weight
Enter fullscreen mode Exit fullscreen mode

Nested layouts:

Layouts.Vertical()
    .WithChild(
        Layouts.Horizontal()
            .WithChild(sidebar.Width(20))
            .WithChild(content.Fill())
    )
    .WithChild(footer.Height(1));
Enter fullscreen mode Exit fullscreen mode

Component Library

Display Components

TextNode

  • Renders styled text with word wrapping
  • Supports foreground/background colors
  • Methods: .Bold(), .Dim(), .Italic(), .Underline(), .Strike(), .WithForeground(), .NoWrap()

PanelNode

  • Bordered container with optional title
  • Border styles: BorderStyle.Single, BorderStyle.Double, BorderStyle.Rounded
  • Methods: .WithTitle(), .WithBorder(), .WithBorderColor(), .WithTitleColor(), .WithContent()

SpinnerNode

  • Animated loading indicator
  • Various spinner styles available

StreamingTextNode

  • For real-time, token-by-token content rendering
  • Created via StreamingTextNode.Create()
  • Add content: .Append(chunk)
  • Subscription pattern for ViewModel observables

GraphNode

  • Live scrolling graph with gradient coloring

ProgressBarNode

  • Progress bar with gradient fill and label

Input Components

TextInputNode

  • Single-line text input with cursor
  • Keyboard input handling

TextAreaNode

  • Multi-line text input
  • Word wrap and vertical scrolling

SelectionListNode

  • Interactive list selection
  • Keyboard navigation

FilePickerNode

  • File/folder picker with directory navigation

CopyableTextNode

  • Read-only text with keyboard selection and clipboard support

Container Components

ScrollableContainer

  • Vertical scrolling container for overflow content

StackLayout

  • Overlapping children (z-stack) for layering

ModalNode

  • Modal overlay with backdrop

Reactive Components

ReactiveLayoutNode

  • Updates content from observables

ConditionalNode

  • Show/hide content based on condition

DynamicLayoutNode

  • Re-evaluates factory on invalidation

KeyedDynamicLayoutNode

  • Key-based content switching with caching

WizardNode

  • Multi-step wizard with progress, navigation, and focus

Utility Components

EmptyNode

  • Placeholder that renders nothing

DeferredNode

  • Delegates to node without owning it

Input Handling

Keyboard Input

Input is accessed via Input.OfType<IInputEvent, KeyPressed>() in ViewModels:

public override void OnActivated()
{
    Input.OfType<IInputEvent, KeyPressed>()
        .Subscribe(HandleKeyPress)
        .DisposeWith(Subscriptions);
}

private void HandleKeyPress(KeyPressed key)
{
    switch (key.KeyInfo.Key)
    {
        case ConsoleKey.UpArrow:
            // Handle up arrow
            break;
        case ConsoleKey.Enter:
            // Handle enter
            break;
        case ConsoleKey.Escape:
            Shutdown();
            break;
        default:
            // Handle printable characters
            if (key.KeyInfo.KeyChar >= 32 && key.KeyInfo.KeyChar < 127)
            {
                // Character input
            }
            break;
    }
}
Enter fullscreen mode Exit fullscreen mode

Printable Character Handling

Access the character via key.KeyInfo.KeyChar property.

Shutdown

Call Shutdown() to exit the application.

Routing

Route Registration

Routes are registered during host configuration:

builder.Services.AddTermina("/", termina =>
{
    termina.RegisterRoute<HomePage, HomeViewModel>("/");
    termina.RegisterRoute<TasksPage, TasksViewModel>("/tasks");
    termina.RegisterRoute<TaskDetailPage, TaskDetailViewModel>("/tasks/{id:int}");
    termina.RegisterRoute<UserPage, UserViewModel>("/users/{name}");
});
Enter fullscreen mode Exit fullscreen mode

Route Parameters

Routes support type-safe parameters with type constraints:

// Route template with parameter
"/tasks/{id:int}"

// ViewModel with route parameter injection
public partial class TaskDetailViewModel : ReactiveViewModel
{
    [FromRoute] private int _id;  // Injected from route

    public override void OnActivated()
    {
        LoadTask(Id);  // Id is already populated
    }
}
Enter fullscreen mode Exit fullscreen mode

Navigation

Navigate between routes programmatically:

// Simple navigation
Navigate("/tasks/42");

// With parameters
NavigateWithParams("/tasks/{id}", new { id = 42 });

// Exit application
Shutdown();
Enter fullscreen mode Exit fullscreen mode

Styling and Colors

Color System

Termina supports ANSI colors:

  • Basic colors: Color.Black, Color.Red, Color.Green, Color.Yellow, Color.Blue, Color.Magenta, Color.Cyan, Color.White, Color.Gray
  • Bright variants: Color.BrightRed, Color.BrightGreen, etc.

Text Styling

Apply styling via fluent API:

  • .WithForeground(Color.Cyan) - Text color
  • .WithBackground(Color.Black) - Background color
  • .Bold() - Bold text
  • .Dim() - Dimmed text
  • .Italic() - Italic text
  • .Underline() - Underlined text
  • .Strike() - Strikethrough text

Word Wrapping

Control text wrapping:

  • .NoWrap() - Disable word wrapping, truncate at edge

Reactive Bindings

Observable to Layout Pattern

Convert observables to layout nodes:

ViewModel.Count
    .Select<int, ILayoutNode>(count => new TextNode($"Count: {count}")
        .WithForeground(Color.BrightCyan))
    .AsLayout()
Enter fullscreen mode Exit fullscreen mode

Multiple Observable Bindings

Combine multiple observables:

Observable.CombineLatest(
    ViewModel.Title,
    ViewModel.Message
)
.Select(x => new TextNode($"{x[0]}: {x[1]}").WithForeground(Color.Cyan))
.AsLayout()
Enter fullscreen mode Exit fullscreen mode

Streaming Content Pattern

For real-time content like LLM output:

// In Page
private StreamingTextNode _output = null!;

protected override void OnBound()
{
    _output = StreamingTextNode.Create();
    ViewModel.StreamOutput.Subscribe(chunk => _output.Append(chunk));
}

// In ViewModel
public Observable<string> StreamOutput => _streamOutput;
private readonly Subject<string> _streamOutput = new();

private async Task StreamResponse()
{
    await foreach (var chunk in GetStreamingData())
    {
        _streamOutput.OnNext(chunk);  // Character-level updates
    }
}
Enter fullscreen mode Exit fullscreen mode

Testing

VirtualInputSource

Use VirtualInputSource for automated testing with scripted input:

var scriptedInput = new VirtualInputSource();
builder.Services.AddTerminaVirtualInput(scriptedInput);

// Queue input
scriptedInput.EnqueueKey(ConsoleKey.UpArrow);
scriptedInput.EnqueueString("Hello World");
scriptedInput.EnqueueKey(ConsoleKey.Enter);
scriptedInput.Complete();

await host.RunAsync();
Enter fullscreen mode Exit fullscreen mode

Test Mode Detection

Detect test mode with command-line flag:

var testMode = args.Contains("--test");

if (testMode)
{
    var scriptedInput = new VirtualInputSource();
    builder.Services.AddTerminaVirtualInput(scriptedInput);

    // Queue test inputs
    scriptedInput.EnqueueKey(ConsoleKey.UpArrow);
    // ... more inputs
    scriptedInput.Complete();
}
Enter fullscreen mode Exit fullscreen mode

Complete Application Template

Step 1: Define ViewModel

using R3;
using Termina.Input;
using Termina.Reactive;

public class CounterViewModel : ReactiveViewModel
{
    public ReactiveProperty<int> Count { get; } = new(0);
    public ReactiveProperty<string> Message { get; } = new("Press Up/Down");

    public override void OnActivated()
    {
        Input.OfType<IInputEvent, KeyPressed>()
            .Subscribe(HandleKey)
            .DisposeWith(Subscriptions);
    }

    private void HandleKey(KeyPressed key)
    {
        switch (key.KeyInfo.Key)
        {
            case ConsoleKey.UpArrow:
                Count.Value++;
                Message.Value = $"Count: {Count.Value}";
                break;
            case ConsoleKey.DownArrow:
                Count.Value--;
                Message.Value = $"Count: {Count.Value}";
                break;
            case ConsoleKey.Escape:
                Shutdown();
                break;
        }
    }

    public override void Dispose()
    {
        Count.Dispose();
        Message.Dispose();
        base.Dispose();
    }
}
Enter fullscreen mode Exit fullscreen mode

Step 2: Define Page

using R3;
using Termina.Extensions;
using Termina.Layout;
using Termina.Reactive;
using Termina.Rendering;
using Termina.Terminal;

public class CounterPage : ReactivePage<CounterViewModel>
{
    public override ILayoutNode BuildLayout()
    {
        return Layouts.Vertical()
            .WithChild(
                new PanelNode()
                    .WithTitle("Counter Demo")
                    .WithBorder(BorderStyle.Rounded)
                    .WithBorderColor(Color.Cyan)
                    .WithContent(
                        ViewModel.Count
                            .Select<int, ILayoutNode>(count => new TextNode($"Count: {count}")
                                .WithForeground(Color.BrightCyan))
                            .AsLayout())
                    .Height(5))
            .WithChild(
                ViewModel.Message
                    .Select<string, ILayoutNode>(msg => new TextNode(msg))
                    .AsLayout()
                    .Height(1));
    }
}
Enter fullscreen mode Exit fullscreen mode

Step 3: Configure Host

using Microsoft.Extensions.Hosting;
using Termina.Hosting;

var builder = Host.CreateApplicationBuilder(args);

// Optional: suppress host logging
builder.Logging.SetMinimumLevel(LogLevel.Warning);

// Register Termina
builder.Services.AddTermina("/counter", termina =>
{
    termina.RegisterRoute<CounterPage, CounterViewModel>("/counter");
});

var host = builder.Build();
await host.RunAsync();
Enter fullscreen mode Exit fullscreen mode

Common Patterns

List Display

ViewModel.Items
    .Select<List<string>, ILayoutNode>(items => new TextNode(
        string.Join("\n", items.Take(10))  // Show first 10 items
    ))
    .AsLayout()
Enter fullscreen mode Exit fullscreen mode

Status Display with Reactive Updates

new PanelNode()
    .WithTitle("Status")
    .WithContent(
        Observable.CombineLatest(
            ViewModel.Status,
            ViewModel.Progress
        )
        .Select(x => new TextNode($"{x[0]} - {x[1]}%"))
        .AsLayout()
    )
Enter fullscreen mode Exit fullscreen mode

Modal Dialog

new ModalNode()
    .WithTitle("Confirm")
    .WithContent(new TextNode("Are you sure?"))
    .WithBackdrop(true)
Enter fullscreen mode Exit fullscreen mode

Scrollable Content

new ScrollableContainer()
    .WithChild(contentNode)
    .Height(20)
Enter fullscreen mode Exit fullscreen mode

Loading Indicator

new SpinnerNode()
    .WithLabel("Loading...")
Enter fullscreen mode Exit fullscreen mode

Performance Considerations

  1. Surgical Rendering: Only changed regions re-render automatically
  2. ReactiveProperty Disposal: Always dispose in ViewModel.Dispose()
  3. Subscription Management: Use .DisposeWith(Subscriptions) in OnActivated()
  4. String Concatenation: For frequently updated content, consider StringBuilder
  5. Observable Subscriptions: Subscribe only to properties that change in BuildLayout()

Breaking Changes

Version 0.7.0

Version 0.11.0

Resources

Akka.NET Integration Potential

While Termina doesn't have a hard dependency on Akka.NET, the framework was created by Aaron Stannard, who also founded Akka.NET. This creates significant architectural synergy opportunities:

Observable-to-Observable Bridge

Termina's R3 observables can integrate with Akka.NET's streaming infrastructure:

// Termina observable to Akka.Streams
public ReactiveProperty<string> MessageStream { get; } = new("");

// Bridge to Akka.Streams
Source.FromObservable(ViewModel.MessageStream)
    .RunForEach(msg => {
        // Process Akka.NET actor messages into Termina UI
    });
Enter fullscreen mode Exit fullscreen mode

Actor-Based Backend with Termina UI

Use Akka.NET actors for backend logic and Termina for the TUI frontend:

// Host configuration with both Akka.NET and Termina
var builder = Host.CreateApplicationBuilder(args);

// Register Akka.NET
builder.Services.AddAkka("termina-app", configBuilder => {
    configBuilder.AddActorSystem();
});

// Register Termina
builder.Services.AddTermina("/dashboard", termina => {
    termina.RegisterRoute<DashboardPage, DashboardViewModel>("/dashboard");
});
Enter fullscreen mode Exit fullscreen mode

Real-Time Dashboard Pattern

Common use case: Use Akka.NET actors to process distributed data and Termina to display it:

  • Akka.NET: Handles concurrent data aggregation, event sourcing, clustering
  • Termina: Displays real-time metrics with surgical rendering and responsive UI
  • Integration: R3 observables bridge actor state to UI bindings

Streaming Architecture

Akka.Streams integrates with observables (since version 1.3.2):

// Expose Akka.Streams as observable
IObservable<T> observable = akkaStream.RunWith(Sink.AsObservable<T>(), materializer);

// Use in Termina UI
observable
    .Select(item => new TextNode($"{item}"))
    .AsLayout()
Enter fullscreen mode Exit fullscreen mode

Backpressure and Flow Control

Akka.Streams offers backpressure awareness that R3 observables can leverage:

  • Prevents UI from being overwhelmed by rapid state changes
  • Buffers with configurable overflow strategies
  • Maintains responsiveness under high-volume actor message streams

Testing Distributed Systems

Combine Akka.TestKit with Termina's VirtualInputSource for end-to-end testing:

// Test Akka actors and their UI representation
var probe = CreateTestProbe();
var actorRef = ActorOf<MyActor>();

// Verify UI behavior with scripted input
var scriptedInput = new VirtualInputSource();
builder.Services.AddTerminaVirtualInput(scriptedInput);

// Send messages to actor, verify UI updates
actorRef.Tell(new MyMessage());
scriptedInput.EnqueueKey(ConsoleKey.Enter);
Enter fullscreen mode Exit fullscreen mode

Deployment Scenarios

Single-Node Monitor:

  • Termina TUI for local terminal-based dashboards
  • Akka.NET local actors for data collection

Distributed Monitoring:

  • Akka.Cluster processes data on multiple nodes
  • Termina TUI on each node for local monitoring
  • Akka.Remote for inter-node communication

CLI Tools with Actor Backend:

  • Termina provides interactive CLI interface
  • Akka.NET handles long-running background tasks
  • Observable streams bridge progress/status updates

Related Concepts

  • MVVM Pattern: Model-View-ViewModel architectural pattern
  • Reactive Programming: Using observables for state management
  • Terminal UI (TUI): Text-based user interfaces in terminal/console
  • ANSI Escape Codes: Terminal formatting and color codes
  • Dependency Injection: Microsoft.Extensions.DependencyInjection
  • Async/Await: Asynchronous programming model for .NET
  • R3 Observables: Modern reactive extensions library for .NET
  • Akka.NET: Actor model framework for distributed/concurrent systems
  • Akka.Streams: Stream processing with backpressure on top of actors
  • Microsoft.Extensions.Hosting: Host lifecycle management

Summary

Termina is a production-ready, modern terminal UI framework that brings ASP.NET Core patterns to terminal applications. Its reactive architecture, surgical rendering, and declarative layout system make it ideal for building interactive CLI tools, dashboards, monitoring applications, and any text-based UI that needs real-time updates. The framework's AOT compatibility ensures you can deploy single-file executables for any platform.

Complementary Technologies

Aaron Stannard (Termina's creator) is also the founder of Akka.NET, which creates natural architectural synergy:

  • Termina: Excellent for responsive, declarative TUI layer
  • Akka.NET: Ideal for concurrent, distributed, fault-tolerant backend
  • Observable Bridge: R3 observables seamlessly connect actor state to UI updates

For systems requiring both sophisticated backend logic (actor model, distributed processing, event sourcing) and interactive terminal interfaces (dashboards, monitoring, real-time displays), combining Akka.NET and Termina provides a cohesive, modern development experience across the full stack.

Top comments (0)