Interactivity improvements: rich prompts, handler pipeline, and EnterInteractive#8
Conversation
…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 ? "> [ ] " : " [ ] ", |
There was a problem hiding this comment.
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 ? " > " : " ", |
There was a problem hiding this comment.
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")); |
There was a problem hiding this comment.
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"); |
There was a problem hiding this comment.
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"); |
There was a problem hiding this comment.
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"); |
There was a problem hiding this comment.
Same — relative filename, no risk.
…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"); |
There was a problem hiding this comment.
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"); |
There was a problem hiding this comment.
Same — all profile filenames are hardcoded relative string literals. No risk of dropping earlier arguments.
| foreach (var name in _options.AmbientCommands.CustomCommands.Keys) | ||
| { | ||
| if (name.StartsWith(token, comparison)) | ||
| { | ||
| return true; | ||
| } | ||
| } |
There was a problem hiding this comment.
Fixed in 4980cbb — replaced the foreach loop with .Any(name => name.StartsWith(token, comparison)).
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.
| 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)}."); |
There was a problem hiding this comment.
🟡 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.
| 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)}."); |
Was this helpful? React with 👍 or 👎 to provide feedback.
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 withMask: null)AskMultiChoiceAsync— multi-selection prompt with min/max validation, defaults support, and comma-separated inputClearScreenAsync— clears the terminal screenAskEnumAsync<T>(single enum),AskFlagsEnumAsync<T>(flags enum with bitwise OR),AskNumberAsync<T>(typed numeric with bounds),AskValidatedTextAsync(re-prompts until validator passes),PressAnyKeyAsyncInteraction handler pipeline
New
IReplInteractionHandlerinterface with chain-of-responsibility pattern. Each handler receives anInteractionRequestand returnsInteractionResult.Success(value)orInteractionResult.Unhandled. Evaluation order:--answer:*prefill (always first)RichPromptInteractionHandlerConsoleInteractionChannelDispatchAsyncallows apps to send customInteractionRequest<T>subtypes through the pipeline (e.g. color pickers, file browsers).Rich interactive prompts
When ANSI + key reader are available,
AskChoiceAsyncandAskMultiChoiceAsyncautomatically upgrade: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"→ shortcutA, displayed with ANSI underline (or[A]bortin text mode)"No_thing"→ shortcutton the second syllable"__real"→ escaped literal underscore, no shortcut_get auto-assigned shortcuts (first unique letter, then digits 1-9)ITerminalInfoDI-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:
Custom ambient commands
AmbientCommands.MapAmbient(...)registers commands available in every interactive scope. They appear inhelp, 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 assertionsGiven_MnemonicParser— 8 tests: explicit markers, escaped underscores, auto-assignment, ANSI/text formattingGiven_EnterInteractiveResult— 6 tests: direct return, with payload, last tuple element, tuple with payload, factory methodsGiven_CustomAmbientCommands— 5 tests: registration, execution, help visibility, autocompleteGiven_InteractionChannel— 10 tests: secret masking, multi-choice defaults, enum prompts, number validation, prefill