Getting Started
This guide will walk you through creating your first Termina application.
Prerequisites
- .NET 10 SDK or later
- A terminal emulator (Windows Terminal, iTerm2, or any terminal with ANSI support)
Create a New Project
bash
# Create a new console application
dotnet new console -n MyTerminaApp
cd MyTerminaApp
# Add Termina package
dotnet add package TerminaUnderstanding the Pattern
Termina uses an MVVM pattern with three key pieces:
- ViewModel - Manages state with
[Reactive]properties, handles keyboard input, and provides navigation/shutdown actions - Page - Builds the UI layout from state, manages focus for modals and interactive controls
- Host - Wires everything together with routing and dependency injection
Example: Counter Demo
Here's a complete working example from the Termina demos.
The ViewModel
The ViewModel manages state with [Reactive] properties that automatically generate observable change notifications:
csharp
// Copyright (c) Petabridge, LLC. All rights reserved.
// Licensed under the Apache 2.0 license. See LICENSE file in the project root for full license information.
using System.Reactive.Linq;
using Termina.Input;
using Termina.Reactive;
namespace Termina.Demo.RegionBased;
/// <summary>
/// ViewModel for the counter demo.
/// Demonstrates reactive properties with the v2 rendering infrastructure.
/// </summary>
public partial class CounterViewModel : ReactiveViewModel
{
[Reactive] private int _count;
[Reactive] private string _statusMessage = "Press Up/Down to change count, Enter to submit message, Escape to quit";
[Reactive] private string _inputText = "";
[Reactive] private List<string> _messages = new();
public override void OnActivated()
{
// Subscribe to keyboard input
Input.OfType<KeyPressed>()
.Subscribe(HandleKeyPress)
.DisposeWith(Subscriptions);
}
private void HandleKeyPress(KeyPressed key)
{
switch (key.KeyInfo.Key)
{
case ConsoleKey.UpArrow:
Count++;
StatusMessage = $"Incremented to {Count}";
break;
case ConsoleKey.DownArrow:
Count--;
StatusMessage = $"Decremented to {Count}";
break;
case ConsoleKey.Enter:
if (!string.IsNullOrWhiteSpace(InputText))
{
var newMessages = new List<string>(Messages)
{
$"[{DateTime.Now:HH:mm:ss}] {InputText}"
};
// Keep last 8 messages
if (newMessages.Count > 8)
newMessages.RemoveAt(0);
Messages = newMessages;
InputText = "";
StatusMessage = "Message sent!";
}
break;
case ConsoleKey.Backspace:
if (InputText.Length > 0)
InputText = InputText[..^1];
break;
case ConsoleKey.Escape:
Shutdown();
break;
default:
// Handle printable characters for input
if (key.KeyInfo.KeyChar >= 32 && key.KeyInfo.KeyChar < 127)
{
InputText += key.KeyInfo.KeyChar;
}
break;
}
}
}The [Reactive] Attribute
The [Reactive] attribute uses source generation to create:
- A public property (e.g.,
Countfrom_count) - An
IObservable<T>property (e.g.,CountChanged) - Automatic change notifications
Your class must be partial for the source generator to work.
The Page
The Page builds the UI as a tree of layout nodes with reactive bindings:
csharp
// Copyright (c) Petabridge, LLC. All rights reserved.
// Licensed under the Apache 2.0 license. See LICENSE file in the project root for full license information.
using System.Reactive.Linq;
using Termina.Extensions;
using Termina.Layout;
using Termina.Reactive;
using Termina.Rendering;
using Termina.Terminal;
namespace Termina.Demo.RegionBased;
/// <summary>
/// Page for the counter demo.
/// Demonstrates the declarative layout API with reactive bindings.
/// </summary>
public class CounterPage : ReactivePage<CounterViewModel>
{
public override ILayoutNode BuildLayout()
{
return Layouts.Vertical()
// Header panel
.WithChild(
new PanelNode()
.WithTitle("Termina v2 Demo")
.WithBorder(BorderStyle.Double)
.WithBorderColor(Color.Blue)
.WithTitleColor(Color.BrightCyan)
.WithContent(
new TextNode("Reactive Region-Based Rendering")
.WithForeground(Color.Cyan))
.Height(3))
// Counter display - reactive binding
.WithChild(
new PanelNode()
.WithTitle("Counter")
.WithBorder(BorderStyle.Single)
.WithBorderColor(Color.Green)
.WithContent(
ViewModel.CountChanged
.Select(count => new TextNode($"Count: {count}")
.WithForeground(Color.BrightCyan))
.AsLayout())
.Height(3))
// Input panel - reactive binding (NoWrap for single-line input)
.WithChild(
new PanelNode()
.WithTitle("Input")
.WithBorder(BorderStyle.Single)
.WithBorderColor(Color.Yellow)
.WithContent(
ViewModel.InputTextChanged
.Select(text => new TextNode($"> {text}_")
.WithForeground(Color.White)
.NoWrap())
.AsLayout())
.Height(3))
// Messages panel - reactive binding
.WithChild(
new PanelNode()
.WithTitle("Messages")
.WithBorder(BorderStyle.Single)
.WithBorderColor(Color.Magenta)
.WithContent(
ViewModel.MessagesChanged
.Select(messages => new TextNode(messages.Count > 0
? string.Join("\n", messages)
: "(no messages yet)")
.WithForeground(Color.Gray))
.AsLayout())
.Fill())
// Status bar at the bottom - reactive binding with instructions
// Note: Height(1) means only 1 row is available - any wrapped text will be clipped
.WithChild(
Layouts.Horizontal()
.WithChild(
ViewModel.StatusMessageChanged
.Select(status => new TextNode(status)
.WithForeground(Color.BrightYellow)
.NoWrap()) // Status should truncate, not wrap
.AsLayout()
.Fill())
.WithChild(
new TextNode("[↑/↓] Count [Enter] Send [Esc] Quit")
.WithForeground(Color.BrightBlack)
.NoWrap() // Instructions should truncate at narrow widths
.WidthAuto()) // Take only the width needed
.Height(1));
}
}The Program
Wire it all together with hosting and routing:
csharp
// Copyright (c) Petabridge, LLC. All rights reserved.
// Licensed under the Apache 2.0 license. See LICENSE file in the project root for full license information.
using Microsoft.Extensions.Hosting;
using Termina.Demo.RegionBased;
using Termina.Hosting;
using Termina.Input;
// Check for --test flag (used in CI/CD to run scripted test and exit)
var testMode = args.Contains("--test");
var builder = Host.CreateApplicationBuilder(args);
// Set up input source based on mode
VirtualInputSource? scriptedInput = null;
if (testMode)
{
scriptedInput = new VirtualInputSource();
builder.Services.AddTerminaVirtualInput(scriptedInput);
}
// Register Termina with the tree-based reactive page
builder.Services.AddTermina("/counter", termina =>
{
termina.RegisterRoute<CounterPage, CounterViewModel>("/counter");
});
var host = builder.Build();
// Test mode - queue up scripted input then quit
if (testMode && scriptedInput != null)
{
_ = Task.Run(async () =>
{
await Task.Delay(100); // Wait for initial render
// Increment counter
scriptedInput.EnqueueKey(ConsoleKey.UpArrow);
scriptedInput.EnqueueKey(ConsoleKey.UpArrow);
scriptedInput.EnqueueKey(ConsoleKey.UpArrow);
await Task.Delay(50);
// Decrement counter
scriptedInput.EnqueueKey(ConsoleKey.DownArrow);
await Task.Delay(50);
// Type a message
scriptedInput.EnqueueString("Hello from test!");
await Task.Delay(50);
scriptedInput.EnqueueKey(ConsoleKey.Enter);
await Task.Delay(50);
// Type another message
scriptedInput.EnqueueString("Testing reactive v2");
scriptedInput.EnqueueKey(ConsoleKey.Enter);
await Task.Delay(50);
// Quit
scriptedInput.EnqueueKey(ConsoleKey.Escape);
scriptedInput.Complete();
});
}
await host.RunAsync();Run the Application
bash
dotnet runYou should see a terminal UI with a counter. Press ↑ to increment, ↓ to decrement, type to add messages, and Escape to quit.
What Just Happened?
- Host Builder - We used
Microsoft.Extensions.Hostingfor application lifecycle management - AddTermina - Registered Termina with the starting route
/counter - RegisterRoute - Associated the route with our Page and ViewModel
- BuildLayout - Defined our UI as a tree of layout nodes
- Reactive Binding -
CountChanged.Select(...).AsLayout()automatically updates the UI whenCountchanges
Next Steps
- Installation - Learn about package options and dependencies
- Layout System - Understand how layouts work
- Components - Explore all available UI components
- Counter Tutorial - Detailed walkthrough with more features