Skip to content

Todo List Tutorial

Build a todo list manager with list selection, item management, and multi-page navigation.

What You'll Build

A todo list application with:

  • Scrollable list with selection highlighting
  • Add, toggle, and delete items
  • Text input mode for new items
  • Navigation to other pages
  • Progress tracking

Project Setup

This tutorial extends from the counter app. If starting fresh:

bash
dotnet new console -n TodoDemo
cd TodoDemo
dotnet add package Termina
dotnet add package Microsoft.Extensions.Hosting

Step 1: Create the Data Model

First, define a simple record for todo items:

csharp
public record TodoItem(string Description, bool IsCompleted);

Step 2: Create the ViewModel

The ViewModel manages the list state and handles all input.

View complete TodoListViewModel.cs
csharp
using System.Reactive.Linq;
using Termina.Input;
using Termina.Reactive;

namespace Termina.Demo.Pages;

/// <summary>
/// ViewModel for a todo list demo.
/// Contains only state and business logic - no UI concerns.
/// </summary>
public partial class TodoListViewModel : ReactiveViewModel
{
    private readonly TraceFileInfo _traceFileInfo;

    public TodoListViewModel(TraceFileInfo traceFileInfo)
    {
        _traceFileInfo = traceFileInfo;
    }

    /// <summary>
    /// Gets the path to the trace log file for display in the UI.
    /// </summary>
    public string TraceFilePath => _traceFileInfo.FilePath;

    [Reactive] private IReadOnlyList<TodoItem> _items = new List<TodoItem>
    {
        new("Learn Termina reactive patterns", false),
        new("Build a TUI app", false),
        new("Deploy to production", false),
        new("Celebrate success", false)
    };

    [Reactive] private int _selectedIndex;
    [Reactive] private string _statusMessage = "Navigate with ↑/↓, Space to toggle, C for counter, Q to quit";
    [Reactive] private bool _isAddingItem;
    [Reactive] private bool _showPriorityModal;
    [Reactive] private string _pendingTaskText = "";

    public override void OnActivated()
    {
        // Subscribe to keyboard input
        Input.OfType<KeyPressed>()
            .Subscribe(HandleKeyPress)
            .DisposeWith(Subscriptions);
    }

    /// <summary>
    /// Called by Page when text input is submitted.
    /// </summary>
    public void OnTextInputSubmitted(string text)
    {
        if (!string.IsNullOrWhiteSpace(text))
        {
            PendingTaskText = text.Trim();
            ShowPriorityModal = true;
            StatusMessage = "Select priority with ↑/↓ or number keys, Enter to confirm";
        }
        else
        {
            CancelAddItem();
        }
    }

    /// <summary>
    /// Called by Page when priority is selected.
    /// </summary>
    public void OnPrioritySelected(string priority)
    {
        var newItems = Items.ToList();
        var displayText = priority != "Normal" ? $"[{priority}] {PendingTaskText}" : PendingTaskText;
        newItems.Add(new TodoItem(displayText, false));
        Items = newItems;
        SelectedIndex = Items.Count - 1;
        StatusMessage = $"Added: {displayText}";

        // Clean up state
        ShowPriorityModal = false;
        IsAddingItem = false;
        PendingTaskText = "";
    }

    /// <summary>
    /// Called by Page when priority selection is cancelled.
    /// </summary>
    public void OnPriorityCancelled()
    {
        ShowPriorityModal = false;
    }

    /// <summary>
    /// Called by Page when add modal is dismissed.
    /// </summary>
    public void OnAddModalDismissed()
    {
        CancelAddItem();
    }

    /// <summary>
    /// Called by Page when priority modal is dismissed.
    /// </summary>
    public void OnPriorityModalDismissed()
    {
        ShowPriorityModal = false;
        IsAddingItem = false;
        StatusMessage = "Cancelled adding item";
    }

    private void HandleKeyPress(KeyPressed key)
    {
        // Don't handle keys when in modal mode - let the modals handle input
        if (IsAddingItem || ShowPriorityModal)
            return;

        switch (key.KeyInfo.Key)
        {
            case ConsoleKey.UpArrow:
                if (SelectedIndex > 0)
                {
                    SelectedIndex--;
                    StatusMessage = $"Selected: {Items[SelectedIndex].Description}";
                }
                break;

            case ConsoleKey.DownArrow:
                if (SelectedIndex < Items.Count - 1)
                {
                    SelectedIndex++;
                    StatusMessage = $"Selected: {Items[SelectedIndex].Description}";
                }
                break;

            case ConsoleKey.Spacebar:
                ToggleSelected();
                break;

            case ConsoleKey.A:
                StartAddingItem();
                break;

            case ConsoleKey.D:
                DeleteSelected();
                break;

            case ConsoleKey.C:
                Navigate("/counter");
                break;

            case ConsoleKey.Q:
                Shutdown();
                break;
        }
    }

    private void StartAddingItem()
    {
        IsAddingItem = true;
        ShowPriorityModal = false;
        PendingTaskText = "";
        StatusMessage = "Enter task name and press Enter, or Escape to cancel";
    }

    private void CancelAddItem()
    {
        IsAddingItem = false;
        ShowPriorityModal = false;
        PendingTaskText = "";
        StatusMessage = "Cancelled adding item";
    }

    private void ToggleSelected()
    {
        if (Items.Count == 0) return;

        var item = Items[SelectedIndex];
        var newItem = item with { IsCompleted = !item.IsCompleted };

        var newItems = Items.ToList();
        newItems[SelectedIndex] = newItem;
        Items = newItems;

        StatusMessage = newItem.IsCompleted
            ? $"Completed: {newItem.Description}"
            : $"Uncompleted: {newItem.Description}";
    }

    private void DeleteSelected()
    {
        if (Items.Count == 0) return;

        var deleted = Items[SelectedIndex];
        var newItems = Items.ToList();
        newItems.RemoveAt(SelectedIndex);
        Items = newItems;

        if (SelectedIndex >= Items.Count && Items.Count > 0)
        {
            SelectedIndex = Items.Count - 1;
        }

        StatusMessage = $"Deleted: {deleted.Description}";
    }
}

/// <summary>
/// A simple todo item.
/// </summary>
public record TodoItem(string Description, bool IsCompleted);

Key Points

Collection State

csharp
[Reactive] private IReadOnlyList<TodoItem> _items = new List<TodoItem> { ... };
[Reactive] private int _selectedIndex;

Use immutable collections with [Reactive]. To update, create a new list:

csharp
var newItems = Items.ToList();
newItems.Add(new TodoItem("New task", false));
Items = newItems;  // Triggers UI update

Input Mode Handling

csharp
[Reactive] private bool _isAddingItem;
[Reactive] private string _newItemText = "";

private void HandleKeyPress(KeyPressed key)
{
    if (IsAddingItem)
    {
        HandleTextEntryKeyPress(key);
        return;
    }
    // Normal mode handling...
}

Switch input handling based on current mode.

Navigation

csharp
case ConsoleKey.C:
    Navigate("/counter");
    break;

Use Navigate() to move between pages.

Step 3: Create the Page

The Page renders the list and responds to state changes.

View complete TodoListPage.cs
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.Input;
using Termina.Layout;
using Termina.Reactive;
using Termina.Rendering;
using Termina.Terminal;

namespace Termina.Demo.Pages;

/// <summary>
/// Page for the todo list demo.
/// Handles all UI concerns including layout nodes and focus management.
/// Reacts to ViewModel state changes.
/// </summary>
public class TodoListPage : ReactivePage<TodoListViewModel>
{
    // Layout nodes owned by the Page
    private ModalNode _addModal = null!;
    private ModalNode _priorityModal = null!;
    private SelectionListNode<string> _priorityList = null!;
    private TextInputNode _textInput = null!;

    protected override void OnBound()
    {
        // Layout nodes will be created in BuildLayout() where they're used
    }

    public override void OnNavigatedTo()
    {
        base.OnNavigatedTo();

        // Subscribe to text input submission
        _textInput.Submitted
            .Subscribe(text => ViewModel.OnTextInputSubmitted(text))
            .DisposeWith(Subscriptions);

        // Subscribe to priority selection events
        _priorityList.SelectionConfirmed
            .Subscribe(selected =>
            {
                var priority = selected.FirstOrDefault() ?? "Normal";
                ViewModel.OnPrioritySelected(priority);
            })
            .DisposeWith(Subscriptions);

        _priorityList.OtherSelected
            .Subscribe(customPriority => ViewModel.OnPrioritySelected(customPriority))
            .DisposeWith(Subscriptions);

        _priorityList.Cancelled
            .Subscribe(_ => ViewModel.OnPriorityCancelled())
            .DisposeWith(Subscriptions);

        _addModal.Dismissed
            .Subscribe(_ => ViewModel.OnAddModalDismissed())
            .DisposeWith(Subscriptions);

        _priorityModal.Dismissed
            .Subscribe(_ => ViewModel.OnPriorityModalDismissed())
            .DisposeWith(Subscriptions);

        // React to state changes for focus management
        // When IsAddingItem becomes true (and not showing priority), focus add modal
        ViewModel.IsAddingItemChanged
            .CombineLatest(
                ViewModel.ShowPriorityModalChanged,
                (adding, showPriority) => (adding, showPriority))
            .DistinctUntilChanged()
            .Subscribe(state =>
            {
                if (state.adding && !state.showPriority)
                {
                    _textInput.Clear();
                    Focus.PushFocus(_addModal);
                    Focus.PushFocus(_textInput);
                }
                else if (!state.adding && !state.showPriority)
                {
                    Focus.ClearFocus();
                }
            })
            .DisposeWith(Subscriptions);

        // When ShowPriorityModal becomes true, switch focus to priority modal
        ViewModel.ShowPriorityModalChanged
            .Where(show => show)
            .Subscribe(_ =>
            {
                Focus.PopFocus(); // Remove add modal focus
                Focus.PushFocus(_priorityModal);
                Focus.PushFocus(_priorityList);
            })
            .DisposeWith(Subscriptions);
    }

    public override ILayoutNode BuildLayout()
    {
        // Create layout nodes as part of the layout tree lifecycle
        _textInput = new TextInputNode()
            .WithPlaceholder("Enter task description...");

        _priorityList = Layouts.SelectionList("High", "Medium", "Low")
            .WithMode(SelectionMode.Single)
            .WithShowNumbers(true)
            .WithHighlightColors(Color.Black, Color.Cyan)
            .WithOtherOption("Custom priority...");

        _addModal = Layouts.Modal()
            .WithTitle("Add New Task")
            .WithBorder(BorderStyle.Rounded)
            .WithBorderColor(Color.Cyan)
            .WithBackdrop(BackdropStyle.Dim)
            .WithPosition(ModalPosition.Center)
            .WithPadding(1)
            .WithContent(_textInput)
            .WithDismissOnEscape(true);

        _priorityModal = Layouts.Modal()
            .WithTitle("Select Priority")
            .WithBorder(BorderStyle.Rounded)
            .WithBorderColor(Color.Yellow)
            .WithBackdrop(BackdropStyle.Dim)
            .WithPosition(ModalPosition.Center)
            .WithPadding(1)
            .WithContent(_priorityList)
            .WithDismissOnEscape(true);

        // Build the main content
        var mainContent = Layouts.Vertical()
            .WithChild(
                new PanelNode()
                    .WithTitle("Todo List Demo")
                    .WithBorder(BorderStyle.Double)
                    .WithBorderColor(Color.Cyan)
                    .WithContent(
                        ViewModel.ItemsChanged
                            .CombineLatest(ViewModel.SelectedIndexChanged, (items, selectedIdx) => (items, selectedIdx))
                            .Select(tuple => BuildTodoList(tuple.items, tuple.selectedIdx))
                            .AsLayout())
                    .Fill())
            .WithChild(
                new TextNode("[↑/↓] Navigate [Space] Toggle [A] Add [D] Delete [C] Counter [Q] Quit")
                    .WithForeground(Color.BrightBlack)
                    .Height(1))
            .WithChild(
                ViewModel.StatusMessageChanged
                    .Select(msg => new TextNode(msg).WithForeground(Color.White))
                    .AsLayout()
                    .Height(1))
            .WithChild(
                new TextNode($"Trace log: {ViewModel.TraceFilePath}")
                    .WithForeground(Color.DarkGray)
                    .Height(1));

        // Build the layer with modal overlays
        return Layouts.Stack()
            .WithChild(mainContent)
            .WithChild(
                ViewModel.IsAddingItemChanged
                    .CombineLatest(ViewModel.ShowPriorityModalChanged, (adding, showPriority) => adding && !showPriority)
                    .Select(showModal => showModal
                        ? (ILayoutNode)_addModal
                        : Layouts.Empty())
                    .AsLayout())
            .WithChild(
                ViewModel.ShowPriorityModalChanged
                    .Select(showPriority => showPriority
                        ? (ILayoutNode)_priorityModal
                        : Layouts.Empty())
                    .AsLayout());
    }

    private static ILayoutNode BuildTodoList(IReadOnlyList<TodoItem> items, int selectedIndex)
    {
        if (items.Count == 0)
        {
            return new TextNode("\n  No items - press [A] to add\n")
                .WithForeground(Color.DarkGray)
                .Italic();
        }

        var completedCount = items.Count(i => i.IsCompleted);
        var statsText = $"{completedCount}/{items.Count} completed";

        var container = Layouts.Vertical()
            .WithChild(
                new TextNode($"\n  {statsText}\n")
                    .WithForeground(Color.Gray));

        for (var i = 0; i < items.Count; i++)
        {
            var item = items[i];
            var isSelected = i == selectedIndex;
            var checkbox = item.IsCompleted ? "[✓]" : "[ ]";
            var text = $"  {checkbox} {item.Description}";

            var textNode = new TextNode(text);

            if (isSelected)
            {
                textNode = textNode.WithForeground(Color.Black).WithBackground(Color.Green);
            }
            else if (item.IsCompleted)
            {
                textNode = textNode.WithForeground(Color.DarkGray);
            }
            else
            {
                textNode = textNode.WithForeground(Color.White);
            }

            container = container.WithChild(textNode.Height(1));
        }

        return container;
    }
}

Key Points

Combining Observables

csharp
ViewModel.ItemsChanged
    .CombineLatest(ViewModel.SelectedIndexChanged, (items, idx) => (items, idx))
    .Select(tuple => BuildTodoList(tuple.items, tuple.idx))
    .AsLayout()

Use CombineLatest when the UI depends on multiple reactive properties.

Conditional Rendering

csharp
ViewModel.IsAddingItemChanged
    .Select(isAdding => isAdding
        ? BuildTextInputRow()
        : BuildHelpText())
    .AsLayout()

Switch layouts based on state.

Selection Highlighting

csharp
if (isSelected)
{
    textNode = textNode
        .WithForeground(Color.Black)
        .WithBackground(Color.Green);
}

Style items based on selection state.

Dynamic List Building

csharp
private static ILayoutNode BuildTodoList(IReadOnlyList<TodoItem> items, int selectedIndex)
{
    var container = Layouts.Vertical();

    for (var i = 0; i < items.Count; i++)
    {
        container = container.WithChild(BuildItem(items[i], i == selectedIndex));
    }

    return container;
}

Build layout nodes dynamically from collections.

Step 4: Register Routes

View complete Program.cs
csharp
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Termina.Demo;
using Termina.Demo.Pages;
using Termina.Diagnostics;
using Termina.Hosting;
using Termina.Input;
using Termina.Pages;

// Check for --test flag (used in CI/CD to run scripted test and exit)
var testMode = args.Contains("--test");

// Set up diagnostic tracing with timestamped log file in temp directory
var traceDir = Path.Combine(Path.GetTempPath(), "termina-logs");
Directory.CreateDirectory(traceDir);
var traceFile = Path.Combine(traceDir, $"trace-{DateTime.Now:yyyyMMdd-HHmmss}.log");

var builder = Host.CreateApplicationBuilder(args);

// Enable file tracing and register the path for UI display
builder.Services.AddTerminaFileTracing(traceFile, TerminaTraceCategory.All, TerminaTraceLevel.Debug);
builder.Services.AddSingleton(new TraceFileInfo(traceFile));

// Set up input source based on mode
VirtualInputSource? scriptedInput = null;
if (testMode)
{
    scriptedInput = new VirtualInputSource();
    builder.Services.AddTerminaVirtualInput(scriptedInput);
}

// Register Termina with reactive pages using route-based navigation
// Use PreserveState so state persists when navigating between pages
builder.Services.AddTermina("/counter", termina =>
{
    termina.RegisterRoute<CounterPage, CounterViewModel>("/counter", NavigationBehavior.PreserveState);
    termina.RegisterRoute<TodoListPage, TodoListViewModel>("/todos", NavigationBehavior.PreserveState);
});

var host = builder.Build();

if (testMode && scriptedInput != null)
{
    // Queue up scripted input to test basic functionality then quit
    _ = Task.Run(async () =>
    {
        await Task.Delay(500); // Wait for initial render

        // Test counter: increment twice
        scriptedInput.EnqueueKey(ConsoleKey.UpArrow);
        scriptedInput.EnqueueKey(ConsoleKey.UpArrow);

        // Navigate to todo list
        scriptedInput.EnqueueKey(ConsoleKey.T);
        await Task.Delay(200);

        // Navigate in todo list
        scriptedInput.EnqueueKey(ConsoleKey.DownArrow);
        scriptedInput.EnqueueKey(ConsoleKey.Spacebar); // Toggle item

        // Quit
        scriptedInput.EnqueueKey(ConsoleKey.Q);
        scriptedInput.Complete();
    });
}

await host.RunAsync();

Key Points

Multiple Routes

csharp
builder.Services.AddTermina("/counter", termina =>
{
    termina.RegisterRoute<CounterPage, CounterViewModel>("/counter");
    termina.RegisterRoute<TodoListPage, TodoListViewModel>("/todos");
});

Register multiple pages with their routes.

PreserveState

csharp
termina.RegisterRoute<TodoListPage, TodoListViewModel>(
    "/todos",
    NavigationBehavior.PreserveState);

Use PreserveState to keep state when navigating away and back.

Run the App

bash
dotnet run

Controls:

  • / - Navigate list
  • Space - Toggle completion
  • A - Add new item
  • D - Delete selected
  • C - Go to counter page
  • Q - Quit

Patterns Demonstrated

Immutable Updates

Always create new collections to trigger updates:

csharp
// Bad - mutation doesn't trigger update
Items.Add(newItem);  // Won't update UI!

// Good - replacement triggers update
Items = Items.Append(newItem).ToList();

Mode-Based Input

Use state to control input behavior:

csharp
private void HandleKeyPress(KeyPressed key)
{
    if (IsAddingItem)
    {
        HandleTextEntryMode(key);
        return;
    }
    HandleNormalMode(key);
}

Progress Tracking

Calculate derived values from state:

csharp
var completedCount = items.Count(i => i.IsCompleted);
var statsText = $"{completedCount}/{items.Count} completed";

Complete Code

cs
using System.Reactive.Linq;
using Termina.Input;
using Termina.Reactive;

namespace Termina.Demo.Pages;

/// <summary>
/// ViewModel for a todo list demo.
/// Contains only state and business logic - no UI concerns.
/// </summary>
public partial class TodoListViewModel : ReactiveViewModel
{
    private readonly TraceFileInfo _traceFileInfo;

    public TodoListViewModel(TraceFileInfo traceFileInfo)
    {
        _traceFileInfo = traceFileInfo;
    }

    /// <summary>
    /// Gets the path to the trace log file for display in the UI.
    /// </summary>
    public string TraceFilePath => _traceFileInfo.FilePath;

    [Reactive] private IReadOnlyList<TodoItem> _items = new List<TodoItem>
    {
        new("Learn Termina reactive patterns", false),
        new("Build a TUI app", false),
        new("Deploy to production", false),
        new("Celebrate success", false)
    };

    [Reactive] private int _selectedIndex;
    [Reactive] private string _statusMessage = "Navigate with ↑/↓, Space to toggle, C for counter, Q to quit";
    [Reactive] private bool _isAddingItem;
    [Reactive] private bool _showPriorityModal;
    [Reactive] private string _pendingTaskText = "";

    public override void OnActivated()
    {
        // Subscribe to keyboard input
        Input.OfType<KeyPressed>()
            .Subscribe(HandleKeyPress)
            .DisposeWith(Subscriptions);
    }

    /// <summary>
    /// Called by Page when text input is submitted.
    /// </summary>
    public void OnTextInputSubmitted(string text)
    {
        if (!string.IsNullOrWhiteSpace(text))
        {
            PendingTaskText = text.Trim();
            ShowPriorityModal = true;
            StatusMessage = "Select priority with ↑/↓ or number keys, Enter to confirm";
        }
        else
        {
            CancelAddItem();
        }
    }

    /// <summary>
    /// Called by Page when priority is selected.
    /// </summary>
    public void OnPrioritySelected(string priority)
    {
        var newItems = Items.ToList();
        var displayText = priority != "Normal" ? $"[{priority}] {PendingTaskText}" : PendingTaskText;
        newItems.Add(new TodoItem(displayText, false));
        Items = newItems;
        SelectedIndex = Items.Count - 1;
        StatusMessage = $"Added: {displayText}";

        // Clean up state
        ShowPriorityModal = false;
        IsAddingItem = false;
        PendingTaskText = "";
    }

    /// <summary>
    /// Called by Page when priority selection is cancelled.
    /// </summary>
    public void OnPriorityCancelled()
    {
        ShowPriorityModal = false;
    }

    /// <summary>
    /// Called by Page when add modal is dismissed.
    /// </summary>
    public void OnAddModalDismissed()
    {
        CancelAddItem();
    }

    /// <summary>
    /// Called by Page when priority modal is dismissed.
    /// </summary>
    public void OnPriorityModalDismissed()
    {
        ShowPriorityModal = false;
        IsAddingItem = false;
        StatusMessage = "Cancelled adding item";
    }

    private void HandleKeyPress(KeyPressed key)
    {
        // Don't handle keys when in modal mode - let the modals handle input
        if (IsAddingItem || ShowPriorityModal)
            return;

        switch (key.KeyInfo.Key)
        {
            case ConsoleKey.UpArrow:
                if (SelectedIndex > 0)
                {
                    SelectedIndex--;
                    StatusMessage = $"Selected: {Items[SelectedIndex].Description}";
                }
                break;

            case ConsoleKey.DownArrow:
                if (SelectedIndex < Items.Count - 1)
                {
                    SelectedIndex++;
                    StatusMessage = $"Selected: {Items[SelectedIndex].Description}";
                }
                break;

            case ConsoleKey.Spacebar:
                ToggleSelected();
                break;

            case ConsoleKey.A:
                StartAddingItem();
                break;

            case ConsoleKey.D:
                DeleteSelected();
                break;

            case ConsoleKey.C:
                Navigate("/counter");
                break;

            case ConsoleKey.Q:
                Shutdown();
                break;
        }
    }

    private void StartAddingItem()
    {
        IsAddingItem = true;
        ShowPriorityModal = false;
        PendingTaskText = "";
        StatusMessage = "Enter task name and press Enter, or Escape to cancel";
    }

    private void CancelAddItem()
    {
        IsAddingItem = false;
        ShowPriorityModal = false;
        PendingTaskText = "";
        StatusMessage = "Cancelled adding item";
    }

    private void ToggleSelected()
    {
        if (Items.Count == 0) return;

        var item = Items[SelectedIndex];
        var newItem = item with { IsCompleted = !item.IsCompleted };

        var newItems = Items.ToList();
        newItems[SelectedIndex] = newItem;
        Items = newItems;

        StatusMessage = newItem.IsCompleted
            ? $"Completed: {newItem.Description}"
            : $"Uncompleted: {newItem.Description}";
    }

    private void DeleteSelected()
    {
        if (Items.Count == 0) return;

        var deleted = Items[SelectedIndex];
        var newItems = Items.ToList();
        newItems.RemoveAt(SelectedIndex);
        Items = newItems;

        if (SelectedIndex >= Items.Count && Items.Count > 0)
        {
            SelectedIndex = Items.Count - 1;
        }

        StatusMessage = $"Deleted: {deleted.Description}";
    }
}

/// <summary>
/// A simple todo item.
/// </summary>
public record TodoItem(string Description, bool IsCompleted);
cs
// 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.Input;
using Termina.Layout;
using Termina.Reactive;
using Termina.Rendering;
using Termina.Terminal;

namespace Termina.Demo.Pages;

/// <summary>
/// Page for the todo list demo.
/// Handles all UI concerns including layout nodes and focus management.
/// Reacts to ViewModel state changes.
/// </summary>
public class TodoListPage : ReactivePage<TodoListViewModel>
{
    // Layout nodes owned by the Page
    private ModalNode _addModal = null!;
    private ModalNode _priorityModal = null!;
    private SelectionListNode<string> _priorityList = null!;
    private TextInputNode _textInput = null!;

    protected override void OnBound()
    {
        // Layout nodes will be created in BuildLayout() where they're used
    }

    public override void OnNavigatedTo()
    {
        base.OnNavigatedTo();

        // Subscribe to text input submission
        _textInput.Submitted
            .Subscribe(text => ViewModel.OnTextInputSubmitted(text))
            .DisposeWith(Subscriptions);

        // Subscribe to priority selection events
        _priorityList.SelectionConfirmed
            .Subscribe(selected =>
            {
                var priority = selected.FirstOrDefault() ?? "Normal";
                ViewModel.OnPrioritySelected(priority);
            })
            .DisposeWith(Subscriptions);

        _priorityList.OtherSelected
            .Subscribe(customPriority => ViewModel.OnPrioritySelected(customPriority))
            .DisposeWith(Subscriptions);

        _priorityList.Cancelled
            .Subscribe(_ => ViewModel.OnPriorityCancelled())
            .DisposeWith(Subscriptions);

        _addModal.Dismissed
            .Subscribe(_ => ViewModel.OnAddModalDismissed())
            .DisposeWith(Subscriptions);

        _priorityModal.Dismissed
            .Subscribe(_ => ViewModel.OnPriorityModalDismissed())
            .DisposeWith(Subscriptions);

        // React to state changes for focus management
        // When IsAddingItem becomes true (and not showing priority), focus add modal
        ViewModel.IsAddingItemChanged
            .CombineLatest(
                ViewModel.ShowPriorityModalChanged,
                (adding, showPriority) => (adding, showPriority))
            .DistinctUntilChanged()
            .Subscribe(state =>
            {
                if (state.adding && !state.showPriority)
                {
                    _textInput.Clear();
                    Focus.PushFocus(_addModal);
                    Focus.PushFocus(_textInput);
                }
                else if (!state.adding && !state.showPriority)
                {
                    Focus.ClearFocus();
                }
            })
            .DisposeWith(Subscriptions);

        // When ShowPriorityModal becomes true, switch focus to priority modal
        ViewModel.ShowPriorityModalChanged
            .Where(show => show)
            .Subscribe(_ =>
            {
                Focus.PopFocus(); // Remove add modal focus
                Focus.PushFocus(_priorityModal);
                Focus.PushFocus(_priorityList);
            })
            .DisposeWith(Subscriptions);
    }

    public override ILayoutNode BuildLayout()
    {
        // Create layout nodes as part of the layout tree lifecycle
        _textInput = new TextInputNode()
            .WithPlaceholder("Enter task description...");

        _priorityList = Layouts.SelectionList("High", "Medium", "Low")
            .WithMode(SelectionMode.Single)
            .WithShowNumbers(true)
            .WithHighlightColors(Color.Black, Color.Cyan)
            .WithOtherOption("Custom priority...");

        _addModal = Layouts.Modal()
            .WithTitle("Add New Task")
            .WithBorder(BorderStyle.Rounded)
            .WithBorderColor(Color.Cyan)
            .WithBackdrop(BackdropStyle.Dim)
            .WithPosition(ModalPosition.Center)
            .WithPadding(1)
            .WithContent(_textInput)
            .WithDismissOnEscape(true);

        _priorityModal = Layouts.Modal()
            .WithTitle("Select Priority")
            .WithBorder(BorderStyle.Rounded)
            .WithBorderColor(Color.Yellow)
            .WithBackdrop(BackdropStyle.Dim)
            .WithPosition(ModalPosition.Center)
            .WithPadding(1)
            .WithContent(_priorityList)
            .WithDismissOnEscape(true);

        // Build the main content
        var mainContent = Layouts.Vertical()
            .WithChild(
                new PanelNode()
                    .WithTitle("Todo List Demo")
                    .WithBorder(BorderStyle.Double)
                    .WithBorderColor(Color.Cyan)
                    .WithContent(
                        ViewModel.ItemsChanged
                            .CombineLatest(ViewModel.SelectedIndexChanged, (items, selectedIdx) => (items, selectedIdx))
                            .Select(tuple => BuildTodoList(tuple.items, tuple.selectedIdx))
                            .AsLayout())
                    .Fill())
            .WithChild(
                new TextNode("[↑/↓] Navigate [Space] Toggle [A] Add [D] Delete [C] Counter [Q] Quit")
                    .WithForeground(Color.BrightBlack)
                    .Height(1))
            .WithChild(
                ViewModel.StatusMessageChanged
                    .Select(msg => new TextNode(msg).WithForeground(Color.White))
                    .AsLayout()
                    .Height(1))
            .WithChild(
                new TextNode($"Trace log: {ViewModel.TraceFilePath}")
                    .WithForeground(Color.DarkGray)
                    .Height(1));

        // Build the layer with modal overlays
        return Layouts.Stack()
            .WithChild(mainContent)
            .WithChild(
                ViewModel.IsAddingItemChanged
                    .CombineLatest(ViewModel.ShowPriorityModalChanged, (adding, showPriority) => adding && !showPriority)
                    .Select(showModal => showModal
                        ? (ILayoutNode)_addModal
                        : Layouts.Empty())
                    .AsLayout())
            .WithChild(
                ViewModel.ShowPriorityModalChanged
                    .Select(showPriority => showPriority
                        ? (ILayoutNode)_priorityModal
                        : Layouts.Empty())
                    .AsLayout());
    }

    private static ILayoutNode BuildTodoList(IReadOnlyList<TodoItem> items, int selectedIndex)
    {
        if (items.Count == 0)
        {
            return new TextNode("\n  No items - press [A] to add\n")
                .WithForeground(Color.DarkGray)
                .Italic();
        }

        var completedCount = items.Count(i => i.IsCompleted);
        var statsText = $"{completedCount}/{items.Count} completed";

        var container = Layouts.Vertical()
            .WithChild(
                new TextNode($"\n  {statsText}\n")
                    .WithForeground(Color.Gray));

        for (var i = 0; i < items.Count; i++)
        {
            var item = items[i];
            var isSelected = i == selectedIndex;
            var checkbox = item.IsCompleted ? "[✓]" : "[ ]";
            var text = $"  {checkbox} {item.Description}";

            var textNode = new TextNode(text);

            if (isSelected)
            {
                textNode = textNode.WithForeground(Color.Black).WithBackground(Color.Green);
            }
            else if (item.IsCompleted)
            {
                textNode = textNode.WithForeground(Color.DarkGray);
            }
            else
            {
                textNode = textNode.WithForeground(Color.White);
            }

            container = container.WithChild(textNode.Height(1));
        }

        return container;
    }
}
cs
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Termina.Demo;
using Termina.Demo.Pages;
using Termina.Diagnostics;
using Termina.Hosting;
using Termina.Input;
using Termina.Pages;

// Check for --test flag (used in CI/CD to run scripted test and exit)
var testMode = args.Contains("--test");

// Set up diagnostic tracing with timestamped log file in temp directory
var traceDir = Path.Combine(Path.GetTempPath(), "termina-logs");
Directory.CreateDirectory(traceDir);
var traceFile = Path.Combine(traceDir, $"trace-{DateTime.Now:yyyyMMdd-HHmmss}.log");

var builder = Host.CreateApplicationBuilder(args);

// Enable file tracing and register the path for UI display
builder.Services.AddTerminaFileTracing(traceFile, TerminaTraceCategory.All, TerminaTraceLevel.Debug);
builder.Services.AddSingleton(new TraceFileInfo(traceFile));

// Set up input source based on mode
VirtualInputSource? scriptedInput = null;
if (testMode)
{
    scriptedInput = new VirtualInputSource();
    builder.Services.AddTerminaVirtualInput(scriptedInput);
}

// Register Termina with reactive pages using route-based navigation
// Use PreserveState so state persists when navigating between pages
builder.Services.AddTermina("/counter", termina =>
{
    termina.RegisterRoute<CounterPage, CounterViewModel>("/counter", NavigationBehavior.PreserveState);
    termina.RegisterRoute<TodoListPage, TodoListViewModel>("/todos", NavigationBehavior.PreserveState);
});

var host = builder.Build();

if (testMode && scriptedInput != null)
{
    // Queue up scripted input to test basic functionality then quit
    _ = Task.Run(async () =>
    {
        await Task.Delay(500); // Wait for initial render

        // Test counter: increment twice
        scriptedInput.EnqueueKey(ConsoleKey.UpArrow);
        scriptedInput.EnqueueKey(ConsoleKey.UpArrow);

        // Navigate to todo list
        scriptedInput.EnqueueKey(ConsoleKey.T);
        await Task.Delay(200);

        // Navigate in todo list
        scriptedInput.EnqueueKey(ConsoleKey.DownArrow);
        scriptedInput.EnqueueKey(ConsoleKey.Spacebar); // Toggle item

        // Quit
        scriptedInput.EnqueueKey(ConsoleKey.Q);
        scriptedInput.Complete();
    });
}

await host.RunAsync();

Next Steps

Released under the Apache 2.0 License.