.NET TUIs with Spectre.Console & Terminal.Gui

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production; see the section bibliography for the reference material consulted while preparing these pages.

NET has two mature ways to go beyond plain Console.WriteLine, and they solve different problems. Spectre.Console

is a rendering library: you describe tables, trees, panels, progress bars and prompts, and it writes them to the terminal, downgrading colours and box-drawing characters to what the terminal supports. The program is still a command-line program that runs top to bottom and exits. Terminal.Gui is a toolkit: it owns the screen, runs an event loop, routes keyboard and mouse input to a tree of views (windows, menus, lists, tables, dialogs) and gives you the widgets and layout engine of a desktop framework inside a terminal. Choosing between them is mostly a question of who owns the main loop, and this page shows both on the same Bookshelf shelf tool.

The page was written against Spectre.Console 0.57.2 and Terminal.Gui v2.5.0 (version 2 reached general availability in April 2026 and the project lives in the tui-cs/Terminal.Gui repository), running on .NET 10 LTS with C# 14, with the documentation current in October 2026. Both libraries are pre-1.0 or recently re-architected, so check the release notes of the version you install. Behaviour is described for Windows 10/11, macOS and Linux.

Which snippets were compiled or run. No .NET SDK was available on the machine this page was written on, so none of the C# snippets below were compiled or executed. They are assembled from the code and signatures in the Spectre.Console and Terminal.Gui documentation linked after each example, and where the documentation does not show a member’s exact signature the example uses only the forms the documentation does show. The terminal output drawn in the page is illustrative, not captured. Treat every snippet as a starting point to build and test yourself.

This page builds on the cross-language material of this section and does not repeat it: TUI Architecture & Concepts (the main loop, cell buffers, terminal lifecycle), Escape Sequences & Terminal Capabilities (what the libraries emit and how capabilities are detected) and .NET Console Applications (the Console class, signals, publishing). The rules for when a program may colour, prompt or animate at all are in TTY, Colour & Interactivity and TUIs vs. CLIs. A first look at Spectre.Console.Cli and System.CommandLine is in Frameworks: Swift & .NET; the sections below cover the parts of Spectre.Console that page leaves out.

Choosing between them

flowchart TD A["shelf needs a richer terminal experience"] --> B{"Does the program keep the screen and react to keys and the mouse until the user quits?"} B -->|"no, it prints and exits"| C["Spectre.Console: tables, trees, panels, markup"] C --> D{"Long-running work to show?"} D -->|"progress is measurable"| P["Progress"] D -->|"duration unknown"| S["Status spinner"] D -->|"a dashboard that redraws in place"| L["Live display"] C --> Q{"Needs answers from a person?"} Q -->|"yes, and a TTY is attached"| R["TextPrompt, SelectionPrompt, MultiSelectionPrompt"] B -->|"yes, windows, menus, focus, dialogs"| T["Terminal.Gui v2: IApplication, Window, views"] T --> U{"Which driver?"} U -->|"Windows"| W["windows driver (Win32 console API) by default"] U -->|"macOS or Linux"| X["ansi driver by default"]

The two can live in one executable. A common shape for shelf is: shelf list, shelf add and shelf sync use Spectre.Console and exit, while shelf tui starts Terminal.Gui and owns the terminal until the user quits. The one rule that follows from the documentation is to never run them at the same time: Spectre.Console’s live displays and prompts are documented as not thread safe and as unsupported when combined with other interactive components, and Terminal.Gui expects to be the only writer to the terminal while it runs.

Spectre.Console Terminal.Gui v2

Model

Immediate: you call AnsiConsole.Write(...), the library renders and returns. Live displays redraw a region in place.

Retained: a tree of View objects, an event loop, focus, key and mouse routing, layout.

Who owns the main loop

Your program.

The library, inside app.Run(...).

Input

Prompts only (TextPrompt, SelectionPrompt, MultiSelectionPrompt, confirmation).

Full keyboard and mouse, key bindings, hot keys, commands.

Layout

Widgets such as Table, Grid, Columns, Panel, Layout composed at render time.

Pos / Dim constraints on every view, adornments (margin, border, padding), scrolling.

Typical use in shelf

list, add, sync, loan output, wizards.

shelf tui, a catalogue browser.

Piped output

Detects a non-interactive, non-ANSI terminal through AnsiConsole.Profile.Capabilities and degrades.

Not designed for it. Do not start it when output is redirected.

Package

Spectre.Console (the command framework is the separate Spectre.Console.Cli).

Terminal.Gui, with optional Terminal.Gui.Templates.

Spectre.Console: rendering rich output

Install the package with dotnet add package Spectre.Console. Everything starts from the static AnsiConsole class, which wraps a console with a detected capability profile. The rendering model, described in the documentation, is a two-step protocol: every widget implements IRenderable, whose Measure method reports minimum and maximum widths and whose Render method produces a sequence of Segment objects (text plus style, line breaks and control codes). The console then converts segments to ANSI escape sequences, or drops the styling when ANSI is not supported. Because a container such as Panel or Table measures its children first, widgets nest freely, and a table inside a panel inside a layout cell adapts to the width it is given.

Markup

Markup is a small inline language in square brackets: [bold red]text[/], [green on black], hexadecimal colours such as [#ff9900], rgb(r,g,b) values, decorations such as bold, italic, underline, strikethrough and invert, and a link attribute for hyperlinks on terminals that support them. A [/] closes the most recent open tag, tags nest, and emoji shortcodes such as :rocket: are expanded.

The important point for a tool that prints other people’s data is escaping. A literal bracket is written doubled, and dynamic text must go through Markup.Escape, otherwise a book titled "[Draft] Notes" is parsed as a style tag and throws or silently disappears. The documentation also lists MarkupLineInterpolated for interpolated strings and Markup.Remove to strip tags. The following example prints one line, escaping the data and not the template:

using Spectre.Console;

string title = "[Draft] Notes on Terminals";
AnsiConsole.MarkupLine($"[bold]{Markup.Escape(title)}[/] is [green]available[/]");

Tables, trees, panels and layouts

A Table is built fluently from columns and rows. The documented options include the border (RoundedBorder() and sixteen-odd other built-in styles, or Border(TableBorder.None)), BorderColor, markup-capable Title and Caption, HideHeaders, ShowRowSeparators, Expand (a table sizes to its content unless told to fill the width) and per-column configuration through a lambda: Width, NoWrap, PadLeft, PadRight and alignment (LeftAligned, Centered, RightAligned). A table can be a cell of another table. Footers are set per column. A Tree has a label, AddNode (which returns the new node so children can be chained), AddNodes, a Guide (TreeGuide.Line is the default, with Ascii, DoubleLine and BoldLine), a Style for the guide lines and Expanded / Collapse() to fold branches. A Panel wraps any renderable in a border with an optional Header, Padding, Expand and a fixed Width.

The first example is the one-shot shelf list view: a table of books followed by a tree of the physical shelves. The two widgets are written one after the other, because the documentation pages consulted show no side-by-side composition.

Program.cs — shelf list with Table and Tree (not compiled)
using Spectre.Console;

record Book(string Title, string Author, int Year, string Shelf, bool OnLoan);

Book[] books =
[
    new("Programming Pearls", "Jon Bentley", 1986, "A1", false),
    new("The C Programming Language", "Kernighan & Ritchie", 1978, "A1", true),
    new("Designing Data-Intensive Applications", "Martin Kleppmann", 2017, "B2", false),
];

var table = new Table()
    .RoundedBorder()
    .BorderColor(Color.Aqua)
    .Title("[yellow]Bookshelf[/]")
    .Caption("[grey]3 books[/]")
    .AddColumn("Title")
    .AddColumn("Author")
    .AddColumn("Year", col => col.RightAligned())
    .AddColumn("Status", col => col.NoWrap());

foreach (var b in books)
{
    table.AddRow(
        Markup.Escape(b.Title),
        Markup.Escape(b.Author),
        b.Year.ToString(),
        b.OnLoan ? "[red]on loan[/]" : "[green]available[/]");
}

AnsiConsole.Write(table);

var tree = new Tree("Shelves").Guide(TreeGuide.Line);
foreach (var shelf in books.GroupBy(b => b.Shelf).OrderBy(g => g.Key))
{
    var node = tree.AddNode($"[bold]{shelf.Key}[/]");
    foreach (var b in shelf)
    {
        node.AddNode(Markup.Escape(b.Title));
    }
}

AnsiConsole.Write(tree);

When several regions must be arranged on screen, the documentation offers Layout. A layout is a named region that splits into columns or rows (SplitColumns, SplitRows), sized with Size(n) for a fixed width or height, Ratio(n) for a proportional share and MinimumSize(n); a child is reached by name (layout["Left"]) and filled with Update(renderable), and IsVisible = false hides a region. The documentation page renders a layout once with AnsiConsole.Write(layout) and, for updates, points to the live display. It does not describe a full-screen mode, so treat Layout as a way to compose a snapshot of a dashboard, not as a windowing system: it has no focus, no input and no scrolling. When you need those, use Terminal.Gui.

Live displays: Live, Progress and Status

Three APIs update a region of the terminal in place. They share three documented properties: each has synchronous Start and asynchronous StartAsync methods (with overloads that return a value), none is thread safe, and none may be combined with prompts or with each other.

API Use it for Documented options

AnsiConsole.Status()

Work of unknown duration. An animated spinner and a message.

Start(message, ctx => ...), StartAsync, Spinner(Spinner.Known.X), SpinnerStyle(Style), AutoRefresh(bool). Inside the callback, ctx.Status(text), ctx.Spinner(...), ctx.Refresh().

AnsiConsole.Progress()

Measurable work. One or more ProgressTask bars.

Columns(...) with TaskDescriptionColumn, ProgressBarColumn, PercentageColumn, RemainingTimeColumn, SpinnerColumn, ElapsedTimeColumn, DownloadedColumn, TransferSpeedColumn; AutoClear, HideCompleted, AutoRefresh, RefreshRate; tasks via ctx.AddTask(description, maxValue) with Increment, Value, MaxValue, Description, IsFinished, IsIndeterminate().

AnsiConsole.Live(renderable)

Any renderable that you mutate and redraw: a table of loans that fills as rows arrive.

Start, StartAsync, ctx.Refresh() after mutating, ctx.UpdateTarget(other), AutoClear(bool), Overflow(VerticalOverflow.Ellipsis | Crop | Visible), Cropping(VerticalOverflowCropping.Top | Bottom).

The sync example below uses all three in the only combination the documentation allows, which is one at a time and in sequence: a status spinner while the program connects, a progress display while it downloads catalogue pages, and a live table while it prints the loans that changed.

Sync.cs — shelf sync (not compiled)
using Spectre.Console;

static async Task SyncAsync(CancellationToken ct)
{
    await AnsiConsole.Status()
        .Spinner(Spinner.Known.Dots)
        .StartAsync("Connecting to the catalogue server...", async ctx =>
        {
            await Task.Delay(800, ct);
            ctx.Status("Authenticating...");
            await Task.Delay(500, ct);
        });

    await AnsiConsole.Progress()
        .AutoClear(false)
        .Columns(
            new TaskDescriptionColumn(),
            new ProgressBarColumn(),
            new PercentageColumn(),
            new RemainingTimeColumn())
        .StartAsync(async ctx =>
        {
            var pages = ctx.AddTask("Downloading catalogue pages", maxValue: 40);
            var covers = ctx.AddTask("Downloading cover images", maxValue: 40);

            while (!ctx.IsFinished)
            {
                ct.ThrowIfCancellationRequested();
                await Task.Delay(40, ct);
                pages.Increment(1);
                covers.Increment(0.5);
            }
        });

    var changes = new Table().AddColumn("Book").AddColumn("Change");
    await AnsiConsole.Live(changes)
        .StartAsync(async ctx =>
        {
            foreach (var (book, change) in new[] { ("Programming Pearls", "returned"), ("SICP", "loaned to ana") })
            {
                changes.AddRow(Markup.Escape(book), change);
                ctx.Refresh();
                await Task.Delay(500, ct);
            }
        });
}

using var cts = new CancellationTokenSource();
Console.CancelKeyPress += (_, e) => { e.Cancel = true; cts.Cancel(); };
await SyncAsync(cts.Token);

Source: Spectre.Console — Status, Progress and Live display; the cancellation wiring uses the Console.CancelKeyPress event described in Console.CancelKeyPress.

The async-patterns guide of Spectre.Console adds that progress can track several concurrent tasks (for example with Task.WhenAll), that a CancellationToken can be passed through the live displays, and that exceptions in the async work should be handled so they do not break the display. The pages consulted for this section do not say how Status, Progress or Live behave when output is redirected or on a continuous-integration log. Check the profile yourself, as described below, and test the redirected case.

Prompts

Prompts block for an answer. TextPrompt<T> converts the input to T and supports DefaultValue with ShowDefaultValue, AllowEmpty, Validate (a predicate with a message, or a function returning ValidationResult.Success() / ValidationResult.Error(...)), Secret() (asterisks), Secret(char) and Secret(null) (fully hidden), AddChoice with ShowChoices / HideChoices, and styling through PromptStyle, DefaultValueStyle and ChoicesStyle. SelectionPrompt<T> offers arrow-key selection with Title, AddChoices, AddChoiceGroup, UseConverter (display text for an object, returning the object), PageSize (default 10), MoreChoicesText, WrapAround, EnableSearch with SearchPlaceholderText, Mode(SelectionMode.Leaf | Independent) and HighlightStyle. MultiSelectionPrompt<T> is the check-box variant. Both TextPrompt and the selection prompts have Show(IAnsiConsole) and ShowAsync(IAnsiConsole, CancellationToken), and the documentation uses AnsiConsole.Prompt(prompt) as the usual entry point.

The documentation pages for the prompts do not describe what happens when no terminal is attached. The capability table does: Interactive is the property that says whether someone can answer prompts, and it is forced to false on GitHub Actions, Azure Pipelines, GitLab CI, Jenkins, Travis CI, TeamCity, AppVeyor, Bitbucket Pipelines, CircleCI and the other CI systems listed. A command-line tool should therefore read it before it prompts and fall back to a flag or a failure with a usage error, as the rules in TTY, Colour & Interactivity require.

Add.cs — shelf add with prompts (not compiled)
using Spectre.Console;

static Book PromptForBook(string[] existingTitles)
{
    if (!AnsiConsole.Profile.Capabilities.Interactive)
    {
        throw new InvalidOperationException(
            "No interactive terminal. Pass --title, --author and --year instead.");
    }

    var title = AnsiConsole.Prompt(
        new TextPrompt<string>("[green]Title[/]?")
            .Validate(t => !string.IsNullOrWhiteSpace(t), "[red]A title is required[/]")
            .Validate(t => !existingTitles.Contains(t, StringComparer.OrdinalIgnoreCase),
                      "[red]That title is already on the shelf[/]"));

    var author = AnsiConsole.Prompt(
        new TextPrompt<string>("[green]Author[/]?").DefaultValue("Unknown").ShowDefaultValue());

    var year = AnsiConsole.Prompt(
        new TextPrompt<int>("[green]Year[/]?")
            .Validate(y => y is > 0 and <= 2100
                ? ValidationResult.Success()
                : ValidationResult.Error("[red]Enter a year between 1 and 2100[/]")));

    var shelf = AnsiConsole.Prompt(
        new SelectionPrompt<string>()
            .Title("Which [green]shelf[/]?")
            .PageSize(5)
            .WrapAround()
            .AddChoices("A1", "A2", "B1", "B2"));

    return new Book(title, author, year, shelf);
}

record Book(string Title, string Author, int Year, string Shelf);

Capability detection with AnsiConsole.Profile

At startup Spectre.Console inspects environment variables, the console encoding and the CI environment and stores the result in AnsiConsole.Profile.Capabilities. The documented properties are:

Capability Meaning and effect

ColorSystem

Colour depth: none, 8 colours, 16, 256 or 24-bit. Colours you ask for are downgraded to what is available.

Ansi

Whether escape sequences are emitted at all. When false the styling is dropped and plain text remains.

Links

Whether text can be a clickable hyperlink (the link markup attribute).

Interactive

Whether prompts can be answered.

Unicode

Whether box-drawing and other Unicode glyphs are used, or ASCII fallbacks such as - and | are drawn.

Legacy

Whether this is an older-style Windows console.

The environment variables the capability reference names are NO_COLOR (disables all colour), COLORTERM (truecolor or 24bit enables 24-bit colour on Unix), TERM (for example xterm-256color) and ConEmuANSI (set to On on Windows under ConEmu when colours do not work). For GitHub Actions the library enables ANSI even though normal detection would not recognise it, and marks Interactive and Links as false. The width the console renders to is resolved in order from an explicitly set Profile.Width, the output stream’s width, Console.BufferWidth and finally a default of 80 columns. The profile and the AnsiConsoleSettings object can override detection, but the pages consulted show no code for doing so, so look at the API reference before relying on a particular property. As elsewhere in this section, NO_COLOR, COLORTERM and similar names are community conventions rather than something a standard guarantees, which is why the details of detection are in Escape Sequences & Terminal Capabilities.

Testing output

The documentation recommends not writing to the static AnsiConsole from code you want to test. Take an IAnsiConsole parameter, call console.MarkupLine(...) and console.Prompt(...), pass AnsiConsole.Console in production and a TestConsole (from the separate Spectre.Console.Testing package) in a test. A test console collects what was written in its Output and accepts queued keystrokes for prompts through console.Input.PushTextWithEnter(...). The documentation page for testing is short; for snapshot tests of whole screens and for driving a real pseudo-terminal see Testing Terminal Apps & TUIs.

Pitfalls with Spectre.Console

  • Unescaped data in markup. Any value that came from a file, a user or a network must go through Markup.Escape (or be passed to a Text renderable, which does not parse markup).

  • Mixing live displays. A prompt inside Progress, a Status inside Live, or two live displays at once are documented as unsupported. Finish one before starting the next.

  • Threads. Updating a ProgressTask or calling ctx.Refresh() from several threads is not supported as documented. Funnel updates through one place, for example a channel read by the task that owns the display.

  • Width. Wide East Asian characters and emoji occupy two cells. Segment.CellCount() accounts for this in the library, but strings you pad by hand with PadRight do not.

  • Redirected output. Test shelf list > out.txt and shelf list | less: the table should still be readable, and a machine-readable mode (see Machine-Readable Output) should skip Spectre.Console entirely.

  • Spectre.Console.Cli. The command framework was split into its own repository and package, so check the version numbers of the two independently.

Terminal.Gui v2: full-screen applications

Terminal.Gui is a cross-platform toolkit for terminal user interfaces on Windows, macOS and Linux. Version 2 was announced as released in April 2026 and the documentation moved with the repository to tui-cs/Terminal.Gui; the project’s earlier documentation address no longer works, and links to it are out of date. The documentation describes a layout system for responsive layouts, keyboard-first and mouse-enabled interaction, built-in views (text editors, tables, trees, menus, Markdown views, charts, progress indicators, colour pickers, file and directory browsers), themes and persistent configuration, and support for inline as well as full-screen applications.

Add it with dotnet add package Terminal.Gui, or start from the project template with dotnet new install Terminal.Gui.Templates@2.* followed by dotnet new tui-simple -n shelf-tui.

The application model

In version 2 the static Application class is obsolete. Application.Create() returns an IApplication instance, app.Init() starts it and returns the same instance so calls can be chained, and app.Run(runnable) starts the event loop and blocks until the runnable stops. Disposing the application stops its input thread and restores the terminal, which is why the documentation uses a using declaration. An instance cannot be reused after it has been disposed: calling Init again throws ObjectDisposedException.

using Terminal.Gui.App;
using Terminal.Gui.ViewBase;
using Terminal.Gui.Views;

using IApplication app = Application.Create ();
app.Init ();

using Window window = new () { Title = "Hello World (Esc to quit)" };
Label label = new ()
{
    Text = "Hello, Terminal.Gui v2!",
    X = Pos.Center (),
    Y = Pos.Center ()
};
window.Add (label);

app.Run (window);

The documented lifecycle pieces are:

Member Behaviour

Application.Create()

Returns a new IApplication. Separate instances allow several application contexts and easier testing.

app.Init(string? driverName = null)

Starts the application and its input thread. The driver can be chosen by argument, by app.ForceDriver or by configuration.

app.Run(IRunnable)

The caller creates and owns the runnable and must dispose it.

app.Run<TRunnable>()

The framework creates the runnable and disposes it when Run returns. The typed result is read with app.GetResult<T>().

app.Begin(runnable) / app.End(token)

Push and pop a session on the session stack, which allows nested sessions such as a dialog over a window.

app.TopRunnable, app.SessionStack

The runnable on top of the stack (the modal one) and the stack of running sessions.

Runnable<TResult> and IRunnable

Base class and interface that let a view take part in session management; Result carries the typed result, and IsRunning, IsModal and the IsRunningChanging / IsRunningChanged / IsModalChanged events describe its state.

app.RequestStop()

Asks the current session to stop. Command.Quit calls it.

app.Dispose()

Stops the input thread, performs terminal clean-up and replaces the obsolete Application.Shutdown().

The documentation does not say what state the terminal is left in when an unhandled exception escapes the loop, so keep the using scope tight, catch exceptions around app.Run and let Dispose run. The general rule that a TUI must restore the terminal on every exit path is in TUI Architecture & Concepts.

Views, hot keys and events

Everything on screen is a View. A view is built with an object initialiser, added with Add(...) to a container (SubViews is the read-only list, SuperView the back reference), and disposed with Dispose(), which also disposes adornments, scroll bars and sub-views. The caller disposes any view it created. The properties that matter most are:

  • Text and Title (the title is drawn in the border and requires the border settings to include the title flag), with HotKeySpecifier (default '_'): in a title written as _Quit the letter after the underscore becomes the hot key.

  • CanFocus (false by default in version 2, see the migration table), TabStop (a TabBehavior: NoStop, TabStop or TabGroup) and TabIndex.

  • Visible and Enabled.

  • BorderStyle, Border.Thickness, Margin, Padding, and Scheme for colours.

  • Events: Accepting (raised when Command.Accept runs, cancellable) and Accepted (after it), Activating and Activated, HasFocusChanging and HasFocusChanged.

The documentation states the convention: use the -ed event for side effects (button.Accepted += (_, _) => DoTheThing ();) and the -ing event only to inspect or veto (button.Accepting += (_, e) => { if (!CanProceed ()) e.Handled = true; };). Cancellable virtual methods return bool and true cancels. The older Accept event and v1’s Clicked are gone.

Keyboard input is expressed with Key (for example Key.Q.WithCtrl, Key.F1, Key.Enter). A view declares the operations it supports with AddCommand(Command.X, handler) and maps keys to them with KeyBindings.Add(key, command); HotKeyBindings apply regardless of focus. Application-wide defaults, including Command.Quit, are held in Application.DefaultKeyBindings and can be changed in code or in a JSON configuration. The documentation shows rebinding the quit key with Application.DefaultKeyBindings[Command.Quit] = Bind.All (Key.Q.WithCtrl); and the JSON configuration accepts a Quit entry whose All list holds key names such as Esc and Ctrl+Q, but it does not state what the default quit key is, so bind one explicitly if your help text mentions it. A Shortcut displays a command, its help text and its key and can be invoked even when its owning view is not focused.

Layout: Pos and Dim

Terminal.Gui’s layout engine is Pos for X and Y and Dim for Width and Height, both relative to the content area of the container.

Factory Meaning

Pos.Absolute(n)

A fixed coordinate (replaces Pos.At from v1).

Pos.Percent(n)

A percentage of the container.

Pos.Center()

Centred in the container.

Pos.AnchorEnd() / Pos.AnchorEnd(n)

Anchored from the end of the dimension.

Pos.Left(v), Pos.Right(v), Pos.Top(v), Pos.Bottom(v), Pos.X(v)

Relative to another view, for example Pos.Right (nameLabel) + 1.

Pos.Align(Alignment.End)

Aligns a group of views (the documentation says the modes are start, center, end and fill); Dialog uses it for its buttons.

Pos.Func(...)

Computed by a function.

Dim.Absolute(n)

A fixed size (replaces Dim.Sized).

Dim.Auto()

Sizes to the content, with minimumContentDim and maximumContentDim (replaces AutoSize).

Dim.Percent(n)

A percentage of the container.

Dim.Fill() / Dim.Fill(to: view)

The remaining space, or the space up to another view. Dim.Fill() does not contribute to a parent’s Dim.Auto() unless minimumContentDim is given.

Dim.Width(v), Dim.Height(v), Dim.Func(...)

Tracks another view’s size, or is computed.

Values combine with arithmetic: Dim.Fill () - 10, Pos.Bottom (anotherView) + 5, Dim.Percent (20) - 1. The view’s Frame is a result of layout, so read it after layout has run and do not assign it. A view also has a content area (always at 0,0, sized by GetContentSize() or SetContentSize(...)) and a viewport that is the visible part of it: when the content is larger than the viewport the view scrolls, which in version 2 is built into every View, with scroll bars enabled through ViewportSettings (HasVerticalScrollBar, HasHorizontalScrollBar, HasScrollBars). Adornments sit between the frame and the viewport: margin outermost, then border (which draws the lines and the title), then padding (where scroll bars are drawn). Each has a Thickness.

Menus, status bars and dialogs

The menu system was redesigned in version 2 and is built on Bar and Shortcut. A MenuBar is made of MenuBarItem objects, each holding MenuItem`s (title with an underscore hot key, optional key and action) separated by `Line. MenuBar.Key defaults to F10 and PopoverMenu.Key to Shift+F10. A context menu is a PopoverMenu that must be registered with Application.Popover before it is made visible. A StatusBar snaps to the bottom of its container and shows context-sensitive shortcuts.

Dialogs are runnable views. Dialog is a modal window with buttons along the bottom and a zero-based result index of the pressed button, Dialog<T> returns a custom result type, Prompt<TView, TResult> wraps any view with OK and Cancel buttons (the documentation recommends nullable result types so cancellation can be told apart from a valid result), and OpenDialog, SaveDialog and FileDialog handle files. MessageBox.Query(...) appears in the documentation’s examples, but the pages consulted do not describe its parameters, so look it up in the API reference before using it.

A shelf catalogue browser

The example builds the shelf tui screen of this section: a menu bar, a search box, a list of books on the left, a details pane on the right and a status bar. It uses only members that appear in the documentation pages cited below and could not be compiled for this page, so the exact event argument types (in particular for ListView and TextField) should be checked in the API reference when you build it.

ShelfTui.cs — shelf tui (not compiled)
using System.Collections.ObjectModel;
using Terminal.Gui.App;
using Terminal.Gui.Input;
using Terminal.Gui.ViewBase;
using Terminal.Gui.Views;

record Book(string Title, string Author, int Year, string Shelf, bool OnLoan);

static class ShelfTui
{
    public static int Run(IReadOnlyList<Book> books)
    {
        using IApplication app = Application.Create ();
        app.Init ();

        using Window window = new () { Title = "shelf (Ctrl+Q to quit)" };

        MenuBar menu = new ([
            new MenuBarItem ("_Shelf", [
                new MenuItem ("_Loan...", Key.L.WithCtrl, () => { /* open a loan dialog */ }),
                new MenuItem ("_Return", Key.R.WithCtrl, () => { /* mark returned */ }),
                new Line (),
                new MenuItem ("E_xit", Key.Q.WithCtrl, () => app.RequestStop ())
            ]),
            new MenuBarItem ("_Help", [
                new MenuItem ("_About...", () => { /* show about */ })
            ])
        ]);

        Label searchLabel = new () { Text = "_Search:", X = 0, Y = 1 };
        TextField search = new ()
        {
            X = Pos.Right (searchLabel) + 1,
            Y = 1,
            Width = Dim.Fill ()
        };

        ObservableCollection<string> titles = new (books.Select (b => b.Title));
        ListView list = new ()
        {
            X = 0,
            Y = 2,
            Width = Dim.Percent (40),
            Height = Dim.Fill (1),
            CanFocus = true
        };
        list.SetSource (titles);

        FrameView detailsFrame = new ()
        {
            Title = "Details",
            X = Pos.Right (list),
            Y = 2,
            Width = Dim.Fill (),
            Height = Dim.Fill (1)
        };
        Label details = new () { Text = "Select a book", X = 1, Y = 0, Width = Dim.Fill (1) };
        detailsFrame.Add (details);

        list.ValueChanged += (_, args) =>
        {
            if (args.NewValue is int i && i >= 0 && i < books.Count)
            {
                Book b = books[i];
                details.Text = $"{b.Title}\n{b.Author} ({b.Year})\nShelf {b.Shelf}\n{(b.OnLoan ? "On loan" : "Available")}";
            }
        };

        StatusBar status = new ();
        status.Add (new Shortcut { Title = "_Quit", Key = Key.Q.WithCtrl });

        window.Add (menu, searchLabel, search, list, detailsFrame, status);

        app.Run (window);
        return 0;
    }
}

Source: Terminal.Gui — Menus, Layout, View and Views overview. ListView.SetSource taking an ObservableCollection<T> and the ValueChanged event come from the ListView API reference. The Accepted and Activating events described above replace v1’s selection and click events, but the documentation does not state which of them a ListView raises on Enter, so a search filter or an "open this book" action should be tested on the version you install.

For a loans table, TableView takes an ITableSource through its Table property (built-in sources are DataTableSource, EnumerableTableSource<T>, ListTableSource and TreeTableSource<T>), exposes the selection as Value.SelectedCell, supports FullRowSelect and MultiSelect, raises ValueChanged and Accepted (on double-click or the Accept key) and styles columns through TableStyle and ColumnStyles. The pages consulted do not mention CellActivated or SelectedRow, which were v1 names.

Threading: updating the UI from background work

All UI operations must run on the main thread; modifying views from another thread has undefined behaviour and may crash. From a view, App?.Invoke(...) runs a callback on the main thread; from the application, app.Invoke(...) and app.InvokeAsync(...) do the same, the latter returning a task that completes after the callback ran. After an await in code that started on the main thread, execution comes back to the main thread, so views can be updated directly. Periodic work uses App?.AddTimeout(TimeSpan, callback): return true to keep the timer and false to stop it, remove timers in Dispose to avoid leaks, and keep callbacks fast (the documentation gives about 100 ms as the maximum useful update rate). The shelf sync command inside the TUI therefore looks like this:

Task.Run (async () =>
{
    string result = await CatalogueClient.SyncAsync ();   // a hypothetical client
    app.Invoke (() => status.Title = result);              // back on the UI thread
});

Source: Terminal.Gui — Multitasking. The app.Invoke(...) form is named in that page together with App?.Invoke(...); its exact parameter list should be taken from the API reference.

Drivers and platform differences

A driver is the layer between the toolkit and the terminal. The documentation names three:

Driver Platforms Notes

windows

Windows only

Uses the native Win32 console API: reads INPUT_RECORD structures, writes with double buffering and detects resizes through input events. Highest performance on Windows; the default there. Enables mouse input through SetConsoleMode.

ansi

Windows, macOS, Linux

Reads raw ANSI sequences and writes pure ANSI escape codes. The default on Unix and macOS, and the documentation calls it a showcase driver for all platforms and suited to testing and CI. It needs proper ANSI support, parses mouse events from escape sequences, and is the driver for which inline mode and the kitty keyboard protocol (key release events) are documented.

dotnet

Windows, macOS, Linux

Built on System.Console alone, the simplest but lowest performance, with a limited feature set and known ReadKey bugs on Windows.

Selection priority is a provided component factory, then the driverName argument to Init, then ForceDriver, then the platform default. ForceDriver can be set in JSON ({ "Application": { "ForceDriver": "ansi" } }), as an argument (app.Init (driverName: DriverRegistry.Names.ANSI)) or on the instance before Init (app.ForceDriver = DriverRegistry.Names.ANSI). Use Application.IsDriverNameValid and Application.GetRegisteredDriverNames() to validate a --driver option of your own tool. Colour defaults to 24-bit with an automatic fall back to 16 colours; ColorCapabilities reports the level (no colour, 16, 256, true colour) from TERM, COLORTERM and related variables, and Force16Colors forces the 16-colour palette. In inline mode (AppModel set to Inline, ANSI driver) the application stays in the primary buffer instead of the alternate screen, which suits a one-shot picker that leaves its answer in the scrollback, and app.PrintAboveAsync(...) prints completed text above the inline region.

What the documentation consulted does not state: which Windows versions are required for the ansi driver (see Windows Console & Processes for what Windows consoles support natively), whether Terminal.Gui supports trimming or Native AOT publishing (see .NET Console Applications for the general rules), and how the toolkit behaves under a multiplexer such as tmux. Test those on your targets.

Configuration, themes and schemes

Version 2.5.0 replaced ConfigurationManager with TuiConfigurationBuilder, built on Microsoft.Extensions.Configuration. Sources are applied from lowest to highest priority: the library defaults, the library’s embedded config.json, the application’s embedded config.json, ~/.tui/config.json and ./.tui/config.json, ~/.tui/{appName}.config.json and ./.tui/{appName}.config.json, the TUI_CONFIG environment variable (inline JSON) and finally RuntimeConfig. ./.tui/ resolves against the current working directory, not the install directory, which matters for a security-conscious tool: a shelf tui started inside a directory someone else controls reads that directory’s configuration. Themes are switched with builder.ThemeManager.SwitchTheme ("Dark"); schemes (colours and text styles such as bold and underline) are applied per view through view.Scheme or globally.

Migrating from Terminal.Gui v1

The v1 to v2 guide lists the breaking changes. The ones that affect most programs are:

Area v1 v2

Start-up and shutdown

Application.Init(), Application.Run(top), Application.Shutdown()

using IApplication app = Application.Create ().Init ();, app.Run(top), app.Dispose(). Views the caller created must be disposed by the caller.

Construction

new View(new Rect(10, 10, 40, 10))

new View { X = 10, Y = 10, Width = 40, Height = 10 }

Positions and sizes

Pos.At, Dim.Sized, AutoSize, LayoutStyle

Pos.Absolute, Dim.Absolute, Dim.Auto(), with Pos and Dim only

Visible area

Bounds

Viewport

Borders

view.Border = new Border { ... }

BorderStyle, Border.Thickness, Title

Focus

CanFocus defaults to true

CanFocus defaults to false; set it explicitly

Keys

KeyEvent, KeyCode, Key.CtrlMask with Key.Q

Key, Key.Q.WithCtrl; handle with AddCommand and KeyBindings

Mouse

MouseClick event

Removed; use MouseBindings and Command.Activate, raising Activating

Buttons

Button.Clicked

Button.Accepting / Accepted

Lists

new ListView(items), SelectedChanged

new ListView() then SetSource(items), SelectedItemChanged per the guide, or ValueChanged per the API reference

Text editing

TextView

Editor per the migration guide, while the views list still names TextView

Scrolling

ScrollView with ContentSize

Removed; every view scrolls via SetContentSize and ViewportSettings

Types

Rect, NStack.ustring

Rectangle, string with GetColumns()

Colour

Color.Brown, Attribute.Make

Color.Yellow, new Attribute(fg, bg), 24-bit by default

Configuration

ConfigurationManager

TuiConfigurationBuilder (since 2.5.0)

Two rows deserve a comment. The migration guide and the views list disagree about the multi-line editor (Editor in the guide, TextView in the views overview), and the guide and the API reference disagree about the list selection event. Where the documentation is inconsistent, trust the API reference of the version you have installed and let the compiler tell you. The legacy static Application.Init() still works in 2.x, which allows an incremental migration, but the guide marks Application.Shutdown() obsolete.

Combining the two

A realistic shelf binary dispatches on its arguments before touching the terminal. Subcommands that print and exit use Spectre.Console, with plain tab-separated output when Console.IsOutputRedirected is true, and shelf tui refuses to start unless both standard input and standard output are terminals:

if (args is ["tui"])
{
    if (Console.IsInputRedirected || Console.IsOutputRedirected)
    {
        Console.Error.WriteLine("shelf tui needs an interactive terminal.");
        return 2;
    }

    return ShelfTui.Run(LoadBooks());
}

The "a TUI is only a view over the same operations" rule from TUIs vs. CLIs applies unchanged: keep the operations (list, loan, return) in a library that both the Spectre.Console commands and the Terminal.Gui views call, so the full-screen interface never has features a script cannot reach.

Pitfalls and security notes

  • Starting a TUI without a terminal. Terminal.Gui does not protect you against redirected input or output. Guard it as above.

  • Forgetting to dispose the application. Disposal is what restores the terminal. Use using, and do not call Environment.Exit from inside the loop.

  • Modifying views from a background thread. Marshal with Invoke.

  • Forgetting CanFocus = true. In version 2 a custom view or a ListView you create may not receive the keyboard until you opt in.

  • Following v1 tutorials. Most blog posts and Stack Overflow answers describe the static Application API, Rect and Clicked. Prefer the version 2 documentation.

  • Current-directory configuration. ./.tui/ configuration is read relative to the working directory, as described above.

  • Markup injection in Spectre.Console. Escape every external string, as shown.

  • Secrets. TextPrompt<string>().Secret() hides input on screen, but the string still lives in managed memory like any other; do not log it. A TextField has a Secret property for the same purpose in Terminal.Gui. Prefer reading tokens from the environment or a credential store, as described in Configuration & Auth.

  • Accessibility. Neither library documents screen-reader support, and full-screen redraws are difficult for assistive technology; offer a plain command-line equivalent for every TUI function.

See also