.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. |
Console.WriteLine, and they solve different problems. Spectre.Consoleis 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
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 |
Retained: a tree of |
Who owns the main loop |
Your program. |
The library, inside |
Input |
Prompts only ( |
Full keyboard and mouse, key bindings, hot keys, commands. |
Layout |
Widgets such as |
|
Typical use in |
|
|
Piped output |
Detects a non-interactive, non-ANSI terminal through |
Not designed for it. Do not start it when output is redirected. |
Package |
|
|
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.
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);
Source: Spectre.Console — Table and Spectre.Console — 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.
Source: Spectre.Console — Layout
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 |
|---|---|---|
|
Work of unknown duration. An animated spinner and a message. |
|
|
Measurable work. One or more |
|
|
Any renderable that you mutate and redraw: a table of loans that fills as rows arrive. |
|
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.
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.
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 |
|---|---|
|
Colour depth: none, 8 colours, 16, 256 or 24-bit. Colours you ask for are downgraded to what is available. |
|
Whether escape sequences are emitted at all. When false the styling is dropped and plain text remains. |
|
Whether text can be a clickable hyperlink (the |
|
Whether prompts can be answered. |
|
Whether box-drawing and other Unicode glyphs are used, or ASCII fallbacks such as |
|
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 aTextrenderable, which does not parse markup). -
Mixing live displays. A prompt inside
Progress, aStatusinsideLive, or two live displays at once are documented as unsupported. Finish one before starting the next. -
Threads. Updating a
ProgressTaskor callingctx.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 withPadRightdo not. -
Redirected output. Test
shelf list > out.txtandshelf 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);
Source: Terminal.Gui — Getting Started
The documented lifecycle pieces are:
| Member | Behaviour |
|---|---|
|
Returns a new |
|
Starts the application and its input thread. The driver can be chosen by argument, by |
|
The caller creates and owns the runnable and must dispose it. |
|
The framework creates the runnable and disposes it when |
|
Push and pop a session on the session stack, which allows nested sessions such as a dialog over a window. |
|
The runnable on top of the stack (the modal one) and the stack of running sessions. |
|
Base class and interface that let a view take part in session management; |
|
Asks the current session to stop. |
|
Stops the input thread, performs terminal clean-up and replaces the obsolete |
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:
-
TextandTitle(the title is drawn in the border and requires the border settings to include the title flag), withHotKeySpecifier(default'_'): in a title written as_Quitthe letter after the underscore becomes the hot key. -
CanFocus(false by default in version 2, see the migration table),TabStop(aTabBehavior:NoStop,TabStoporTabGroup) andTabIndex. -
VisibleandEnabled. -
BorderStyle,Border.Thickness,Margin,Padding, andSchemefor colours. -
Events:
Accepting(raised whenCommand.Acceptruns, cancellable) andAccepted(after it),ActivatingandActivated,HasFocusChangingandHasFocusChanged.
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 |
|---|---|
|
A fixed coordinate (replaces |
|
A percentage of the container. |
|
Centred in the container. |
|
Anchored from the end of the dimension. |
|
Relative to another view, for example |
|
Aligns a group of views (the documentation says the modes are start, center, end and fill); |
|
Computed by a function. |
|
A fixed size (replaces |
|
Sizes to the content, with |
|
A percentage of the container. |
|
The remaining space, or the space up to another view. |
|
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.
Source: Terminal.Gui — Layout
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.
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.
Source: Terminal.Gui — TableView
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 only |
Uses the native Win32 console API: reads |
|
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. |
|
Windows, macOS, Linux |
Built on |
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.
Source: Terminal.Gui — Configuration
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 |
|
|
Construction |
|
|
Positions and sizes |
|
|
Visible area |
|
|
Borders |
|
|
Focus |
|
|
Keys |
|
|
Mouse |
|
Removed; use |
Buttons |
|
|
Lists |
|
|
Text editing |
|
|
Scrolling |
|
Removed; every view scrolls via |
Types |
|
|
Colour |
|
|
Configuration |
|
|
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());
}
Source: Console.IsOutputRedirected and Console.IsInputRedirected
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 callEnvironment.Exitfrom inside the loop. -
Modifying views from a background thread. Marshal with
Invoke. -
Forgetting
CanFocus = true. In version 2 a custom view or aListViewyou create may not receive the keyboard until you opt in. -
Following v1 tutorials. Most blog posts and Stack Overflow answers describe the static
ApplicationAPI,RectandClicked. 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. ATextFieldhas aSecretproperty 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
-
Other TUI Libraries compares these two libraries with the options in other languages.
-
Interactive Prompts & Line Editing and Progress, Colour & Rich Output cover the same needs across languages.
-
Native Binaries & Cross-Compilation covers shipping a .NET tool as a single binary.