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 anObservable<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()andNavigateWithParams() - 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
StreamingTextNodefor 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
-
VirtualInputSourcefor 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
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 inDispose()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.Hostingfor lifecycle management - Registers routes via
AddTermina()andRegisterRoute<TPage, TViewModel>() - Supports virtual input for testing
Reactive Property Pattern
ReactiveProperty<T> is the foundation of Termina's reactivity:
- Holds a value accessible via
.Valueproperty - Emits observables when value changes
- Built-in
DistinctUntilChangedbehavior (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));
Horizontal layout with weighted fills:
Layouts.Horizontal()
.WithChild(menu.Width(30))
.WithChild(main.Fill(2)) // 2x weight
.WithChild(aside.Fill(1)); // 1x weight
Nested layouts:
Layouts.Vertical()
.WithChild(
Layouts.Horizontal()
.WithChild(sidebar.Width(20))
.WithChild(content.Fill())
)
.WithChild(footer.Height(1));
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;
}
}
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}");
});
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
}
}
Navigation
Navigate between routes programmatically:
// Simple navigation
Navigate("/tasks/42");
// With parameters
NavigateWithParams("/tasks/{id}", new { id = 42 });
// Exit application
Shutdown();
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()
Multiple Observable Bindings
Combine multiple observables:
Observable.CombineLatest(
ViewModel.Title,
ViewModel.Message
)
.Select(x => new TextNode($"{x[0]}: {x[1]}").WithForeground(Color.Cyan))
.AsLayout()
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
}
}
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();
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();
}
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();
}
}
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));
}
}
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();
Common Patterns
List Display
ViewModel.Items
.Select<List<string>, ILayoutNode>(items => new TextNode(
string.Join("\n", items.Take(10)) // Show first 10 items
))
.AsLayout()
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()
)
Modal Dialog
new ModalNode()
.WithTitle("Confirm")
.WithContent(new TextNode("Are you sure?"))
.WithBackdrop(true)
Scrollable Content
new ScrollableContainer()
.WithChild(contentNode)
.Height(20)
Loading Indicator
new SpinnerNode()
.WithLabel("Loading...")
Performance Considerations
- Surgical Rendering: Only changed regions re-render automatically
- ReactiveProperty Disposal: Always dispose in ViewModel.Dispose()
-
Subscription Management: Use
.DisposeWith(Subscriptions)in OnActivated() - String Concatenation: For frequently updated content, consider StringBuilder
- Observable Subscriptions: Subscribe only to properties that change in BuildLayout()
Breaking Changes
Version 0.7.0
- Migrated from System.Reactive to R3
- R3 observable syntax may differ slightly from System.Reactive
- See migration guide at https://aaronstannard.com/termina/guide/migration-0.7.html
Version 0.11.0
- Changes to render loop threading
- See upgrade guide at https://aaronstannard.com/termina/guide/upgrade-0.11.html
Resources
- Official Documentation: https://aaronstannard.com/termina/
- GitHub Repository: https://github.com/Aaronontheweb/Termina
- NuGet Package: https://www.nuget.org/packages/Termina
- Author: Aaron Stannard (https://aaronstannard.com/)
- License: Apache 2.0
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
});
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");
});
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()
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);
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)