Skip to content

Interactivity improvements: rich prompts, handler pipeline, and EnterInteractive#8

Merged
carldebilly merged 13 commits intomainfrom
dev/cdb/interactivity-improvement
Mar 13, 2026
Merged

Interactivity improvements: rich prompts, handler pipeline, and EnterInteractive#8
carldebilly merged 13 commits intomainfrom
dev/cdb/interactivity-improvement

Conversation

@carldebilly
Copy link
Member

@carldebilly carldebilly commented Mar 13, 2026

Summary

Major interactivity overhaul adding rich terminal prompts, an extensible handler pipeline, and new interaction primitives.

New interaction primitives

  • AskSecretAsync — masked input for passwords/tokens (configurable mask character, or fully hidden with Mask: null)
  • AskMultiChoiceAsync — multi-selection prompt with min/max validation, defaults support, and comma-separated input
  • ClearScreenAsync — clears the terminal screen
  • Extension methods: AskEnumAsync<T> (single enum), AskFlagsEnumAsync<T> (flags enum with bitwise OR), AskNumberAsync<T> (typed numeric with bounds), AskValidatedTextAsync (re-prompts until validator passes), PressAnyKeyAsync

Interaction handler pipeline

New IReplInteractionHandler interface with chain-of-responsibility pattern. Each handler receives an InteractionRequest and returns InteractionResult.Success(value) or InteractionResult.Unhandled. Evaluation order:

  1. --answer:* prefill (always first)
  2. User-registered handlers via DI
  3. Built-in RichPromptInteractionHandler
  4. Text fallback in ConsoleInteractionChannel

DispatchAsync allows apps to send custom InteractionRequest<T> subtypes through the pipeline (e.g. color pickers, file browsers).

Rich interactive prompts

When ANSI + key reader are available, AskChoiceAsync and AskMultiChoiceAsync automatically upgrade:

  • Single-choice: arrow-key navigation (Up/Down), Enter to confirm, Esc to cancel, mnemonic shortcut keys for direct selection
  • Multi-choice: arrow-key navigation, Space to toggle checkboxes, Enter to confirm (enforces min/max constraints with inline error message), Esc to cancel

Degradation is automatic and transparent — same API, the framework picks the best rendering mode.

Mnemonic shortcuts

Underscore convention in choice labels defines keyboard shortcuts:

  • "_Abort" → shortcut A, displayed with ANSI underline (or [A]bort in text mode)
  • "No_thing" → shortcut t on the second syllable
  • "__real" → escaped literal underscore, no shortcut
  • Labels without _ get auto-assigned shortcuts (first unique letter, then digits 1-9)

ITerminalInfo

DI-injectable service exposing terminal capabilities (IsAnsiSupported, CanReadKeys, WindowSize, Palette). Allows custom handlers to adapt their rendering to the current environment without hardcoding detection logic.

Results.EnterInteractive()

New result type: when returned from a CLI one-shot command, the process renders the optional payload then enters interactive REPL mode instead of exiting. Also works as the last element of a tuple:

app.Map("setup", () => (Results.Ok("Initialized"), Results.EnterInteractive()));

Custom ambient commands

AmbientCommands.MapAmbient(...) registers commands available in every interactive scope. They appear in help, participate in autocomplete, and support the same parameter injection as regular handlers.

Remote transport fixes

SignalR and WebSocket text writers now handle concurrent writes safely, fixing garbled output in multi-session scenarios.

New tests

  • Given_RichPrompts — 11 tests: arrow-key navigation, wrapping, mnemonic shortcut selection, Esc cancel, checkbox toggle, min/max enforcement, per-line rendering assertions
  • Given_MnemonicParser — 8 tests: explicit markers, escaped underscores, auto-assignment, ANSI/text formatting
  • Given_EnterInteractiveResult — 6 tests: direct return, with payload, last tuple element, tuple with payload, factory methods
  • Given_CustomAmbientCommands — 5 tests: registration, execution, help visibility, autocomplete
  • Given_InteractionChannel — 10 tests: secret masking, multi-choice defaults, enum prompts, number validation, prefill

Open with Devin

…plInteractionChannel

Enrich the interaction channel with new prompt types inspired by modern TUI
libraries (Spectre.Console, Inquirer.js, dialoguer):

- AskSecretAsync: masked password/token input with configurable mask char,
  AllowEmpty option, and countdown timeout support
- AskMultiChoiceAsync: checkbox-style multi-selection with comma-separated
  input, name/index parsing, and min/max selection constraints
- AskEnumAsync<T>: enum-based single choice with [Description]/[Display]
  attribute support and PascalCase humanization
- AskFlagsEnumAsync<T>: [Flags] enum multi-choice via AskMultiChoiceAsync
- AskNumberAsync<T>: typed numeric input with min/max bounds validation
- AskValidatedTextAsync: text input with inline re-prompt validation loop
- PressAnyKeyAsync: simple interactive pause

All methods support --answer:name=value prefill for CI/automation.
Includes integration tests, option records, and sample commands.
Introduce ClearScreenAsync on IReplInteractionChannel so commands can
programmatically clear the terminal via an ANSI escape sequence
(silently ignored when ANSI is unavailable).

Make the ambient command system extensible: apps can register custom
ambient commands via AmbientCommandOptions.MapAmbient() that are
available in every interactive scope, appear in help output, and
participate in autocomplete. Handlers use the same DI-based parameter
injection as regular commands.

Add a sample "clear" ambient command in 02-scoped-contacts using both
features, and update docs/commands.md accordingly.
Introduce a chain-of-responsibility pipeline for interaction requests,
enabling extensible custom controls (Spectre, GUI, TUI) without breaking
IReplInteractionChannel. Each request type (AskText, AskChoice, etc.)
is a typed record dispatched through IReplInteractionHandler before
falling back to the built-in console handler.

- Add InteractionRequest/InteractionResult types and IReplInteractionHandler
- Wire handler pipeline into ConsoleInteractionChannel (after prefill, before console)
- Resolve IReplInteractionPresenter and handlers from DI in ReplApp
- Split ConsoleInteractionChannel into partial classes (ConsoleIO extraction)
- Add integration tests for AskFlagsEnum, AskNumber bounds, AskValidatedText, ClearScreen
- Expand sample 04 with AskFlagsEnumAsync and ambient clear command
- Add docs/interaction.md with pipeline, prefill, and extensibility guide
- Add DispatchAsync<TResult> to IReplInteractionChannel for dispatching
  custom InteractionRequest types through the handler pipeline, throwing
  NotSupportedException when no handler handles the request
- Add RunCommandAsync overload with answers dictionary on ReplSessionHandle
  for prefilling interactive prompts during tests
- Add SessionDescriptor.Answers for session-wide default prompt answers
  with per-command override semantics
- Add interactive commands (greet, deploy, region) and 5 test cases to
  sample 06-testing demonstrating the new testing API
- Document DispatchAsync and custom request types in docs/interaction.md
…ransports

Rich arrow-key menus for AskChoice (radio) and AskMultiChoice (checkboxes)
with ANSI rendering when supported, text fallback otherwise. Mnemonic
shortcuts via underscore convention (_Abort → [A] hotkey). ITerminalInfo
DI service for custom handlers. Fix sync Write buffering in WebSocket and
SignalR TextWriters so rich menus render over remote transports. Fix Space
key mapping in VtKeyReader for remote sessions. Fix \r\n line endings in
plain-mode multi-choice fallback.
Move interactive arrow-key menu rendering from ConsoleInteractionChannel
into a dedicated IReplInteractionHandler that participates in the handler
pipeline. User-registered handlers get first priority, the built-in rich
handler runs next (when ANSI + key reader are available), and the text
fallback in ConsoleInteractionChannel is the final resort.
New result type that signals the process should enter interactive REPL
mode after rendering an optional payload. Works both as a direct return
and as the last element of a tuple return.
Add sections to interaction.md covering the rich interactive prompt
fallback chain, mnemonic shortcut convention, and ITerminalInfo DI
service. Update commands.md with EnterInteractive result type and
rich prompt mention. Add new sample 05 commands to its README.
var prefix = isChecked switch
{
true => isCursor ? "> [x] " : " [x] ",
false => isCursor ? "> [ ] " : " [ ] ",
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

False positive — RenderMenuLine accepts bool? isChecked to handle both single-choice (null) and multi-choice (true/false) in a single method. All three switch arms are exercised depending on the caller. No change needed.

{
true => isCursor ? "> [x] " : " [x] ",
false => isCursor ? "> [ ] " : " [ ] ",
null => isCursor ? " > " : " ",
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as above — all three switch arms are reachable depending on whether the caller is a single-choice or multi-choice menu. False positive.

[Description("Regression guard: verifies all status labels use the same ANSI style so no shell section appears in a different color.")]
public async Task When_StatusIsRenderedWithAnsi_Then_AllLabelsUseCommandStyle()
{
var root = Path.Combine(Path.GetTempPath(), "repl-shell-completion-ansi-label-tests", Guid.NewGuid().ToString("N"));
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

False positive — the second argument is always a relative filename (.bashrc, ps.profile, etc.), never a rooted path. No risk of dropping earlier arguments.

{
var root = Path.Combine(Path.GetTempPath(), "repl-shell-completion-ansi-label-tests", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(root);
var profilePath = Path.Combine(root, ".bashrc");
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same — relative filename, no risk.

var options = new ReplOptions();
options.ShellCompletion.PreferredShell = ShellKind.Bash;
options.ShellCompletion.BashProfilePath = profilePath;
options.ShellCompletion.PowerShellProfilePath = Path.Combine(root, "ps.profile");
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same — relative filename, no risk.

options.ShellCompletion.PreferredShell = ShellKind.Bash;
options.ShellCompletion.BashProfilePath = profilePath;
options.ShellCompletion.PowerShellProfilePath = Path.Combine(root, "ps.profile");
options.ShellCompletion.ZshProfilePath = Path.Combine(root, ".zshrc");
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same — relative filename, no risk.

devin-ai-integration[bot]

This comment was marked as resolved.

…SI cleanup

- Apply default '*' mask when AskSecretAsync is called without options
- Throw OperationCanceledException on Esc in rich prompt handler (consistent with text fallback)
- Use CancellationToken.None for ANSI cleanup flush in remote catch blocks
- Split null-check in BuildNumberPrompt to satisfy CodeQL
- Narrow empty catch blocks to IOException in test cleanup
options.ShellCompletion.BashProfilePath = profilePath;
options.ShellCompletion.PowerShellProfilePath = Path.Combine(root, "ps.profile");
options.ShellCompletion.ZshProfilePath = Path.Combine(root, ".zshrc");
options.ShellCompletion.FishProfilePath = Path.Combine(root, "config.fish");
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

False positive — the second argument is a hardcoded relative filename literal ("config.fish"). There is no scenario where it becomes a rooted path. Adding TrimStart or a helper for string constants would be over-engineering.

options.ShellCompletion.PowerShellProfilePath = Path.Combine(root, "ps.profile");
options.ShellCompletion.ZshProfilePath = Path.Combine(root, ".zshrc");
options.ShellCompletion.FishProfilePath = Path.Combine(root, "config.fish");
options.ShellCompletion.NuProfilePath = Path.Combine(root, "config.nu");
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same — all profile filenames are hardcoded relative string literals. No risk of dropping earlier arguments.

devin-ai-integration[bot]

This comment was marked as resolved.

Comment on lines +1624 to +1630
foreach (var name in _options.AmbientCommands.CustomCommands.Keys)
{
if (name.StartsWith(token, comparison))
{
return true;
}
}
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 4980cbb — replaced the foreach loop with .Any(name => name.StartsWith(token, comparison)).

devin-ai-integration[bot]

This comment was marked as resolved.

AskNumberAsync and AskValidatedTextAsync could loop forever when a
--answer:* prefill value failed bounds or predicate validation, since
the prefill is returned unchanged on every call. Detect repeated
identical input and throw InvalidOperationException instead.
The null-coalescing fix for the default mask incorrectly coerced an
explicit Mask: null (documented no-echo mode) back to '*'. Use a
ternary on options presence instead so null mask is only defaulted
when no options are provided.
@carldebilly carldebilly merged commit 1a94252 into main Mar 13, 2026
10 of 11 checks passed
@carldebilly carldebilly deleted the dev/cdb/interactivity-improvement branch March 13, 2026 04:21
Copy link

@devin-ai-integration devin-ai-integration bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 1 new potential issue.

View 18 additional findings in Devin Review.

Open in Devin Review

Comment on lines +99 to +106
string[] featureNames = ["Authentication", "Logging", "Caching", "Metrics"];
var selected = await channel.AskMultiChoiceAsync(
"features",
"Enable features:",
["_Authentication", "_Logging", "_Caching", "_Metrics"],
defaultIndices: [0, 1]);
var labels = selected.Select(i => featureNames[i]);
return Results.Ok($"Enabled: {string.Join(", ", labels)}.");

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 RemoteModule configure indexes into featureNames using indices from a different choices list

The configure command passes mnemonic-prefixed labels (["_Authentication", "_Logging", "_Caching", "_Metrics"]) to AskMultiChoiceAsync, but then uses the returned indices to index into a separate featureNames array (["Authentication", "Logging", "Caching", "Metrics"]). While the arrays happen to be the same length and order today, this is fragile and the two arrays can drift apart. More importantly, if the rich prompt handler is active, mnemonic parsing means the display labels differ from the raw labels, but the returned indices still correspond to the choices list — so currently there's no actual index mismatch. However, this pattern is error-prone and inconsistent with how the same feature list is passed in samples/04-interactive-ops/Program.cs:265 which uses un-prefixed labels directly.

Suggested change
string[] featureNames = ["Authentication", "Logging", "Caching", "Metrics"];
var selected = await channel.AskMultiChoiceAsync(
"features",
"Enable features:",
["_Authentication", "_Logging", "_Caching", "_Metrics"],
defaultIndices: [0, 1]);
var labels = selected.Select(i => featureNames[i]);
return Results.Ok($"Enabled: {string.Join(", ", labels)}.");
string[] featureNames = ["_Authentication", "_Logging", "_Caching", "_Metrics"];
var selected = await channel.AskMultiChoiceAsync(
"features",
"Enable features:",
featureNames,
defaultIndices: [0, 1]);
var parsed = selected.Select(i => Repl.Interaction.MnemonicParser.Parse(featureNames[i]).Display);
return Results.Ok($"Enabled: {string.Join(", ", parsed)}.");
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant