Skip to content

Interactivity

Repl provides a typed interaction channel (IReplInteractionChannel) that works across all execution modes: interactive REPL, hosted sessions, MCP tools, and automated scripts. The same handler code asks questions in a terminal, receives answers from an MCP client, or reads pre-filled values from --answer:* flags.

Inject IReplInteractionChannel (namespace Repl.Interaction) into your handler:

using Repl.Interaction;
app.Map("add", async (IReplInteractionChannel ui, IContactStore store, CancellationToken ct) =>
{
var name = await ui.AskTextAsync("name", "Full name:", ct: ct);
var email = await ui.AskTextAsync("email", "Email address:", ct: ct);
var contact = store.Add(name, email);
return Results.Success($"Added {contact.Name} (#{contact.Id}).");
});

The first argument to every prompt method is a name — a stable identifier used to match --answer:name=value flags and to route MCP elicitation responses to the right prompt.

var name = await ui.AskTextAsync("name", "Full name:", ct: ct);
var password = await ui.AskSecretAsync("password", "Password:", ct: ct);

In MCP mode, AskSecretAsync is prefill-only — the agent must supply the value upfront; elicitation and sampling are never used for secrets.

var confirmed = await ui.AskConfirmationAsync("confirm", "Delete this record? This cannot be undone.", ct: ct);
if (!confirmed)
return Results.Cancelled("Deletion cancelled.");
string[] options = ["admin", "editor", "viewer"];
var roleIndex = await ui.AskChoiceAsync("role", "Assign role:", options, ct: ct);
var role = options[roleIndex];

AskChoiceAsync returns the index of the selected item. In a capable terminal, this renders as an arrow-key menu; in plain mode it falls back to numbered choices.

string[] allPerms = ["read", "write", "delete", "export"];
var indices = await ui.AskMultiChoiceAsync("permissions", "Select permissions:", allPerms, ct: ct);
var selected = indices.Select(i => allPerms[i]).ToArray();

AskMultiChoiceAsync returns IReadOnlyList<int> — the indices of all selected items.

await ui.WriteWarningAsync("Rate limit approaching.", ct);
await ui.WriteProblemAsync("Connection failed.", ct);
await ui.WriteStatusAsync("Processing...", ct);
await ui.PressAnyKeyAsync("Press any key to continue.", ct);
await ui.ClearScreenAsync(ct);

Call WriteProgressAsync on each update — pass the label, a double? between 0 and 1, and the cancellation token:

app.Map("import {file}", static async (string file, IReplInteractionChannel ui, CancellationToken ct) =>
{
var lines = await File.ReadAllLinesAsync(file, ct);
for (int i = 0; i < lines.Length; i++)
{
ct.ThrowIfCancellationRequested();
await ImportLine(lines[i], ct);
await ui.WriteProgressAsync(
$"Importing {lines.Length} records…",
(double)(i + 1) / lines.Length,
ct);
}
return Results.Success($"Imported {lines.Length} records.");
});

Each WriteProgressAsync call is independent — there is no progress session object to manage.

await ui.WriteIndeterminateProgressAsync("Connecting…", ct: ct);
// … work …
await ui.WriteProgressAsync("Connecting…", 1.0, ct); // signals completion

Pre-fill prompts by name from the command line. Each --answer:name=value targets the prompt whose name parameter matches:

Terminal window
# Answers the 'name' prompt with "Alice", 'email' with "alice@example.com"
myapp add --answer:name=Alice --answer:email=alice@example.com

Positional answers (by order) also work:

Terminal window
myapp add --answer:1=Alice --answer:2=alice@example.com

Pass answers as a dictionary to the second overload of RunCommandAsync:

var result = await session.RunCommandAsync(
"add",
new Dictionary<string, string>
{
["name"] = "Alice",
["email"] = "alice@example.com",
},
ct);

When a command is called via MCP, Repl tries to answer prompts in this order:

TierMechanismNotes
1Prefill — from tool call argumentsAlways tried first
2Elicitation — structured form through the agentRequires elicitation capability
3Sampling — LLM generates an answerRequires sampling capability
4Default / FailDepends on InteractivityMode

Configure the mode when enabling MCP:

app.UseMcpServer(o =>
{
o.InteractivityMode = InteractivityMode.PrefillThenElicitation;
// or: PrefillThenFail | PrefillThenDefaults | PrefillThenSampling
});

Add Repl.Spectre and register:

app.UseSpectreConsole();

All IReplInteractionChannel calls upgrade automatically to Spectre.Console widgets:

CallSpectre widget
AskTextAsyncTextPrompt<string>
AskSecretAsyncTextPrompt<string> (masked)
AskConfirmationAsyncConfirmationPrompt
AskChoiceAsyncSelectionPrompt
AskMultiChoiceAsyncMultiSelectionPrompt
WriteProgressAsyncLive Progress display
WriteIndeterminateProgressAsyncLive Status spinner

No handler changes required.


Prefix a choice item with _ to designate it as the keyboard shortcut:

var actions = new[] { "_Retry", "_Abort", "_Skip" };
var idx = await ui.AskChoiceAsync("action", "What next?", actions, ct: ct);

The user can press R, A, or S without navigating the menu.


Every IReplInteractionChannel method accepts a CancellationToken. When Ctrl+C is pressed or the session is terminated, the token is cancelled, and the prompt throws OperationCanceledException — which Repl catches and reports as Results.Cancelled.

Press Esc to cancel a single prompt without cancelling the whole command (the method returns the prompt’s default value instead of throwing).