Contributor Tutorial 3: Dialogs
July 8, 2026 · View on GitHub
Difficulty: Intermediate (contributor) | Prerequisites: Read Adding a Control first | Estimated reading time: ~25 minutes
← Adding a Control | Contributing → Hub
What you'll build: Dialogs.PickAsync<T>(system, title, items) — a new modal that shows a list of items and returns the one the user chooses (or default if cancelled). You'll add it alongside the existing ConfirmAsync / PromptAsync / RunWithProgressAsync without changing any of them — the additive discipline in practice.
The previous tutorial added a control — a leaf that paints its own cells. This one adds a dialog primitive: a modal window that runs an async round-trip with the user and hands back a typed result. You will not touch the render engine at all. A dialog is pure plumbing: a small content class that assembles existing controls into the three-band modal shape, plus a one-method entry point. Everything hard — layout, scrolling, focus, the border tint, closing on completion — is already solved by the shared host.
Read the real thing alongside this tutorial. Open two source files and keep them next to you:
SharpConsoleUI/Flows/PrimitiveStepContents.cs— home ofConfirmContentandPromptContent.SharpConsoleUI/Dialogs/MessageDialogs.cs— home ofDialogs.PromptAsyncand the sharedShowContentModal.
Your PickContent<T> is structurally a PromptContent whose body is a selectable list instead of a single-line text field. If you can read PromptContent and PromptAsync, you can write the picker — every framework API you need is already used by them.
Step 1: The dialog architecture
Every built-in dialog in SharpConsoleUI is the same three-band modal:
┌─ StickyTop ────────────────────────┐ ← title banner + accent rule (built by the HOST)
│ ⟳ My Dialog │
│ ──────────────────────────────── │
│ │
│ (scrollable body) │ ← your BuildContent(chrome)
│ │
│ ──────────────────────────────── │ ← ruler + right-aligned buttons (your BuildBottomBand)
│ [ Cancel ] │ ← StickyBottom
└─────────────────────────────────────┘
Three pieces cooperate:
-
A content class implementing two interfaces:
IFlowStepContent<T>— suppliesIWindowControl BuildContent(FlowChrome chrome)(the scrollable middle band) andTask<T?> Completion(the result the caller awaits).IFlowChromeBands— suppliesIReadOnlyList<IWindowControl> BuildBottomBand(FlowChrome chrome)(the ruler + button toolbar).
The content owns a
TaskCompletionSource<T?>and exposes it asCompletion. When the user acts — clicks a button, activates a list item, or dismisses the window — the content calls_tcs.TrySetResult(...)and the awaiting caller unblocks. -
ShowContentModal(inMessageDialogs.cs) — the shared host. It builds a modalWindowBuilder, adds the top band + wrapped body + bottom band as window children (only the window's content layout honoursStickyPosition), tints the border by severity, wiresOnClosed→ your dismiss handler, and closes the window automatically whenCompletionfinishes. -
The entry point — a
public static Task<T?> PickAsync<T>(...)on theDialogsclass that constructs your content, builds aFlowChrome, callsShowContentModal, and returnscontent.Completion.
The HOST always builds the top band from
FlowChrome. Content classes never build their own title banner — that is what keeps every dialog visually uniform. You only supply the middle body and the bottom button band.
Background reading (skim, don't memorize): docs/FLOWS.md for the flow/step model and docs/DIALOGS.md for the dialog primitives.
Step 2: Author PickContent<T>
Add a new class to SharpConsoleUI/Flows/PrimitiveStepContents.cs, next to ConfirmContent and PromptContent. Model it on PromptContent line for line — the only real difference is that the body is a selectable list and the list's activation event (not a text field's Entered) resolves the result.
/// <summary>
/// A picker-dialog body: displays a selectable list of items and completes with the
/// item the user activates (Enter / double-click), or <c>default(T)</c> on Cancel or dismiss.
/// </summary>
/// <typeparam name="T">The type of the items offered for selection.</typeparam>
internal sealed class PickContent<T> : IFlowStepContent<T>, IFlowChromeBands
{
private readonly TaskCompletionSource<T?> _tcs = new();
private readonly string _message;
private readonly IReadOnlyList<T> _items;
private readonly Func<T, string> _labelSelector;
private readonly NotificationSeverityEnum _severity;
/// <summary>
/// Initializes a new <see cref="PickContent{T}"/>.
/// </summary>
/// <param name="message">The prompt label displayed above the list.</param>
/// <param name="items">The items offered for selection.</param>
/// <param name="labelSelector">Maps each item to its display text.</param>
/// <param name="severity">Severity that controls the glyph and accent role.</param>
public PickContent(
string message,
IReadOnlyList<T> items,
Func<T, string> labelSelector,
NotificationSeverityEnum severity = NotificationSeverityEnum.Info)
{
_message = message;
_items = items;
_labelSelector = labelSelector;
_severity = severity;
}
/// <inheritdoc/>
public Task<T?> Completion => _tcs.Task;
/// <inheritdoc/>
public event System.Action? StateChanged;
/// <inheritdoc/>
public IWindowControl BuildContent(FlowChrome chrome)
{
// A selectable list. Each ListItem carries its source item in Tag, so activation
// can hand the real T back — not just the display string.
var listBuilder = Ctl.List()
.WithName("flow-pick-list")
.Selectable(true)
.WithVerticalAlignment(VerticalAlignment.Fill)
.WithMargin(1, 0, 1, 1);
foreach (var item in _items)
listBuilder.AddItem(_labelSelector(item), tag: item);
// Enter / double-click on a row resolves the picker with that row's source item.
listBuilder.OnItemActivated((_, listItem) =>
{
if (listItem.Tag is T chosen)
_tcs.TrySetResult(chosen);
});
// Scrollable body: message label + the list, filling the window content height so the
// StickyBottom band anchors to the true window bottom (matching PromptContent).
return Ctl.ScrollablePanel()
.WithVerticalAlignment(VerticalAlignment.Fill)
.AddControl(Ctl.Markup()
.AddLine(MarkupParser.Escape(_message))
.WithMargin(1, 1, 1, 0)
.Build())
.AddControl(listBuilder.Build())
.Build();
}
/// <summary>StickyBottom band: ruler + right-aligned toolbar holding the Cancel button.</summary>
public IReadOnlyList<IWindowControl> BuildBottomBand(FlowChrome chrome)
{
var role = FlowContentHelpers.SeverityToRole(_severity);
var cancelBtn = Ctl.Button("Cancel")
.WithName("flow-pick-cancel")
.Build();
cancelBtn.Click += (_, _) => CancelFromDismiss();
return FlowContentHelpers.BuildBottomBand(role, cancelBtn);
}
/// <summary>Resolves the content as cancelled (<c>default</c>) when the host window is dismissed.</summary>
internal void CancelFromDismiss() => _tcs.TrySetResult(default);
}
Every member here mirrors a real one in PromptContent:
PromptContent | PickContent<T> |
|---|---|
TaskCompletionSource<string?> _tcs | TaskCompletionSource<T?> _tcs |
Task<string?> Completion => _tcs.Task | Task<T?> Completion => _tcs.Task |
BuildContent returns message + Ctl.Prompt(...) input | BuildContent returns message + Ctl.List(...) |
promptCtrl.Entered += (_, text) => _tcs.TrySetResult(text) | .OnItemActivated((_, listItem) => _tcs.TrySetResult(chosen)) |
BuildBottomBand with OK + Cancel buttons | BuildBottomBand with a Cancel button |
CancelFromDismiss() => _tcs.TrySetResult(null) | CancelFromDismiss() => _tcs.TrySetResult(default) |
Notes on the real APIs used:
Ctlis the file'susing Ctl = SharpConsoleUI.Builders.Controls;alias — already present at the top ofPrimitiveStepContents.cs.Ctl.List()returns aListBuilder.AddItem(string text, object? tag = null)attaches your source item to the row viaListItem.Tag, andOnItemActivated(EventHandler<ListItem>)fires when a row is activated. These are the real builder methods — verify them inSharpConsoleUI/Builders/ListBuilder.cs.- The picker has no OK button — activating a list row is the affirmative action (exactly like pressing Enter in
PromptContent's field). The only button is Cancel, so the bottom band is a one-button toolbar likeProgressContent's.
Reactive-contract note: this class is dialog plumbing — it configures controls through their builders and never defines a control property setter. If you ever do add a property to a control while wiring a dialog, it must go through
SetProperty(ref _field, value), never a hand-rolledset { _field = value; ... }. See patterns.md. It doesn't arise here, but keep it in mind.
Step 3: The Dialogs.PickAsync<T> entry point
Add the entry point to SharpConsoleUI/Dialogs/MessageDialogs.cs, inside the Dialogs class, right next to PromptAsync. It must live inside that class because ShowContentModal is internal. Model it on PromptAsync (MessageDialogs.cs:239):
/// <summary>
/// Shows a modal picker that lists <paramref name="items"/> and completes with the item the
/// user activates (Enter / double-click), or <c>default(T)</c> on Cancel or dismiss.
/// </summary>
/// <typeparam name="T">The type of the items offered for selection.</typeparam>
/// <param name="windowSystem">The window system to host the dialog in.</param>
/// <param name="title">Title displayed in the dialog window chrome.</param>
/// <param name="items">The items offered for selection.</param>
/// <param name="message">The prompt label displayed above the list. Defaults to <c>"Select an item:"</c>.</param>
/// <param name="labelSelector">
/// Maps each item to its display text. Defaults to <c>item?.ToString()</c>.
/// </param>
/// <param name="severity">
/// Severity that controls the glyph, accent rule color, and window border tint.
/// Defaults to <see cref="NotificationSeverityEnum.Info"/>.
/// </param>
/// <param name="parent">
/// Optional parent window. When provided the dialog is modal to that window only.
/// </param>
/// <returns>
/// A <see cref="Task{TResult}"/> that completes with the chosen item, or <c>default(T)</c>
/// on Cancel/dismiss.
/// </returns>
public static Task<T?> PickAsync<T>(
ConsoleWindowSystem windowSystem,
string title,
IReadOnlyList<T> items,
string message = "Select an item:",
Func<T, string>? labelSelector = null,
NotificationSeverityEnum severity = NotificationSeverityEnum.Info,
Window? parent = null)
{
var selector = labelSelector ?? (item => item?.ToString() ?? string.Empty);
var content = new PickContent<T>(message, items, selector, severity);
var chrome = new FlowChrome(title, widthHint: 50, severity: severity, autoSizeHeight: true);
ShowContentModal(
windowSystem,
FlowContentHelpers.BuildTopBand(chrome),
content.BuildContent(chrome),
content.BuildBottomBand(chrome),
chrome,
parent,
onDismiss: content.CancelFromDismiss,
completion: content.Completion);
return content.Completion;
}
This is PromptAsync with three swaps: the content type (PickContent<T> for PromptContent), the return type (Task<T?> for Task<string?>), and the extra items / labelSelector parameters. The FlowChrome construction is identical (widthHint: 50, autoSizeHeight: true), and the ShowContentModal(...) argument list is copied verbatim — same eight arguments in the same order, including onDismiss: content.CancelFromDismiss and completion: content.Completion.
Because PickContent<T> lives in SharpConsoleUI.Flows, add using SharpConsoleUI.Flows; at the top of MessageDialogs.cs if it isn't already there (the existing primitives reference FlowContentHelpers and FlowChrome, so it will be).
Step 4: Cancellation
You don't write a cancellation path — you inherit one. ShowContentModal wires:
modal.OnClosed += (_, _) => onDismiss();
So when the user presses Esc or clicks the title-bar close button, the window closes, OnClosed fires, and onDismiss — which you passed as content.CancelFromDismiss — runs. For the picker that is:
internal void CancelFromDismiss() => _tcs.TrySetResult(default);
TrySetResult (not SetResult) means it's safe even if the result was already set by an activation — the first setter wins, the second is a no-op. The awaiting caller gets default(T) on cancel, exactly as PromptAsync returns null and RunWithProgressAsync returns default. The Cancel button uses the same path (its Click handler calls CancelFromDismiss() directly), so a click and an Esc are indistinguishable to the caller.
Step 5: The "real thing" test
Drive the real Dialogs.PickAsync on a headless MockConsoleDriver-backed system, resolve it, and assert the awaited result. Model the setup on SharpConsoleUI.Tests/Dialogs/RunWithProgressAsyncTests.cs, which uses the exact headless-await pattern (TestWindowSystemBuilder.CreateTestSystem(...), then await DialogsApi.XAsync(...)).
The awaited PickAsync completes when the content's TaskCompletionSource is resolved. In a headless test the cleanest way to drive that is through the public surface the user sees — but you can also resolve directly through the content, exactly as the progress tests resolve by reporting progress and returning. Add SharpConsoleUI.Tests/Dialogs/PickAsyncTests.cs:
using System.Collections.Generic;
using System.Threading.Tasks;
using SharpConsoleUI;
using SharpConsoleUI.Core;
using SharpConsoleUI.Tests.Infrastructure;
using Xunit;
using DialogsApi = SharpConsoleUI.Dialogs.Dialogs;
namespace SharpConsoleUI.Tests.Dialogs;
public class PickAsyncTests
{
[Fact]
public async Task Cancel_ResolvesToDefault()
{
var sys = TestWindowSystemBuilder.CreateTestSystem(80, 24);
var items = new List<string> { "Alpha", "Beta", "Gamma" };
// Kick off the dialog but do not await yet — capture the pending task.
var pending = DialogsApi.PickAsync<string>(sys, "Pick one", items);
// Dismiss the modal (Esc / close) → OnClosed → CancelFromDismiss → default.
var modal = sys.WindowStateService.ActiveWindow!;
modal.Close();
var result = await pending;
Assert.Null(result); // default(string) == null
}
}
Then add the positive "real thing" test — launch the real modal and drive a real row activation, then assert the awaited value equals the chosen item. The point of a "real thing" test (see the testing guidance in CODE_QUALITY.md) is that it exercises the actual dialog path — PickAsync → ShowContentModal → a live modal window — not an isolated PickContent in a vacuum. Locate the list by its "flow-pick-list" name (Window.FindControl exists in Window.Controls.cs), select a row, and drive activation through the real input path so the list's ItemActivated fires exactly as it would for a user:
[Fact]
public async Task ActivateItem_ResolvesToThatItem()
{
var sys = TestWindowSystemBuilder.CreateTestSystem(80, 24);
var items = new List<string> { "Alpha", "Beta", "Gamma" };
var pending = DialogsApi.PickAsync<string>(sys, "Pick one", items);
// Find the live list in the real modal (ActiveWindow is public on the system).
var modal = sys.ActiveWindow!;
var list = modal.FindControl<ListControl>("flow-pick-list")!;
list.SelectedIndex = 1; // highlight "Beta"
// Drive Enter through the real input path so ItemActivated fires as it would for a user.
// Mirror the exact key-injection helper the existing List/dialog tests use
// (e.g. InputStateService.EnqueueKey + sys.Input.ProcessInput, or the list's ProcessKey).
var result = await pending;
Assert.Equal("Beta", result);
}
Point-at-reference:
ListControlhas no public "activate the selected row" method — activation is raised internally from the keyboard/mouse handlers (ListControl.Keyboard.cs/ListControl.Mouse.csinvokeItemActivated). So the affirmative test must reach activation through the real input path (enqueue an Enter key and pump input), exactly as a "real thing" test should (see CODE_QUALITY.md) and as the existing List tests already do. Before writing the test, open a currentListControltest and copy its precise key-injection helper — do not invent anActivate...()method. The load-bearing shape is fixed: launch the realPickAsync, drive the real modal via real input,awaitthe returned task, assert the value survives.
Step 6: The additive discipline
Look back at what you changed:
- Added one class:
internal sealed class PickContent<T>inPrimitiveStepContents.cs. - Added one method:
public static Task<T?> PickAsync<T>(...)inMessageDialogs.cs. - Added one test file.
You changed nothing else. ConfirmContent, PromptContent, ProgressContent<T>, ConfirmAsync, PromptAsync, and RunWithProgressAsync are byte-for-byte untouched. ShowContentModal, FlowChrome, BuildTopBand, and BuildBottomBand were reused, not modified. No existing method signature moved; no default behavior changed.
That is the whole discipline: new capability is a new symbol, never a mutation of an existing one. SharpConsoleUI has real third-party NuGet users, and the rule is absolute — see the no-breaking-changes section of CONTRIBUTING.md. Because PickAsync and PickContent<T> are brand-new names, there is zero chance of breaking a caller: nobody was using a symbol that didn't exist. This is why "add alongside" is almost always the safe move — an additive change cannot regress code that never referenced it.
Step 7: Open the PR
With the class, the method, and both tests in place and the suite green, open a pull request:
- Title:
feat(dialogs): add Dialogs.PickAsync<T> modal picker. - In the description, state plainly that it is additive: one new content class, one new entry point, existing dialogs untouched.
- Confirm you ran
dotnet format SharpConsoleUI/SharpConsoleUI.csproj(the CI format gate blocks on tabs) and that the full test suite passes.
You've now added a dialog primitive the same way the framework's own Prompt and Confirm were built — by composing the three-band host, not reinventing it.
← Adding a Control | Contributing → Hub