Skip to content

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 Termina

Understanding the Pattern

Termina uses an MVVM pattern with three key pieces:

  1. ViewModel - Manages state with [Reactive] properties, handles keyboard input, and provides navigation/shutdown actions
  2. Page - Builds the UI layout from state, manages focus for modals and interactive controls
  3. 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., Count from _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 run

You should see a terminal UI with a counter. Press ↑ to increment, ↓ to decrement, type to add messages, and Escape to quit.

What Just Happened?

  1. Host Builder - We used Microsoft.Extensions.Hosting for application lifecycle management
  2. AddTermina - Registered Termina with the starting route /counter
  3. RegisterRoute - Associated the route with our Page and ViewModel
  4. BuildLayout - Defined our UI as a tree of layout nodes
  5. Reactive Binding - CountChanged.Select(...).AsLayout() automatically updates the UI when Count changes

Next Steps

Released under the Apache 2.0 License.