The interaction channel is a bidirectional contract between command handlers and the host. Handlers emit semantic requests (prompts, status, progress); the host decides how to render them.
See also: sample 04-interactive-ops for a working demo.
These methods are defined on IReplInteractionChannel and implemented by every host (console, WebSocket, test harness).
Free-form text input with optional default.
var name = await channel.AskTextAsync("name", "Contact name?");
var name = await channel.AskTextAsync("name", "Name?", defaultValue: "Alice");N-way choice prompt with default index and prefix matching.
var index = await channel.AskChoiceAsync(
"action", "How to handle duplicates?",
["Skip", "Overwrite", "Cancel"],
defaultIndex: 0,
new AskOptions(Timeout: TimeSpan.FromSeconds(10)));Yes/no confirmation with a safe default.
var confirmed = await channel.AskConfirmationAsync(
"confirm", "Delete all contacts?", defaultValue: false);Masked input for passwords and tokens. Characters are echoed as the mask character (default *), or hidden entirely with Mask: null.
var password = await channel.AskSecretAsync("password", "Password?");
var token = await channel.AskSecretAsync("token", "API Token?",
new AskSecretOptions(Mask: null, AllowEmpty: true));Multi-selection prompt. Users enter comma-separated indices (1-based) or names.
var selected = await channel.AskMultiChoiceAsync(
"features", "Enable features:",
["Auth", "Logging", "Caching", "Metrics"],
defaultIndices: [0, 1],
new AskMultiChoiceOptions(MinSelections: 1, MaxSelections: 3));Clears the terminal screen.
await channel.ClearScreenAsync(cancellationToken);Inline feedback (validation errors, status messages).
await channel.WriteStatusAsync("Import started", cancellationToken);These compose on top of the core primitives and are available via using Repl.Interaction;.
Single choice from an enum type. Uses [Description] or [Display(Name)] attributes when present, otherwise humanizes PascalCase names.
var theme = await channel.AskEnumAsync<AppTheme>("theme", "Choose a theme:", AppTheme.System);Multi-selection from a [Flags] enum. Selected values are combined with bitwise OR.
var perms = await channel.AskFlagsEnumAsync<ContactPermissions>(
"permissions", "Select permissions:",
ContactPermissions.Read | ContactPermissions.Write);Typed numeric input with optional min/max bounds. Re-prompts until a valid value is entered.
var limit = await channel.AskNumberAsync<int>(
"limit", "Max contacts?",
defaultValue: 100,
new AskNumberOptions<int>(Min: 1, Max: 10000));Text input with a validation predicate. Re-prompts until the validator returns null (valid).
var email = await channel.AskValidatedTextAsync(
"email", "Email?",
input => MailAddress.TryCreate(input, out _) ? null : "Invalid email.");Pauses execution until the user presses a key.
await channel.PressAnyKeyAsync("Press any key to continue...", cancellationToken);Handlers inject IProgress<T> to report progress. The framework creates the appropriate adapter automatically.
app.Map("sync", async (IProgress<double> progress, CancellationToken ct) =>
{
for (var i = 1; i <= 10; i++)
{
progress.Report(i * 10.0);
await Task.Delay(100, ct);
}
return "done";
});app.Map("import", async (IProgress<ReplProgressEvent> progress, CancellationToken ct) =>
{
for (var i = 1; i <= total; i++)
{
progress.Report(new ReplProgressEvent("Importing", Current: i, Total: total));
}
return "done";
});Every prompt method supports deterministic prefill for non-interactive automation:
| Prompt type | Prefill syntax |
|---|---|
AskTextAsync |
--answer:name=value |
AskChoiceAsync |
--answer:name=label (case-insensitive label or prefix match) |
AskConfirmationAsync |
--answer:name=y or --answer:name=no (y/yes/true/1 or n/no/false/0) |
AskSecretAsync |
--answer:name=value |
AskMultiChoiceAsync |
--answer:name=1,3 (1-based indices) or --answer:name=Auth,Cache (names) |
AskEnumAsync |
--answer:name=Dark (enum member name or description) |
AskFlagsEnumAsync |
--answer:name=Read,Write (description names, comma-separated) |
AskNumberAsync |
--answer:name=42 |
AskValidatedTextAsync |
--answer:name=value (must pass validation) |
Pass a Timeout via options to auto-select the default after a countdown:
var choice = await channel.AskChoiceAsync(
"action", "Continue?", ["Yes", "No"],
defaultIndex: 0,
new AskOptions(Timeout: TimeSpan.FromSeconds(10)));The host displays a countdown and selects the default when time expires.
- Esc during a prompt cancels the prompt
- Ctrl+C during a command cancels the per-command
CancellationToken - A second Ctrl+C exits the session
The interaction channel delegates all rendering to an IReplInteractionPresenter. By default, the built-in console presenter is used, but you can replace it via DI:
var app = ReplApp.Create(services =>
{
services.AddSingleton<IReplInteractionPresenter, MyCustomPresenter>();
});This enables third-party packages (e.g. Spectre.Console, Terminal.Gui, or GUI frameworks) to provide their own rendering without replacing the channel logic (validation, retry, prefill, timeout).
The presenter receives strongly-typed semantic events:
| Event type | When emitted |
|---|---|
ReplPromptEvent |
Before each prompt |
ReplStatusEvent |
Status and validation messages |
ReplProgressEvent |
Progress updates |
ReplClearScreenEvent |
Clear screen requests |
All events inherit from ReplInteractionEvent(DateTimeOffset Timestamp).
For richer control over the interaction experience (e.g. Spectre.Console autocomplete, Terminal.Gui dialogs, or GUI pop-ups), register an IReplInteractionHandler via DI. Handlers form a chain-of-responsibility pipeline: each handler pattern-matches on the request type and either returns a result or delegates to the next handler. The built-in console handler is always the final fallback.
var app = ReplApp.Create(services =>
{
services.AddSingleton<IReplInteractionHandler, MyInteractionHandler>();
});- Prefill (
--answer:*) is checked first — it always takes precedence. - The handler pipeline is walked in registration order.
- Each handler receives an
InteractionRequestand returns eitherInteractionResult.Success(value)orInteractionResult.Unhandled. - The first handler that returns
Successwins — subsequent handlers are skipped. - If no handler handles the request, the built-in console presenter renders it.
Each core primitive has a corresponding request record:
| Request type | Result type | Corresponding method |
|---|---|---|
AskTextRequest |
string |
AskTextAsync |
AskChoiceRequest |
int |
AskChoiceAsync |
AskConfirmationRequest |
bool |
AskConfirmationAsync |
AskSecretRequest |
string |
AskSecretAsync |
AskMultiChoiceRequest |
IReadOnlyList<int> |
AskMultiChoiceAsync |
ClearScreenRequest |
— | ClearScreenAsync |
WriteStatusRequest |
— | WriteStatusAsync |
WriteProgressRequest |
— | WriteProgressAsync |
All request types derive from InteractionRequest<TResult> (or InteractionRequest for void operations) and carry the same parameters as the corresponding channel method.
public class SpectreInteractionHandler : IReplInteractionHandler
{
public ValueTask<InteractionResult> TryHandleAsync(
InteractionRequest request, CancellationToken ct) => request switch
{
AskChoiceRequest r => HandleChoice(r, ct),
AskSecretRequest r => HandleSecret(r, ct),
_ => new ValueTask<InteractionResult>(InteractionResult.Unhandled),
};
private async ValueTask<InteractionResult> HandleChoice(
AskChoiceRequest r, CancellationToken ct)
{
// Spectre.Console rendering...
var index = 0; // resolved from Spectre prompt
return InteractionResult.Success(index);
}
private async ValueTask<InteractionResult> HandleSecret(
AskSecretRequest r, CancellationToken ct)
{
// Spectre.Console secret prompt...
var secret = ""; // resolved from Spectre prompt
return InteractionResult.Success(secret);
}
}| Concern | IReplInteractionPresenter |
IReplInteractionHandler |
|---|---|---|
| What it controls | Visual rendering of events | Full interaction flow (input + output) |
| Granularity | Display only — no input | Reads user input and returns results |
| Pipeline position | After the built-in logic | Before the built-in logic |
| Use case | Custom progress bars, styled text | Spectre prompts, GUI dialogs, TUI |
Use a presenter when you only want to change how things look. Use a handler when you want to replace the entire interaction for a given request type.
Apps can define their own InteractionRequest<TResult> subtypes for app-specific controls:
public sealed record AskColorPickerRequest(string Name, string Prompt)
: InteractionRequest<Color>(Name, Prompt);Dispatch them through the pipeline via DispatchAsync:
var color = await channel.DispatchAsync(
new AskColorPickerRequest("color", "Pick a color:"),
cancellationToken);If no registered handler handles the request, a NotSupportedException is thrown with a clear message identifying the unhandled request type. This ensures app authors are immediately aware when a required handler is missing.
When the terminal supports ANSI escape sequences and individual key reads, AskChoiceAsync and AskMultiChoiceAsync automatically upgrade to rich interactive menus:
- Single-choice: arrow-key menu (
Up/Downto navigate,Enterto confirm,Escto cancel). Mnemonic shortcut keys select items directly. - Multi-choice: checkbox-style menu (
Up/Downto navigate,Spaceto toggle,Enterto confirm with min/max validation,Escto cancel).
The upgrade is transparent — command handlers call the same AskChoiceAsync / AskMultiChoiceAsync API; the framework selects the best rendering mode automatically.
The interaction pipeline evaluates handlers in this order:
- Prefill (
--answer:*) — always checked first. - User handlers —
IReplInteractionHandlerimplementations registered via DI. - Built-in rich handler (
RichPromptInteractionHandler) — renders arrow-key menus when ANSI + key reader are available. - Text fallback — numbered list with typed input; works in all environments (redirected stdin, hosted sessions, no ANSI).
If the terminal cannot support rich prompts (e.g. ANSI disabled, stdin redirected, or hosted session), the framework falls back to the text-based prompt automatically.
Choice labels support an underscore convention to define keyboard shortcuts:
| Label | Display | Shortcut |
|---|---|---|
"_Abort" |
Abort |
A |
"No_thing" |
Nothing |
t |
"__real" |
_real |
(none — escaped underscore) |
"Plain" |
Plain |
(auto-assigned) |
- ANSI mode: the shortcut letter is rendered with an underline (
ESC[4m/ESC[24m). - Text mode: the shortcut letter is wrapped in brackets:
[A]bort / [R]etry / [F]ail.
When a label has no explicit _ marker, the framework auto-assigns a shortcut:
- First unique letter of the display text.
- If taken, scan remaining letters.
- If all letters are taken, assign digits
1–9.
var index = await channel.AskChoiceAsync(
"action", "How to proceed?",
["_Abort", "_Retry", "_Fail"],
defaultIndex: 0);The ITerminalInfo service exposes terminal capabilities for custom IReplInteractionHandler implementations. It is registered automatically by the framework and available via DI.
public interface ITerminalInfo
{
bool IsAnsiSupported { get; }
bool CanReadKeys { get; }
(int Width, int Height)? WindowSize { get; }
AnsiPalette? Palette { get; }
}public class MyHandler(ITerminalInfo terminal) : IReplInteractionHandler
{
public ValueTask<InteractionResult> TryHandleAsync(
InteractionRequest request, CancellationToken ct)
{
if (!terminal.IsAnsiSupported || !terminal.CanReadKeys)
return new(InteractionResult.Unhandled);
// Rich rendering using terminal.WindowSize, terminal.Palette, etc.
...
}
}Register via DI as usual:
var app = ReplApp.Create(services =>
{
services.AddSingleton<IReplInteractionHandler, MyHandler>();
});The framework injects ITerminalInfo automatically — no manual registration required.
The Repl.Spectre package provides a production-ready IReplInteractionHandler that renders
all prompts as rich Spectre.Console widgets, plus injectable IAnsiConsole for custom renderables.
var app = ReplApp.Create(services =>
{
services.AddSpectreConsole();
})
.UseSpectreConsole();With this setup:
AskChoiceAsyncrenders as a SpectreSelectionPrompt(arrow-key navigation)AskMultiChoiceAsyncrenders as aMultiSelectionPrompt(checkbox-style)AskConfirmationAsyncrenders as aConfirmationPromptAskTextAsyncrenders as aTextPrompt<string>AskSecretAsyncrenders as aTextPrompt<string>.Secret()- Collections returned from handlers render as bordered Spectre tables
Command handlers remain unchanged — the upgrade from built-in prompts to Spectre prompts is transparent.
See also: Repl.Spectre README | sample 07-spectre