From 63c8e7823a223f32decdab8aa368be1e0351df04 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Wed, 4 Mar 2026 07:10:52 -0500 Subject: [PATCH 01/13] Add AskSecretAsync, AskMultiChoiceAsync, and extension methods to IReplInteractionChannel 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: enum-based single choice with [Description]/[Display] attribute support and PascalCase humanization - AskFlagsEnumAsync: [Flags] enum multi-choice via AskMultiChoiceAsync - AskNumberAsync: 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. --- samples/04-interactive-ops/ContactStore.cs | 13 + samples/04-interactive-ops/Program.cs | 90 ++++ src/Repl.Core/ConsoleInteractionChannel.cs | 424 +++++++++++++++++- .../Public/AskMultiChoiceOptions.cs | 24 + .../Interaction/Public/AskNumberOptions.cs | 20 + .../Interaction/Public/AskSecretOptions.cs | 25 ++ .../Public/IReplInteractionChannel.cs | 28 ++ .../ReplInteractionChannelExtensions.cs | 240 ++++++++++ .../ShellCompletion/ShellCompletionRuntime.cs | 5 + .../DefaultsInteractionChannel.cs | 14 + .../Given_InteractionChannel.cs | 161 +++++++ .../Given_ShellCompletionSetup.cs | 63 +++ src/Repl.IntegrationTests/SampleColor.cs | 8 + .../Given_ShellCompletionRuntime.cs | 42 ++ 14 files changed, 1151 insertions(+), 6 deletions(-) create mode 100644 src/Repl.Core/Interaction/Public/AskMultiChoiceOptions.cs create mode 100644 src/Repl.Core/Interaction/Public/AskNumberOptions.cs create mode 100644 src/Repl.Core/Interaction/Public/AskSecretOptions.cs create mode 100644 src/Repl.Core/Interaction/Public/ReplInteractionChannelExtensions.cs create mode 100644 src/Repl.IntegrationTests/SampleColor.cs diff --git a/samples/04-interactive-ops/ContactStore.cs b/samples/04-interactive-ops/ContactStore.cs index ca045ba..bac0e91 100644 --- a/samples/04-interactive-ops/ContactStore.cs +++ b/samples/04-interactive-ops/ContactStore.cs @@ -1,5 +1,18 @@ +using System.ComponentModel; using System.ComponentModel.DataAnnotations; +internal enum AppTheme +{ + [Description("Use system setting")] + System, + [Description("Light mode")] + Light, + [Description("Dark mode")] + Dark, + [Description("High contrast")] + HighContrast, +} + internal sealed record Contact( [property: Display(Order = 0)] string Name, [property: Display(Order = 1)] string Email); diff --git a/samples/04-interactive-ops/Program.cs b/samples/04-interactive-ops/Program.cs index 7118652..21e25e7 100644 --- a/samples/04-interactive-ops/Program.cs +++ b/samples/04-interactive-ops/Program.cs @@ -6,6 +6,12 @@ // - AskTextAsync with retry-on-invalid (add) // - AskChoiceAsync with default + prefix matching (import) // - AskConfirmationAsync with safe default (clear) +// - AskSecretAsync for masked input (login) +// - AskMultiChoiceAsync for multi-selection (configure) +// - AskEnumAsync for enum-based choice (theme) +// - AskNumberAsync for typed numeric input (set-limit) +// - AskValidatedTextAsync for validated text (set-email) +// - PressAnyKeyAsync for interactive pause (demo) // - WriteStatusAsync for inline feedback (import, add) // - IProgress structured progress (import) // - IProgress simple progress (sync) @@ -35,6 +41,12 @@ Try: contact clear (confirmation prompt) Try: contact sync (simple progress) Try: contact watch (Ctrl+C to stop) + Try: contact login (secret/password input) + Try: contact configure (multi-choice selection) + Try: contact theme (enum-based choice) + Try: contact set-limit (typed numeric input) + Try: contact set-email (validated text input) + Try: contact demo (press any key pause) """) .UseDefaultInteractive() .UseCliProfile(); @@ -216,6 +228,84 @@ await channel.WriteStatusAsync( return Results.Cancelled("Watch stopped."); }); + + // Secret input — password/token prompt with masked echo. + // Characters are shown as '*' by default, never echoed in clear. + // Prefillable via --answer:password=value for CI. + contact.Map( + "login", + [Description("Simulate login (secret input)")] + async (IReplInteractionChannel channel, CancellationToken cancellationToken) => + { + var password = await channel.AskSecretAsync("password", "Password?"); + return Results.Success($"Logged in (password length: {password.Length})."); + }); + + // Multi-choice — select one or more features from a list. + // Enter numbers separated by commas: 1,3 + contact.Map( + "configure", + [Description("Configure features (multi-choice)")] + async (IReplInteractionChannel channel, CancellationToken cancellationToken) => + { + var selected = await channel.AskMultiChoiceAsync( + "features", + "Enable features:", + ["Authentication", "Logging", "Caching", "Metrics"], + defaultIndices: [0, 1]); + return Results.Success($"Enabled feature indices: {string.Join(", ", selected)}."); + }); + + // Enum choice — pick from enum values, humanized automatically. + contact.Map( + "theme", + [Description("Choose a theme (enum choice)")] + async (IReplInteractionChannel channel, CancellationToken cancellationToken) => + { + var theme = await channel.AskEnumAsync("theme", "Choose a theme:", AppTheme.System); + return Results.Success($"Theme set to {theme}."); + }); + + // Number input — typed numeric prompt with min/max bounds. + contact.Map( + "set-limit", + [Description("Set import limit (numeric input)")] + async (IReplInteractionChannel channel, CancellationToken cancellationToken) => + { + var limit = await channel.AskNumberAsync( + "limit", + "Max contacts to import?", + defaultValue: 100, + new AskNumberOptions(Min: 1, Max: 10000)); + return Results.Success($"Import limit set to {limit}."); + }); + + // Validated text input — re-prompts until validation passes. + contact.Map( + "set-email", + [Description("Set notification email (validated)")] + async (IReplInteractionChannel channel, CancellationToken cancellationToken) => + { + var email = await channel.AskValidatedTextAsync( + "email", + "Notification email?", + input => System.Net.Mail.MailAddress.TryCreate(input, out _) + ? null + : $"'{input}' is not a valid email address."); + return Results.Success($"Notification email set to {email}."); + }); + + // Press any key — simple interactive pause. + contact.Map( + "demo", + [Description("Interactive demo (press any key)")] + async (IReplInteractionChannel channel, CancellationToken cancellationToken) => + { + await channel.WriteStatusAsync("Step 1: Preparing data...", cancellationToken); + await channel.PressAnyKeyAsync("Press any key to continue to step 2...", cancellationToken); + await channel.WriteStatusAsync("Step 2: Processing complete.", cancellationToken); + return Results.Success("Demo finished."); + }); }); return app.Run(args); diff --git a/src/Repl.Core/ConsoleInteractionChannel.cs b/src/Repl.Core/ConsoleInteractionChannel.cs index 7c3d7d5..625a376 100644 --- a/src/Repl.Core/ConsoleInteractionChannel.cs +++ b/src/Repl.Core/ConsoleInteractionChannel.cs @@ -103,7 +103,14 @@ await _presenter.PresentAsync( } } - private static int MatchChoice(IReadOnlyList choices, string input) + private static int MatchChoice(IReadOnlyList choices, string input) => + MatchChoiceByName(input, choices); + + /// + /// Matches a text input against a choice list: exact match first, then unambiguous prefix. + /// Returns the zero-based index of the matched choice, or -1 if no match found. + /// + private static int MatchChoiceByName(string input, IReadOnlyList choices) { // Exact match first. for (var i = 0; i < choices.Count; i++) @@ -212,16 +219,421 @@ public async ValueTask AskTextAsync( return line; } + public async ValueTask AskSecretAsync( + string name, + string prompt, + AskSecretOptions? options = null) + { + _ = ValidateName(name); + prompt = string.IsNullOrWhiteSpace(prompt) + ? throw new ArgumentException("Prompt cannot be empty.", nameof(prompt)) + : prompt; + var effectiveCt = ResolveToken(options?.CancellationToken); + effectiveCt.ThrowIfCancellationRequested(); + + if (_options.TryGetPrefilledAnswer(name, out var prefilledSecret)) + { + return prefilledSecret ?? string.Empty; + } + + var allowEmpty = options?.AllowEmpty ?? false; + while (true) + { + await _presenter.PresentAsync( + new ReplPromptEvent(name, prompt, "secret"), + effectiveCt) + .ConfigureAwait(false); + + string? line; + if (options?.Timeout is not null && options.Timeout.Value > TimeSpan.Zero) + { + if (Console.IsInputRedirected || ReplSessionIO.IsSessionActive) + { + line = await ReadWithTimeoutRedirectedAsync(effectiveCt, options.Timeout.Value) + .ConfigureAwait(false); + } + else + { + line = await ReadSecretWithCountdownAsync( + options.Timeout.Value, options?.Mask, effectiveCt) + .ConfigureAwait(false); + } + } + else + { + line = await ReadSecretLineAsync(options?.Mask, effectiveCt).ConfigureAwait(false); + } + + if (string.IsNullOrEmpty(line)) + { + if (allowEmpty) + { + return HandleMissingAnswer(string.Empty, "secret"); + } + + await _presenter.PresentAsync( + new ReplStatusEvent("A value is required."), + effectiveCt) + .ConfigureAwait(false); + continue; + } + + return line; + } + } + + public async ValueTask> AskMultiChoiceAsync( + string name, + string prompt, + IReadOnlyList choices, + IReadOnlyList? defaultIndices = null, + AskMultiChoiceOptions? options = null) + { + ValidateMultiChoiceArgs(name, ref prompt, ref choices, defaultIndices); + + var effectiveCt = ResolveToken(options?.CancellationToken); + effectiveCt.ThrowIfCancellationRequested(); + + var effectiveDefaults = defaultIndices ?? []; + var minSelections = options?.MinSelections ?? 0; + var maxSelections = options?.MaxSelections; + + if (_options.TryGetPrefilledAnswer(name, out var prefilledMulti) && !string.IsNullOrWhiteSpace(prefilledMulti)) + { + var parsed = ParseMultiChoiceInput(prefilledMulti, choices); + if (parsed is not null && IsValidSelection(parsed, minSelections, maxSelections)) + { + return parsed; + } + } + + var choiceDisplay = FormatMultiChoiceDisplay(choices, effectiveDefaults); + var defaultLabel = FormatMultiChoiceDefaultLabel(effectiveDefaults); + + return await ReadMultiChoiceLoopAsync( + name, prompt, choices, effectiveDefaults, choiceDisplay, defaultLabel, + minSelections, maxSelections, effectiveCt, options?.Timeout).ConfigureAwait(false); + } + + private static void ValidateMultiChoiceArgs( + string name, ref string prompt, ref IReadOnlyList choices, IReadOnlyList? defaultIndices) + { + _ = ValidateName(name); + prompt = string.IsNullOrWhiteSpace(prompt) + ? throw new ArgumentException("Prompt cannot be empty.", nameof(prompt)) + : prompt; + choices = choices is null || choices.Count == 0 + ? throw new ArgumentException("At least one choice is required.", nameof(choices)) + : choices; + if (defaultIndices is null) + { + return; + } + + foreach (var idx in defaultIndices) + { + if (idx < 0 || idx >= choices.Count) + { + throw new ArgumentOutOfRangeException(nameof(defaultIndices)); + } + } + } + + private static string FormatMultiChoiceDisplay(IReadOnlyList choices, IReadOnlyList defaults) + { + var defaultSet = new HashSet(defaults); + return string.Join(" ", choices.Select((c, i) => + defaultSet.Contains(i) ? $"[{i + 1}*] {c}" : $"[{i + 1}] {c}")); + } + + private static string? FormatMultiChoiceDefaultLabel(IReadOnlyList defaults) => + defaults.Count > 0 + ? string.Join(',', defaults.Select(i => (i + 1).ToString(System.Globalization.CultureInfo.InvariantCulture))) + : null; + + private async ValueTask> ReadMultiChoiceLoopAsync( + string name, string prompt, IReadOnlyList choices, + IReadOnlyList effectiveDefaults, string choiceDisplay, string? defaultLabel, + int minSelections, int? maxSelections, CancellationToken ct, TimeSpan? timeout) + { + while (true) + { + var line = await ReadPromptLineAsync( + name, $"{prompt}\n {choiceDisplay}\n Enter numbers (comma-separated)", + kind: "multi-choice", ct, timeout, defaultLabel: defaultLabel) + .ConfigureAwait(false); + + if (string.IsNullOrWhiteSpace(line)) + { + return HandleMissingAnswer(effectiveDefaults, "multi-choice"); + } + + var selected = ParseMultiChoiceInput(line, choices); + if (selected is null) + { + await _presenter.PresentAsync( + new ReplStatusEvent($"Invalid input '{line}'. Enter numbers 1-{choices.Count} separated by commas."), + ct) + .ConfigureAwait(false); + continue; + } + + if (!IsValidSelection(selected, minSelections, maxSelections)) + { + var msg = maxSelections is not null + ? $"Please select between {minSelections} and {maxSelections.Value} option(s)." + : $"Please select at least {minSelections} option(s)."; + await _presenter.PresentAsync(new ReplStatusEvent(msg), ct).ConfigureAwait(false); + continue; + } + + return selected; + } + } + + private static int[]? ParseMultiChoiceInput(string input, IReadOnlyList choices) + { + var parts = input.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + if (parts.Length == 0) + { + return null; + } + + var result = new List(parts.Length); + foreach (var part in parts) + { + // Try as 1-based number first. + if (int.TryParse(part, System.Globalization.CultureInfo.InvariantCulture, out var num) && num >= 1 && num <= choices.Count) + { + result.Add(num - 1); + continue; + } + + // Try as choice name (exact or prefix match). + var matchIndex = MatchChoiceByName(part, choices); + if (matchIndex < 0) + { + return null; + } + + result.Add(matchIndex); + } + + return result.Distinct().Order().ToArray(); + } + + private static bool IsValidSelection(int[] selected, int min, int? max) => + selected.Length >= min && (max is null || selected.Length <= max.Value); + + private static async ValueTask ReadSecretLineAsync(char? mask, CancellationToken ct) + { + if (Console.IsInputRedirected || ReplSessionIO.IsSessionActive) + { + return await ReadLineWithEscAsync(ct).ConfigureAwait(false); + } + + return await Task.Run(() => ReadSecretSync(mask, ct), ct).ConfigureAwait(false); + } + + private static string? ReadSecretSync(char? mask, CancellationToken ct) + { + ConsoleInputGate.Gate.Wait(ct); + try + { + return ReadSecretCore(mask, ct); + } + finally + { + ConsoleInputGate.Gate.Release(); + } + } + + private static string? ReadSecretCore(char? mask, CancellationToken ct) + { + var buffer = new System.Text.StringBuilder(); + while (!ct.IsCancellationRequested) + { + if (!Console.KeyAvailable) + { + Thread.Sleep(15); + continue; + } + + var result = HandleSecretKey(buffer, mask, ct); + if (result is not null) + { + return result; + } + } + + return null; + } + + private async ValueTask ReadSecretWithCountdownAsync( + TimeSpan timeout, + char? mask, + CancellationToken cancellationToken) + { + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + using var timer = _timeProvider.CreateTimer( + callback: static state => + { + try { ((CancellationTokenSource)state!).Cancel(); } + catch (ObjectDisposedException) { /* CTS disposed before timer fired. */ } + }, + state: timeoutCts, dueTime: timeout, period: Timeout.InfiniteTimeSpan); + + try + { + return await Task.Run( + function: () => ReadSecretWithCountdownSync(timeout, mask, timeoutCts.Token, cancellationToken), + cancellationToken: cancellationToken) + .ConfigureAwait(false); + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + return null; + } + } + + private static string? ReadSecretWithCountdownSync( + TimeSpan timeout, + char? mask, + CancellationToken timeoutCt, + CancellationToken externalCt) + { + ConsoleInputGate.Gate.Wait(externalCt); + try + { + return ReadSecretWithCountdownCore(timeout, mask, timeoutCt, externalCt); + } + finally + { + ConsoleInputGate.Gate.Release(); + } + } + + private static string? ReadSecretWithCountdownCore( + TimeSpan timeout, + char? mask, + CancellationToken timeoutCt, + CancellationToken externalCt) + { + var remaining = (int)Math.Ceiling(timeout.TotalSeconds); + var buffer = new System.Text.StringBuilder(); + var lastSuffix = FormatCountdownSuffix(remaining, defaultLabel: null); + var lastTickMs = Environment.TickCount64; + var userTyping = false; + + Console.Write(lastSuffix); + + while (!externalCt.IsCancellationRequested && (!timeoutCt.IsCancellationRequested || userTyping)) + { + if (Console.KeyAvailable) + { + if (!userTyping) + { + userTyping = true; + if (lastSuffix.Length > 0) + { + EraseInline(lastSuffix.Length); + lastSuffix = string.Empty; + } + } + + var result = HandleSecretKey(buffer, mask, externalCt); + if (result is not null) + { + return result; + } + + continue; + } + + Thread.Sleep(15); + + if (!userTyping && remaining > 0) + { + (remaining, lastSuffix, lastTickMs) = TickCountdown( + remaining, defaultLabel: null, lastSuffix, lastTickMs); + } + } + + if (lastSuffix.Length > 0) + { + EraseInline(lastSuffix.Length); + } + + Console.WriteLine(); + return null; + } + + /// + /// Handles a single keypress during a secret prompt with countdown. + /// Returns the completed input string on Enter, or null if more input is needed. + /// + private static string? HandleSecretKey( + System.Text.StringBuilder buffer, + char? mask, + CancellationToken ct) + { + var key = Console.ReadKey(intercept: true); + + if (key.Key == ConsoleKey.Escape) + { + if (buffer.Length > 0 && mask is not null) + { + EraseInline(buffer.Length); + } + + throw new OperationCanceledException("Prompt cancelled via Esc.", ct); + } + + if (key.Key == ConsoleKey.Enter) + { + Console.WriteLine(); + return buffer.ToString(); + } + + if (key.Key == ConsoleKey.Backspace) + { + if (buffer.Length > 0) + { + buffer.Remove(buffer.Length - 1, 1); + if (mask is not null) + { + Console.Write("\b \b"); + } + } + + return null; + } + + if (key.KeyChar != '\0') + { + buffer.Append(key.KeyChar); + if (mask is not null) + { + Console.Write(mask.Value); + } + } + + return null; + } + + private CancellationToken ResolveToken(CancellationToken? explicitToken) + { + var ct = explicitToken ?? default; + return ct != default ? ct : _commandToken; + } + private static string ValidateName(string name) => string.IsNullOrWhiteSpace(name) ? throw new ArgumentException("Prompt name cannot be empty.", nameof(name)) : name; - private CancellationToken ResolveToken(AskOptions? options) - { - var explicit_ = options?.CancellationToken ?? default; - return explicit_ != default ? explicit_ : _commandToken; - } + private CancellationToken ResolveToken(AskOptions? options) => + ResolveToken(options?.CancellationToken); private async ValueTask ReadPromptLineAsync( string name, diff --git a/src/Repl.Core/Interaction/Public/AskMultiChoiceOptions.cs b/src/Repl.Core/Interaction/Public/AskMultiChoiceOptions.cs new file mode 100644 index 0000000..3778243 --- /dev/null +++ b/src/Repl.Core/Interaction/Public/AskMultiChoiceOptions.cs @@ -0,0 +1,24 @@ +namespace Repl.Interaction; + +/// +/// Options for . +/// +/// +/// Explicit cancellation token. When default, the channel uses the +/// ambient per-command token set by the framework before each command dispatch. +/// +/// +/// Optional timeout for the prompt. When the timeout elapses, the default +/// selections are returned. +/// +/// +/// Minimum number of selections required. null means no minimum. +/// +/// +/// Maximum number of selections allowed. null means no maximum. +/// +public record AskMultiChoiceOptions( + CancellationToken CancellationToken = default, + TimeSpan? Timeout = null, + int? MinSelections = null, + int? MaxSelections = null); diff --git a/src/Repl.Core/Interaction/Public/AskNumberOptions.cs b/src/Repl.Core/Interaction/Public/AskNumberOptions.cs new file mode 100644 index 0000000..c1c2c61 --- /dev/null +++ b/src/Repl.Core/Interaction/Public/AskNumberOptions.cs @@ -0,0 +1,20 @@ +namespace Repl.Interaction; + +/// +/// Options for . +/// +/// The numeric type. +/// +/// Explicit cancellation token. When default, the channel uses the +/// ambient per-command token set by the framework before each command dispatch. +/// +/// +/// Optional timeout for the prompt. +/// +/// Optional minimum bound (inclusive). +/// Optional maximum bound (inclusive). +public record AskNumberOptions( + CancellationToken CancellationToken = default, + TimeSpan? Timeout = null, + T? Min = null, + T? Max = null) where T : struct; diff --git a/src/Repl.Core/Interaction/Public/AskSecretOptions.cs b/src/Repl.Core/Interaction/Public/AskSecretOptions.cs new file mode 100644 index 0000000..87bc0c2 --- /dev/null +++ b/src/Repl.Core/Interaction/Public/AskSecretOptions.cs @@ -0,0 +1,25 @@ +namespace Repl.Interaction; + +/// +/// Options for . +/// +/// +/// Explicit cancellation token. When default, the channel uses the +/// ambient per-command token set by the framework before each command dispatch. +/// +/// +/// Optional timeout for the prompt. When the timeout elapses, an empty string +/// is returned and a countdown is displayed inline. +/// +/// +/// Character used to echo each typed character. Use '*' for asterisks +/// or null for invisible (no echo). +/// +/// +/// When false, the prompt loops until a non-empty value is entered. +/// +public record AskSecretOptions( + CancellationToken CancellationToken = default, + TimeSpan? Timeout = null, + char? Mask = '*', + bool AllowEmpty = false); diff --git a/src/Repl.Core/Interaction/Public/IReplInteractionChannel.cs b/src/Repl.Core/Interaction/Public/IReplInteractionChannel.cs index 5dbb216..836f982 100644 --- a/src/Repl.Core/Interaction/Public/IReplInteractionChannel.cs +++ b/src/Repl.Core/Interaction/Public/IReplInteractionChannel.cs @@ -71,4 +71,32 @@ ValueTask AskTextAsync( string prompt, string? defaultValue = null, AskOptions? options = null); + + /// + /// Prompts the user for a secret (masked input such as a password). + /// + /// Prompt name (used for prefill via --answer:name=value). + /// Prompt text displayed to the user. + /// Optional secret-specific options (mask character, allow empty, cancellation, timeout). + /// The captured secret text. + ValueTask AskSecretAsync( + string name, + string prompt, + AskSecretOptions? options = null); + + /// + /// Prompts the user to select one or more options from a choice list. + /// + /// Prompt name (used for prefill via --answer:name=1,3). + /// Prompt text displayed to the user. + /// Available choices. + /// Indices of pre-selected choices, or null for none. + /// Optional multi-choice options (min/max selections, cancellation, timeout). + /// The zero-based indices of the selected choices. + ValueTask> AskMultiChoiceAsync( + string name, + string prompt, + IReadOnlyList choices, + IReadOnlyList? defaultIndices = null, + AskMultiChoiceOptions? options = null); } diff --git a/src/Repl.Core/Interaction/Public/ReplInteractionChannelExtensions.cs b/src/Repl.Core/Interaction/Public/ReplInteractionChannelExtensions.cs new file mode 100644 index 0000000..3bea5c8 --- /dev/null +++ b/src/Repl.Core/Interaction/Public/ReplInteractionChannelExtensions.cs @@ -0,0 +1,240 @@ +using System.ComponentModel; +using System.ComponentModel.DataAnnotations; +using System.Numerics; +using System.Reflection; +using System.Text; + +namespace Repl.Interaction; + +/// +/// Extension methods that compose on top of primitives. +/// +public static class ReplInteractionChannelExtensions +{ + /// + /// Prompts the user to select a value from an enum type. + /// Uses or names when present, + /// otherwise humanizes the enum member name (CamelCase becomes Camel case). + /// + public static async ValueTask AskEnumAsync( + this IReplInteractionChannel channel, + string name, + string prompt, + TEnum? defaultValue = null, + AskOptions? options = null) where TEnum : struct, Enum + { + var values = Enum.GetValues(); + var names = values.Select(FormatEnumName).ToList(); + var defaultIndex = defaultValue is not null + ? Array.IndexOf(values, defaultValue.Value) + : (int?)null; + var index = await channel.AskChoiceAsync(name, prompt, names, defaultIndex, options) + .ConfigureAwait(false); + return values[index]; + } + + /// + /// Prompts the user to select one or more values from a [Flags] enum type. + /// + public static async ValueTask AskFlagsEnumAsync( + this IReplInteractionChannel channel, + string name, + string prompt, + TEnum? defaultValue = null, + AskMultiChoiceOptions? options = null) where TEnum : struct, Enum + { + var values = Enum.GetValues(); + var names = values.Select(FormatEnumName).ToList(); + + IReadOnlyList? defaultIndices = null; + if (defaultValue is not null) + { + var defaultLong = Convert.ToInt64(defaultValue.Value, System.Globalization.CultureInfo.InvariantCulture); + defaultIndices = values + .Select((v, i) => (Value: Convert.ToInt64(v, System.Globalization.CultureInfo.InvariantCulture), Index: i)) + .Where(x => x.Value != 0 && (defaultLong & x.Value) == x.Value) + .Select(x => x.Index) + .ToArray(); + } + + var selectedIndices = await channel.AskMultiChoiceAsync(name, prompt, names, defaultIndices, options) + .ConfigureAwait(false); + + long result = 0; + foreach (var idx in selectedIndices) + { + result |= Convert.ToInt64(values[idx], System.Globalization.CultureInfo.InvariantCulture); + } + + return (TEnum)Enum.ToObject(typeof(TEnum), result); + } + + /// + /// Prompts the user for a typed numeric value with optional min/max bounds. + /// + public static async ValueTask AskNumberAsync( + this IReplInteractionChannel channel, + string name, + string prompt, + T? defaultValue = null, + AskNumberOptions? options = null) where T : struct, INumber, IParsable + { + var decoratedPrompt = BuildNumberPrompt(prompt, defaultValue, options); + var askOptions = options is not null + ? new AskOptions(options.CancellationToken, options.Timeout) + : null; + var defaultText = defaultValue?.ToString(); + + while (true) + { + var line = await channel.AskTextAsync(name, decoratedPrompt, defaultText, askOptions) + .ConfigureAwait(false); + + if (T.TryParse(line, System.Globalization.NumberStyles.Any, + System.Globalization.CultureInfo.InvariantCulture, out var value)) + { + if (options?.Min is not null && value < options.Min.Value) + { + await channel.WriteStatusAsync( + $"Value must be at least {options.Min.Value}.", + options.CancellationToken) + .ConfigureAwait(false); + continue; + } + + if (options?.Max is not null && value > options.Max.Value) + { + await channel.WriteStatusAsync( + $"Value must be at most {options.Max.Value}.", + options.CancellationToken) + .ConfigureAwait(false); + continue; + } + + return value; + } + + var ct = options?.CancellationToken ?? default; + await channel.WriteStatusAsync($"'{line}' is not a valid {typeof(T).Name}.", ct) + .ConfigureAwait(false); + } + } + + /// + /// Prompts the user for text with inline validation. Re-prompts until the validator + /// returns null (meaning valid). + /// + /// The interaction channel. + /// Prompt name. + /// Prompt text. + /// + /// Validator function. Returns null when the input is valid, or an error + /// message string otherwise. + /// + /// Optional default value. + /// Optional ask options. + /// The validated text. + public static async ValueTask AskValidatedTextAsync( + this IReplInteractionChannel channel, + string name, + string prompt, + Func validate, + string? defaultValue = null, + AskOptions? options = null) + { + ArgumentNullException.ThrowIfNull(validate); + var ct = options?.CancellationToken ?? default; + + while (true) + { + var input = await channel.AskTextAsync(name, prompt, defaultValue, options) + .ConfigureAwait(false); + var error = validate(input); + if (error is null) + { + return input; + } + + await channel.WriteStatusAsync(error, ct).ConfigureAwait(false); + } + } + + /// + /// Displays a message and waits for the user to press any key. + /// + public static async ValueTask PressAnyKeyAsync( + this IReplInteractionChannel channel, + string prompt = "Press any key to continue...", + CancellationToken cancellationToken = default) + { + await channel.AskTextAsync("__press_any_key__", prompt, string.Empty, + new AskOptions(cancellationToken)) + .ConfigureAwait(false); + } + + internal static string FormatEnumName(TEnum value) where TEnum : struct, Enum + { + var name = value.ToString(); + var memberInfo = typeof(TEnum).GetField(name); + if (memberInfo is not null) + { + var descAttr = memberInfo.GetCustomAttribute(); + if (descAttr is not null) + { + return descAttr.Description; + } + + var displayAttr = memberInfo.GetCustomAttribute(); + if (displayAttr?.Name is not null) + { + return displayAttr.Name; + } + } + + return HumanizePascalCase(name); + } + + internal static string HumanizePascalCase(string input) + { + if (string.IsNullOrEmpty(input)) + { + return input; + } + + var sb = new StringBuilder(input.Length + 4); + for (var i = 0; i < input.Length; i++) + { + var c = input[i]; + if (i > 0 && char.IsUpper(c) && !char.IsUpper(input[i - 1])) + { + sb.Append(' '); + sb.Append(char.ToLowerInvariant(c)); + } + else + { + sb.Append(c); + } + } + + return sb.ToString(); + } + + private static string BuildNumberPrompt( + string prompt, + T? defaultValue, + AskNumberOptions? options) where T : struct, INumber + { + if (options?.Min is null && options?.Max is null) + { + return prompt; + } + + var sb = new StringBuilder(prompt); + sb.Append(" ("); + sb.Append(options?.Min?.ToString() ?? ".."); + sb.Append(".."); + sb.Append(options?.Max?.ToString() ?? ""); + sb.Append(')'); + return sb.ToString(); + } +} diff --git a/src/Repl.Core/ShellCompletion/ShellCompletionRuntime.cs b/src/Repl.Core/ShellCompletion/ShellCompletionRuntime.cs index 388517e..c093c9b 100644 --- a/src/Repl.Core/ShellCompletion/ShellCompletionRuntime.cs +++ b/src/Repl.Core/ShellCompletion/ShellCompletionRuntime.cs @@ -190,6 +190,11 @@ public async ValueTask HandleUninstallRouteAsync( return Results.Exit(0); } + if (!operation.Changed) + { + return Results.Text(operation.Message); + } + return new ShellCompletionUninstallModel { Success = operation.Success, diff --git a/src/Repl.Defaults/DefaultsInteractionChannel.cs b/src/Repl.Defaults/DefaultsInteractionChannel.cs index 3556af5..597312b 100644 --- a/src/Repl.Defaults/DefaultsInteractionChannel.cs +++ b/src/Repl.Defaults/DefaultsInteractionChannel.cs @@ -42,4 +42,18 @@ public ValueTask AskTextAsync( string? defaultValue = null, AskOptions? options = null) => _inner.AskTextAsync(name, prompt, defaultValue, options); + + public ValueTask AskSecretAsync( + string name, + string prompt, + AskSecretOptions? options = null) => + _inner.AskSecretAsync(name, prompt, options); + + public ValueTask> AskMultiChoiceAsync( + string name, + string prompt, + IReadOnlyList choices, + IReadOnlyList? defaultIndices = null, + AskMultiChoiceOptions? options = null) => + _inner.AskMultiChoiceAsync(name, prompt, choices, defaultIndices, options); } diff --git a/src/Repl.IntegrationTests/Given_InteractionChannel.cs b/src/Repl.IntegrationTests/Given_InteractionChannel.cs index a495275..7f085f6 100644 --- a/src/Repl.IntegrationTests/Given_InteractionChannel.cs +++ b/src/Repl.IntegrationTests/Given_InteractionChannel.cs @@ -152,4 +152,165 @@ public void When_TextAnswerIsPrefilled_Then_CommandUsesPrefilledValue() output.Text.Should().Contain("Alice"); output.Text.Should().NotContain("Name?"); } + + [TestMethod] + [Description("Regression guard: verifies prefilled secret answer bypasses interactive prompt.")] + public void When_SecretAnswerIsPrefilled_Then_PrefilledValueIsUsed() + { + var sut = ReplApp.Create() + .Options(o => o.Interaction.PromptFallback = PromptFallback.Fail); + sut.Map("login", async (IReplInteractionChannel channel, CancellationToken ct) => + { + var secret = await channel.AskSecretAsync("password", "Password?").ConfigureAwait(false); + return secret.Length > 0 ? "authenticated" : "empty"; + }); + + var output = ConsoleCaptureHelper.Capture(() => sut.Run(["login", "--answer:password=s3cret"])); + + output.ExitCode.Should().Be(0); + output.Text.Should().Contain("authenticated"); + output.Text.Should().NotContain("Password?"); + } + + [TestMethod] + [Description("Regression guard: verifies prefilled multi-choice answer with comma-separated indices.")] + public void When_MultiChoiceAnswerIsPrefilled_Then_PrefilledIndicesAreUsed() + { + var sut = ReplApp.Create() + .Options(o => o.Interaction.PromptFallback = PromptFallback.Fail); + sut.Map("features", async (IReplInteractionChannel channel, CancellationToken ct) => + { + var selected = await channel.AskMultiChoiceAsync( + "features", + "Select features:", + ["Auth", "Logging", "Cache"], + defaultIndices: null).ConfigureAwait(false); + return string.Join(',', selected); + }); + + var output = ConsoleCaptureHelper.Capture(() => sut.Run(["features", "--answer:features=1,3"])); + + output.ExitCode.Should().Be(0); + output.Text.Should().Contain("0,2"); // 1-based "1,3" maps to 0-based [0,2] + } + + [TestMethod] + [Description("Regression guard: verifies prefilled multi-choice answer with choice names.")] + public void When_MultiChoiceAnswerIsPrefilledByName_Then_MatchingIndicesAreUsed() + { + var sut = ReplApp.Create() + .Options(o => o.Interaction.PromptFallback = PromptFallback.Fail); + sut.Map("features", async (IReplInteractionChannel channel, CancellationToken ct) => + { + var selected = await channel.AskMultiChoiceAsync( + "features", + "Select features:", + ["Auth", "Logging", "Cache"]).ConfigureAwait(false); + return string.Join(',', selected); + }); + + var output = ConsoleCaptureHelper.Capture(() => sut.Run(["features", "--answer:features=Auth,Cache"])); + + output.ExitCode.Should().Be(0); + output.Text.Should().Contain("0,2"); + } + + [TestMethod] + [Description("Regression guard: verifies multi-choice fallback to default indices when no input provided.")] + public void When_MultiChoiceHasDefaultsAndNoInput_Then_DefaultIndicesAreReturned() + { + var sut = ReplApp.Create() + .Options(o => o.Interaction.PromptFallback = PromptFallback.UseDefault); + sut.Map("features", async (IReplInteractionChannel channel, CancellationToken ct) => + { + var selected = await channel.AskMultiChoiceAsync( + "features", + "Select features:", + ["Auth", "Logging", "Cache"], + defaultIndices: [0, 2]).ConfigureAwait(false); + return string.Join(',', selected); + }); + + var output = ConsoleCaptureHelper.CaptureWithInput(string.Empty, () => sut.Run(["features"])); + + output.ExitCode.Should().Be(0); + output.Text.Should().Contain("0,2"); + } + + [TestMethod] + [Description("Regression guard: verifies AskEnumAsync prefill by enum value name.")] + public void When_EnumAnswerIsPrefilledByName_Then_EnumValueIsReturned() + { + var sut = ReplApp.Create() + .Options(o => o.Interaction.PromptFallback = PromptFallback.Fail); + sut.Map("choose-color", async (IReplInteractionChannel channel, CancellationToken ct) => + { + var color = await channel.AskEnumAsync("color", "Pick a color:").ConfigureAwait(false); + return color.ToString(); + }); + + var output = ConsoleCaptureHelper.Capture(() => sut.Run(["choose-color", "--answer:color=Green"])); + + output.ExitCode.Should().Be(0); + output.Text.Should().Contain("Green"); + } + + [TestMethod] + [Description("Regression guard: verifies AskNumberAsync prefill with numeric value.")] + public void When_NumberAnswerIsPrefilled_Then_ParsedValueIsReturned() + { + var sut = ReplApp.Create() + .Options(o => o.Interaction.PromptFallback = PromptFallback.Fail); + sut.Map("set-count", async (IReplInteractionChannel channel, CancellationToken ct) => + { + var count = await channel.AskNumberAsync("count", "How many?").ConfigureAwait(false); + return count.ToString(System.Globalization.CultureInfo.InvariantCulture); + }); + + var output = ConsoleCaptureHelper.Capture(() => sut.Run(["set-count", "--answer:count=42"])); + + output.ExitCode.Should().Be(0); + output.Text.Should().Contain("42"); + } + + [TestMethod] + [Description("Regression guard: verifies AskValidatedTextAsync accepts valid input through prefill.")] + public void When_ValidatedTextAnswerIsPrefilled_Then_ValidValueIsAccepted() + { + var sut = ReplApp.Create() + .Options(o => o.Interaction.PromptFallback = PromptFallback.Fail); + sut.Map("set-email", async (IReplInteractionChannel channel, CancellationToken ct) => + { + var email = await channel.AskValidatedTextAsync( + "email", + "Email?", + input => input.Contains('@') ? null : "Must contain @").ConfigureAwait(false); + return email; + }); + + var output = ConsoleCaptureHelper.Capture(() => sut.Run(["set-email", "--answer:email=a@b.com"])); + + output.ExitCode.Should().Be(0); + output.Text.Should().Contain("a@b.com"); + } + + [TestMethod] + [Description("Regression guard: verifies secret prompt fallback uses empty string when AllowEmpty is true.")] + public void When_SecretAllowsEmptyAndNoInput_Then_EmptyStringIsReturned() + { + var sut = ReplApp.Create() + .Options(o => o.Interaction.PromptFallback = PromptFallback.UseDefault); + sut.Map("token", async (IReplInteractionChannel channel, CancellationToken ct) => + { + var token = await channel.AskSecretAsync( + "token", "API Token?", + new AskSecretOptions(AllowEmpty: true)).ConfigureAwait(false); + return token.Length == 0 ? "none" : token; + }); + + var output = ConsoleCaptureHelper.CaptureWithInput(string.Empty, () => sut.Run(["token"])); + + output.ExitCode.Should().Be(0); + output.Text.Should().Contain("none"); + } } diff --git a/src/Repl.IntegrationTests/Given_ShellCompletionSetup.cs b/src/Repl.IntegrationTests/Given_ShellCompletionSetup.cs index 4c5550d..3ad511c 100644 --- a/src/Repl.IntegrationTests/Given_ShellCompletionSetup.cs +++ b/src/Repl.IntegrationTests/Given_ShellCompletionSetup.cs @@ -313,6 +313,69 @@ public void When_CompletionUninstallCommandIsUsed_Then_ManagedProfileBlockIsRemo } } + [TestMethod] + [Description("Regression guard: verifies uninstall command shows plain message instead of misleading Success: True when nothing was installed.")] + public void When_CompletionUninstallHasNothingToRemove_Then_OutputDoesNotShowSuccessTrue() + { + var paths = CreateTempPaths(); + try + { + File.WriteAllText(paths.ProfilePath, "# empty profile\n"); + var environment = CreateEnvironment(paths, preferredShell: ShellKind.Bash); + var output = RunSetup(["completion", "uninstall", "--shell", "bash", "--no-logo"], environment); + + output.ExitCode.Should().Be(0); + output.Text.Should().NotContainEquivalentOf("Success"); + output.Text.Should().Contain("not found"); + } + finally + { + TryDelete(paths.RootPath); + } + } + + [TestMethod] + [Description("Regression guard: verifies uninstall command shows plain message when profile file does not exist.")] + public void When_CompletionUninstallProfileDoesNotExist_Then_OutputDoesNotShowSuccessTrue() + { + var paths = CreateTempPaths(); + try + { + var environment = CreateEnvironment(paths, preferredShell: ShellKind.Bash); + var output = RunSetup(["completion", "uninstall", "--shell", "bash", "--no-logo"], environment); + + output.ExitCode.Should().Be(0); + output.Text.Should().NotContainEquivalentOf("Success"); + output.Text.Should().Contain("not installed"); + } + finally + { + TryDelete(paths.RootPath); + } + } + + [TestMethod] + [Description("Regression guard: verifies uninstall command shows structured result with Changed: True when a block was actually removed.")] + public void When_CompletionUninstallRemovesBlock_Then_OutputShowsChangedTrue() + { + var paths = CreateTempPaths(); + try + { + var environment = CreateEnvironment(paths, preferredShell: ShellKind.Bash); + _ = RunSetup(["completion", "install", "--shell", "bash", "--no-logo"], environment); + var uninstall = RunSetup(["completion", "uninstall", "--shell", "bash", "--no-logo"], environment); + + uninstall.ExitCode.Should().Be(0); + uninstall.Text.Should().Contain("Changed"); + uninstall.Text.Should().Contain("True"); + uninstall.Text.Should().Contain("Removed"); + } + finally + { + TryDelete(paths.RootPath); + } + } + [TestMethod] [Description("Regression guard: verifies install/uninstall updates only the current app shell block and leaves foreign managed blocks intact.")] public void When_ProfileContainsForeignManagedBlock_Then_InstallAndUninstallOnlyTouchCurrentAppBlock() diff --git a/src/Repl.IntegrationTests/SampleColor.cs b/src/Repl.IntegrationTests/SampleColor.cs new file mode 100644 index 0000000..f427ef9 --- /dev/null +++ b/src/Repl.IntegrationTests/SampleColor.cs @@ -0,0 +1,8 @@ +namespace Repl.IntegrationTests; + +internal enum SampleColor +{ + Red, + Green, + Blue, +} diff --git a/src/Repl.Tests/Given_ShellCompletionRuntime.cs b/src/Repl.Tests/Given_ShellCompletionRuntime.cs index d52524a..cc4de5f 100644 --- a/src/Repl.Tests/Given_ShellCompletionRuntime.cs +++ b/src/Repl.Tests/Given_ShellCompletionRuntime.cs @@ -157,6 +157,48 @@ public void When_StatusIsRequested_Then_ProfileExistsFieldsReflectFilesystem() } } + [TestMethod] + [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")); + Directory.CreateDirectory(root); + var profilePath = Path.Combine(root, ".bashrc"); + File.WriteAllText(profilePath, string.Empty); + try + { + var options = new ReplOptions(); + options.ShellCompletion.PreferredShell = ShellKind.Bash; + 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"); + options.ShellCompletion.NuProfilePath = Path.Combine(root, "config.nu"); + var runtime = CreateRuntime(options, tryReadProfileContent: _ => string.Empty); + + var status = runtime.HandleStatusRoute(); + + var palette = new DefaultAnsiPaletteProvider().Create(ThemeMode.Dark); + var settings = new HumanRenderSettings(Width: 200, UseAnsi: true, Palette: palette); + var transformer = new HumanOutputTransformer(() => settings); + var rendered = await transformer.TransformAsync(status, CancellationToken.None); + + var lines = rendered.Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries); + var expectedPrefix = palette.CommandStyle; + var labelLines = lines.Where(line => line.Contains(':')).ToArray(); + labelLines.Should().NotBeEmpty(); + foreach (var line in labelLines) + { + line.Should().StartWith(expectedPrefix, + $"label line should begin with CommandStyle ANSI code: {line}"); + } + } + finally + { + try { Directory.Delete(root, recursive: true); } catch { } + } + } + private static ShellCompletionRuntime CreateRuntime( ReplOptions? options = null, Func? resolveCandidates = null, From 5851d5189d8b88d4c832673e2a7ffaedf2969b4c Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Wed, 4 Mar 2026 08:17:38 -0500 Subject: [PATCH 02/13] Add ClearScreenAsync and extensible custom ambient 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. --- docs/commands.md | 17 ++ samples/02-scoped-contacts/Program.cs | 10 +- src/Repl.Core/AmbientCommandDefinition.cs | 24 +++ src/Repl.Core/AmbientCommandOptions.cs | 26 +++ src/Repl.Core/ConsoleInteractionChannel.cs | 7 + .../ConsoleReplInteractionPresenter.cs | 9 + src/Repl.Core/CoreReplApp.Interactive.cs | 56 ++++++- src/Repl.Core/HelpTextBuilder.cs | 5 + .../Public/IReplInteractionChannel.cs | 7 + .../Public/ReplClearScreenEvent.cs | 6 + .../DefaultsInteractionChannel.cs | 3 + .../Given_CustomAmbientCommands.cs | 156 ++++++++++++++++++ src/Repl.Tests/Given_AmbientCommandOptions.cs | 59 +++++++ 13 files changed, 383 insertions(+), 2 deletions(-) create mode 100644 src/Repl.Core/AmbientCommandDefinition.cs create mode 100644 src/Repl.Core/Interaction/Public/ReplClearScreenEvent.cs create mode 100644 src/Repl.IntegrationTests/Given_CustomAmbientCommands.cs create mode 100644 src/Repl.Tests/Given_AmbientCommandOptions.cs diff --git a/docs/commands.md b/docs/commands.md index 47fa946..53f4d57 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -241,6 +241,23 @@ Notes: - `history` and `autocomplete` return explicit errors outside interactive mode. - `complete` requires a terminal route and a registered `WithCompletion(...)` provider for the selected target. +### Custom ambient commands + +You can register your own ambient commands that are available in every interactive scope. +Custom ambient commands are dispatched after the built-in ones, appear in `help` output under Global Commands, and participate in interactive autocomplete. + +```csharp +app.Options(o => o.AmbientCommands.MapAmbient( + "clear", + async (IReplInteractionChannel channel, CancellationToken ct) => + { + await channel.ClearScreenAsync(ct); + }, + "Clear the screen")); +``` + +Handler parameters are injected using the same binding rules as regular command handlers (DI services, `IReplInteractionChannel`, `CancellationToken`, etc.). + ## Shell completion management commands When shell completion is enabled, the `completion` context is available in CLI mode: diff --git a/samples/02-scoped-contacts/Program.cs b/samples/02-scoped-contacts/Program.cs index d76ecb6..fdbe2b1 100644 --- a/samples/02-scoped-contacts/Program.cs +++ b/samples/02-scoped-contacts/Program.cs @@ -1,5 +1,6 @@ using System.ComponentModel; using Repl; +using Repl.Interaction; using Microsoft.Extensions.DependencyInjection; // Sample goal: @@ -25,7 +26,14 @@ Then: contact Alice (enters scope), show, remove """) .UseDefaultInteractive() - .UseCliProfile(); + .UseCliProfile() + .Options(o => o.AmbientCommands.MapAmbient( + "clear", + [Description("Clear the screen")] + async (IReplInteractionChannel channel, CancellationToken ct) => + { + await channel.ClearScreenAsync(ct); + })); app.Context( "contact", diff --git a/src/Repl.Core/AmbientCommandDefinition.cs b/src/Repl.Core/AmbientCommandDefinition.cs new file mode 100644 index 0000000..3fb9234 --- /dev/null +++ b/src/Repl.Core/AmbientCommandDefinition.cs @@ -0,0 +1,24 @@ +namespace Repl; + +/// +/// Defines a custom ambient command available in all interactive scopes. +/// +public sealed class AmbientCommandDefinition +{ + /// + /// Gets the command name (matched case-insensitively). + /// + public required string Name { get; init; } + + /// + /// Gets the optional description shown in help output. + /// + public string? Description { get; init; } + + /// + /// Gets the handler delegate. Parameters are injected using the same + /// binding rules as regular command handlers (DI services, + /// , , etc.). + /// + public required Delegate Handler { get; init; } +} diff --git a/src/Repl.Core/AmbientCommandOptions.cs b/src/Repl.Core/AmbientCommandOptions.cs index 236d3bf..2f5f875 100644 --- a/src/Repl.Core/AmbientCommandOptions.cs +++ b/src/Repl.Core/AmbientCommandOptions.cs @@ -21,4 +21,30 @@ public sealed class AmbientCommandOptions /// is shown in interactive help output. /// public bool ShowCompleteInHelp { get; set; } + + /// + /// Gets the registered custom ambient commands. + /// + internal Dictionary CustomCommands { get; } = + new(StringComparer.OrdinalIgnoreCase); + + /// + /// Registers a custom ambient command available in all interactive scopes. + /// + /// Command name (matched case-insensitively). + /// Handler delegate with injectable parameters. + /// Optional description shown in help output. + /// This instance for fluent chaining. + public AmbientCommandOptions MapAmbient(string name, Delegate handler, string? description = null) + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + ArgumentNullException.ThrowIfNull(handler); + CustomCommands[name] = new AmbientCommandDefinition + { + Name = name, + Description = description, + Handler = handler, + }; + return this; + } } diff --git a/src/Repl.Core/ConsoleInteractionChannel.cs b/src/Repl.Core/ConsoleInteractionChannel.cs index 625a376..df1b8b9 100644 --- a/src/Repl.Core/ConsoleInteractionChannel.cs +++ b/src/Repl.Core/ConsoleInteractionChannel.cs @@ -282,6 +282,13 @@ await _presenter.PresentAsync( } } + public async ValueTask ClearScreenAsync(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + await _presenter.PresentAsync(new ReplClearScreenEvent(), cancellationToken) + .ConfigureAwait(false); + } + public async ValueTask> AskMultiChoiceAsync( string name, string prompt, diff --git a/src/Repl.Core/ConsoleReplInteractionPresenter.cs b/src/Repl.Core/ConsoleReplInteractionPresenter.cs index e26006c..c7000ea 100644 --- a/src/Repl.Core/ConsoleReplInteractionPresenter.cs +++ b/src/Repl.Core/ConsoleReplInteractionPresenter.cs @@ -36,6 +36,15 @@ public async ValueTask PresentAsync(ReplInteractionEvent evt, CancellationToken case ReplProgressEvent progress: await WriteProgressAsync(progress).ConfigureAwait(false); break; + + case ReplClearScreenEvent: + await CloseProgressLineIfNeededAsync().ConfigureAwait(false); + if (_useAnsi) + { + await ReplSessionIO.Output.WriteAsync("\x1b[2J\x1b[H").ConfigureAwait(false); + } + + break; } } diff --git a/src/Repl.Core/CoreReplApp.Interactive.cs b/src/Repl.Core/CoreReplApp.Interactive.cs index cc25400..cf80145 100644 --- a/src/Repl.Core/CoreReplApp.Interactive.cs +++ b/src/Repl.Core/CoreReplApp.Interactive.cs @@ -326,6 +326,13 @@ private async ValueTask TryHandleAmbientCommandAsync( .ConfigureAwait(false); } + if (_options.AmbientCommands.CustomCommands.TryGetValue(token, out var customAmbient)) + { + await ExecuteCustomAmbientCommandAsync(customAmbient, serviceProvider, cancellationToken) + .ConfigureAwait(false); + return AmbientCommandOutcome.Handled; + } + return AmbientCommandOutcome.NotHandled; } @@ -518,6 +525,26 @@ private static async ValueTask HandleHistoryAmbientCommandCoreAsync( } } + private async ValueTask ExecuteCustomAmbientCommandAsync( + AmbientCommandDefinition command, + IServiceProvider serviceProvider, + CancellationToken cancellationToken) + { + var bindingContext = new InvocationBindingContext( + routeValues: new Dictionary(StringComparer.OrdinalIgnoreCase), + namedOptions: new Dictionary>(StringComparer.OrdinalIgnoreCase), + positionalArguments: [], + optionSchema: Internal.Options.OptionSchema.Empty, + optionCaseSensitivity: _options.Parsing.OptionCaseSensitivity, + contextValues: [], + numericFormatProvider: _options.Parsing.NumericFormatProvider ?? CultureInfo.InvariantCulture, + serviceProvider: serviceProvider, + interactionOptions: _options.Interaction, + cancellationToken: cancellationToken); + var arguments = HandlerArgumentBinder.Bind(command.Handler, bindingContext); + await CommandInvoker.InvokeAsync(command.Handler, arguments).ConfigureAwait(false); + } + private string[] GetDeepestContextScopePath(IReadOnlyList matchedPathTokens) { var activeGraph = ResolveActiveRoutingGraph(); @@ -781,6 +808,10 @@ private AutocompleteResolutionState ResolveAutocompleteState( ]; } + [SuppressMessage( + "Maintainability", + "MA0051:Method is too long", + Justification = "Ambient autocomplete candidates are kept together for discoverability.")] private List CollectAmbientAutocompleteCandidates( string currentTokenPrefix, StringComparison comparison) @@ -834,6 +865,16 @@ private AutocompleteResolutionState ResolveAutocompleteState( comparison); } + foreach (var cmd in _options.AmbientCommands.CustomCommands.Values) + { + AddAmbientSuggestion( + suggestions, + value: cmd.Name, + description: cmd.Description ?? string.Empty, + currentTokenPrefix, + comparison); + } + return suggestions; } @@ -1575,7 +1616,20 @@ private bool HasAmbientCommandPrefix(string token, StringComparison comparison) return true; } - return _options.AmbientCommands.ShowCompleteInHelp && "complete".StartsWith(token, comparison); + if (_options.AmbientCommands.ShowCompleteInHelp && "complete".StartsWith(token, comparison)) + { + return true; + } + + foreach (var name in _options.AmbientCommands.CustomCommands.Keys) + { + if (name.StartsWith(token, comparison)) + { + return true; + } + } + + return false; } private static bool TryClassifyTemplateSegment( diff --git a/src/Repl.Core/HelpTextBuilder.cs b/src/Repl.Core/HelpTextBuilder.cs index 023fa3e..0d498c8 100644 --- a/src/Repl.Core/HelpTextBuilder.cs +++ b/src/Repl.Core/HelpTextBuilder.cs @@ -719,6 +719,11 @@ private static string[][] BuildGlobalCommandRows(AmbientCommandOptions ambientOp rows.Add(CompleteRow); } + foreach (var cmd in ambientOptions.CustomCommands.Values) + { + rows.Add([cmd.Name, cmd.Description ?? string.Empty]); + } + return [.. rows]; } diff --git a/src/Repl.Core/Interaction/Public/IReplInteractionChannel.cs b/src/Repl.Core/Interaction/Public/IReplInteractionChannel.cs index 836f982..49382db 100644 --- a/src/Repl.Core/Interaction/Public/IReplInteractionChannel.cs +++ b/src/Repl.Core/Interaction/Public/IReplInteractionChannel.cs @@ -99,4 +99,11 @@ ValueTask> AskMultiChoiceAsync( IReadOnlyList choices, IReadOnlyList? defaultIndices = null, AskMultiChoiceOptions? options = null); + + /// + /// Clears the terminal screen. + /// + /// Cancellation token. + /// An asynchronous operation. + ValueTask ClearScreenAsync(CancellationToken cancellationToken); } diff --git a/src/Repl.Core/Interaction/Public/ReplClearScreenEvent.cs b/src/Repl.Core/Interaction/Public/ReplClearScreenEvent.cs new file mode 100644 index 0000000..f1daf94 --- /dev/null +++ b/src/Repl.Core/Interaction/Public/ReplClearScreenEvent.cs @@ -0,0 +1,6 @@ +namespace Repl.Interaction; + +/// +/// Semantic event requesting that the terminal screen be cleared. +/// +public sealed record ReplClearScreenEvent() : ReplInteractionEvent(DateTimeOffset.UtcNow); diff --git a/src/Repl.Defaults/DefaultsInteractionChannel.cs b/src/Repl.Defaults/DefaultsInteractionChannel.cs index 597312b..58438a9 100644 --- a/src/Repl.Defaults/DefaultsInteractionChannel.cs +++ b/src/Repl.Defaults/DefaultsInteractionChannel.cs @@ -49,6 +49,9 @@ public ValueTask AskSecretAsync( AskSecretOptions? options = null) => _inner.AskSecretAsync(name, prompt, options); + public ValueTask ClearScreenAsync(CancellationToken cancellationToken) => + _inner.ClearScreenAsync(cancellationToken); + public ValueTask> AskMultiChoiceAsync( string name, string prompt, diff --git a/src/Repl.IntegrationTests/Given_CustomAmbientCommands.cs b/src/Repl.IntegrationTests/Given_CustomAmbientCommands.cs new file mode 100644 index 0000000..76d20fe --- /dev/null +++ b/src/Repl.IntegrationTests/Given_CustomAmbientCommands.cs @@ -0,0 +1,156 @@ +namespace Repl.IntegrationTests; + +[TestClass] +[DoNotParallelize] +public sealed class Given_CustomAmbientCommands +{ + [TestMethod] + [Description("Verifies a custom ambient command is dispatched in interactive mode.")] + public void When_CustomAmbientCommandIsTyped_Then_HandlerIsExecuted() + { + var executed = false; + var sut = ReplApp.Create() + .UseDefaultInteractive() + .Options(o => o.AmbientCommands.MapAmbient( + "ping", + () => { executed = true; }, + "Test ambient command")); + sut.Map("hello", () => "world"); + + var output = ConsoleCaptureHelper.CaptureWithInput( + "ping\nexit\n", + () => sut.Run([])); + + output.ExitCode.Should().Be(0); + executed.Should().BeTrue(); + } + + [TestMethod] + [Description("Verifies a custom ambient command receives injected services.")] + public void When_CustomAmbientCommandUsesInjection_Then_ServicesAreProvided() + { + string? captured = null; + var sut = ReplApp.Create() + .UseDefaultInteractive() + .Options(o => o.AmbientCommands.MapAmbient( + "greet", + (CancellationToken ct) => { captured = "injected"; })); + sut.Map("hello", () => "world"); + + var output = ConsoleCaptureHelper.CaptureWithInput( + "greet\nexit\n", + () => sut.Run([])); + + output.ExitCode.Should().Be(0); + captured.Should().Be("injected"); + } + + [TestMethod] + [Description("Verifies custom ambient commands appear in help output.")] + public void When_HelpIsShown_Then_CustomAmbientCommandAppearsInGlobalCommands() + { + var sut = ReplApp.Create() + .UseDefaultInteractive() + .Options(o => o.AmbientCommands.MapAmbient( + "ping", + () => { }, + "Send a ping")); + sut.Map("hello", () => "world"); + + var output = ConsoleCaptureHelper.CaptureWithInput( + "help\nexit\n", + () => sut.Run([])); + + output.ExitCode.Should().Be(0); + output.Text.Should().Contain("ping"); + output.Text.Should().Contain("Send a ping"); + } + + [TestMethod] + [Description("Verifies custom ambient commands work inside a nested scope.")] + public void When_CustomAmbientCommandIsTypedInScope_Then_HandlerIsExecuted() + { + var executed = false; + var sut = ReplApp.Create() + .UseDefaultInteractive() + .Options(o => o.AmbientCommands.MapAmbient( + "ping", + () => { executed = true; })); + sut.Context("sub", (IReplMap sub) => + { + sub.Map("foo", () => "bar"); + }); + + var output = ConsoleCaptureHelper.CaptureWithInput( + "sub\nping\nexit\n", + () => sut.Run([])); + + output.ExitCode.Should().Be(0); + executed.Should().BeTrue(); + } + + [TestMethod] + [Description("Verifies ClearScreenAsync can be called from a custom ambient command.")] + public void When_ClearScreenAmbientCommand_Then_NoError() + { + var sut = ReplApp.Create() + .UseDefaultInteractive() + .Options(o => o.AmbientCommands.MapAmbient( + "clear", + [System.ComponentModel.Description("Clear the screen")] + async (IReplInteractionChannel channel, CancellationToken ct) => + { + await channel.ClearScreenAsync(ct).ConfigureAwait(false); + })); + sut.Map("hello", () => "world"); + + var output = ConsoleCaptureHelper.CaptureWithInput( + "clear\nexit\n", + () => sut.Run([])); + + output.ExitCode.Should().Be(0); + } + + [TestMethod] + [Description("Verifies custom ambient command takes precedence over unknown command error.")] + public void When_CustomAmbientCommandIsRegistered_Then_NoUnknownCommandError() + { + var sut = ReplApp.Create() + .UseDefaultInteractive() + .Options(o => o.AmbientCommands.MapAmbient( + "custom", + () => { })); + sut.Map("hello", () => "world"); + + var output = ConsoleCaptureHelper.CaptureWithInput( + "custom\nexit\n", + () => sut.Run([])); + + output.ExitCode.Should().Be(0); + output.Text.Should().NotContain("Unknown command"); + } + + [TestMethod] + [Description("Verifies async custom ambient commands are awaited properly.")] + public void When_AsyncCustomAmbientCommand_Then_HandlerIsAwaited() + { + var executed = false; + var sut = ReplApp.Create() + .UseDefaultInteractive() + .Options(o => o.AmbientCommands.MapAmbient( + "async-ping", + async (CancellationToken ct) => + { + await Task.Yield(); + executed = true; + })); + sut.Map("hello", () => "world"); + + var output = ConsoleCaptureHelper.CaptureWithInput( + "async-ping\nexit\n", + () => sut.Run([])); + + output.ExitCode.Should().Be(0); + executed.Should().BeTrue(); + } +} diff --git a/src/Repl.Tests/Given_AmbientCommandOptions.cs b/src/Repl.Tests/Given_AmbientCommandOptions.cs new file mode 100644 index 0000000..02ea981 --- /dev/null +++ b/src/Repl.Tests/Given_AmbientCommandOptions.cs @@ -0,0 +1,59 @@ +namespace Repl.Tests; + +[TestClass] +public sealed class Given_AmbientCommandOptions +{ + [TestMethod] + [Description("MapAmbient registers a custom command definition.")] + public void When_MapAmbientIsCalled_Then_CommandIsRegistered() + { + var options = new AmbientCommandOptions(); + Delegate handler = () => { }; + options.MapAmbient("clear", handler, "Clear the screen"); + + options.CustomCommands.Should().ContainKey("clear"); + options.CustomCommands["clear"].Name.Should().Be("clear"); + options.CustomCommands["clear"].Description.Should().Be("Clear the screen"); + options.CustomCommands["clear"].Handler.Should().BeSameAs(handler); + } + + [TestMethod] + [Description("MapAmbient returns the same instance for fluent chaining.")] + public void When_MapAmbientIsCalled_Then_ReturnsSameInstance() + { + var options = new AmbientCommandOptions(); + var result = options.MapAmbient("test", () => { }); + + result.Should().BeSameAs(options); + } + + [TestMethod] + [Description("MapAmbient is case-insensitive for command names.")] + public void When_MapAmbientWithDifferentCase_Then_OverridesPrevious() + { + var options = new AmbientCommandOptions(); + options.MapAmbient("clear", () => { }, "first"); + options.MapAmbient("CLEAR", () => { }, "second"); + + options.CustomCommands.Should().HaveCount(1); + options.CustomCommands["clear"].Description.Should().Be("second"); + } + + [TestMethod] + [Description("MapAmbient throws on null or empty name.")] + public void When_MapAmbientWithNullName_Then_Throws() + { + var options = new AmbientCommandOptions(); + var act = () => options.MapAmbient(null!, () => { }); + act.Should().Throw(); + } + + [TestMethod] + [Description("MapAmbient throws on null handler.")] + public void When_MapAmbientWithNullHandler_Then_Throws() + { + var options = new AmbientCommandOptions(); + var act = () => options.MapAmbient("test", null!); + act.Should().Throw(); + } +} From f4e134b59c325ff200efa56c96d936d5e00ef522 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Thu, 12 Mar 2026 19:00:52 -0400 Subject: [PATCH 03/13] Add interaction handler pipeline and split ConsoleInteractionChannel 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 --- docs/commands.md | 6 + docs/interaction.md | 311 ++++++++++ samples/04-interactive-ops/ContactStore.cs | 13 + samples/04-interactive-ops/Program.cs | 28 +- samples/04-interactive-ops/README.md | 23 +- .../ConsoleInteractionChannel.ConsoleIO.cs | 446 ++++++++++++++ src/Repl.Core/ConsoleInteractionChannel.cs | 572 ++++-------------- .../Interaction/Public/AskChoiceRequest.cs | 11 + .../Public/AskConfirmationRequest.cs | 10 + .../Public/AskMultiChoiceRequest.cs | 11 + .../Interaction/Public/AskSecretRequest.cs | 9 + .../Interaction/Public/AskTextRequest.cs | 10 + .../Interaction/Public/ClearScreenRequest.cs | 7 + .../Public/IReplInteractionHandler.cs | 51 ++ .../Interaction/Public/InteractionRequest.cs | 18 + .../Interaction/Public/InteractionResult.cs | 37 ++ .../Public/WriteProgressRequest.cs | 9 + .../Interaction/Public/WriteStatusRequest.cs | 8 + .../DefaultsInteractionChannel.cs | 4 +- src/Repl.Defaults/ReplApp.cs | 10 + .../Given_InteractionChannel.cs | 80 +++ .../SamplePermissions.cs | 12 + 22 files changed, 1215 insertions(+), 471 deletions(-) create mode 100644 docs/interaction.md create mode 100644 src/Repl.Core/ConsoleInteractionChannel.ConsoleIO.cs create mode 100644 src/Repl.Core/Interaction/Public/AskChoiceRequest.cs create mode 100644 src/Repl.Core/Interaction/Public/AskConfirmationRequest.cs create mode 100644 src/Repl.Core/Interaction/Public/AskMultiChoiceRequest.cs create mode 100644 src/Repl.Core/Interaction/Public/AskSecretRequest.cs create mode 100644 src/Repl.Core/Interaction/Public/AskTextRequest.cs create mode 100644 src/Repl.Core/Interaction/Public/ClearScreenRequest.cs create mode 100644 src/Repl.Core/Interaction/Public/IReplInteractionHandler.cs create mode 100644 src/Repl.Core/Interaction/Public/InteractionRequest.cs create mode 100644 src/Repl.Core/Interaction/Public/InteractionResult.cs create mode 100644 src/Repl.Core/Interaction/Public/WriteProgressRequest.cs create mode 100644 src/Repl.Core/Interaction/Public/WriteStatusRequest.cs create mode 100644 src/Repl.IntegrationTests/SamplePermissions.cs diff --git a/docs/commands.md b/docs/commands.md index 53f4d57..91ee957 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -224,6 +224,12 @@ Tuple semantics: - null elements are silently skipped - nested tuples are not flattened — use a flat tuple instead +## Interactive prompts + +Handlers can use `IReplInteractionChannel` for guided prompts (text, choice, confirmation, secret, multi-choice), progress reporting, and status messages. Extension methods add enum prompts, numeric input, validated text, and more. + +See the full guide: [interaction.md](interaction.md) + ## Ambient commands These commands are handled by the runtime (not by your mapped routes): diff --git a/docs/interaction.md b/docs/interaction.md new file mode 100644 index 0000000..91d816f --- /dev/null +++ b/docs/interaction.md @@ -0,0 +1,311 @@ +# Interaction Channel + +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](../samples/04-interactive-ops/) for a working demo. + +## Core primitives + +These methods are defined on `IReplInteractionChannel` and implemented by every host (console, WebSocket, test harness). + +### `AskTextAsync` + +Free-form text input with optional default. + +```csharp +var name = await channel.AskTextAsync("name", "Contact name?"); +var name = await channel.AskTextAsync("name", "Name?", defaultValue: "Alice"); +``` + +### `AskChoiceAsync` + +N-way choice prompt with default index and prefix matching. + +```csharp +var index = await channel.AskChoiceAsync( + "action", "How to handle duplicates?", + ["Skip", "Overwrite", "Cancel"], + defaultIndex: 0, + new AskOptions(Timeout: TimeSpan.FromSeconds(10))); +``` + +### `AskConfirmationAsync` + +Yes/no confirmation with a safe default. + +```csharp +var confirmed = await channel.AskConfirmationAsync( + "confirm", "Delete all contacts?", defaultValue: false); +``` + +### `AskSecretAsync` + +Masked input for passwords and tokens. Characters are echoed as the mask character (default `*`), or hidden entirely with `Mask: null`. + +```csharp +var password = await channel.AskSecretAsync("password", "Password?"); +var token = await channel.AskSecretAsync("token", "API Token?", + new AskSecretOptions(Mask: null, AllowEmpty: true)); +``` + +### `AskMultiChoiceAsync` + +Multi-selection prompt. Users enter comma-separated indices (1-based) or names. + +```csharp +var selected = await channel.AskMultiChoiceAsync( + "features", "Enable features:", + ["Auth", "Logging", "Caching", "Metrics"], + defaultIndices: [0, 1], + new AskMultiChoiceOptions(MinSelections: 1, MaxSelections: 3)); +``` + +### `ClearScreenAsync` + +Clears the terminal screen. + +```csharp +await channel.ClearScreenAsync(cancellationToken); +``` + +### `WriteStatusAsync` + +Inline feedback (validation errors, status messages). + +```csharp +await channel.WriteStatusAsync("Import started", cancellationToken); +``` + +--- + +## Extension methods + +These compose on top of the core primitives and are available via `using Repl.Interaction;`. + +### `AskEnumAsync` + +Single choice from an enum type. Uses `[Description]` or `[Display(Name)]` attributes when present, otherwise humanizes PascalCase names. + +```csharp +var theme = await channel.AskEnumAsync("theme", "Choose a theme:", AppTheme.System); +``` + +### `AskFlagsEnumAsync` + +Multi-selection from a `[Flags]` enum. Selected values are combined with bitwise OR. + +```csharp +var perms = await channel.AskFlagsEnumAsync( + "permissions", "Select permissions:", + ContactPermissions.Read | ContactPermissions.Write); +``` + +### `AskNumberAsync` + +Typed numeric input with optional min/max bounds. Re-prompts until a valid value is entered. + +```csharp +var limit = await channel.AskNumberAsync( + "limit", "Max contacts?", + defaultValue: 100, + new AskNumberOptions(Min: 1, Max: 10000)); +``` + +### `AskValidatedTextAsync` + +Text input with a validation predicate. Re-prompts until the validator returns `null` (valid). + +```csharp +var email = await channel.AskValidatedTextAsync( + "email", "Email?", + input => MailAddress.TryCreate(input, out _) ? null : "Invalid email."); +``` + +### `PressAnyKeyAsync` + +Pauses execution until the user presses a key. + +```csharp +await channel.PressAnyKeyAsync("Press any key to continue...", cancellationToken); +``` + +--- + +## Progress reporting + +Handlers inject `IProgress` to report progress. The framework creates the appropriate adapter automatically. + +### Simple percentage: `IProgress` + +```csharp +app.Map("sync", async (IProgress progress, CancellationToken ct) => +{ + for (var i = 1; i <= 10; i++) + { + progress.Report(i * 10.0); + await Task.Delay(100, ct); + } + return "done"; +}); +``` + +### Structured progress: `IProgress` + +```csharp +app.Map("import", async (IProgress progress, CancellationToken ct) => +{ + for (var i = 1; i <= total; i++) + { + progress.Report(new ReplProgressEvent("Importing", Current: i, Total: total)); + } + return "done"; +}); +``` + +--- + +## Prefill with `--answer:*` + +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) | + +--- + +## Timeout and cancellation + +### Prompt timeout + +Pass a `Timeout` via options to auto-select the default after a countdown: + +```csharp +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. + +### Cancellation + +- **Esc** during a prompt cancels the prompt +- **Ctrl+C** during a command cancels the per-command `CancellationToken` +- A second **Ctrl+C** exits the session + +--- + +## Custom presenters + +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: + +```csharp +var app = ReplApp.Create(services => +{ + services.AddSingleton(); +}); +``` + +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)`. + +--- + +## Custom interaction handlers + +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. + +```csharp +var app = ReplApp.Create(services => +{ + services.AddSingleton(); +}); +``` + +### How the pipeline works + +1. Prefill (`--answer:*`) is checked first — it always takes precedence. +2. The handler pipeline is walked in registration order. +3. Each handler receives an `InteractionRequest` and returns either `InteractionResult.Success(value)` or `InteractionResult.Unhandled`. +4. The first handler that returns `Success` wins — subsequent handlers are skipped. +5. If no handler handles the request, the built-in console presenter renders it. + +### Request types + +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` | `AskMultiChoiceAsync` | +| `ClearScreenRequest` | — | `ClearScreenAsync` | +| `WriteStatusRequest` | — | `WriteStatusAsync` | +| `WriteProgressRequest` | — | `WriteProgressAsync` | + +All request types derive from `InteractionRequest` (or `InteractionRequest` for void operations) and carry the same parameters as the corresponding channel method. + +### Example handler + +```csharp +public class SpectreInteractionHandler : IReplInteractionHandler +{ + public ValueTask TryHandleAsync( + InteractionRequest request, CancellationToken ct) => request switch + { + AskChoiceRequest r => HandleChoice(r, ct), + AskSecretRequest r => HandleSecret(r, ct), + _ => new ValueTask(InteractionResult.Unhandled), + }; + + private async ValueTask HandleChoice( + AskChoiceRequest r, CancellationToken ct) + { + // Spectre.Console rendering... + var index = 0; // resolved from Spectre prompt + return InteractionResult.Success(index); + } + + private async ValueTask HandleSecret( + AskSecretRequest r, CancellationToken ct) + { + // Spectre.Console secret prompt... + var secret = ""; // resolved from Spectre prompt + return InteractionResult.Success(secret); + } +} +``` + +### Handlers vs presenters + +| 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. diff --git a/samples/04-interactive-ops/ContactStore.cs b/samples/04-interactive-ops/ContactStore.cs index bac0e91..e96e9d7 100644 --- a/samples/04-interactive-ops/ContactStore.cs +++ b/samples/04-interactive-ops/ContactStore.cs @@ -13,6 +13,19 @@ internal enum AppTheme HighContrast, } +[Flags] +internal enum ContactPermissions +{ + [Description("View contacts")] + Read = 1, + [Description("Create and edit contacts")] + Write = 2, + [Description("Remove contacts")] + Delete = 4, + [Description("Full administrative access")] + Admin = 8, +} + internal sealed record Contact( [property: Display(Order = 0)] string Name, [property: Display(Order = 1)] string Email); diff --git a/samples/04-interactive-ops/Program.cs b/samples/04-interactive-ops/Program.cs index 21e25e7..51ad0ec 100644 --- a/samples/04-interactive-ops/Program.cs +++ b/samples/04-interactive-ops/Program.cs @@ -11,7 +11,9 @@ // - AskEnumAsync for enum-based choice (theme) // - AskNumberAsync for typed numeric input (set-limit) // - AskValidatedTextAsync for validated text (set-email) +// - AskFlagsEnumAsync for flags-enum multi-selection (permissions) // - PressAnyKeyAsync for interactive pause (demo) +// - ClearScreenAsync via custom ambient command (clear) // - WriteStatusAsync for inline feedback (import, add) // - IProgress structured progress (import) // - IProgress simple progress (sync) @@ -46,10 +48,19 @@ Try: contact theme (enum-based choice) Try: contact set-limit (typed numeric input) Try: contact set-email (validated text input) + Try: contact permissions (flags-enum multi-selection) Try: contact demo (press any key pause) + Try: clear (custom ambient command) """) .UseDefaultInteractive() - .UseCliProfile(); + .UseCliProfile() + .Options(o => o.AmbientCommands.MapAmbient( + "clear", + async (IReplInteractionChannel channel, CancellationToken ct) => + { + await channel.ClearScreenAsync(ct); + }, + "Clear the screen")); app.Context( "contact", @@ -295,6 +306,21 @@ await channel.WriteStatusAsync( return Results.Success($"Notification email set to {email}."); }); + // Flags enum — select one or more permissions from a [Flags] enum. + // Uses AskFlagsEnumAsync which maps to AskMultiChoiceAsync under the hood, + // combining selected values with bitwise OR. + contact.Map( + "permissions", + [Description("Set contact permissions (flags enum)")] + async (IReplInteractionChannel channel, CancellationToken cancellationToken) => + { + var perms = await channel.AskFlagsEnumAsync( + "permissions", + "Select permissions:", + ContactPermissions.Read | ContactPermissions.Write); + return Results.Success($"Permissions set to {perms}."); + }); + // Press any key — simple interactive pause. contact.Map( "demo", diff --git a/samples/04-interactive-ops/README.md b/samples/04-interactive-ops/README.md index ea7ed4c..d193f44 100644 --- a/samples/04-interactive-ops/README.md +++ b/samples/04-interactive-ops/README.md @@ -8,9 +8,15 @@ It exercises the full **`IReplInteractionChannel`** surface: - text prompts (with retry-on-invalid), - n-way choice prompts (default, prefix matching), - confirmations (safe defaults), +- secret/password input (masked echo), +- multi-choice selection, +- enum and flags-enum prompts, +- typed numeric input (with min/max bounds), +- validated text (re-prompt on failure), - status messages, - two progress models (**`IProgress`** and **`IProgress`**), - prompt timeouts (with countdown → auto-default), +- custom ambient commands (clear screen), - and cancellation patterns (**Esc during prompts**, **Ctrl+C during commands**). The goal: make interactive commands feel **production-ready**, while still remaining **scriptable** and **automation-friendly**. @@ -68,15 +74,30 @@ Presentation stays configurable and host-specific. ## Channel coverage (at a glance) -| Channel method | Example Command | Pattern | +### Core primitives (`IReplInteractionChannel`) + +| Method | Example Command | Pattern | |--------------------------------|--------------------------|-----------------------------------------------------| | `AskTextAsync` | `add` | retry-on-invalid (loop) | | `AskChoiceAsync` | `import` | n-way choice + default + prefix match + 10s timeout | | `AskConfirmationAsync` | `clear` | safe default (`false`) | +| `AskSecretAsync` | `login` | masked input (`*` echo) | +| `AskMultiChoiceAsync` | `configure` | multi-selection with defaults | +| `ClearScreenAsync` | `clear` (ambient) | terminal clear via custom ambient command | | `WriteStatusAsync` | `add`, `import`, `watch` | inline feedback | | `IProgress` | `import` | structured progress (current/total) | | `IProgress` | `sync` | simple percentage | +### Extension methods (`ReplInteractionChannelExtensions`) + +| Method | Example Command | Pattern | +|-------------------------|-----------------|------------------------------------------------| +| `AskEnumAsync` | `theme` | single enum choice with humanized names | +| `AskFlagsEnumAsync` | `permissions` | `[Flags]` enum multi-selection with bitwise OR | +| `AskNumberAsync` | `set-limit` | typed numeric input with min/max bounds | +| `AskValidatedTextAsync` | `set-email` | text with validation predicate (re-prompts) | +| `PressAnyKeyAsync` | `demo` | simple interactive pause | + Also demonstrated: - Optional route parameters (`{name?}`, `{email?:email}`) → prompt for missing values diff --git a/src/Repl.Core/ConsoleInteractionChannel.ConsoleIO.cs b/src/Repl.Core/ConsoleInteractionChannel.ConsoleIO.cs new file mode 100644 index 0000000..e51461d --- /dev/null +++ b/src/Repl.Core/ConsoleInteractionChannel.ConsoleIO.cs @@ -0,0 +1,446 @@ +namespace Repl; + +/// +/// Console I/O helpers: secret reading, countdown, prompt line reading. +/// +internal sealed partial class ConsoleInteractionChannel +{ + private static async ValueTask ReadSecretLineAsync(char? mask, CancellationToken ct) + { + if (Console.IsInputRedirected || ReplSessionIO.IsSessionActive) + { + return await ReadLineWithEscAsync(ct).ConfigureAwait(false); + } + + return await Task.Run(() => ReadSecretSync(mask, ct), ct).ConfigureAwait(false); + } + + private static string? ReadSecretSync(char? mask, CancellationToken ct) + { + ConsoleInputGate.Gate.Wait(ct); + try + { + return ReadSecretCore(mask, ct); + } + finally + { + ConsoleInputGate.Gate.Release(); + } + } + + private static string? ReadSecretCore(char? mask, CancellationToken ct) + { + var buffer = new System.Text.StringBuilder(); + while (!ct.IsCancellationRequested) + { + if (!Console.KeyAvailable) + { + Thread.Sleep(15); + continue; + } + + var result = HandleSecretKey(buffer, mask, ct); + if (result is not null) + { + return result; + } + } + + return null; + } + + private async ValueTask ReadSecretWithCountdownAsync( + TimeSpan timeout, + char? mask, + CancellationToken cancellationToken) + { + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + using var timer = _timeProvider.CreateTimer( + callback: static state => + { + try { ((CancellationTokenSource)state!).Cancel(); } + catch (ObjectDisposedException) { /* CTS disposed before timer fired. */ } + }, + state: timeoutCts, dueTime: timeout, period: Timeout.InfiniteTimeSpan); + + try + { + return await Task.Run( + function: () => ReadSecretWithCountdownSync(timeout, mask, timeoutCts.Token, cancellationToken), + cancellationToken: cancellationToken) + .ConfigureAwait(false); + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + return null; + } + } + + private static string? ReadSecretWithCountdownSync( + TimeSpan timeout, + char? mask, + CancellationToken timeoutCt, + CancellationToken externalCt) + { + ConsoleInputGate.Gate.Wait(externalCt); + try + { + return ReadSecretWithCountdownCore(timeout, mask, timeoutCt, externalCt); + } + finally + { + ConsoleInputGate.Gate.Release(); + } + } + + private static string? ReadSecretWithCountdownCore( + TimeSpan timeout, + char? mask, + CancellationToken timeoutCt, + CancellationToken externalCt) + { + var remaining = (int)Math.Ceiling(timeout.TotalSeconds); + var buffer = new System.Text.StringBuilder(); + var lastSuffix = FormatCountdownSuffix(remaining, defaultLabel: null); + var lastTickMs = Environment.TickCount64; + var userTyping = false; + + Console.Write(lastSuffix); + + while (!externalCt.IsCancellationRequested && (!timeoutCt.IsCancellationRequested || userTyping)) + { + if (Console.KeyAvailable) + { + if (!userTyping) + { + userTyping = true; + if (lastSuffix.Length > 0) + { + EraseInline(lastSuffix.Length); + lastSuffix = string.Empty; + } + } + + var result = HandleSecretKey(buffer, mask, externalCt); + if (result is not null) + { + return result; + } + + continue; + } + + Thread.Sleep(15); + + if (!userTyping && remaining > 0) + { + (remaining, lastSuffix, lastTickMs) = TickCountdown( + remaining, defaultLabel: null, lastSuffix, lastTickMs); + } + } + + if (lastSuffix.Length > 0) + { + EraseInline(lastSuffix.Length); + } + + Console.WriteLine(); + return null; + } + + private static string? HandleSecretKey( + System.Text.StringBuilder buffer, + char? mask, + CancellationToken ct) + { + var key = Console.ReadKey(intercept: true); + + if (key.Key == ConsoleKey.Escape) + { + if (buffer.Length > 0 && mask is not null) + { + EraseInline(buffer.Length); + } + + throw new OperationCanceledException("Prompt cancelled via Esc.", ct); + } + + if (key.Key == ConsoleKey.Enter) + { + Console.WriteLine(); + return buffer.ToString(); + } + + if (key.Key == ConsoleKey.Backspace) + { + if (buffer.Length > 0) + { + buffer.Remove(buffer.Length - 1, 1); + if (mask is not null) + { + Console.Write("\b \b"); + } + } + + return null; + } + + if (key.KeyChar != '\0') + { + buffer.Append(key.KeyChar); + if (mask is not null) + { + Console.Write(mask.Value); + } + } + + return null; + } + + private async ValueTask ReadPromptLineAsync( + string name, + string prompt, + string kind, + CancellationToken cancellationToken, + TimeSpan? timeout = null, + string? defaultLabel = null) + { + await _presenter.PresentAsync( + new ReplPromptEvent(name, prompt, kind), + cancellationToken) + .ConfigureAwait(false); + + if (timeout is null || timeout.Value <= TimeSpan.Zero) + { + return await ReadLineWithEscAsync(cancellationToken).ConfigureAwait(false); + } + + if (Console.IsInputRedirected || ReplSessionIO.IsSessionActive) + { + return await ReadWithTimeoutRedirectedAsync(cancellationToken, timeout.Value) + .ConfigureAwait(false); + } + + return await ReadLineWithCountdownAsync(timeout.Value, defaultLabel, cancellationToken) + .ConfigureAwait(false); + } + + private async ValueTask ReadWithTimeoutRedirectedAsync( + CancellationToken cancellationToken, + TimeSpan timeout) + { + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + using var _ = _timeProvider.CreateTimer( + static state => + { + try { ((CancellationTokenSource)state!).Cancel(); } + catch (ObjectDisposedException) { /* CTS disposed before timer fired. */ } + }, + timeoutCts, timeout, Timeout.InfiniteTimeSpan); + try + { + return await ReadLineWithEscAsync(timeoutCts.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + return null; + } + } + + private async ValueTask ReadLineWithCountdownAsync( + TimeSpan timeout, + string? defaultLabel, + CancellationToken cancellationToken) + { + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + using var _ = _timeProvider.CreateTimer( + static state => + { + try { ((CancellationTokenSource)state!).Cancel(); } + catch (ObjectDisposedException) { /* CTS disposed before timer fired. */ } + }, + timeoutCts, timeout, Timeout.InfiniteTimeSpan); + + var result = await Task.Run( + () => ReadLineWithCountdownSync(timeout, defaultLabel, timeoutCts.Token, cancellationToken), + cancellationToken) + .ConfigureAwait(false); + + if (result.Escaped) + { + throw new OperationCanceledException("Prompt cancelled via Esc.", cancellationToken); + } + + return result.Line; + } + + /// + /// Combined countdown + key reading loop. The countdown suffix is displayed + /// while the user hasn't typed anything. As soon as the first key arrives, + /// the suffix is erased and normal key-by-key reading takes over. + /// + private static ConsoleLineReader.ReadResult ReadLineWithCountdownSync( + TimeSpan timeout, + string? defaultLabel, + CancellationToken timeoutCt, + CancellationToken externalCt) + { + ConsoleInputGate.Gate.Wait(externalCt); + try + { + return ReadLineWithCountdownCore(timeout, defaultLabel, timeoutCt, externalCt); + } + finally + { + ConsoleInputGate.Gate.Release(); + } + } + + private static ConsoleLineReader.ReadResult ReadLineWithCountdownCore( + TimeSpan timeout, + string? defaultLabel, + CancellationToken timeoutCt, + CancellationToken externalCt) + { + var remaining = (int)Math.Ceiling(timeout.TotalSeconds); + var buffer = new System.Text.StringBuilder(); + var lastSuffix = FormatCountdownSuffix(remaining, defaultLabel); + var lastTickMs = Environment.TickCount64; + var userTyping = false; + + Console.Write(lastSuffix); + + while (!externalCt.IsCancellationRequested && (!timeoutCt.IsCancellationRequested || userTyping)) + { + if (Console.KeyAvailable) + { + if (!userTyping) + { + userTyping = true; + if (lastSuffix.Length > 0) + { + EraseInline(lastSuffix.Length); + lastSuffix = string.Empty; + } + } + + var result = HandleCountdownKey(buffer); + if (result is not null) + { + return result.Value; + } + + continue; + } + + Thread.Sleep(15); + + if (!userTyping && remaining > 0) + { + (remaining, lastSuffix, lastTickMs) = TickCountdown( + remaining, defaultLabel, lastSuffix, lastTickMs); + } + } + + if (lastSuffix.Length > 0) + { + EraseInline(lastSuffix.Length); + } + + Console.WriteLine(); + return new ConsoleLineReader.ReadResult(Line: null, Escaped: false); + } + + private static ConsoleLineReader.ReadResult? HandleCountdownKey( + System.Text.StringBuilder buffer) + { + var key = Console.ReadKey(intercept: true); + + if (key.Key == ConsoleKey.Escape) + { + if (buffer.Length > 0) + { + EraseInline(buffer.Length); + } + + return new ConsoleLineReader.ReadResult(Line: null, Escaped: true); + } + + if (key.Key == ConsoleKey.Enter) + { + Console.WriteLine(); + return new ConsoleLineReader.ReadResult(buffer.ToString(), Escaped: false); + } + + if (key.Key == ConsoleKey.Backspace) + { + if (buffer.Length > 0) + { + buffer.Remove(buffer.Length - 1, 1); + Console.Write("\b \b"); + } + + return null; + } + + if (key.KeyChar != '\0') + { + buffer.Append(key.KeyChar); + Console.Write(key.KeyChar); + } + + return null; + } + + private static (int Remaining, string Suffix, long LastTickMs) TickCountdown( + int remaining, + string? defaultLabel, + string lastSuffix, + long lastTickMs) + { + var now = Environment.TickCount64; + if (now - lastTickMs < 1000) + { + return (remaining, lastSuffix, lastTickMs); + } + + remaining--; + EraseInline(lastSuffix.Length); + + if (remaining > 0) + { + lastSuffix = FormatCountdownSuffix(remaining, defaultLabel); + Console.Write(lastSuffix); + } + else + { + lastSuffix = string.Empty; + } + + return (remaining, lastSuffix, now); + } + + private static void EraseInline(int length) + { + Console.Write(new string('\b', length) + new string(' ', length) + new string('\b', length)); + } + + private static async ValueTask ReadLineWithEscAsync(CancellationToken ct) + { + var result = await ConsoleLineReader.ReadLineAsync(ct).ConfigureAwait(false); + if (result.Escaped) + { + throw new OperationCanceledException("Prompt cancelled via Esc.", ct); + } + + return result.Line; + } + + /// + /// Formats the inline countdown suffix shown next to a prompt (e.g. " (10s -> Skip)"). + /// + internal static string FormatCountdownSuffix(int remainingSeconds, string? defaultLabel) => + string.IsNullOrWhiteSpace(defaultLabel) + ? $" ({remainingSeconds}s)" + : $" ({remainingSeconds}s -> {defaultLabel})"; +} diff --git a/src/Repl.Core/ConsoleInteractionChannel.cs b/src/Repl.Core/ConsoleInteractionChannel.cs index df1b8b9..11fb38c 100644 --- a/src/Repl.Core/ConsoleInteractionChannel.cs +++ b/src/Repl.Core/ConsoleInteractionChannel.cs @@ -1,16 +1,38 @@ namespace Repl; -internal sealed class ConsoleInteractionChannel( +internal sealed partial class ConsoleInteractionChannel( InteractionOptions options, OutputOptions? outputOptions = null, IReplInteractionPresenter? presenter = null, + IReadOnlyList? handlers = null, TimeProvider? timeProvider = null) : IReplInteractionChannel, ICommandTokenReceiver { private readonly InteractionOptions _options = options; private readonly IReplInteractionPresenter _presenter = presenter ?? new ConsoleReplInteractionPresenter(options, outputOptions); + private readonly IReadOnlyList _handlers = handlers ?? []; private readonly TimeProvider _timeProvider = timeProvider ?? TimeProvider.System; private CancellationToken _commandToken; + /// + /// Dispatches a request through the handler pipeline. + /// Returns the from the first handler that handles it, + /// or if none did. + /// + private async ValueTask TryDispatchAsync( + InteractionRequest request, CancellationToken ct) + { + foreach (var handler in _handlers) + { + var result = await handler.TryHandleAsync(request, ct).ConfigureAwait(false); + if (result.Handled) + { + return result; + } + } + + return InteractionResult.Unhandled; + } + /// /// Sets the ambient per-command token. Called by the framework before each command dispatch. /// @@ -25,6 +47,14 @@ public async ValueTask WriteProgressAsync( ? throw new ArgumentException("Label cannot be empty.", nameof(label)) : label; cancellationToken.ThrowIfCancellationRequested(); + + var dispatched = await TryDispatchAsync(new WriteProgressRequest(label, percent, cancellationToken), cancellationToken) + .ConfigureAwait(false); + if (dispatched.Handled) + { + return; + } + await _presenter.PresentAsync( new ReplProgressEvent(label, Percent: percent), cancellationToken) @@ -37,6 +67,14 @@ public async ValueTask WriteStatusAsync(string text, CancellationToken cancellat ? throw new ArgumentException("Status text cannot be empty.", nameof(text)) : text; cancellationToken.ThrowIfCancellationRequested(); + + var dispatched = await TryDispatchAsync(new WriteStatusRequest(text, cancellationToken), cancellationToken) + .ConfigureAwait(false); + if (dispatched.Handled) + { + return; + } + await _presenter.PresentAsync(new ReplStatusEvent(text), cancellationToken).ConfigureAwait(false); } @@ -73,6 +111,21 @@ public async ValueTask AskChoiceAsync( } } + var dispatched = await TryDispatchAsync( + new AskChoiceRequest(name, prompt, choices, defaultIndex, options), effectiveCt).ConfigureAwait(false); + if (dispatched.Handled) + { + return (int)dispatched.Value!; + } + + return await ReadChoiceLoopAsync(name, prompt, choices, effectiveDefaultIndex, effectiveCt, options?.Timeout) + .ConfigureAwait(false); + } + + private async ValueTask ReadChoiceLoopAsync( + string name, string prompt, IReadOnlyList choices, + int effectiveDefaultIndex, CancellationToken ct, TimeSpan? timeout) + { var choiceDisplay = string.Join('/', choices.Select((c, i) => i == effectiveDefaultIndex ? c.ToUpperInvariant() : c.ToLowerInvariant())); while (true) @@ -81,8 +134,8 @@ public async ValueTask AskChoiceAsync( name, $"{prompt} [{choiceDisplay}]", kind: "choice", - effectiveCt, - options?.Timeout, + ct, + timeout, defaultLabel: choices[effectiveDefaultIndex]) .ConfigureAwait(false); if (string.IsNullOrWhiteSpace(line)) @@ -98,7 +151,7 @@ public async ValueTask AskChoiceAsync( await _presenter.PresentAsync( new ReplStatusEvent($"Invalid choice '{line}'. Please enter one of: {string.Join(", ", choices)}."), - effectiveCt) + ct) .ConfigureAwait(false); } } @@ -158,6 +211,13 @@ public async ValueTask AskConfirmationAsync( return resolvedPrefilled; } + var dispatched = await TryDispatchAsync( + new AskConfirmationRequest(name, prompt, defaultValue, options), effectiveCt).ConfigureAwait(false); + if (dispatched.Handled) + { + return (bool)dispatched.Value!; + } + var line = await ReadPromptLineAsync( name, $"{prompt} [{(defaultValue ? "Y/n" : "y/N")}]", @@ -204,6 +264,13 @@ public async ValueTask AskTextAsync( return prefilledText ?? string.Empty; } + var dispatched = await TryDispatchAsync( + new AskTextRequest(name, prompt, defaultValue, options), effectiveCt).ConfigureAwait(false); + if (dispatched.Handled) + { + return (string)dispatched.Value!; + } + var decoratedPrompt = string.IsNullOrWhiteSpace(defaultValue) ? prompt : $"{prompt} [{defaultValue}]"; @@ -236,12 +303,25 @@ public async ValueTask AskSecretAsync( return prefilledSecret ?? string.Empty; } + var dispatched = await TryDispatchAsync( + new AskSecretRequest(name, prompt, options), effectiveCt).ConfigureAwait(false); + if (dispatched.Handled) + { + return (string)dispatched.Value!; + } + + return await ReadSecretLoopAsync(name, prompt, options, effectiveCt).ConfigureAwait(false); + } + + private async ValueTask ReadSecretLoopAsync( + string name, string prompt, AskSecretOptions? options, CancellationToken ct) + { var allowEmpty = options?.AllowEmpty ?? false; while (true) { await _presenter.PresentAsync( new ReplPromptEvent(name, prompt, "secret"), - effectiveCt) + ct) .ConfigureAwait(false); string? line; @@ -249,19 +329,19 @@ await _presenter.PresentAsync( { if (Console.IsInputRedirected || ReplSessionIO.IsSessionActive) { - line = await ReadWithTimeoutRedirectedAsync(effectiveCt, options.Timeout.Value) + line = await ReadWithTimeoutRedirectedAsync(ct, options.Timeout.Value) .ConfigureAwait(false); } else { line = await ReadSecretWithCountdownAsync( - options.Timeout.Value, options?.Mask, effectiveCt) + options.Timeout.Value, options?.Mask, ct) .ConfigureAwait(false); } } else { - line = await ReadSecretLineAsync(options?.Mask, effectiveCt).ConfigureAwait(false); + line = await ReadSecretLineAsync(options?.Mask, ct).ConfigureAwait(false); } if (string.IsNullOrEmpty(line)) @@ -273,7 +353,7 @@ await _presenter.PresentAsync( await _presenter.PresentAsync( new ReplStatusEvent("A value is required."), - effectiveCt) + ct) .ConfigureAwait(false); continue; } @@ -285,6 +365,14 @@ await _presenter.PresentAsync( public async ValueTask ClearScreenAsync(CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); + + var dispatched = await TryDispatchAsync(new ClearScreenRequest(), cancellationToken) + .ConfigureAwait(false); + if (dispatched.Handled) + { + return; + } + await _presenter.PresentAsync(new ReplClearScreenEvent(), cancellationToken) .ConfigureAwait(false); } @@ -314,6 +402,13 @@ public async ValueTask> AskMultiChoiceAsync( } } + var dispatched = await TryDispatchAsync( + new AskMultiChoiceRequest(name, prompt, choices, defaultIndices, options), effectiveCt).ConfigureAwait(false); + if (dispatched.Handled) + { + return (IReadOnlyList)dispatched.Value!; + } + var choiceDisplay = FormatMultiChoiceDisplay(choices, effectiveDefaults); var defaultLabel = FormatMultiChoiceDefaultLabel(effectiveDefaults); @@ -432,202 +527,6 @@ await _presenter.PresentAsync( private static bool IsValidSelection(int[] selected, int min, int? max) => selected.Length >= min && (max is null || selected.Length <= max.Value); - private static async ValueTask ReadSecretLineAsync(char? mask, CancellationToken ct) - { - if (Console.IsInputRedirected || ReplSessionIO.IsSessionActive) - { - return await ReadLineWithEscAsync(ct).ConfigureAwait(false); - } - - return await Task.Run(() => ReadSecretSync(mask, ct), ct).ConfigureAwait(false); - } - - private static string? ReadSecretSync(char? mask, CancellationToken ct) - { - ConsoleInputGate.Gate.Wait(ct); - try - { - return ReadSecretCore(mask, ct); - } - finally - { - ConsoleInputGate.Gate.Release(); - } - } - - private static string? ReadSecretCore(char? mask, CancellationToken ct) - { - var buffer = new System.Text.StringBuilder(); - while (!ct.IsCancellationRequested) - { - if (!Console.KeyAvailable) - { - Thread.Sleep(15); - continue; - } - - var result = HandleSecretKey(buffer, mask, ct); - if (result is not null) - { - return result; - } - } - - return null; - } - - private async ValueTask ReadSecretWithCountdownAsync( - TimeSpan timeout, - char? mask, - CancellationToken cancellationToken) - { - using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - using var timer = _timeProvider.CreateTimer( - callback: static state => - { - try { ((CancellationTokenSource)state!).Cancel(); } - catch (ObjectDisposedException) { /* CTS disposed before timer fired. */ } - }, - state: timeoutCts, dueTime: timeout, period: Timeout.InfiniteTimeSpan); - - try - { - return await Task.Run( - function: () => ReadSecretWithCountdownSync(timeout, mask, timeoutCts.Token, cancellationToken), - cancellationToken: cancellationToken) - .ConfigureAwait(false); - } - catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) - { - return null; - } - } - - private static string? ReadSecretWithCountdownSync( - TimeSpan timeout, - char? mask, - CancellationToken timeoutCt, - CancellationToken externalCt) - { - ConsoleInputGate.Gate.Wait(externalCt); - try - { - return ReadSecretWithCountdownCore(timeout, mask, timeoutCt, externalCt); - } - finally - { - ConsoleInputGate.Gate.Release(); - } - } - - private static string? ReadSecretWithCountdownCore( - TimeSpan timeout, - char? mask, - CancellationToken timeoutCt, - CancellationToken externalCt) - { - var remaining = (int)Math.Ceiling(timeout.TotalSeconds); - var buffer = new System.Text.StringBuilder(); - var lastSuffix = FormatCountdownSuffix(remaining, defaultLabel: null); - var lastTickMs = Environment.TickCount64; - var userTyping = false; - - Console.Write(lastSuffix); - - while (!externalCt.IsCancellationRequested && (!timeoutCt.IsCancellationRequested || userTyping)) - { - if (Console.KeyAvailable) - { - if (!userTyping) - { - userTyping = true; - if (lastSuffix.Length > 0) - { - EraseInline(lastSuffix.Length); - lastSuffix = string.Empty; - } - } - - var result = HandleSecretKey(buffer, mask, externalCt); - if (result is not null) - { - return result; - } - - continue; - } - - Thread.Sleep(15); - - if (!userTyping && remaining > 0) - { - (remaining, lastSuffix, lastTickMs) = TickCountdown( - remaining, defaultLabel: null, lastSuffix, lastTickMs); - } - } - - if (lastSuffix.Length > 0) - { - EraseInline(lastSuffix.Length); - } - - Console.WriteLine(); - return null; - } - - /// - /// Handles a single keypress during a secret prompt with countdown. - /// Returns the completed input string on Enter, or null if more input is needed. - /// - private static string? HandleSecretKey( - System.Text.StringBuilder buffer, - char? mask, - CancellationToken ct) - { - var key = Console.ReadKey(intercept: true); - - if (key.Key == ConsoleKey.Escape) - { - if (buffer.Length > 0 && mask is not null) - { - EraseInline(buffer.Length); - } - - throw new OperationCanceledException("Prompt cancelled via Esc.", ct); - } - - if (key.Key == ConsoleKey.Enter) - { - Console.WriteLine(); - return buffer.ToString(); - } - - if (key.Key == ConsoleKey.Backspace) - { - if (buffer.Length > 0) - { - buffer.Remove(buffer.Length - 1, 1); - if (mask is not null) - { - Console.Write("\b \b"); - } - } - - return null; - } - - if (key.KeyChar != '\0') - { - buffer.Append(key.KeyChar); - if (mask is not null) - { - Console.Write(mask.Value); - } - } - - return null; - } - private CancellationToken ResolveToken(CancellationToken? explicitToken) { var ct = explicitToken ?? default; @@ -642,269 +541,6 @@ private static string ValidateName(string name) => private CancellationToken ResolveToken(AskOptions? options) => ResolveToken(options?.CancellationToken); - private async ValueTask ReadPromptLineAsync( - string name, - string prompt, - string kind, - CancellationToken cancellationToken, - TimeSpan? timeout = null, - string? defaultLabel = null) - { - await _presenter.PresentAsync( - new ReplPromptEvent(name, prompt, kind), - cancellationToken) - .ConfigureAwait(false); - - if (timeout is null || timeout.Value <= TimeSpan.Zero) - { - return await ReadLineWithEscAsync(cancellationToken).ConfigureAwait(false); - } - - // Timeout path — redirected input: simple read with timer-based timeout. - if (Console.IsInputRedirected || ReplSessionIO.IsSessionActive) - { - return await ReadWithTimeoutRedirectedAsync(cancellationToken, timeout.Value) - .ConfigureAwait(false); - } - - // Timeout path — interactive: combined countdown display + key reading - // in a single sequential loop so they never interfere. - return await ReadLineWithCountdownAsync(timeout.Value, defaultLabel, cancellationToken) - .ConfigureAwait(false); - } - - private async ValueTask ReadWithTimeoutRedirectedAsync( - CancellationToken cancellationToken, - TimeSpan timeout) - { - using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - using var _ = _timeProvider.CreateTimer( - static state => - { - try { ((CancellationTokenSource)state!).Cancel(); } - catch (ObjectDisposedException) { /* CTS disposed before timer fired. */ } - }, - timeoutCts, timeout, Timeout.InfiniteTimeSpan); - try - { - return await ReadLineWithEscAsync(timeoutCts.Token).ConfigureAwait(false); - } - catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) - { - return null; - } - } - - private async ValueTask ReadLineWithCountdownAsync( - TimeSpan timeout, - string? defaultLabel, - CancellationToken cancellationToken) - { - using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - using var _ = _timeProvider.CreateTimer( - static state => - { - try { ((CancellationTokenSource)state!).Cancel(); } - catch (ObjectDisposedException) { /* CTS disposed before timer fired. */ } - }, - timeoutCts, timeout, Timeout.InfiniteTimeSpan); - - var result = await Task.Run( - () => ReadLineWithCountdownSync(timeout, defaultLabel, timeoutCts.Token, cancellationToken), - cancellationToken) - .ConfigureAwait(false); - - if (result.Escaped) - { - throw new OperationCanceledException("Prompt cancelled via Esc.", cancellationToken); - } - - return result.Line; - } - - /// - /// Combined countdown + key reading loop. The countdown suffix is displayed - /// while the user hasn't typed anything. As soon as the first key arrives, - /// the suffix is erased and normal key-by-key reading takes over. - /// This avoids the concurrent-write corruption that occurs when countdown - /// and input run on separate tasks sharing the same console line. - /// - private static ConsoleLineReader.ReadResult ReadLineWithCountdownSync( - TimeSpan timeout, - string? defaultLabel, - CancellationToken timeoutCt, - CancellationToken externalCt) - { - ConsoleInputGate.Gate.Wait(externalCt); - try - { - return ReadLineWithCountdownCore(timeout, defaultLabel, timeoutCt, externalCt); - } - finally - { - ConsoleInputGate.Gate.Release(); - } - } - - private static ConsoleLineReader.ReadResult ReadLineWithCountdownCore( - TimeSpan timeout, - string? defaultLabel, - CancellationToken timeoutCt, - CancellationToken externalCt) - { - var remaining = (int)Math.Ceiling(timeout.TotalSeconds); - var buffer = new System.Text.StringBuilder(); - var lastSuffix = FormatCountdownSuffix(remaining, defaultLabel); - var lastTickMs = Environment.TickCount64; - var userTyping = false; - - // Show initial countdown suffix. - Console.Write(lastSuffix); - - while (!externalCt.IsCancellationRequested && (!timeoutCt.IsCancellationRequested || userTyping)) - { - if (Console.KeyAvailable) - { - // First keypress erases the countdown suffix and disarms the timeout. - if (!userTyping) - { - userTyping = true; - if (lastSuffix.Length > 0) - { - EraseInline(lastSuffix.Length); - lastSuffix = string.Empty; - } - } - - var result = HandleCountdownKey(buffer); - if (result is not null) - { - return result.Value; - } - - continue; - } - - Thread.Sleep(15); - - // Update countdown display (only when user hasn't started typing). - if (!userTyping && remaining > 0) - { - (remaining, lastSuffix, lastTickMs) = TickCountdown( - remaining, defaultLabel, lastSuffix, lastTickMs); - } - } - - // Timeout or cancellation — clean up and signal default. - if (lastSuffix.Length > 0) - { - EraseInline(lastSuffix.Length); - } - - Console.WriteLine(); - return new ConsoleLineReader.ReadResult(Line: null, Escaped: false); - } - - /// - /// Handles a single keypress during the countdown prompt. - /// Returns a if the line is complete - /// (Enter or Esc), or null if more input is needed. - /// - private static ConsoleLineReader.ReadResult? HandleCountdownKey( - System.Text.StringBuilder buffer) - { - var key = Console.ReadKey(intercept: true); - - if (key.Key == ConsoleKey.Escape) - { - if (buffer.Length > 0) - { - EraseInline(buffer.Length); - } - - return new ConsoleLineReader.ReadResult(Line: null, Escaped: true); - } - - if (key.Key == ConsoleKey.Enter) - { - Console.WriteLine(); - return new ConsoleLineReader.ReadResult(buffer.ToString(), Escaped: false); - } - - if (key.Key == ConsoleKey.Backspace) - { - if (buffer.Length > 0) - { - buffer.Remove(buffer.Length - 1, 1); - Console.Write("\b \b"); - } - - return null; - } - - if (key.KeyChar != '\0') - { - buffer.Append(key.KeyChar); - Console.Write(key.KeyChar); - } - - return null; - } - - private static (int Remaining, string Suffix, long LastTickMs) TickCountdown( - int remaining, - string? defaultLabel, - string lastSuffix, - long lastTickMs) - { - var now = Environment.TickCount64; - if (now - lastTickMs < 1000) - { - return (remaining, lastSuffix, lastTickMs); - } - - remaining--; - EraseInline(lastSuffix.Length); - - if (remaining > 0) - { - lastSuffix = FormatCountdownSuffix(remaining, defaultLabel); - Console.Write(lastSuffix); - } - else - { - lastSuffix = string.Empty; - } - - return (remaining, lastSuffix, now); - } - - private static void EraseInline(int length) - { - Console.Write(new string('\b', length) + new string(' ', length) + new string('\b', length)); - } - - private static async ValueTask ReadLineWithEscAsync(CancellationToken ct) - { - var result = await ConsoleLineReader.ReadLineAsync(ct).ConfigureAwait(false); - if (result.Escaped) - { - throw new OperationCanceledException("Prompt cancelled via Esc.", ct); - } - - return result.Line; - } - - /// - /// Formats the inline countdown suffix shown next to a prompt (e.g. " (10s -> Skip)"). - /// Extracted for testability — every character must occupy exactly one console cell - /// so that \b-based erasure works correctly. - /// - internal static string FormatCountdownSuffix(int remainingSeconds, string? defaultLabel) => - string.IsNullOrWhiteSpace(defaultLabel) - ? $" ({remainingSeconds}s)" - : $" ({remainingSeconds}s -> {defaultLabel})"; - private T HandleMissingAnswer(T fallbackValue, string promptKind) { if (_options.PromptFallback == PromptFallback.Fail) diff --git a/src/Repl.Core/Interaction/Public/AskChoiceRequest.cs b/src/Repl.Core/Interaction/Public/AskChoiceRequest.cs new file mode 100644 index 0000000..9b11eeb --- /dev/null +++ b/src/Repl.Core/Interaction/Public/AskChoiceRequest.cs @@ -0,0 +1,11 @@ +namespace Repl.Interaction; + +/// +/// Requests a single choice from a list of options. +/// +public sealed record AskChoiceRequest( + string Name, + string Prompt, + IReadOnlyList Choices, + int? DefaultIndex = null, + AskOptions? Options = null) : InteractionRequest(Name, Prompt); diff --git a/src/Repl.Core/Interaction/Public/AskConfirmationRequest.cs b/src/Repl.Core/Interaction/Public/AskConfirmationRequest.cs new file mode 100644 index 0000000..6a4ed61 --- /dev/null +++ b/src/Repl.Core/Interaction/Public/AskConfirmationRequest.cs @@ -0,0 +1,10 @@ +namespace Repl.Interaction; + +/// +/// Requests a yes/no confirmation. +/// +public sealed record AskConfirmationRequest( + string Name, + string Prompt, + bool DefaultValue = false, + AskOptions? Options = null) : InteractionRequest(Name, Prompt); diff --git a/src/Repl.Core/Interaction/Public/AskMultiChoiceRequest.cs b/src/Repl.Core/Interaction/Public/AskMultiChoiceRequest.cs new file mode 100644 index 0000000..ea37fc0 --- /dev/null +++ b/src/Repl.Core/Interaction/Public/AskMultiChoiceRequest.cs @@ -0,0 +1,11 @@ +namespace Repl.Interaction; + +/// +/// Requests multi-selection from a list of choices. +/// +public sealed record AskMultiChoiceRequest( + string Name, + string Prompt, + IReadOnlyList Choices, + IReadOnlyList? DefaultIndices = null, + AskMultiChoiceOptions? Options = null) : InteractionRequest>(Name, Prompt); diff --git a/src/Repl.Core/Interaction/Public/AskSecretRequest.cs b/src/Repl.Core/Interaction/Public/AskSecretRequest.cs new file mode 100644 index 0000000..ce12e42 --- /dev/null +++ b/src/Repl.Core/Interaction/Public/AskSecretRequest.cs @@ -0,0 +1,9 @@ +namespace Repl.Interaction; + +/// +/// Requests masked secret input (password, token). +/// +public sealed record AskSecretRequest( + string Name, + string Prompt, + AskSecretOptions? Options = null) : InteractionRequest(Name, Prompt); diff --git a/src/Repl.Core/Interaction/Public/AskTextRequest.cs b/src/Repl.Core/Interaction/Public/AskTextRequest.cs new file mode 100644 index 0000000..70d0b57 --- /dev/null +++ b/src/Repl.Core/Interaction/Public/AskTextRequest.cs @@ -0,0 +1,10 @@ +namespace Repl.Interaction; + +/// +/// Requests free-form text input. +/// +public sealed record AskTextRequest( + string Name, + string Prompt, + string? DefaultValue = null, + AskOptions? Options = null) : InteractionRequest(Name, Prompt); diff --git a/src/Repl.Core/Interaction/Public/ClearScreenRequest.cs b/src/Repl.Core/Interaction/Public/ClearScreenRequest.cs new file mode 100644 index 0000000..6d76c74 --- /dev/null +++ b/src/Repl.Core/Interaction/Public/ClearScreenRequest.cs @@ -0,0 +1,7 @@ +namespace Repl.Interaction; + +/// +/// Requests a terminal screen clear. +/// +public sealed record ClearScreenRequest() + : InteractionRequest("__clear_screen__", string.Empty); diff --git a/src/Repl.Core/Interaction/Public/IReplInteractionHandler.cs b/src/Repl.Core/Interaction/Public/IReplInteractionHandler.cs new file mode 100644 index 0000000..3397461 --- /dev/null +++ b/src/Repl.Core/Interaction/Public/IReplInteractionHandler.cs @@ -0,0 +1,51 @@ +namespace Repl.Interaction; + +/// +/// Handles interaction requests in a chain-of-responsibility pipeline. +/// Implementations pattern-match on the type +/// and return for requests they handle, +/// or to delegate to the next handler. +/// +/// +/// +/// The pipeline is walked in registration order. The built-in console handler +/// is always the final fallback — it handles all standard request types. +/// +/// +/// Third-party packages (e.g. Spectre.Console, Terminal.Gui, or GUI frameworks) +/// register their own handler via DI: +/// +/// +/// services.AddSingleton<IReplInteractionHandler, SpectreInteractionHandler>(); +/// +/// +/// A handler implementation typically looks like: +/// +/// +/// public class SpectreInteractionHandler : IReplInteractionHandler +/// { +/// public async ValueTask<InteractionResult> TryHandleAsync( +/// InteractionRequest request, CancellationToken ct) => request switch +/// { +/// AskSecretRequest r => InteractionResult.Success(await HandleSecret(r, ct)), +/// AskChoiceRequest r => InteractionResult.Success(await HandleChoice(r, ct)), +/// _ => InteractionResult.Unhandled, +/// }; +/// } +/// +/// +public interface IReplInteractionHandler +{ + /// + /// Attempts to handle an interaction request. + /// + /// The interaction request. + /// Cancellation token. + /// + /// with the result value if handled, + /// or to delegate to the next handler. + /// + ValueTask TryHandleAsync( + InteractionRequest request, + CancellationToken cancellationToken); +} diff --git a/src/Repl.Core/Interaction/Public/InteractionRequest.cs b/src/Repl.Core/Interaction/Public/InteractionRequest.cs new file mode 100644 index 0000000..380f8b5 --- /dev/null +++ b/src/Repl.Core/Interaction/Public/InteractionRequest.cs @@ -0,0 +1,18 @@ +namespace Repl.Interaction; + +/// +/// Base type for all interaction requests flowing through the handler pipeline. +/// +/// Prompt name (used for prefill via --answer:name=value). +/// Prompt text displayed to the user. +public abstract record InteractionRequest(string Name, string Prompt); + +/// +/// Typed interaction request that declares the expected result type. +/// Derive from this to define new interaction controls. +/// +/// The type returned by the interaction. +/// Prompt name. +/// Prompt text. +public abstract record InteractionRequest(string Name, string Prompt) + : InteractionRequest(Name, Prompt); diff --git a/src/Repl.Core/Interaction/Public/InteractionResult.cs b/src/Repl.Core/Interaction/Public/InteractionResult.cs new file mode 100644 index 0000000..b2440a6 --- /dev/null +++ b/src/Repl.Core/Interaction/Public/InteractionResult.cs @@ -0,0 +1,37 @@ +namespace Repl.Interaction; + +/// +/// Result of an call. +/// Either is true and contains the result, +/// or is false and the pipeline moves to the next handler. +/// +public readonly struct InteractionResult +{ + /// + /// Sentinel value indicating the handler did not handle the request. + /// + public static readonly InteractionResult Unhandled; + + /// + /// Creates a successful result with the given value. + /// + /// The interaction result value. + /// A handled . + public static InteractionResult Success(object? value) => new(handled: true, value: value); + + private InteractionResult(bool handled, object? value) + { + Handled = handled; + Value = value; + } + + /// + /// Gets whether the handler handled the request. + /// + public bool Handled { get; } + + /// + /// Gets the result value. Only meaningful when is true. + /// + public object? Value { get; } +} diff --git a/src/Repl.Core/Interaction/Public/WriteProgressRequest.cs b/src/Repl.Core/Interaction/Public/WriteProgressRequest.cs new file mode 100644 index 0000000..fe1ec4c --- /dev/null +++ b/src/Repl.Core/Interaction/Public/WriteProgressRequest.cs @@ -0,0 +1,9 @@ +namespace Repl.Interaction; + +/// +/// Requests a progress update display. +/// +public sealed record WriteProgressRequest( + string Label, + double? Percent, + CancellationToken CancellationToken = default) : InteractionRequest("__progress__", Label); diff --git a/src/Repl.Core/Interaction/Public/WriteStatusRequest.cs b/src/Repl.Core/Interaction/Public/WriteStatusRequest.cs new file mode 100644 index 0000000..0d1ba10 --- /dev/null +++ b/src/Repl.Core/Interaction/Public/WriteStatusRequest.cs @@ -0,0 +1,8 @@ +namespace Repl.Interaction; + +/// +/// Requests a status message display. +/// +public sealed record WriteStatusRequest( + string Text, + CancellationToken CancellationToken = default) : InteractionRequest("__status__", Text); diff --git a/src/Repl.Defaults/DefaultsInteractionChannel.cs b/src/Repl.Defaults/DefaultsInteractionChannel.cs index 58438a9..7199a1f 100644 --- a/src/Repl.Defaults/DefaultsInteractionChannel.cs +++ b/src/Repl.Defaults/DefaultsInteractionChannel.cs @@ -7,9 +7,11 @@ internal sealed class DefaultsInteractionChannel : IReplInteractionChannel, ICom public DefaultsInteractionChannel( InteractionOptions options, OutputOptions? outputOptions = null, + IReplInteractionPresenter? presenter = null, + IReadOnlyList? handlers = null, TimeProvider? timeProvider = null) { - _inner = new ConsoleInteractionChannel(options, outputOptions, timeProvider: timeProvider); + _inner = new ConsoleInteractionChannel(options, outputOptions, presenter: presenter, handlers: handlers, timeProvider: timeProvider); } void ICommandTokenReceiver.SetCommandToken(CancellationToken ct) => diff --git a/src/Repl.Defaults/ReplApp.cs b/src/Repl.Defaults/ReplApp.cs index 295ad85..5299384 100644 --- a/src/Repl.Defaults/ReplApp.cs +++ b/src/Repl.Defaults/ReplApp.cs @@ -595,6 +595,8 @@ private SessionOverlayServiceProvider CreateSessionOverlay(IServiceProvider exte var channel = new DefaultsInteractionChannel( _core.OptionsSnapshot.Interaction, _core.OptionsSnapshot.Output, + external.GetService(typeof(IReplInteractionPresenter)) as IReplInteractionPresenter, + ResolveHandlers(external), external.GetService(typeof(TimeProvider)) as TimeProvider); defaults[typeof(IReplInteractionChannel)] = channel; defaults[typeof(IReplSessionInfo)] = new LiveSessionInfo(); @@ -602,6 +604,12 @@ private SessionOverlayServiceProvider CreateSessionOverlay(IServiceProvider exte return new SessionOverlayServiceProvider(external, defaults); } + private static IReplInteractionHandler[] ResolveHandlers(IServiceProvider sp) + { + var handlers = sp.GetService(typeof(IEnumerable)) as IEnumerable; + return handlers?.ToArray() ?? []; + } + private sealed class SessionOverlayServiceProvider( IServiceProvider external, IReadOnlyDictionary defaults) : IServiceProvider @@ -629,6 +637,8 @@ private static void EnsureDefaultServices(IServiceCollection services, CoreReplA new DefaultsInteractionChannel( core.OptionsSnapshot.Interaction, core.OptionsSnapshot.Output, + sp.GetService(), + sp.GetServices().ToArray(), sp.GetService()))); services.TryAddSingleton(); services.TryAddSingleton(); diff --git a/src/Repl.IntegrationTests/Given_InteractionChannel.cs b/src/Repl.IntegrationTests/Given_InteractionChannel.cs index 7f085f6..e01a100 100644 --- a/src/Repl.IntegrationTests/Given_InteractionChannel.cs +++ b/src/Repl.IntegrationTests/Given_InteractionChannel.cs @@ -294,6 +294,86 @@ public void When_ValidatedTextAnswerIsPrefilled_Then_ValidValueIsAccepted() output.Text.Should().Contain("a@b.com"); } + [TestMethod] + [Description("Regression guard: verifies AskFlagsEnumAsync prefill by description names returns composite value.")] + public void When_FlagsEnumAnswerIsPrefilledByName_Then_CompositeValueIsReturned() + { + var sut = ReplApp.Create() + .Options(o => o.Interaction.PromptFallback = PromptFallback.Fail); + sut.Map("set-perms", async (IReplInteractionChannel channel, CancellationToken ct) => + { + var perms = await channel.AskFlagsEnumAsync("perms", "Permissions:").ConfigureAwait(false); + return perms.ToString(); + }); + + var output = ConsoleCaptureHelper.Capture(() => sut.Run(["set-perms", "--answer:perms=View items,Remove items"])); + + output.ExitCode.Should().Be(0); + output.Text.Should().Contain("Read, Delete"); + } + + [TestMethod] + [Description("Regression guard: verifies AskNumberAsync re-prompts when value is out of bounds.")] + public void When_NumberAnswerIsOutOfBounds_Then_FallbackDefaultIsUsed() + { + var sut = ReplApp.Create() + .Options(o => o.Interaction.PromptFallback = PromptFallback.UseDefault); + sut.Map("set-count", async (IReplInteractionChannel channel, CancellationToken ct) => + { + var count = await channel.AskNumberAsync( + "count", "How many?", + defaultValue: 10, + new AskNumberOptions(Min: 1, Max: 100)).ConfigureAwait(false); + return count.ToString(System.Globalization.CultureInfo.InvariantCulture); + }); + + // Input "999" is out of bounds; fallback re-prompts and gets empty → uses default 10. + var output = ConsoleCaptureHelper.CaptureWithInput("999\n\n", () => sut.Run(["set-count"])); + + output.ExitCode.Should().Be(0); + output.Text.Should().Contain("at most 100"); + } + + [TestMethod] + [Description("Regression guard: verifies AskValidatedTextAsync re-prompts on invalid input.")] + public void When_ValidatedTextInputIsInvalid_Then_ErrorIsDisplayedAndReprompts() + { + var sut = ReplApp.Create() + .Options(o => o.Interaction.PromptFallback = PromptFallback.UseDefault); + sut.Map("set-email", async (IReplInteractionChannel channel, CancellationToken ct) => + { + var email = await channel.AskValidatedTextAsync( + "email", + "Email?", + input => input.Contains('@') ? null : "Must contain @", + defaultValue: "fallback@test.com").ConfigureAwait(false); + return email; + }); + + // First input "bad" is invalid; second empty input falls back to default. + var output = ConsoleCaptureHelper.CaptureWithInput("bad\n\n", () => sut.Run(["set-email"])); + + output.ExitCode.Should().Be(0); + output.Text.Should().Contain("Must contain @"); + } + + [TestMethod] + [Description("Regression guard: verifies ClearScreenAsync emits clear screen event.")] + public void When_ClearScreenAsyncIsCalled_Then_ClearEventIsEmitted() + { + var sut = ReplApp.Create(); + sut.Map("cls", async (IReplInteractionChannel channel, CancellationToken ct) => + { + await channel.ClearScreenAsync(ct).ConfigureAwait(false); + return "cleared"; + }); + + var output = ConsoleCaptureHelper.Capture(() => sut.Run(["cls"])); + + output.ExitCode.Should().Be(0); + output.Text.Should().Contain("cleared"); + } + [TestMethod] [Description("Regression guard: verifies secret prompt fallback uses empty string when AllowEmpty is true.")] public void When_SecretAllowsEmptyAndNoInput_Then_EmptyStringIsReturned() diff --git a/src/Repl.IntegrationTests/SamplePermissions.cs b/src/Repl.IntegrationTests/SamplePermissions.cs new file mode 100644 index 0000000..01b855c --- /dev/null +++ b/src/Repl.IntegrationTests/SamplePermissions.cs @@ -0,0 +1,12 @@ +namespace Repl.IntegrationTests; + +[Flags] +internal enum SamplePermissions +{ + [System.ComponentModel.Description("View items")] + Read = 1, + [System.ComponentModel.Description("Edit items")] + Write = 2, + [System.ComponentModel.Description("Remove items")] + Delete = 4, +} From ca9e187b61c4309cd64ea95f76b363083b848904 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Thu, 12 Mar 2026 19:49:37 -0400 Subject: [PATCH 04/13] Add DispatchAsync for custom requests and test answers API - Add DispatchAsync 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 --- docs/interaction.md | 19 +++++ samples/06-testing/Given_TestingSample.cs | 79 +++++++++++++++++++ samples/06-testing/SampleReplApp.cs | 17 ++++ src/Repl.Core/ConsoleInteractionChannel.cs | 18 +++++ .../Public/IReplInteractionChannel.cs | 12 +++ .../DefaultsInteractionChannel.cs | 5 ++ src/Repl.Testing/ReplSessionHandle.cs | 73 ++++++++++++++++- src/Repl.Testing/SessionDescriptor.cs | 7 ++ 8 files changed, 226 insertions(+), 4 deletions(-) diff --git a/docs/interaction.md b/docs/interaction.md index 91d816f..2ccaab1 100644 --- a/docs/interaction.md +++ b/docs/interaction.md @@ -309,3 +309,22 @@ public class SpectreInteractionHandler : IReplInteractionHandler | **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. + +### Custom request types + +Apps can define their own `InteractionRequest` subtypes for app-specific controls: + +```csharp +public sealed record AskColorPickerRequest(string Name, string Prompt) + : InteractionRequest(Name, Prompt); +``` + +Dispatch them through the pipeline via `DispatchAsync`: + +```csharp +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. diff --git a/samples/06-testing/Given_TestingSample.cs b/samples/06-testing/Given_TestingSample.cs index 476805b..ed26bc5 100644 --- a/samples/06-testing/Given_TestingSample.cs +++ b/samples/06-testing/Given_TestingSample.cs @@ -101,6 +101,85 @@ public async Task When_CommandIsTooSlow_Then_TimeoutIsRaised() assertion.Which.Message.Should().Contain("timeout"); } + [TestMethod] + [Description("Shows prefilled text prompt answers via the per-command answers API.")] + public async Task When_CommandPromptsForText_Then_PrefillAnswerIsUsed() + { + await using var host = ReplTestHost.Create(() => SampleReplApp.Create()); + await using var session = await host.OpenSessionAsync(); + + var execution = await session.RunCommandAsync( + "greet --no-logo", + new Dictionary { ["name"] = "Alice" }); + + execution.ExitCode.Should().Be(0); + execution.OutputText.Should().Contain("Hello, Alice!"); + } + + [TestMethod] + [Description("Shows prefilled confirmation prompt answers.")] + public async Task When_CommandPromptsForConfirmation_Then_PrefillAnswerIsUsed() + { + await using var host = ReplTestHost.Create(() => SampleReplApp.Create()); + await using var session = await host.OpenSessionAsync(); + + var execution = await session.RunCommandAsync( + "deploy --no-logo", + new Dictionary { ["proceed"] = "yes" }); + + execution.ExitCode.Should().Be(0); + execution.GetResult().Should().BeTrue(); + } + + [TestMethod] + [Description("Shows prefilled choice prompt answers.")] + public async Task When_CommandPromptsForChoice_Then_PrefillAnswerIsUsed() + { + await using var host = ReplTestHost.Create(() => SampleReplApp.Create()); + await using var session = await host.OpenSessionAsync(); + + var execution = await session.RunCommandAsync( + "region --no-logo", + new Dictionary { ["region"] = "eu-west" }); + + execution.ExitCode.Should().Be(0); + execution.GetResult().Should().Be("eu-west"); + } + + [TestMethod] + [Description("Shows session-level default answers applied to all commands.")] + public async Task When_SessionHasDefaultAnswers_Then_AllCommandsUseThem() + { + await using var host = ReplTestHost.Create(() => SampleReplApp.Create()); + await using var session = await host.OpenSessionAsync(new SessionDescriptor + { + Answers = new Dictionary { ["name"] = "Bob" }, + }); + + var execution = await session.RunCommandAsync("greet --no-logo"); + + execution.ExitCode.Should().Be(0); + execution.OutputText.Should().Contain("Hello, Bob!"); + } + + [TestMethod] + [Description("Shows per-command answers overriding session-level defaults.")] + public async Task When_PerCommandAnswerOverridesSession_Then_CommandWins() + { + await using var host = ReplTestHost.Create(() => SampleReplApp.Create()); + await using var session = await host.OpenSessionAsync(new SessionDescriptor + { + Answers = new Dictionary { ["name"] = "Bob" }, + }); + + var execution = await session.RunCommandAsync( + "greet --no-logo", + new Dictionary { ["name"] = "Alice" }); + + execution.ExitCode.Should().Be(0); + execution.OutputText.Should().Contain("Hello, Alice!"); + } + [TestMethod] [Description("Shows a realistic multi-step scenario with two sessions, shared state, typed assertions, and metadata checks.")] public async Task When_RunningComplexScenario_Then_EndToEndBehaviorRemainsReadable() diff --git a/samples/06-testing/SampleReplApp.cs b/samples/06-testing/SampleReplApp.cs index 2425c91..5f00f8f 100644 --- a/samples/06-testing/SampleReplApp.cs +++ b/samples/06-testing/SampleReplApp.cs @@ -2,6 +2,8 @@ namespace Samples.Testing; internal static class SampleReplApp { + private static readonly string[] Regions = ["us-east", "eu-west", "ap-south"]; + public static ReplApp Create() => Create(new SharedState()); public static ReplApp Create(SharedState sharedState) @@ -43,6 +45,21 @@ public static ReplApp Create(SharedState sharedState) await channel.WriteStatusAsync("Import started", ct).ConfigureAwait(false); return "done"; }); + app.Map("greet", async (IReplInteractionChannel channel) => + { + var name = await channel.AskTextAsync("name", "Your name?").ConfigureAwait(false); + return $"Hello, {name}!"; + }); + app.Map("deploy", async (IReplInteractionChannel channel) => + { + var confirmed = await channel.AskConfirmationAsync("proceed", "Deploy to production?").ConfigureAwait(false); + return confirmed; + }); + app.Map("region", async (IReplInteractionChannel channel) => + { + var index = await channel.AskChoiceAsync("region", "Target region:", Regions).ConfigureAwait(false); + return Regions[index]; + }); return app; } diff --git a/src/Repl.Core/ConsoleInteractionChannel.cs b/src/Repl.Core/ConsoleInteractionChannel.cs index 11fb38c..5150c3a 100644 --- a/src/Repl.Core/ConsoleInteractionChannel.cs +++ b/src/Repl.Core/ConsoleInteractionChannel.cs @@ -33,6 +33,24 @@ private async ValueTask TryDispatchAsync( return InteractionResult.Unhandled; } + /// + public async ValueTask DispatchAsync( + InteractionRequest request, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request); + cancellationToken.ThrowIfCancellationRequested(); + + var dispatched = await TryDispatchAsync(request, cancellationToken).ConfigureAwait(false); + if (dispatched.Handled) + { + return (TResult)dispatched.Value!; + } + + throw new NotSupportedException( + $"No handler registered for interaction request '{request.GetType().Name}'."); + } + /// /// Sets the ambient per-command token. Called by the framework before each command dispatch. /// diff --git a/src/Repl.Core/Interaction/Public/IReplInteractionChannel.cs b/src/Repl.Core/Interaction/Public/IReplInteractionChannel.cs index 49382db..b7661f4 100644 --- a/src/Repl.Core/Interaction/Public/IReplInteractionChannel.cs +++ b/src/Repl.Core/Interaction/Public/IReplInteractionChannel.cs @@ -106,4 +106,16 @@ ValueTask> AskMultiChoiceAsync( /// Cancellation token. /// An asynchronous operation. ValueTask ClearScreenAsync(CancellationToken cancellationToken); + + /// + /// Dispatches a custom through the handler pipeline. + /// + /// The expected result type. + /// The interaction request. + /// Cancellation token. + /// The result produced by the first handler that handles the request. + /// No registered handler handled the request. + ValueTask DispatchAsync( + InteractionRequest request, + CancellationToken cancellationToken); } diff --git a/src/Repl.Defaults/DefaultsInteractionChannel.cs b/src/Repl.Defaults/DefaultsInteractionChannel.cs index 7199a1f..95d4e0b 100644 --- a/src/Repl.Defaults/DefaultsInteractionChannel.cs +++ b/src/Repl.Defaults/DefaultsInteractionChannel.cs @@ -61,4 +61,9 @@ public ValueTask> AskMultiChoiceAsync( IReadOnlyList? defaultIndices = null, AskMultiChoiceOptions? options = null) => _inner.AskMultiChoiceAsync(name, prompt, choices, defaultIndices, options); + + public ValueTask DispatchAsync( + InteractionRequest request, + CancellationToken cancellationToken) => + _inner.DispatchAsync(request, cancellationToken); } diff --git a/src/Repl.Testing/ReplSessionHandle.cs b/src/Repl.Testing/ReplSessionHandle.cs index b463e39..e7461b8 100644 --- a/src/Repl.Testing/ReplSessionHandle.cs +++ b/src/Repl.Testing/ReplSessionHandle.cs @@ -13,6 +13,7 @@ public sealed partial class ReplSessionHandle : IAsyncDisposable private readonly ReplScenarioOptions _options; private readonly IServiceProvider _services; private readonly ReplRunOptions _runOptions; + private readonly IReadOnlyDictionary? _sessionAnswers; private readonly SemaphoreSlim _commandGate = new(initialCount: 1, maxCount: 1); private readonly string _sessionId; private bool _disposed; @@ -23,6 +24,7 @@ private ReplSessionHandle( ReplScenarioOptions options, IServiceProvider services, ReplRunOptions runOptions, + IReadOnlyDictionary? sessionAnswers, string sessionId) { _owner = owner; @@ -30,14 +32,40 @@ private ReplSessionHandle( _options = options; _services = services; _runOptions = runOptions; + _sessionAnswers = sessionAnswers; _sessionId = sessionId; } public string SessionId => _sessionId; - public async ValueTask RunCommandAsync( + /// + /// Runs a command in this session. + /// + /// The command text to execute. + /// Cancellation token. + /// The execution result. + public ValueTask RunCommandAsync( string commandText, - CancellationToken cancellationToken = default) + CancellationToken cancellationToken = default) => + ExecuteCommandCoreAsync(commandText, answers: null, cancellationToken); + + /// + /// Runs a command in this session with prefilled answers for interactive prompts. + /// + /// The command text to execute. + /// Prompt answers keyed by prompt name. Overrides session-level answers for the same name. + /// Cancellation token. + /// The execution result. + public ValueTask RunCommandAsync( + string commandText, + IReadOnlyDictionary answers, + CancellationToken cancellationToken = default) => + ExecuteCommandCoreAsync(commandText, answers, cancellationToken); + + private async ValueTask ExecuteCommandCoreAsync( + string commandText, + IReadOnlyDictionary? answers, + CancellationToken cancellationToken) { commandText = string.IsNullOrWhiteSpace(commandText) ? throw new ArgumentException("Command text cannot be empty.", nameof(commandText)) @@ -51,7 +79,7 @@ public async ValueTask RunCommandAsync( using var output = new StringWriter(); var host = new TestSessionHost(_sessionId, output); var observer = new SessionExecutionObserver(); - var args = Tokenize(commandText).ToArray(); + var args = BuildArgsWithAnswers(Tokenize(commandText), _sessionAnswers, answers); using var timeout = CreateTimeoutSource(cancellationToken); var token = timeout?.Token ?? cancellationToken; @@ -151,10 +179,47 @@ internal static ValueTask StartAsync( var serviceCollection = new ServiceCollection(); serviceCollection.AddSingleton(); var services = serviceCollection.BuildServiceProvider(); - var handle = new ReplSessionHandle(owner, app, options, services, runOptions, sessionId); + var handle = new ReplSessionHandle(owner, app, options, services, runOptions, descriptor.Answers, sessionId); return ValueTask.FromResult(handle); } + private static string[] BuildArgsWithAnswers( + List baseTokens, + IReadOnlyDictionary? sessionAnswers, + IReadOnlyDictionary? commandAnswers) + { + if (sessionAnswers is null && commandAnswers is null) + { + return baseTokens.ToArray(); + } + + var merged = new Dictionary(StringComparer.OrdinalIgnoreCase); + if (sessionAnswers is not null) + { + foreach (var pair in sessionAnswers) + { + merged[pair.Key] = pair.Value; + } + } + + if (commandAnswers is not null) + { + foreach (var pair in commandAnswers) + { + merged[pair.Key] = pair.Value; + } + } + + var args = new List(baseTokens.Count + merged.Count); + args.AddRange(baseTokens); + foreach (var pair in merged) + { + args.Add($"--answer:{pair.Key}={pair.Value}"); + } + + return args.ToArray(); + } + private static List BuildTimeline( string outputText, IReadOnlyList events, diff --git a/src/Repl.Testing/SessionDescriptor.cs b/src/Repl.Testing/SessionDescriptor.cs index 5777453..2491bb3 100644 --- a/src/Repl.Testing/SessionDescriptor.cs +++ b/src/Repl.Testing/SessionDescriptor.cs @@ -35,6 +35,13 @@ public sealed record SessionDescriptor /// public TerminalCapabilities? TerminalCapabilities { get; init; } + /// + /// Optional prefilled answers applied to every command in the session. + /// Per-command answers passed to RunCommandAsync override session-level values + /// for the same prompt name. + /// + public IReadOnlyDictionary? Answers { get; init; } + /// /// Optional session-specific run options customization. /// From 5a2ee29d7aba6dd7e8d19f536fee9a50b7e4b817 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Thu, 12 Mar 2026 22:43:34 -0400 Subject: [PATCH 05/13] Add rich interactive prompts with mnemonic shortcuts and fix remote transports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- samples/05-hosting-remote/Program.cs | 8 + .../Properties/launchSettings.json | 12 + samples/05-hosting-remote/RemoteModule.cs | 51 ++ .../05-hosting-remote/SignalRTextWriter.cs | 54 +- samples/05-hosting-remote/wwwroot/index.html | 32 +- .../ConsoleInteractionChannel.RichPrompts.cs | 634 ++++++++++++++++++ src/Repl.Core/ConsoleInteractionChannel.cs | 123 +++- src/Repl.Core/ConsoleTerminalInfo.cs | 39 ++ src/Repl.Core/DefaultAnsiPaletteProvider.cs | 6 +- src/Repl.Core/Interaction/MnemonicParser.cs | 223 ++++++ .../Interaction/Public/ITerminalInfo.cs | 29 + src/Repl.Core/Rendering/Public/AnsiPalette.cs | 3 +- src/Repl.Defaults/ReplApp.cs | 4 + src/Repl.Defaults/VtKeyReader.cs | 1 + src/Repl.Tests/Given_MnemonicParser.cs | 159 +++++ src/Repl.Tests/Given_RichPrompts.cs | 272 ++++++++ src/Repl.WebSocket/WebSocketTextWriter.cs | 69 +- 17 files changed, 1699 insertions(+), 20 deletions(-) create mode 100644 samples/05-hosting-remote/Properties/launchSettings.json create mode 100644 src/Repl.Core/ConsoleInteractionChannel.RichPrompts.cs create mode 100644 src/Repl.Core/ConsoleTerminalInfo.cs create mode 100644 src/Repl.Core/Interaction/MnemonicParser.cs create mode 100644 src/Repl.Core/Interaction/Public/ITerminalInfo.cs create mode 100644 src/Repl.Tests/Given_MnemonicParser.cs create mode 100644 src/Repl.Tests/Given_RichPrompts.cs diff --git a/samples/05-hosting-remote/Program.cs b/samples/05-hosting-remote/Program.cs index ae79ab9..bd4757a 100644 --- a/samples/05-hosting-remote/Program.cs +++ b/samples/05-hosting-remote/Program.cs @@ -47,12 +47,20 @@ var remotePeer = FormatRemotePeer(context); tracker.Add(sessionName, transport: "websocket", remotePeer); ApplyInitialMetadata(tracker, sessionName, context.Request.Query); + var ansiOverride = ParseBoolean(context.Request.Query["ansi"].ToString()); + var terminalParam = context.Request.Query["terminal"].ToString(); + var initialCols = ParsePositiveInt(context.Request.Query["cols"].ToString()); + var initialRows = ParsePositiveInt(context.Request.Query["rows"].ToString()); var options = new ReplRunOptions { TerminalOverrides = new TerminalSessionOverrides { TransportName = "websocket", RemotePeer = remotePeer, + AnsiSupported = ansiOverride, + TerminalIdentity = string.IsNullOrWhiteSpace(terminalParam) ? null : terminalParam, + WindowSize = initialCols is > 0 && initialRows is > 0 + ? (initialCols.Value, initialRows.Value) : null, }, }; diff --git a/samples/05-hosting-remote/Properties/launchSettings.json b/samples/05-hosting-remote/Properties/launchSettings.json new file mode 100644 index 0000000..a52cdb3 --- /dev/null +++ b/samples/05-hosting-remote/Properties/launchSettings.json @@ -0,0 +1,12 @@ +{ + "profiles": { + "HostingRemoteSample": { + "commandName": "Project", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "applicationUrl": "https://localhost:26040;http://localhost:26041" + } + } +} \ No newline at end of file diff --git a/samples/05-hosting-remote/RemoteModule.cs b/samples/05-hosting-remote/RemoteModule.cs index b668a8d..a3787cd 100644 --- a/samples/05-hosting-remote/RemoteModule.cs +++ b/samples/05-hosting-remote/RemoteModule.cs @@ -91,6 +91,57 @@ void Handler(string sender, string msg) => : Results.Ok(string.Join('\n', sessions)); }); + map.Map( + "configure", + [Description("Configure server features (interactive multi-choice)")] + async (IReplInteractionChannel channel, CancellationToken ct) => + { + 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)}."); + }); + + map.Map( + "maintenance", + [Description("Toggle maintenance mode (interactive choice with mnemonics)")] + async (IReplInteractionChannel channel, CancellationToken ct) => + { + var current = settings.Get("maintenance") ?? "off"; + await channel.WriteStatusAsync($"Maintenance is currently: {current}", ct); + + var action = await channel.AskChoiceAsync( + "action", + "What would you like to do?", + ["_Enable maintenance", "_Disable maintenance", "_Cancel"], + defaultIndex: 2); + + if (action is 0 or 1) + { + var value = action == 0 ? "on" : "off"; + settings.Set("maintenance", value); + return Results.Success($"Maintenance set to '{value}'."); + } + + return Results.Ok("Cancelled."); + }); + + map.Map( + "debug", + [Description("Show terminal capabilities for this session")] + (IReplSessionInfo session) => new StatusRow[] + { + new("AnsiSupported", session.AnsiSupported.ToString(), session.AnsiSupported ? "ok" : "warning"), + new("Capabilities", session.TerminalCapabilities.ToString(), "ok"), + new("WindowSize", session.WindowSize is { } sz ? $"{sz.Width}x{sz.Height}" : "unknown", "ok"), + new("Terminal", session.TerminalIdentity ?? "unknown", "ok"), + new("Transport", session.TransportName ?? "local", "ok"), + }); + map.Map( "sessions", [Description("List active sessions with transport and activity details")] diff --git a/samples/05-hosting-remote/SignalRTextWriter.cs b/samples/05-hosting-remote/SignalRTextWriter.cs index 896fa6a..eff7741 100644 --- a/samples/05-hosting-remote/SignalRTextWriter.cs +++ b/samples/05-hosting-remote/SignalRTextWriter.cs @@ -6,21 +6,69 @@ namespace HostingRemoteSample; /// /// A that sends text to a SignalR client via . +/// Synchronous calls are buffered and sent on . /// internal sealed class SignalRTextWriter(ISingleClientProxy caller) : TextWriter { + private readonly Lock _lock = new(); + private readonly StringBuilder _buffer = new(); + public override Encoding Encoding => Encoding.UTF8; - public override Task WriteAsync(string? value) + public override void Write(string? value) + { + if (string.IsNullOrEmpty(value)) + { + return; + } + + lock (_lock) { _buffer.Append(value); } + } + + public override void Write(char value) + { + lock (_lock) { _buffer.Append(value); } + } + + public override void WriteLine(string? value) + { + lock (_lock) + { + _buffer.Append(value ?? string.Empty); + _buffer.Append(Environment.NewLine); + } + } + + public override async Task WriteAsync(string? value) { + await FlushBufferAsync().ConfigureAwait(false); if (string.IsNullOrEmpty(value)) { - return Task.CompletedTask; + return; } - return caller.SendAsync("Output", value); + await caller.SendAsync("Output", value).ConfigureAwait(false); } public override Task WriteLineAsync(string? value) => WriteAsync((value ?? string.Empty) + Environment.NewLine); + + public override Task FlushAsync() => FlushBufferAsync(); + + private Task FlushBufferAsync() + { + string? text; + lock (_lock) + { + if (_buffer.Length == 0) + { + return Task.CompletedTask; + } + + text = _buffer.ToString(); + _buffer.Clear(); + } + + return caller.SendAsync("Output", text); + } } diff --git a/samples/05-hosting-remote/wwwroot/index.html b/samples/05-hosting-remote/wwwroot/index.html index ef711b6..0a45382 100644 --- a/samples/05-hosting-remote/wwwroot/index.html +++ b/samples/05-hosting-remote/wwwroot/index.html @@ -276,6 +276,7 @@

Remote REPL Playground

+ @@ -284,12 +285,15 @@

Remote REPL Playground

What You Can Do

Inspect the runtime with status, list active clients with sessions, - verify connected names with who, update shared settings, and broadcast messages across tabs. + try interactive menus with configure and maintenance, + update shared settings, and broadcast messages across tabs.

status sessions who + configure + maintenance settings show maintenance settings set maintenance on watch @@ -358,12 +362,13 @@

What You Can Do

if (token === 'ws' || token === 'websocket') return 'ws'; if (token === 'telnet') return 'telnet'; if (token === 'signalr' || token === 'sr') return 'signalr'; + if (token === 'plain' || token === 'noansi') return 'plain'; return null; } function connectSelectedMode() { const mode = document.querySelector('input[name="t"]:checked').value; - modeBadge.textContent = mode === 'ws' ? 'websocket' : mode; + modeBadge.textContent = mode === 'ws' ? 'websocket' : mode === 'plain' ? 'plain (no ANSI)' : mode; btn.disabled = true; setConnectionState('connecting'); term.clear(); @@ -371,6 +376,7 @@

What You Can Do

if (inputHandler) { inputHandler.dispose(); inputHandler = null; } if (mode === 'ws') connectWS(); else if (mode === 'telnet') connectTelnet(); + else if (mode === 'plain') connectPlain(); else connectSR(); } @@ -544,6 +550,28 @@

What You Can Do

} } + function connectPlain() { + // Plain text mode: no ANSI, no terminal capabilities, no hello message + const params = new URLSearchParams({ + terminal: 'dumb', + cols: String(term.cols), + rows: String(term.rows), + ansi: 'false', + capabilities: '' + }); + const ws = new WebSocket(`${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/ws/repl?${params.toString()}`); + ws.onmessage = e => term.write(e.data); + ws.onclose = ws.onerror = () => { send = null; setConnectionState('disconnected'); term.writeln('\r\n[disconnected]'); btn.disabled = false; }; + send = d => { if (ws.readyState === 1) ws.send(d); }; + inputHandler = term.onData(send); + // Send hello with ansi=false so server knows this is a dumb terminal + ws.onopen = () => { + setConnectionState('connected'); + send(`@@repl:hello ${JSON.stringify({ terminal: 'dumb', cols: term.cols, rows: term.rows, ansi: false, capabilities: '' })}`); + term.focus(); + }; + } + function connectSR() { const hub = new signalR.HubConnectionBuilder().withUrl(`/hub/repl${buildConnectionQuery()}`).withAutomaticReconnect().build(); hub.on('Output', t => term.write(t)); diff --git a/src/Repl.Core/ConsoleInteractionChannel.RichPrompts.cs b/src/Repl.Core/ConsoleInteractionChannel.RichPrompts.cs new file mode 100644 index 0000000..7df9a3c --- /dev/null +++ b/src/Repl.Core/ConsoleInteractionChannel.RichPrompts.cs @@ -0,0 +1,634 @@ +namespace Repl; + +/// +/// Interactive arrow-key menu rendering for AskChoice and AskMultiChoice. +/// +internal sealed partial class ConsoleInteractionChannel +{ + private const string AnsiReset = "\u001b[0m"; + private const string AnsiCursorHide = "\u001b[?25l"; + private const string AnsiCursorShow = "\u001b[?25h"; + private const string AnsiClearEol = "\u001b[K"; + + /// + /// Renders an interactive single-choice menu (radio-style) using arrow keys and mnemonics. + /// Returns the selected index, or -1 if Esc was pressed. + /// + internal int ReadChoiceInteractiveSync( + string prompt, IReadOnlyList choices, int defaultIndex, CancellationToken ct) + { + if (ReplSessionIO.KeyReader is { } keyReader) + { + return ReadChoiceInteractiveRemote(choices, defaultIndex, keyReader, ct); + } + + ConsoleInputGate.Gate.Wait(ct); + try + { + return ReadChoiceInteractiveCore(choices, defaultIndex, ct); + } + finally + { + ConsoleInputGate.Gate.Release(); + } + } + + private int ReadChoiceInteractiveCore( + IReadOnlyList choices, int defaultIndex, CancellationToken ct) + { + var ctx = PrepareMenuContext(choices); + var cursor = defaultIndex; + var menuLines = choices.Count; + + OutLine(string.Empty); // separate from prompt + Out(AnsiCursorHide); + RenderChoiceMenu(ctx, cursor); + WriteHintLine("↑↓ move Enter select", ctx.Shortcuts, "jump Esc cancel"); + + try + { + return RunChoiceKeyLoopSync(ctx, ref cursor, menuLines, ct); + } + catch (OperationCanceledException) + { + ClearMenuRegion(menuLines); // items above cursor + hint at cursor + Out(AnsiCursorShow); + throw; + } + } + + private int RunChoiceKeyLoopSync( + MenuContext ctx, ref int cursor, int menuLines, CancellationToken ct) + { + while (!ct.IsCancellationRequested) + { + if (!Console.KeyAvailable) + { + Thread.Sleep(15); + continue; + } + + var key = Console.ReadKey(intercept: true); + var result = HandleChoiceKey(key, ctx, ref cursor, menuLines); + if (result is not null) + { + return result.Value; + } + } + + return -1; + } + + private int ReadChoiceInteractiveRemote( + IReadOnlyList choices, int defaultIndex, + IReplKeyReader keyReader, CancellationToken ct) + { + var ctx = PrepareMenuContext(choices); + var cursor = defaultIndex; + var menuLines = choices.Count; + + OutLine(string.Empty); + Out(AnsiCursorHide); + RenderChoiceMenu(ctx, cursor); + WriteHintLine("↑↓ move Enter select", ctx.Shortcuts, "jump Esc cancel"); + Flush(ct); + + try + { + return RunChoiceKeyLoopRemote(ctx, ref cursor, menuLines, keyReader, ct); + } + catch (OperationCanceledException) + { + ClearMenuRegion(menuLines); + Out(AnsiCursorShow); + Flush(ct); + throw; + } + } + + private int RunChoiceKeyLoopRemote( + MenuContext ctx, ref int cursor, int menuLines, + IReplKeyReader keyReader, CancellationToken ct) + { + while (!ct.IsCancellationRequested) + { +#pragma warning disable VSTHRD002 + var key = keyReader.ReadKeyAsync(ct).AsTask().GetAwaiter().GetResult(); +#pragma warning restore VSTHRD002 + var result = HandleChoiceKey(key, ctx, ref cursor, menuLines); + Flush(ct); + if (result is not null) + { + return result.Value; + } + } + + return -1; + } + + private int? HandleChoiceKey( + ConsoleKeyInfo key, MenuContext ctx, ref int cursor, int menuLines) + { + if (key.Key == ConsoleKey.Escape) + { + ClearMenuRegion(menuLines); + Out(AnsiCursorShow); + return -1; + } + + if (key.Key is ConsoleKey.Enter or ConsoleKey.Spacebar) + { + ClearMenuRegion(menuLines); + Out(AnsiCursorShow); + OutLine(ctx.Parsed[cursor].Display); + return cursor; + } + + if (key.Key is ConsoleKey.UpArrow or ConsoleKey.DownArrow) + { + var previous = cursor; + cursor = key.Key == ConsoleKey.UpArrow + ? (cursor > 0 ? cursor - 1 : ctx.Parsed.Length - 1) + : (cursor < ctx.Parsed.Length - 1 ? cursor + 1 : 0); + UpdateMenuLines(ctx, previous, cursor, isChecked: null); + return null; + } + + if (key.KeyChar != '\0' && ctx.ShortcutMap.TryGetValue(char.ToUpperInvariant(key.KeyChar), out var idx)) + { + ClearMenuRegion(menuLines); + Out(AnsiCursorShow); + OutLine(ctx.Parsed[idx].Display); + return idx; + } + + return null; + } + + /// + /// Renders an interactive multi-choice menu (checkbox-style) using arrow keys, Space, and mnemonics. + /// Returns the selected indices array, or null if Esc was pressed. + /// + internal int[]? ReadMultiChoiceInteractiveSync( + string prompt, IReadOnlyList choices, IReadOnlyList defaults, + int minSelections, int? maxSelections, CancellationToken ct) + { + if (ReplSessionIO.KeyReader is { } keyReader) + { + return ReadMultiChoiceInteractiveRemote( + choices, defaults, minSelections, maxSelections, keyReader, ct); + } + + ConsoleInputGate.Gate.Wait(ct); + try + { + return ReadMultiChoiceInteractiveCore(choices, defaults, minSelections, maxSelections, ct); + } + finally + { + ConsoleInputGate.Gate.Release(); + } + } + + private int[]? ReadMultiChoiceInteractiveCore( + IReadOnlyList choices, IReadOnlyList defaults, + int minSelections, int? maxSelections, CancellationToken ct) + { + var ctx = PrepareMenuContext(choices); + var selected = InitSelectedArray(choices.Count, defaults); + var cursor = 0; + var hasError = false; + var menuLines = choices.Count; + + OutLine(string.Empty); + Out(AnsiCursorHide); + RenderMultiChoiceMenu(ctx, selected, cursor); + WriteHintLine("↑↓ move Space toggle", ctx.Shortcuts, "jump Enter confirm Esc cancel"); + + try + { + return RunMultiChoiceKeyLoopSync( + ctx, selected, ref cursor, ref hasError, + menuLines, minSelections, maxSelections, ct); + } + catch (OperationCanceledException) + { + ClearMenuRegion(menuLines, 1 + (hasError ? 1 : 0)); + Out(AnsiCursorShow); + throw; + } + } + + private int[]? RunMultiChoiceKeyLoopSync( + MenuContext ctx, bool[] selected, ref int cursor, ref bool hasError, + int menuLines, int minSelections, int? maxSelections, CancellationToken ct) + { + var escaped = false; + while (!ct.IsCancellationRequested) + { + if (!Console.KeyAvailable) + { + Thread.Sleep(15); + continue; + } + + var key = Console.ReadKey(intercept: true); + var result = HandleMultiChoiceKey( + key, ctx, selected, ref cursor, ref hasError, ref escaped, + menuLines, minSelections, maxSelections); + if (escaped) + { + return null; + } + + if (result is not null) + { + return result; + } + } + + return CollectSelected(selected); + } + + private int[]? ReadMultiChoiceInteractiveRemote( + IReadOnlyList choices, IReadOnlyList defaults, + int minSelections, int? maxSelections, IReplKeyReader keyReader, CancellationToken ct) + { + var ctx = PrepareMenuContext(choices); + var selected = InitSelectedArray(choices.Count, defaults); + var cursor = 0; + var hasError = false; + var menuLines = choices.Count; + + OutLine(string.Empty); + Out(AnsiCursorHide); + RenderMultiChoiceMenu(ctx, selected, cursor); + WriteHintLine("↑↓ move Space toggle", ctx.Shortcuts, "jump Enter confirm Esc cancel"); + Flush(ct); + + try + { + return RunMultiChoiceKeyLoopRemote( + ctx, selected, ref cursor, ref hasError, + menuLines, minSelections, maxSelections, keyReader, ct); + } + catch (OperationCanceledException) + { + ClearMenuRegion(menuLines, 1 + (hasError ? 1 : 0)); + Out(AnsiCursorShow); + Flush(ct); + throw; + } + } + + private int[]? RunMultiChoiceKeyLoopRemote( + MenuContext ctx, bool[] selected, ref int cursor, ref bool hasError, + int menuLines, int minSelections, int? maxSelections, + IReplKeyReader keyReader, CancellationToken ct) + { + var escaped = false; + while (!ct.IsCancellationRequested) + { +#pragma warning disable VSTHRD002 + var key = keyReader.ReadKeyAsync(ct).AsTask().GetAwaiter().GetResult(); +#pragma warning restore VSTHRD002 + var result = HandleMultiChoiceKey( + key, ctx, selected, ref cursor, ref hasError, ref escaped, + menuLines, minSelections, maxSelections); + Flush(ct); + if (escaped) + { + return null; + } + + if (result is not null) + { + return result; + } + } + + return CollectSelected(selected); + } + + private int[]? HandleMultiChoiceKey( + ConsoleKeyInfo key, MenuContext ctx, + bool[] selected, ref int cursor, ref bool hasError, ref bool escaped, + int menuLines, int minSelections, int? maxSelections) + { + if (key.Key == ConsoleKey.Escape) + { + ClearMenuRegion(menuLines, 1 + (hasError ? 1 : 0)); + Out(AnsiCursorShow); + escaped = true; + return null; + } + + if (key.Key == ConsoleKey.Enter) + { + return TryConfirmMultiChoice(ctx, selected, hasError, menuLines, minSelections, maxSelections, ref hasError); + } + + if (key.Key is ConsoleKey.UpArrow or ConsoleKey.DownArrow) + { + var previous = cursor; + cursor = key.Key == ConsoleKey.UpArrow + ? (cursor > 0 ? cursor - 1 : ctx.Parsed.Length - 1) + : (cursor < ctx.Parsed.Length - 1 ? cursor + 1 : 0); + UpdateMultiMenuLines(ctx, selected, previous, cursor); + return null; + } + + if (key.Key == ConsoleKey.Spacebar) + { + selected[cursor] = !selected[cursor]; + UpdateSingleMultiMenuLine(ctx, selected, cursor, isCursor: true); + return null; + } + + HandleMultiChoiceShortcut(key, ctx, selected, ref cursor); + return null; + } + + private static int[]? TryConfirmMultiChoice( + MenuContext ctx, bool[] selected, bool hadError, + int menuLines, int minSelections, int? maxSelections, ref bool hasError) + { + var result = CollectSelected(selected); + if (!IsValidSelection(result, minSelections, maxSelections)) + { + hasError = true; + var msg = maxSelections is not null + ? $"Please select between {minSelections} and {maxSelections.Value} option(s)." + : $"Please select at least {minSelections} option(s)."; + WriteErrorBelow(msg, hadError); + return null; + } + + ClearMenuRegion(menuLines, 1 + (hadError ? 1 : 0)); + Out(AnsiCursorShow); + var selectedLabels = result.Select(i => ctx.Parsed[i].Display).ToArray(); + OutLine(string.Join(", ", selectedLabels)); + return result; + } + + private void HandleMultiChoiceShortcut( + ConsoleKeyInfo key, MenuContext ctx, bool[] selected, ref int cursor) + { + if (key.KeyChar == '\0' || !ctx.ShortcutMap.TryGetValue(char.ToUpperInvariant(key.KeyChar), out var idx)) + { + return; + } + + var previous = cursor; + cursor = idx; + selected[idx] = !selected[idx]; + if (previous != cursor) + { + UpdateMultiMenuLines(ctx, selected, previous, cursor); + } + else + { + UpdateSingleMultiMenuLine(ctx, selected, cursor, isCursor: true); + } + } + + // ---------- Menu context ---------- + + private readonly record struct MenuContext( + (string Display, char? Shortcut)[] Parsed, + char?[] Shortcuts, + Dictionary ShortcutMap); + + private static MenuContext PrepareMenuContext(IReadOnlyList choices) + { + var shortcuts = MnemonicParser.AssignShortcuts(choices); + var parsed = new (string Display, char? Shortcut)[choices.Count]; + for (var i = 0; i < choices.Count; i++) + { + parsed[i] = MnemonicParser.Parse(choices[i]); + } + + return new MenuContext(parsed, shortcuts, BuildShortcutMap(shortcuts)); + } + + private static bool[] InitSelectedArray(int count, IReadOnlyList defaults) + { + var selected = new bool[count]; + foreach (var idx in defaults) + { + selected[idx] = true; + } + + return selected; + } + + // ---------- Rendering helpers ---------- + + private void RenderChoiceMenu(MenuContext ctx, int cursor) + { + for (var i = 0; i < ctx.Parsed.Length; i++) + { + Out(RenderMenuLine(ctx.Parsed[i].Display, i == cursor, isChecked: null, ctx.Shortcuts[i])); + OutLine(string.Empty); + } + } + + private void RenderMultiChoiceMenu(MenuContext ctx, bool[] selected, int cursor) + { + for (var i = 0; i < ctx.Parsed.Length; i++) + { + Out(RenderMenuLine(ctx.Parsed[i].Display, i == cursor, selected[i], ctx.Shortcuts[i])); + OutLine(string.Empty); + } + } + + /// + /// Updates two menu lines after a cursor move (single-choice). + /// Cursor is on the hint line; items are parsed.Length to 1 lines above. + /// + private void UpdateMenuLines( + MenuContext ctx, int previousCursor, int newCursor, bool? isChecked) + { + var itemCount = ctx.Parsed.Length; + + // Move up from hint line to previous cursor item + Out($"\u001b[{itemCount - previousCursor}A\r"); + Out(RenderMenuLine(ctx.Parsed[previousCursor].Display, isCursor: false, isChecked, ctx.Shortcuts[previousCursor])); + Out(AnsiClearEol); + + // Navigate to new cursor item + WriteCursorVertical(newCursor - previousCursor); + + // Redraw new cursor item (selected) + Out(RenderMenuLine(ctx.Parsed[newCursor].Display, isCursor: true, isChecked, ctx.Shortcuts[newCursor])); + Out(AnsiClearEol); + + // Return to hint line + Out($"\u001b[{itemCount - newCursor}B\r"); + } + + /// + /// Updates two menu lines after a cursor move (multi-choice). + /// + private void UpdateMultiMenuLines( + MenuContext ctx, bool[] selected, int previousCursor, int newCursor) + { + var itemCount = ctx.Parsed.Length; + + Out($"\u001b[{itemCount - previousCursor}A\r"); + Out(RenderMenuLine(ctx.Parsed[previousCursor].Display, isCursor: false, selected[previousCursor], ctx.Shortcuts[previousCursor])); + Out(AnsiClearEol); + + WriteCursorVertical(newCursor - previousCursor); + + Out(RenderMenuLine(ctx.Parsed[newCursor].Display, isCursor: true, selected[newCursor], ctx.Shortcuts[newCursor])); + Out(AnsiClearEol); + + Out($"\u001b[{itemCount - newCursor}B\r"); + } + + /// + /// Redraws a single multi-choice line in place (e.g. after Space toggle). + /// + private void UpdateSingleMultiMenuLine( + MenuContext ctx, bool[] selected, int index, bool isCursor) + { + var linesUp = ctx.Parsed.Length - index; + Out($"\u001b[{linesUp}A\r"); + Out(RenderMenuLine(ctx.Parsed[index].Display, isCursor, selected[index], ctx.Shortcuts[index])); + Out(AnsiClearEol); + Out($"\u001b[{linesUp}B\r"); + } + + private static void WriteCursorVertical(int delta) + { + if (delta > 0) + { + Out($"\u001b[{delta}B\r"); + } + else if (delta < 0) + { + Out($"\u001b[{-delta}A\r"); + } + else + { + Out("\r"); + } + } + + private string RenderMenuLine(string label, bool isCursor, bool? isChecked, char? shortcut) + { + var selectionStyle = _palette?.SelectionStyle ?? "\u001b[7m"; + var prefix = isChecked switch + { + true => isCursor ? "> [x] " : " [x] ", + false => isCursor ? "> [ ] " : " [ ] ", + null => isCursor ? " > " : " ", + }; + + var formattedLabel = MnemonicParser.FormatAnsi(label, shortcut); + + return isCursor + ? string.Concat(selectionStyle, prefix, formattedLabel, AnsiReset) + : string.Concat(prefix, formattedLabel); + } + + private static void WriteHintLine(string baseHint, char?[] shortcuts, string suffix) + { + var shortcutHints = new List(); + foreach (var sc in shortcuts) + { + if (sc is not null) + { + shortcutHints.Add(char.ToUpperInvariant(sc.Value).ToString()); + } + } + + var shortcutDisplay = shortcutHints.Count > 0 + ? string.Concat(" ", string.Join('/', shortcutHints), " ", suffix) + : string.Concat(" ", suffix); + Out(string.Concat("\u001b[38;5;244m", baseHint, shortcutDisplay, AnsiReset)); + } + + /// + /// Clears the menu region. Cursor must be on the hint line. + /// = number of item lines above cursor. + /// = lines at/below cursor to clear (1 = hint only, 2 = hint + error). + /// + private static void ClearMenuRegion(int menuLines, int extraBelow = 1) + { + var totalLines = menuLines + extraBelow; + if (menuLines > 0) + { + Out($"\u001b[{menuLines}A"); + } + + Out("\r"); + for (var i = 0; i < totalLines; i++) + { + Out("\u001b[K\n"); + } + + Out($"\u001b[{totalLines}A\r"); + } + + private static void WriteErrorBelow(string message, bool alreadyHasError) + { + if (!alreadyHasError) + { + Out(string.Concat("\r\n\u001b[38;5;203m", message, AnsiReset, AnsiClearEol)); + Out("\u001b[1A\u001b[999C"); + } + else + { + Out("\u001b[1B\r"); + Out(string.Concat("\u001b[38;5;203m", message, AnsiReset, AnsiClearEol)); + Out("\u001b[1A\u001b[999C"); + } + } + + // ---------- I/O routing ---------- + + /// + /// Writes text to the current output. Uses which + /// routes to the hosted session writer when active, or to locally. + /// + private static void Out(string text) => ReplSessionIO.Output.Write(text); + + private static void OutLine(string text) => ReplSessionIO.Output.WriteLine(text); + + private static void Flush(CancellationToken ct) + { +#pragma warning disable VSTHRD002 + ReplSessionIO.Output.FlushAsync(ct).GetAwaiter().GetResult(); +#pragma warning restore VSTHRD002 + } + + private static Dictionary BuildShortcutMap(char?[] shortcuts) + { + var map = new Dictionary(); + for (var i = 0; i < shortcuts.Length; i++) + { + if (shortcuts[i] is { } sc) + { + map.TryAdd(char.ToUpperInvariant(sc), i); + } + } + + return map; + } + + private static int[] CollectSelected(bool[] selected) + { + var result = new List(); + for (var i = 0; i < selected.Length; i++) + { + if (selected[i]) + { + result.Add(i); + } + } + + return [.. result]; + } +} diff --git a/src/Repl.Core/ConsoleInteractionChannel.cs b/src/Repl.Core/ConsoleInteractionChannel.cs index 5150c3a..311eb17 100644 --- a/src/Repl.Core/ConsoleInteractionChannel.cs +++ b/src/Repl.Core/ConsoleInteractionChannel.cs @@ -11,6 +11,9 @@ internal sealed partial class ConsoleInteractionChannel( private readonly IReplInteractionPresenter _presenter = presenter ?? new ConsoleReplInteractionPresenter(options, outputOptions); private readonly IReadOnlyList _handlers = handlers ?? []; private readonly TimeProvider _timeProvider = timeProvider ?? TimeProvider.System; + private readonly bool _useRichPrompts = outputOptions?.IsAnsiEnabled() ?? false; + private readonly AnsiPalette? _palette = outputOptions is not null && outputOptions.IsAnsiEnabled() + ? outputOptions.ResolvePalette() : null; private CancellationToken _commandToken; /// @@ -144,8 +147,43 @@ private async ValueTask ReadChoiceLoopAsync( string name, string prompt, IReadOnlyList choices, int effectiveDefaultIndex, CancellationToken ct, TimeSpan? timeout) { - var choiceDisplay = string.Join('/', choices.Select((c, i) => - i == effectiveDefaultIndex ? c.ToUpperInvariant() : c.ToLowerInvariant())); + if (CanUseRichPrompts()) + { + return await ReadChoiceRichAsync(name, prompt, choices, effectiveDefaultIndex, ct) + .ConfigureAwait(false); + } + + return await ReadChoiceTextFallbackAsync( + name, prompt, choices, effectiveDefaultIndex, ct, timeout).ConfigureAwait(false); + } + + private async ValueTask ReadChoiceRichAsync( + string name, string prompt, IReadOnlyList choices, + int effectiveDefaultIndex, CancellationToken ct) + { + await _presenter.PresentAsync(new ReplPromptEvent(name, prompt, "choice"), ct) + .ConfigureAwait(false); + var richResult = await Task.Run( + () => ReadChoiceInteractiveSync(prompt, choices, effectiveDefaultIndex, ct), ct) + .ConfigureAwait(false); + return richResult >= 0 + ? richResult + : HandleMissingAnswer(fallbackValue: effectiveDefaultIndex, "choice"); + } + + private async ValueTask ReadChoiceTextFallbackAsync( + string name, string prompt, IReadOnlyList choices, + int effectiveDefaultIndex, CancellationToken ct, TimeSpan? timeout) + { + var shortcuts = MnemonicParser.AssignShortcuts(choices); + var parsedChoices = new (string Display, char? Shortcut)[choices.Count]; + for (var i = 0; i < choices.Count; i++) + { + parsedChoices[i] = MnemonicParser.Parse(choices[i]); + } + + var choiceDisplay = FormatChoiceDisplayText(parsedChoices, shortcuts, effectiveDefaultIndex); + while (true) { var line = await ReadPromptLineAsync( @@ -154,26 +192,66 @@ private async ValueTask ReadChoiceLoopAsync( kind: "choice", ct, timeout, - defaultLabel: choices[effectiveDefaultIndex]) + defaultLabel: parsedChoices[effectiveDefaultIndex].Display) .ConfigureAwait(false); if (string.IsNullOrWhiteSpace(line)) { return HandleMissingAnswer(fallbackValue: effectiveDefaultIndex, "choice"); } - var selectedIndex = MatchChoice(choices, line); + var selectedIndex = MatchChoiceWithMnemonics(line, choices, parsedChoices, shortcuts); if (selectedIndex >= 0) { return selectedIndex; } + var displayChoices = parsedChoices.Select(p => p.Display).ToArray(); await _presenter.PresentAsync( - new ReplStatusEvent($"Invalid choice '{line}'. Please enter one of: {string.Join(", ", choices)}."), + new ReplStatusEvent($"Invalid choice '{line}'. Please enter one of: {string.Join(", ", displayChoices)}."), ct) .ConfigureAwait(false); } } + private static string FormatChoiceDisplayText( + (string Display, char? Shortcut)[] parsedChoices, char?[] shortcuts, int defaultIndex) + { + return string.Join(" / ", parsedChoices.Select((p, i) => + { + var text = MnemonicParser.FormatText(p.Display, shortcuts[i]); + return i == defaultIndex ? text.ToUpperInvariant() : text; + })); + } + + private static int MatchChoiceWithMnemonics( + string line, IReadOnlyList choices, + (string Display, char? Shortcut)[] parsedChoices, char?[] shortcuts) + { + // Try matching shortcut key + if (line.Length == 1) + { + for (var i = 0; i < shortcuts.Length; i++) + { + if (shortcuts[i] is { } sc + && char.ToUpperInvariant(sc) == char.ToUpperInvariant(line[0])) + { + return i; + } + } + } + + // Try original label match + var selectedIndex = MatchChoiceByName(line, choices); + if (selectedIndex >= 0) + { + return selectedIndex; + } + + // Try display text match (without mnemonic markers) + var displayChoices = parsedChoices.Select(p => p.Display).ToArray(); + return MatchChoiceByName(line, displayChoices); + } + private static int MatchChoice(IReadOnlyList choices, string input) => MatchChoiceByName(input, choices); @@ -476,10 +554,26 @@ private async ValueTask> ReadMultiChoiceLoopAsync( IReadOnlyList effectiveDefaults, string choiceDisplay, string? defaultLabel, int minSelections, int? maxSelections, CancellationToken ct, TimeSpan? timeout) { + if (CanUseRichPrompts()) + { + await _presenter.PresentAsync(new ReplPromptEvent(name, prompt, "multi-choice"), ct) + .ConfigureAwait(false); + var richResult = await Task.Run( + () => ReadMultiChoiceInteractiveSync(prompt, choices, effectiveDefaults, minSelections, maxSelections, ct), ct) + .ConfigureAwait(false); + if (richResult is not null) + { + return richResult; + } + + // Esc pressed → treat as empty input + return HandleMissingAnswer(effectiveDefaults, "multi-choice"); + } + while (true) { var line = await ReadPromptLineAsync( - name, $"{prompt}\n {choiceDisplay}\n Enter numbers (comma-separated)", + name, $"{prompt}\r\n {choiceDisplay}\r\n Enter numbers (comma-separated)", kind: "multi-choice", ct, timeout, defaultLabel: defaultLabel) .ConfigureAwait(false); @@ -569,6 +663,23 @@ private T HandleMissingAnswer(T fallbackValue, string promptKind) return fallbackValue; } + /// + /// Returns true when the terminal supports interactive arrow-key menus. + /// Works for local console (ANSI enabled + direct keyboard access) and for + /// hosted sessions that have ANSI support and an . + /// + private bool CanUseRichPrompts() + { + // Hosted session path: need ANSI + a key reader. + if (ReplSessionIO.IsSessionActive) + { + return ReplSessionIO.AnsiSupport == true && ReplSessionIO.KeyReader is not null; + } + + // Local console path: need ANSI + non-redirected stdin. + return _useRichPrompts && !Console.IsInputRedirected; + } + private static bool TryParseBoolean(string? input, out bool value) { switch (input?.Trim().ToLowerInvariant()) diff --git a/src/Repl.Core/ConsoleTerminalInfo.cs b/src/Repl.Core/ConsoleTerminalInfo.cs new file mode 100644 index 0000000..cc16dd5 --- /dev/null +++ b/src/Repl.Core/ConsoleTerminalInfo.cs @@ -0,0 +1,39 @@ +namespace Repl; + +/// +/// Default implementation backed by +/// and runtime console state. +/// +internal sealed class ConsoleTerminalInfo(OutputOptions? outputOptions) : ITerminalInfo +{ + public bool IsAnsiSupported => outputOptions?.IsAnsiEnabled() ?? false; + + public bool CanReadKeys => !Console.IsInputRedirected && !ReplSessionIO.IsSessionActive; + + public (int Width, int Height)? WindowSize + { + get + { + if (ReplSessionIO.IsSessionActive && ReplSessionIO.WindowSize is { } sessionSize) + { + return sessionSize; + } + + try + { + var w = Console.WindowWidth; + var h = Console.WindowHeight; + return w > 0 && h > 0 ? (w, h) : null; + } + catch + { + return null; + } + } + } + + public AnsiPalette? Palette => + outputOptions is not null && outputOptions.IsAnsiEnabled() + ? outputOptions.ResolvePalette() + : null; +} diff --git a/src/Repl.Core/DefaultAnsiPaletteProvider.cs b/src/Repl.Core/DefaultAnsiPaletteProvider.cs index 4468a8c..88668bd 100644 --- a/src/Repl.Core/DefaultAnsiPaletteProvider.cs +++ b/src/Repl.Core/DefaultAnsiPaletteProvider.cs @@ -21,7 +21,8 @@ internal sealed class DefaultAnsiPaletteProvider : IAnsiPaletteProvider AutocompleteParameterStyle: "\u001b[38;5;186m", AutocompleteAmbiguousStyle: "\u001b[38;5;222m", AutocompleteErrorStyle: "\u001b[38;5;203m", - AutocompleteHintLabelStyle: "\u001b[38;5;244m"); + AutocompleteHintLabelStyle: "\u001b[38;5;244m", + SelectionStyle: "\u001b[7m"); private static readonly AnsiPalette LightPalette = new( SectionStyle: "\u001b[38;5;25m", @@ -42,7 +43,8 @@ internal sealed class DefaultAnsiPaletteProvider : IAnsiPaletteProvider AutocompleteParameterStyle: "\u001b[38;5;94m", AutocompleteAmbiguousStyle: "\u001b[38;5;130m", AutocompleteErrorStyle: "\u001b[38;5;160m", - AutocompleteHintLabelStyle: "\u001b[38;5;240m"); + AutocompleteHintLabelStyle: "\u001b[38;5;240m", + SelectionStyle: "\u001b[7m"); public AnsiPalette Create(ThemeMode themeMode) => themeMode == ThemeMode.Light ? LightPalette : DarkPalette; diff --git a/src/Repl.Core/Interaction/MnemonicParser.cs b/src/Repl.Core/Interaction/MnemonicParser.cs new file mode 100644 index 0000000..f198b61 --- /dev/null +++ b/src/Repl.Core/Interaction/MnemonicParser.cs @@ -0,0 +1,223 @@ +namespace Repl.Interaction; + +/// +/// Parses mnemonic markers (_X) from choice labels and formats display text. +/// +/// Convention: +/// +/// "_Abort" → display "Abort", shortcut 'A' +/// "No_thing" → display "Nothing", shortcut 't' +/// "__real" → display "_real", no shortcut (escaped underscore) +/// "Plain" → display "Plain", no shortcut +/// +/// +/// +internal static class MnemonicParser +{ + /// + /// Parses a label and returns its display text and optional shortcut character. + /// + public static (string Display, char? Shortcut) Parse(string label) + { + if (string.IsNullOrEmpty(label)) + { + return (label ?? string.Empty, null); + } + + var display = new System.Text.StringBuilder(label.Length); + char? shortcut = null; + var i = 0; + while (i < label.Length) + { + if (label[i] == '_') + { + if (i + 1 < label.Length && label[i + 1] == '_') + { + display.Append('_'); + i += 2; + continue; + } + + if (shortcut is null && i + 1 < label.Length) + { + shortcut = label[i + 1]; + display.Append(label[i + 1]); + i += 2; + continue; + } + + display.Append('_'); + i++; + continue; + } + + display.Append(label[i]); + i++; + } + + return (display.ToString(), shortcut); + } + + /// + /// Assigns shortcut characters for a list of labels. + /// Explicit _X markers are honored first, then auto-assignment fills gaps. + /// + public static char?[] AssignShortcuts(IReadOnlyList labels) + { + var results = new char?[labels.Count]; + var parsed = new (string Display, char? Shortcut)[labels.Count]; + var usedChars = new HashSet(); + + // Pass 1: parse explicit mnemonics + for (var i = 0; i < labels.Count; i++) + { + parsed[i] = Parse(labels[i]); + if (parsed[i].Shortcut is { } sc) + { + results[i] = sc; + usedChars.Add(char.ToUpperInvariant(sc)); + } + } + + // Pass 2: auto-assign for labels without explicit mnemonics + for (var i = 0; i < labels.Count; i++) + { + if (results[i] is not null) + { + continue; + } + + var display = parsed[i].Display; + var assigned = TryAutoAssignLetter(display, usedChars); + if (assigned is not null) + { + results[i] = assigned; + usedChars.Add(char.ToUpperInvariant(assigned.Value)); + } + } + + // Pass 3: assign digits 1-9 for any remaining unassigned + var nextDigit = 1; + for (var i = 0; i < labels.Count; i++) + { + if (results[i] is not null) + { + continue; + } + + while (nextDigit <= 9 && usedChars.Contains((char)('0' + nextDigit))) + { + nextDigit++; + } + + if (nextDigit <= 9) + { + var digit = (char)('0' + nextDigit); + results[i] = digit; + usedChars.Add(digit); + nextDigit++; + } + } + + return results; + } + + /// + /// Formats a label for ANSI display: the shortcut character is underlined. + /// + public static string FormatAnsi(string display, char? shortcut, string? underlineStart = null) + { + if (shortcut is null || string.IsNullOrEmpty(display)) + { + return display; + } + + underlineStart ??= "\u001b[4m"; + const string reset = "\u001b[24m"; + + var idx = FindShortcutIndex(display, shortcut.Value); + if (idx < 0) + { + return display; + } + + return string.Concat( + display[..idx], + underlineStart, + display[idx].ToString(), + reset, + display[(idx + 1)..]); + } + + /// + /// Formats a label for plain text display: the shortcut character is wrapped in brackets. + /// + public static string FormatText(string display, char? shortcut) + { + if (shortcut is null || string.IsNullOrEmpty(display)) + { + return display; + } + + // For digit shortcuts not found in display, prefix with [N] + if (char.IsDigit(shortcut.Value)) + { + var digitIdx = FindShortcutIndex(display, shortcut.Value); + if (digitIdx < 0) + { + return string.Concat("[", shortcut.Value.ToString(), "] ", display); + } + } + + var idx = FindShortcutIndex(display, shortcut.Value); + if (idx < 0) + { + return string.Concat("[", char.ToUpperInvariant(shortcut.Value).ToString(), "]", display); + } + + return string.Concat( + display[..idx], + "[", + display[idx].ToString(), + "]", + display[(idx + 1)..]); + } + + private static char? TryAutoAssignLetter(string display, HashSet used) + { + if (string.IsNullOrEmpty(display)) + { + return null; + } + + // Try first letter + if (char.IsLetter(display[0]) && !used.Contains(char.ToUpperInvariant(display[0]))) + { + return display[0]; + } + + // Try remaining letters + for (var i = 1; i < display.Length; i++) + { + if (char.IsLetter(display[i]) && !used.Contains(char.ToUpperInvariant(display[i]))) + { + return display[i]; + } + } + + return null; + } + + private static int FindShortcutIndex(string display, char shortcut) + { + for (var i = 0; i < display.Length; i++) + { + if (char.ToUpperInvariant(display[i]) == char.ToUpperInvariant(shortcut)) + { + return i; + } + } + + return -1; + } +} diff --git a/src/Repl.Core/Interaction/Public/ITerminalInfo.cs b/src/Repl.Core/Interaction/Public/ITerminalInfo.cs new file mode 100644 index 0000000..298e6ba --- /dev/null +++ b/src/Repl.Core/Interaction/Public/ITerminalInfo.cs @@ -0,0 +1,29 @@ +namespace Repl.Interaction; + +/// +/// Exposes terminal capabilities for custom implementations. +/// Register or resolve via DI to adapt prompts to the current terminal environment. +/// +public interface ITerminalInfo +{ + /// + /// Gets a value indicating whether the terminal supports ANSI escape sequences. + /// + bool IsAnsiSupported { get; } + + /// + /// Gets a value indicating whether the process can read individual key presses + /// (i.e. stdin is not redirected and no hosted session is active). + /// + bool CanReadKeys { get; } + + /// + /// Gets the current terminal window size, or null when unavailable. + /// + (int Width, int Height)? WindowSize { get; } + + /// + /// Gets the active ANSI color palette, or null when ANSI is disabled. + /// + AnsiPalette? Palette { get; } +} diff --git a/src/Repl.Core/Rendering/Public/AnsiPalette.cs b/src/Repl.Core/Rendering/Public/AnsiPalette.cs index 593185d..3a58c5f 100644 --- a/src/Repl.Core/Rendering/Public/AnsiPalette.cs +++ b/src/Repl.Core/Rendering/Public/AnsiPalette.cs @@ -22,4 +22,5 @@ public sealed record AnsiPalette( string AutocompleteParameterStyle = "", string AutocompleteAmbiguousStyle = "", string AutocompleteErrorStyle = "", - string AutocompleteHintLabelStyle = ""); + string AutocompleteHintLabelStyle = "", + string SelectionStyle = ""); diff --git a/src/Repl.Defaults/ReplApp.cs b/src/Repl.Defaults/ReplApp.cs index 5299384..9cc5186 100644 --- a/src/Repl.Defaults/ReplApp.cs +++ b/src/Repl.Defaults/ReplApp.cs @@ -592,6 +592,8 @@ private SessionOverlayServiceProvider CreateSessionOverlay(IServiceProvider exte [typeof(IReplIoContext)] = new LiveReplIoContext(), }; + defaults[typeof(ITerminalInfo)] = new ConsoleTerminalInfo(_core.OptionsSnapshot.Output); + var channel = new DefaultsInteractionChannel( _core.OptionsSnapshot.Interaction, _core.OptionsSnapshot.Output, @@ -643,6 +645,8 @@ private static void EnsureDefaultServices(IServiceCollection services, CoreReplA services.TryAddSingleton(); services.TryAddSingleton(); services.TryAddSingleton(); + services.TryAddSingleton( + _ => new ConsoleTerminalInfo(core.OptionsSnapshot.Output)); } private sealed class ScopedReplApp(ICoreReplApp map, IServiceCollection services) : IReplApp diff --git a/src/Repl.Defaults/VtKeyReader.cs b/src/Repl.Defaults/VtKeyReader.cs index 089b5cb..534ed0e 100644 --- a/src/Repl.Defaults/VtKeyReader.cs +++ b/src/Repl.Defaults/VtKeyReader.cs @@ -39,6 +39,7 @@ public async ValueTask ReadKeyAsync(CancellationToken ct) '\x1b' => await ParseEscapeAsync(ct).ConfigureAwait(false), '\r' or '\n' => MakeKey(ConsoleKey.Enter, '\r'), '\t' => MakeKey(ConsoleKey.Tab, '\t'), + ' ' => MakeKey(ConsoleKey.Spacebar, ' '), '\x7f' => MakeKey(ConsoleKey.Backspace, '\b'), '\b' => MakeKey(ConsoleKey.Backspace, '\b'), _ => MakeCharKey(ch), diff --git a/src/Repl.Tests/Given_MnemonicParser.cs b/src/Repl.Tests/Given_MnemonicParser.cs new file mode 100644 index 0000000..29b626b --- /dev/null +++ b/src/Repl.Tests/Given_MnemonicParser.cs @@ -0,0 +1,159 @@ +namespace Repl.Tests; + +[TestClass] +public sealed class Given_MnemonicParser +{ + [TestMethod] + [DataRow("_Abort", "Abort", 'A')] + [DataRow("No_thing", "Nothing", 't')] + [DataRow("_Retry", "Retry", 'R')] + [DataRow("_Fail", "Fail", 'F')] + public void When_ExplicitMnemonic_Then_ShortcutIsExtracted( + string label, string expectedDisplay, char expectedShortcut) + { + var (display, shortcut) = MnemonicParser.Parse(label); + + display.Should().Be(expectedDisplay); + shortcut.Should().Be(expectedShortcut); + } + + [TestMethod] + public void When_DoubleUnderscore_Then_LiteralUnderscoreAndNoShortcut() + { + var (display, shortcut) = MnemonicParser.Parse("__real"); + + display.Should().Be("_real"); + shortcut.Should().BeNull(); + } + + [TestMethod] + public void When_NoUnderscore_Then_NoShortcut() + { + var (display, shortcut) = MnemonicParser.Parse("Plain"); + + display.Should().Be("Plain"); + shortcut.Should().BeNull(); + } + + [TestMethod] + public void When_TrailingUnderscore_Then_LiteralAndNoShortcut() + { + var (display, shortcut) = MnemonicParser.Parse("Test_"); + + display.Should().Be("Test_"); + shortcut.Should().BeNull(); + } + + [TestMethod] + public void When_EmptyLabel_Then_EmptyDisplayAndNoShortcut() + { + var (display, shortcut) = MnemonicParser.Parse(""); + + display.Should().BeEmpty(); + shortcut.Should().BeNull(); + } + + [TestMethod] + public void When_AssignShortcuts_WithExplicitMnemonics_Then_ExplicitHonored() + { + var labels = new[] { "_Abort", "_Retry", "_Fail" }; + var shortcuts = MnemonicParser.AssignShortcuts(labels); + + shortcuts[0].Should().Be('A'); + shortcuts[1].Should().Be('R'); + shortcuts[2].Should().Be('F'); + } + + [TestMethod] + public void When_AssignShortcuts_WithoutMnemonics_Then_AutoAssigned() + { + var labels = new[] { "Save", "Cancel" }; + var shortcuts = MnemonicParser.AssignShortcuts(labels); + + shortcuts[0].Should().Be('S'); + shortcuts[1].Should().Be('C'); + } + + [TestMethod] + public void When_AssignShortcuts_WithConflict_Then_FallsBackToOtherLetters() + { + // "Save" and "Skip" both start with 'S' + var labels = new[] { "Save", "Skip", "Cancel" }; + var shortcuts = MnemonicParser.AssignShortcuts(labels); + + // First gets 'S', second tries 'S' (taken) then next letters + shortcuts[0].Should().Be('S'); + shortcuts[1].Should().NotBe('S'); + shortcuts[1].Should().NotBeNull(); + shortcuts[2].Should().Be('C'); + } + + [TestMethod] + public void When_AssignShortcuts_Mixed_Then_ExplicitTakesPriority() + { + var labels = new[] { "_Save", "Cancel" }; + var shortcuts = MnemonicParser.AssignShortcuts(labels); + + shortcuts[0].Should().Be('S'); + shortcuts[1].Should().Be('C'); + } + + [TestMethod] + public void When_FormatAnsi_Then_ShortcutIsUnderlined() + { + var result = MnemonicParser.FormatAnsi("Abort", 'A'); + + result.Should().Contain("\u001b[4m"); + result.Should().Contain("A"); + result.Should().Contain("\u001b[24m"); + } + + [TestMethod] + public void When_FormatAnsi_NoShortcut_Then_LabelUnchanged() + { + var result = MnemonicParser.FormatAnsi("Plain", shortcut: null); + + result.Should().Be("Plain"); + } + + [TestMethod] + public void When_FormatText_Then_ShortcutIsBracketed() + { + var result = MnemonicParser.FormatText("Abort", 'A'); + + result.Should().Be("[A]bort"); + } + + [TestMethod] + public void When_FormatText_MiddleShortcut_Then_BracketsInPlace() + { + var result = MnemonicParser.FormatText("Nothing", 't'); + + result.Should().Be("No[t]hing"); + } + + [TestMethod] + public void When_FormatText_DigitNotInDisplay_Then_PrefixedWithBrackets() + { + var result = MnemonicParser.FormatText("Save", '1'); + + result.Should().Be("[1] Save"); + } + + [TestMethod] + public void When_FormatText_NoShortcut_Then_LabelUnchanged() + { + var result = MnemonicParser.FormatText("Plain", shortcut: null); + + result.Should().Be("Plain"); + } + + [TestMethod] + public void When_DoubleUnderscoreFollowedByMnemonic_Then_LiteralUnderscoreAndMnemonic() + { + var (display, shortcut) = MnemonicParser.Parse("__under_score"); + + display.Should().Be("_underscore"); + shortcut.Should().Be('s'); + } +} diff --git a/src/Repl.Tests/Given_RichPrompts.cs b/src/Repl.Tests/Given_RichPrompts.cs new file mode 100644 index 0000000..0f5472b --- /dev/null +++ b/src/Repl.Tests/Given_RichPrompts.cs @@ -0,0 +1,272 @@ +using Repl.Tests.TerminalSupport; + +namespace Repl.Tests; + +[TestClass] +[DoNotParallelize] +public sealed class Given_RichPrompts +{ + [TestMethod] + [Description("Choice menu renders all items and hint, Enter selects the default.")] + public void When_ChoiceMenu_Enter_Then_SelectsDefault() + { + var harness = new TerminalHarness(cols: 60, rows: 12); + var keys = new FakeKeyReader([Key(ConsoleKey.Enter, '\r')]); + using var ctx = CreateContext(harness, keys); + + var result = ctx.Channel.ReadChoiceInteractiveSync( + "Pick one", ["Alpha", "Bravo", "Charlie"], defaultIndex: 0, CancellationToken.None); + + result.Should().Be(0); + + // All three items should appear in the terminal viewport + harness.Frames.Should().Contain(f => + f.Lines.Any(l => l.Contains("Alpha", StringComparison.Ordinal)) + && f.Lines.Any(l => l.Contains("Bravo", StringComparison.Ordinal)) + && f.Lines.Any(l => l.Contains("Charlie", StringComparison.Ordinal))); + + // Hint line should appear + harness.Frames.Should().Contain(f => + f.Lines.Any(l => l.Contains("move", StringComparison.Ordinal))); + } + + [TestMethod] + [Description("Arrow down moves cursor to the next item.")] + public void When_ChoiceMenu_DownArrow_Then_CursorMoves() + { + var harness = new TerminalHarness(cols: 60, rows: 12); + var keys = new FakeKeyReader([Key(ConsoleKey.DownArrow), Key(ConsoleKey.Enter, '\r')]); + using var ctx = CreateContext(harness, keys); + + var result = ctx.Channel.ReadChoiceInteractiveSync( + "Pick one", ["Alpha", "Bravo", "Charlie"], defaultIndex: 0, CancellationToken.None); + + result.Should().Be(1); + // After clearing the menu, the inline result should say "Bravo" + harness.GetVisibleLines().Should().Contain( + line => line.Contains("Bravo", StringComparison.Ordinal)); + } + + [TestMethod] + [Description("Arrow up wraps from first to last item.")] + public void When_ChoiceMenu_UpArrowAtTop_Then_WrapsToBottom() + { + var harness = new TerminalHarness(cols: 60, rows: 12); + var keys = new FakeKeyReader([Key(ConsoleKey.UpArrow), Key(ConsoleKey.Enter, '\r')]); + using var ctx = CreateContext(harness, keys); + + var result = ctx.Channel.ReadChoiceInteractiveSync( + "Pick one", ["Alpha", "Bravo", "Charlie"], defaultIndex: 0, CancellationToken.None); + + result.Should().Be(2); + } + + [TestMethod] + [Description("Shortcut key selects the matching item directly.")] + public void When_ChoiceMenu_ShortcutKey_Then_SelectsMatchingItem() + { + var harness = new TerminalHarness(cols: 60, rows: 12); + var keys = new FakeKeyReader([Key(ConsoleKey.B, 'b')]); + using var ctx = CreateContext(harness, keys); + + var result = ctx.Channel.ReadChoiceInteractiveSync( + "Pick one", ["Alpha", "Bravo", "Charlie"], defaultIndex: 0, CancellationToken.None); + + result.Should().Be(1); + } + + [TestMethod] + [Description("Esc cancels and returns -1.")] + public void When_ChoiceMenu_Escape_Then_ReturnsMinus1() + { + var harness = new TerminalHarness(cols: 60, rows: 12); + var keys = new FakeKeyReader([Key(ConsoleKey.Escape)]); + using var ctx = CreateContext(harness, keys); + + var result = ctx.Channel.ReadChoiceInteractiveSync( + "Pick one", ["Alpha", "Bravo"], defaultIndex: 0, CancellationToken.None); + + result.Should().Be(-1); + } + + [TestMethod] + [Description("Explicit mnemonic _Abort makes 'A' a shortcut.")] + public void When_ChoiceMenu_ExplicitMnemonic_Then_ShortcutWorks() + { + var harness = new TerminalHarness(cols: 60, rows: 12); + var keys = new FakeKeyReader([Key(ConsoleKey.R, 'r')]); + using var ctx = CreateContext(harness, keys); + + var result = ctx.Channel.ReadChoiceInteractiveSync( + "How to proceed?", ["_Abort", "_Retry", "_Fail"], defaultIndex: 0, CancellationToken.None); + + result.Should().Be(1); + // Menu should have rendered underline ANSI for the mnemonic character + harness.RawOutput.Should().MatchRegex(@"\u001b\[4m[A-Z]\u001b\[24m"); + } + + [TestMethod] + [Description("Multi-choice renders checkboxes and Space toggles selection.")] + public void When_MultiChoice_SpaceToggles_Then_SelectionChanges() + { + var harness = new TerminalHarness(cols: 60, rows: 12); + var keys = new FakeKeyReader([Key(ConsoleKey.Spacebar, ' '), Key(ConsoleKey.Enter, '\r')]); + using var ctx = CreateContext(harness, keys); + + var result = ctx.Channel.ReadMultiChoiceInteractiveSync( + "Select", ["Auth", "Logging", "Cache"], defaults: [], + minSelections: 0, maxSelections: null, CancellationToken.None); + + result.Should().Equal(0); // Only first item toggled on + + // Verify both checked and unchecked checkboxes rendered + harness.Frames.Should().Contain(f => + f.Lines.Any(l => l.Contains("[x]", StringComparison.Ordinal)) + && f.Lines.Any(l => l.Contains("[ ]", StringComparison.Ordinal))); + } + + [TestMethod] + [Description("Multi-choice with defaults shows pre-selected items.")] + public void When_MultiChoice_WithDefaults_Then_PreSelected() + { + var harness = new TerminalHarness(cols: 60, rows: 12); + var keys = new FakeKeyReader([Key(ConsoleKey.Enter, '\r')]); + using var ctx = CreateContext(harness, keys); + + var result = ctx.Channel.ReadMultiChoiceInteractiveSync( + "Select", ["Auth", "Logging", "Cache"], defaults: [0, 2], + minSelections: 0, maxSelections: null, CancellationToken.None); + + result.Should().Equal(0, 2); + } + + [TestMethod] + [Description("Multi-choice Esc cancels and returns null.")] + public void When_MultiChoice_Escape_Then_ReturnsNull() + { + var harness = new TerminalHarness(cols: 60, rows: 12); + var keys = new FakeKeyReader([Key(ConsoleKey.Escape)]); + using var ctx = CreateContext(harness, keys); + + var result = ctx.Channel.ReadMultiChoiceInteractiveSync( + "Select", ["Auth", "Logging"], defaults: [], + minSelections: 0, maxSelections: null, CancellationToken.None); + + result.Should().BeNull(); + } + + [TestMethod] + [Description("Multi-choice enforces minimum selections.")] + public void When_MultiChoice_BelowMin_Then_ShowsErrorAndRetries() + { + var harness = new TerminalHarness(cols: 60, rows: 12); + // First Enter with 0 selected fails min=1, then Space+Enter succeeds + var keys = new FakeKeyReader([ + Key(ConsoleKey.Enter, '\r'), + Key(ConsoleKey.Spacebar, ' '), + Key(ConsoleKey.Enter, '\r'), + ]); + using var ctx = CreateContext(harness, keys); + + var result = ctx.Channel.ReadMultiChoiceInteractiveSync( + "Select", ["Auth", "Logging"], defaults: [], + minSelections: 1, maxSelections: null, CancellationToken.None); + + result.Should().ContainSingle(); + harness.RawOutput.Should().Contain("Please select at least 1 option(s)."); + } + + [TestMethod] + [Description("Multi-choice Down+Space toggles second item.")] + public void When_MultiChoice_NavigateAndToggle_Then_CorrectItemSelected() + { + var harness = new TerminalHarness(cols: 60, rows: 12); + var keys = new FakeKeyReader([ + Key(ConsoleKey.DownArrow), + Key(ConsoleKey.Spacebar, ' '), + Key(ConsoleKey.Enter, '\r'), + ]); + using var ctx = CreateContext(harness, keys); + + var result = ctx.Channel.ReadMultiChoiceInteractiveSync( + "Select", ["Auth", "Logging", "Cache"], defaults: [], + minSelections: 0, maxSelections: null, CancellationToken.None); + + result.Should().Equal(1); + } + + [TestMethod] + [Description("Choice menu items are separated from each other in terminal viewport.")] + public void When_ChoiceMenu_Rendered_Then_EachItemOnOwnLine() + { + var harness = new TerminalHarness(cols: 60, rows: 12); + var keys = new FakeKeyReader([Key(ConsoleKey.Enter, '\r')]); + using var ctx = CreateContext(harness, keys); + + ctx.Channel.ReadChoiceInteractiveSync( + "Pick", ["Alpha", "Bravo", "Charlie"], defaultIndex: 0, CancellationToken.None); + + // Find a frame that has all 3 items visible + var menuFrame = FindFrame(harness, "Alpha", "Bravo", "Charlie"); + menuFrame.Should().NotBeNull("all three items should appear in the terminal viewport"); + + // Verify items are on separate lines + var alphaLine = menuFrame!.Lines.First(l => l.Contains("Alpha", StringComparison.Ordinal)); + var bravoLine = menuFrame.Lines.First(l => l.Contains("Bravo", StringComparison.Ordinal)); + alphaLine.Should().NotContain("Bravo", "Alpha and Bravo should be on separate lines"); + bravoLine.Should().NotContain("Charlie", "Bravo and Charlie should be on separate lines"); + } + + [TestMethod] + [Description("Hint line is on its own line, not mixed with menu items.")] + public void When_ChoiceMenu_Rendered_Then_HintLineIsSeparate() + { + var harness = new TerminalHarness(cols: 80, rows: 12); + var keys = new FakeKeyReader([Key(ConsoleKey.Enter, '\r')]); + using var ctx = CreateContext(harness, keys); + + ctx.Channel.ReadChoiceInteractiveSync( + "Pick", ["Alpha", "Bravo"], defaultIndex: 0, CancellationToken.None); + + // Find a frame with both hint and menu items visible + var menuFrame = FindFrame(harness, "move", "Alpha"); + menuFrame.Should().NotBeNull(); + + // Hint should NOT be on the same line as any menu item + var hintLine = menuFrame!.Lines.First(l => l.Contains("move", StringComparison.Ordinal)); + hintLine.Should().NotContain("Alpha"); + hintLine.Should().NotContain("Bravo"); + } + + // ---------- Helpers ---------- + + private static TerminalHarness.TerminalFrame? FindFrame( + TerminalHarness harness, params string[] requiredTexts) + { + return harness.Frames.FirstOrDefault(f => + requiredTexts.All(text => + f.Lines.Any(l => l.Contains(text, StringComparison.Ordinal)))); + } + + private static TestContext CreateContext(TerminalHarness harness, FakeKeyReader keyReader) + { + var scope = ReplSessionIO.SetSession(harness.Writer, TextReader.Null); + ReplSessionIO.KeyReader = keyReader; + ReplSessionIO.AnsiSupport = true; + + var outputOptions = new OutputOptions { AnsiMode = AnsiMode.Always }; + var interactionOptions = new InteractionOptions(); + var channel = new ConsoleInteractionChannel(interactionOptions, outputOptions); + return new TestContext(channel, scope); + } + + private sealed class TestContext(ConsoleInteractionChannel channel, IDisposable scope) : IDisposable + { + public ConsoleInteractionChannel Channel { get; } = channel; + + public void Dispose() => scope.Dispose(); + } + + private static ConsoleKeyInfo Key(ConsoleKey key, char ch = '\0') => + new(ch, key, shift: false, alt: false, control: false); +} diff --git a/src/Repl.WebSocket/WebSocketTextWriter.cs b/src/Repl.WebSocket/WebSocketTextWriter.cs index f5af0b0..a305941 100644 --- a/src/Repl.WebSocket/WebSocketTextWriter.cs +++ b/src/Repl.WebSocket/WebSocketTextWriter.cs @@ -5,30 +5,87 @@ namespace Repl.WebSocket; /// /// A that sends UTF-8 text frames over a . +/// Synchronous calls are buffered and sent on . /// public sealed class WebSocketTextWriter(System.Net.WebSockets.WebSocket socket, CancellationToken cancellationToken) : TextWriter { + private readonly Lock _lock = new(); + private readonly StringBuilder _buffer = new(); + /// public override Encoding Encoding => Encoding.UTF8; /// - public override Task WriteAsync(string? value) + public override void Write(string? value) { if (string.IsNullOrEmpty(value)) { - return Task.CompletedTask; + return; } - if (socket.State is not (WebSocketState.Open or WebSocketState.CloseReceived)) + lock (_lock) { _buffer.Append(value); } + } + + /// + public override void Write(char value) + { + lock (_lock) { _buffer.Append(value); } + } + + /// + public override void WriteLine(string? value) + { + lock (_lock) { - return Task.CompletedTask; + _buffer.Append(value ?? string.Empty); + _buffer.Append(Environment.NewLine); } + } - var payload = Encoding.UTF8.GetBytes(value); - return socket.SendAsync(payload, WebSocketMessageType.Text, endOfMessage: true, cancellationToken); + /// + public override async Task WriteAsync(string? value) + { + await FlushBufferAsync().ConfigureAwait(false); + if (string.IsNullOrEmpty(value)) + { + return; + } + + await SendAsync(value).ConfigureAwait(false); } /// public override Task WriteLineAsync(string? value) => WriteAsync((value ?? string.Empty) + Environment.NewLine); + + /// + public override Task FlushAsync() => FlushBufferAsync(); + + private Task FlushBufferAsync() + { + string? text; + lock (_lock) + { + if (_buffer.Length == 0) + { + return Task.CompletedTask; + } + + text = _buffer.ToString(); + _buffer.Clear(); + } + + return SendAsync(text); + } + + private Task SendAsync(string text) + { + if (socket.State is not (WebSocketState.Open or WebSocketState.CloseReceived)) + { + return Task.CompletedTask; + } + + var payload = Encoding.UTF8.GetBytes(text); + return socket.SendAsync(payload, WebSocketMessageType.Text, endOfMessage: true, cancellationToken); + } } From 03fdb4231f450b13d32753bc7ae30f7884e4e495 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Thu, 12 Mar 2026 23:06:09 -0400 Subject: [PATCH 06/13] Extract rich prompts into RichPromptInteractionHandler 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. --- src/Repl.Core/ConsoleInteractionChannel.cs | 70 +------------- src/Repl.Core/CoreReplApp.cs | 4 +- ...RichPromptInteractionHandler.Rendering.cs} | 9 +- .../RichPromptInteractionHandler.cs | 91 +++++++++++++++++++ src/Repl.Defaults/ReplApp.cs | 22 +++-- src/Repl.Tests/Given_RichPrompts.cs | 35 ++++--- 6 files changed, 134 insertions(+), 97 deletions(-) rename src/Repl.Core/{ConsoleInteractionChannel.RichPrompts.cs => Interaction/RichPromptInteractionHandler.Rendering.cs} (98%) create mode 100644 src/Repl.Core/Interaction/RichPromptInteractionHandler.cs diff --git a/src/Repl.Core/ConsoleInteractionChannel.cs b/src/Repl.Core/ConsoleInteractionChannel.cs index 311eb17..dead774 100644 --- a/src/Repl.Core/ConsoleInteractionChannel.cs +++ b/src/Repl.Core/ConsoleInteractionChannel.cs @@ -11,9 +11,6 @@ internal sealed partial class ConsoleInteractionChannel( private readonly IReplInteractionPresenter _presenter = presenter ?? new ConsoleReplInteractionPresenter(options, outputOptions); private readonly IReadOnlyList _handlers = handlers ?? []; private readonly TimeProvider _timeProvider = timeProvider ?? TimeProvider.System; - private readonly bool _useRichPrompts = outputOptions?.IsAnsiEnabled() ?? false; - private readonly AnsiPalette? _palette = outputOptions is not null && outputOptions.IsAnsiEnabled() - ? outputOptions.ResolvePalette() : null; private CancellationToken _commandToken; /// @@ -139,38 +136,10 @@ public async ValueTask AskChoiceAsync( return (int)dispatched.Value!; } - return await ReadChoiceLoopAsync(name, prompt, choices, effectiveDefaultIndex, effectiveCt, options?.Timeout) + return await ReadChoiceTextFallbackAsync(name, prompt, choices, effectiveDefaultIndex, effectiveCt, options?.Timeout) .ConfigureAwait(false); } - private async ValueTask ReadChoiceLoopAsync( - string name, string prompt, IReadOnlyList choices, - int effectiveDefaultIndex, CancellationToken ct, TimeSpan? timeout) - { - if (CanUseRichPrompts()) - { - return await ReadChoiceRichAsync(name, prompt, choices, effectiveDefaultIndex, ct) - .ConfigureAwait(false); - } - - return await ReadChoiceTextFallbackAsync( - name, prompt, choices, effectiveDefaultIndex, ct, timeout).ConfigureAwait(false); - } - - private async ValueTask ReadChoiceRichAsync( - string name, string prompt, IReadOnlyList choices, - int effectiveDefaultIndex, CancellationToken ct) - { - await _presenter.PresentAsync(new ReplPromptEvent(name, prompt, "choice"), ct) - .ConfigureAwait(false); - var richResult = await Task.Run( - () => ReadChoiceInteractiveSync(prompt, choices, effectiveDefaultIndex, ct), ct) - .ConfigureAwait(false); - return richResult >= 0 - ? richResult - : HandleMissingAnswer(fallbackValue: effectiveDefaultIndex, "choice"); - } - private async ValueTask ReadChoiceTextFallbackAsync( string name, string prompt, IReadOnlyList choices, int effectiveDefaultIndex, CancellationToken ct, TimeSpan? timeout) @@ -508,7 +477,7 @@ public async ValueTask> AskMultiChoiceAsync( var choiceDisplay = FormatMultiChoiceDisplay(choices, effectiveDefaults); var defaultLabel = FormatMultiChoiceDefaultLabel(effectiveDefaults); - return await ReadMultiChoiceLoopAsync( + return await ReadMultiChoiceTextFallbackAsync( name, prompt, choices, effectiveDefaults, choiceDisplay, defaultLabel, minSelections, maxSelections, effectiveCt, options?.Timeout).ConfigureAwait(false); } @@ -549,27 +518,11 @@ private static string FormatMultiChoiceDisplay(IReadOnlyList choices, IR ? string.Join(',', defaults.Select(i => (i + 1).ToString(System.Globalization.CultureInfo.InvariantCulture))) : null; - private async ValueTask> ReadMultiChoiceLoopAsync( + private async ValueTask> ReadMultiChoiceTextFallbackAsync( string name, string prompt, IReadOnlyList choices, IReadOnlyList effectiveDefaults, string choiceDisplay, string? defaultLabel, int minSelections, int? maxSelections, CancellationToken ct, TimeSpan? timeout) { - if (CanUseRichPrompts()) - { - await _presenter.PresentAsync(new ReplPromptEvent(name, prompt, "multi-choice"), ct) - .ConfigureAwait(false); - var richResult = await Task.Run( - () => ReadMultiChoiceInteractiveSync(prompt, choices, effectiveDefaults, minSelections, maxSelections, ct), ct) - .ConfigureAwait(false); - if (richResult is not null) - { - return richResult; - } - - // Esc pressed → treat as empty input - return HandleMissingAnswer(effectiveDefaults, "multi-choice"); - } - while (true) { var line = await ReadPromptLineAsync( @@ -663,23 +616,6 @@ private T HandleMissingAnswer(T fallbackValue, string promptKind) return fallbackValue; } - /// - /// Returns true when the terminal supports interactive arrow-key menus. - /// Works for local console (ANSI enabled + direct keyboard access) and for - /// hosted sessions that have ANSI support and an . - /// - private bool CanUseRichPrompts() - { - // Hosted session path: need ANSI + a key reader. - if (ReplSessionIO.IsSessionActive) - { - return ReplSessionIO.AnsiSupport == true && ReplSessionIO.KeyReader is not null; - } - - // Local console path: need ANSI + non-redirected stdin. - return _useRichPrompts && !Console.IsInputRedirected; - } - private static bool TryParseBoolean(string? input, out bool value) { switch (input?.Trim().ToLowerInvariant()) diff --git a/src/Repl.Core/CoreReplApp.cs b/src/Repl.Core/CoreReplApp.cs index 646cfca..fbefa79 100644 --- a/src/Repl.Core/CoreReplApp.cs +++ b/src/Repl.Core/CoreReplApp.cs @@ -1360,7 +1360,9 @@ private DefaultServiceProvider CreateDefaultServiceProvider() [typeof(CoreReplApp)] = this, [typeof(ICoreReplApp)] = this, [typeof(IReplSessionState)] = new InMemoryReplSessionState(), - [typeof(IReplInteractionChannel)] = new ConsoleInteractionChannel(_options.Interaction, _options.Output), + [typeof(IReplInteractionChannel)] = new ConsoleInteractionChannel( + _options.Interaction, _options.Output, + handlers: [new RichPromptInteractionHandler(_options.Output)]), [typeof(IHistoryProvider)] = _options.Interactive.HistoryProvider ?? new InMemoryHistoryProvider(), [typeof(IReplKeyReader)] = new ConsoleKeyReader(), [typeof(IReplSessionInfo)] = new LiveSessionInfo(), diff --git a/src/Repl.Core/ConsoleInteractionChannel.RichPrompts.cs b/src/Repl.Core/Interaction/RichPromptInteractionHandler.Rendering.cs similarity index 98% rename from src/Repl.Core/ConsoleInteractionChannel.RichPrompts.cs rename to src/Repl.Core/Interaction/RichPromptInteractionHandler.Rendering.cs index 7df9a3c..807235d 100644 --- a/src/Repl.Core/ConsoleInteractionChannel.RichPrompts.cs +++ b/src/Repl.Core/Interaction/RichPromptInteractionHandler.Rendering.cs @@ -3,7 +3,7 @@ namespace Repl; /// /// Interactive arrow-key menu rendering for AskChoice and AskMultiChoice. /// -internal sealed partial class ConsoleInteractionChannel +internal sealed partial class RichPromptInteractionHandler { private const string AnsiReset = "\u001b[0m"; private const string AnsiCursorHide = "\u001b[?25l"; @@ -587,12 +587,11 @@ private static void WriteErrorBelow(string message, bool alreadyHasError) } } + private static bool IsValidSelection(int[] selected, int min, int? max) => + selected.Length >= min && (max is null || selected.Length <= max.Value); + // ---------- I/O routing ---------- - /// - /// Writes text to the current output. Uses which - /// routes to the hosted session writer when active, or to locally. - /// private static void Out(string text) => ReplSessionIO.Output.Write(text); private static void OutLine(string text) => ReplSessionIO.Output.WriteLine(text); diff --git a/src/Repl.Core/Interaction/RichPromptInteractionHandler.cs b/src/Repl.Core/Interaction/RichPromptInteractionHandler.cs new file mode 100644 index 0000000..536f4f5 --- /dev/null +++ b/src/Repl.Core/Interaction/RichPromptInteractionHandler.cs @@ -0,0 +1,91 @@ +using Repl.Interaction; + +namespace Repl; + +/// +/// Built-in that renders interactive +/// arrow-key menus for and +/// when the terminal supports ANSI escape sequences and direct key reading. +/// Returns when conditions are not met, +/// allowing the text-based fallback in to run. +/// +internal sealed partial class RichPromptInteractionHandler( + OutputOptions? outputOptions = null, + IReplInteractionPresenter? presenter = null) : IReplInteractionHandler +{ + private readonly bool _useRichPrompts = outputOptions?.IsAnsiEnabled() ?? false; + private readonly AnsiPalette? _palette = outputOptions is not null && outputOptions.IsAnsiEnabled() + ? outputOptions.ResolvePalette() : null; + + /// + public async ValueTask TryHandleAsync( + InteractionRequest request, CancellationToken cancellationToken) + { + if (!CanUseRichPrompts()) + { + return InteractionResult.Unhandled; + } + + return request switch + { + AskChoiceRequest r => await HandleChoiceRequestAsync(r, cancellationToken) + .ConfigureAwait(false), + AskMultiChoiceRequest r => await HandleMultiChoiceRequestAsync(r, cancellationToken) + .ConfigureAwait(false), + _ => InteractionResult.Unhandled, + }; + } + + private async ValueTask HandleChoiceRequestAsync( + AskChoiceRequest r, CancellationToken ct) + { + await PresentPromptAsync(r.Name, r.Prompt, "choice", ct).ConfigureAwait(false); + var defaultIndex = r.DefaultIndex ?? 0; + var richResult = await Task.Run( + () => ReadChoiceInteractiveSync(r.Prompt, r.Choices, defaultIndex, ct), ct) + .ConfigureAwait(false); + return InteractionResult.Success(richResult >= 0 ? richResult : defaultIndex); + } + + private async ValueTask HandleMultiChoiceRequestAsync( + AskMultiChoiceRequest r, CancellationToken ct) + { + await PresentPromptAsync(r.Name, r.Prompt, "multi-choice", ct).ConfigureAwait(false); + var defaults = r.DefaultIndices ?? []; + var min = r.Options?.MinSelections ?? 0; + var max = r.Options?.MaxSelections; + var richResult = await Task.Run( + () => ReadMultiChoiceInteractiveSync(r.Prompt, r.Choices, defaults, min, max, ct), ct) + .ConfigureAwait(false); + if (richResult is not null) + { + return InteractionResult.Success((IReadOnlyList)richResult); + } + + // Esc pressed → return defaults + return InteractionResult.Success((IReadOnlyList)defaults); + } + + private async ValueTask PresentPromptAsync( + string name, string prompt, string kind, CancellationToken ct) + { + if (presenter is not null) + { + await presenter.PresentAsync(new ReplPromptEvent(name, prompt, kind), ct) + .ConfigureAwait(false); + } + } + + /// + /// Returns true when the terminal supports interactive arrow-key menus. + /// + private bool CanUseRichPrompts() + { + if (ReplSessionIO.IsSessionActive) + { + return ReplSessionIO.AnsiSupport == true && ReplSessionIO.KeyReader is not null; + } + + return _useRichPrompts && !Console.IsInputRedirected; + } +} diff --git a/src/Repl.Defaults/ReplApp.cs b/src/Repl.Defaults/ReplApp.cs index 9cc5186..fb9d07d 100644 --- a/src/Repl.Defaults/ReplApp.cs +++ b/src/Repl.Defaults/ReplApp.cs @@ -594,11 +594,15 @@ private SessionOverlayServiceProvider CreateSessionOverlay(IServiceProvider exte defaults[typeof(ITerminalInfo)] = new ConsoleTerminalInfo(_core.OptionsSnapshot.Output); + var presenterInstance = external.GetService(typeof(IReplInteractionPresenter)) as IReplInteractionPresenter; + var userHandlers = ResolveHandlers(external); + var richHandler = new RichPromptInteractionHandler(_core.OptionsSnapshot.Output, presenterInstance); + IReplInteractionHandler[] allHandlers = [.. userHandlers, richHandler]; var channel = new DefaultsInteractionChannel( _core.OptionsSnapshot.Interaction, _core.OptionsSnapshot.Output, - external.GetService(typeof(IReplInteractionPresenter)) as IReplInteractionPresenter, - ResolveHandlers(external), + presenterInstance, + allHandlers, external.GetService(typeof(TimeProvider)) as TimeProvider); defaults[typeof(IReplInteractionChannel)] = channel; defaults[typeof(IReplSessionInfo)] = new LiveSessionInfo(); @@ -636,12 +640,18 @@ private static void EnsureDefaultServices(IServiceCollection services, CoreReplA services.TryAddSingleton(); services.TryAddSingleton(TimeProvider.System); services.TryAdd(ServiceDescriptor.Singleton(sp => - new DefaultsInteractionChannel( + { + var presenterSvc = sp.GetService(); + var userHandlers = sp.GetServices().ToArray(); + var richHandler = new RichPromptInteractionHandler(core.OptionsSnapshot.Output, presenterSvc); + IReplInteractionHandler[] allHandlers = [.. userHandlers, richHandler]; + return new DefaultsInteractionChannel( core.OptionsSnapshot.Interaction, core.OptionsSnapshot.Output, - sp.GetService(), - sp.GetServices().ToArray(), - sp.GetService()))); + presenterSvc, + allHandlers, + sp.GetService()); + })); services.TryAddSingleton(); services.TryAddSingleton(); services.TryAddSingleton(); diff --git a/src/Repl.Tests/Given_RichPrompts.cs b/src/Repl.Tests/Given_RichPrompts.cs index 0f5472b..da44562 100644 --- a/src/Repl.Tests/Given_RichPrompts.cs +++ b/src/Repl.Tests/Given_RichPrompts.cs @@ -14,7 +14,7 @@ public void When_ChoiceMenu_Enter_Then_SelectsDefault() var keys = new FakeKeyReader([Key(ConsoleKey.Enter, '\r')]); using var ctx = CreateContext(harness, keys); - var result = ctx.Channel.ReadChoiceInteractiveSync( + var result = ctx.Handler.ReadChoiceInteractiveSync( "Pick one", ["Alpha", "Bravo", "Charlie"], defaultIndex: 0, CancellationToken.None); result.Should().Be(0); @@ -38,7 +38,7 @@ public void When_ChoiceMenu_DownArrow_Then_CursorMoves() var keys = new FakeKeyReader([Key(ConsoleKey.DownArrow), Key(ConsoleKey.Enter, '\r')]); using var ctx = CreateContext(harness, keys); - var result = ctx.Channel.ReadChoiceInteractiveSync( + var result = ctx.Handler.ReadChoiceInteractiveSync( "Pick one", ["Alpha", "Bravo", "Charlie"], defaultIndex: 0, CancellationToken.None); result.Should().Be(1); @@ -55,7 +55,7 @@ public void When_ChoiceMenu_UpArrowAtTop_Then_WrapsToBottom() var keys = new FakeKeyReader([Key(ConsoleKey.UpArrow), Key(ConsoleKey.Enter, '\r')]); using var ctx = CreateContext(harness, keys); - var result = ctx.Channel.ReadChoiceInteractiveSync( + var result = ctx.Handler.ReadChoiceInteractiveSync( "Pick one", ["Alpha", "Bravo", "Charlie"], defaultIndex: 0, CancellationToken.None); result.Should().Be(2); @@ -69,7 +69,7 @@ public void When_ChoiceMenu_ShortcutKey_Then_SelectsMatchingItem() var keys = new FakeKeyReader([Key(ConsoleKey.B, 'b')]); using var ctx = CreateContext(harness, keys); - var result = ctx.Channel.ReadChoiceInteractiveSync( + var result = ctx.Handler.ReadChoiceInteractiveSync( "Pick one", ["Alpha", "Bravo", "Charlie"], defaultIndex: 0, CancellationToken.None); result.Should().Be(1); @@ -83,7 +83,7 @@ public void When_ChoiceMenu_Escape_Then_ReturnsMinus1() var keys = new FakeKeyReader([Key(ConsoleKey.Escape)]); using var ctx = CreateContext(harness, keys); - var result = ctx.Channel.ReadChoiceInteractiveSync( + var result = ctx.Handler.ReadChoiceInteractiveSync( "Pick one", ["Alpha", "Bravo"], defaultIndex: 0, CancellationToken.None); result.Should().Be(-1); @@ -97,7 +97,7 @@ public void When_ChoiceMenu_ExplicitMnemonic_Then_ShortcutWorks() var keys = new FakeKeyReader([Key(ConsoleKey.R, 'r')]); using var ctx = CreateContext(harness, keys); - var result = ctx.Channel.ReadChoiceInteractiveSync( + var result = ctx.Handler.ReadChoiceInteractiveSync( "How to proceed?", ["_Abort", "_Retry", "_Fail"], defaultIndex: 0, CancellationToken.None); result.Should().Be(1); @@ -113,7 +113,7 @@ public void When_MultiChoice_SpaceToggles_Then_SelectionChanges() var keys = new FakeKeyReader([Key(ConsoleKey.Spacebar, ' '), Key(ConsoleKey.Enter, '\r')]); using var ctx = CreateContext(harness, keys); - var result = ctx.Channel.ReadMultiChoiceInteractiveSync( + var result = ctx.Handler.ReadMultiChoiceInteractiveSync( "Select", ["Auth", "Logging", "Cache"], defaults: [], minSelections: 0, maxSelections: null, CancellationToken.None); @@ -133,7 +133,7 @@ public void When_MultiChoice_WithDefaults_Then_PreSelected() var keys = new FakeKeyReader([Key(ConsoleKey.Enter, '\r')]); using var ctx = CreateContext(harness, keys); - var result = ctx.Channel.ReadMultiChoiceInteractiveSync( + var result = ctx.Handler.ReadMultiChoiceInteractiveSync( "Select", ["Auth", "Logging", "Cache"], defaults: [0, 2], minSelections: 0, maxSelections: null, CancellationToken.None); @@ -148,7 +148,7 @@ public void When_MultiChoice_Escape_Then_ReturnsNull() var keys = new FakeKeyReader([Key(ConsoleKey.Escape)]); using var ctx = CreateContext(harness, keys); - var result = ctx.Channel.ReadMultiChoiceInteractiveSync( + var result = ctx.Handler.ReadMultiChoiceInteractiveSync( "Select", ["Auth", "Logging"], defaults: [], minSelections: 0, maxSelections: null, CancellationToken.None); @@ -168,7 +168,7 @@ public void When_MultiChoice_BelowMin_Then_ShowsErrorAndRetries() ]); using var ctx = CreateContext(harness, keys); - var result = ctx.Channel.ReadMultiChoiceInteractiveSync( + var result = ctx.Handler.ReadMultiChoiceInteractiveSync( "Select", ["Auth", "Logging"], defaults: [], minSelections: 1, maxSelections: null, CancellationToken.None); @@ -188,7 +188,7 @@ public void When_MultiChoice_NavigateAndToggle_Then_CorrectItemSelected() ]); using var ctx = CreateContext(harness, keys); - var result = ctx.Channel.ReadMultiChoiceInteractiveSync( + var result = ctx.Handler.ReadMultiChoiceInteractiveSync( "Select", ["Auth", "Logging", "Cache"], defaults: [], minSelections: 0, maxSelections: null, CancellationToken.None); @@ -203,7 +203,7 @@ public void When_ChoiceMenu_Rendered_Then_EachItemOnOwnLine() var keys = new FakeKeyReader([Key(ConsoleKey.Enter, '\r')]); using var ctx = CreateContext(harness, keys); - ctx.Channel.ReadChoiceInteractiveSync( + ctx.Handler.ReadChoiceInteractiveSync( "Pick", ["Alpha", "Bravo", "Charlie"], defaultIndex: 0, CancellationToken.None); // Find a frame that has all 3 items visible @@ -225,7 +225,7 @@ public void When_ChoiceMenu_Rendered_Then_HintLineIsSeparate() var keys = new FakeKeyReader([Key(ConsoleKey.Enter, '\r')]); using var ctx = CreateContext(harness, keys); - ctx.Channel.ReadChoiceInteractiveSync( + ctx.Handler.ReadChoiceInteractiveSync( "Pick", ["Alpha", "Bravo"], defaultIndex: 0, CancellationToken.None); // Find a frame with both hint and menu items visible @@ -255,14 +255,13 @@ private static TestContext CreateContext(TerminalHarness harness, FakeKeyReader ReplSessionIO.AnsiSupport = true; var outputOptions = new OutputOptions { AnsiMode = AnsiMode.Always }; - var interactionOptions = new InteractionOptions(); - var channel = new ConsoleInteractionChannel(interactionOptions, outputOptions); - return new TestContext(channel, scope); + var handler = new RichPromptInteractionHandler(outputOptions); + return new TestContext(handler, scope); } - private sealed class TestContext(ConsoleInteractionChannel channel, IDisposable scope) : IDisposable + private sealed class TestContext(RichPromptInteractionHandler handler, IDisposable scope) : IDisposable { - public ConsoleInteractionChannel Channel { get; } = channel; + public RichPromptInteractionHandler Handler { get; } = handler; public void Dispose() => scope.Dispose(); } From 02b9c73b599dda5949b016c4954fdb55cb02fe71 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Thu, 12 Mar 2026 23:33:42 -0400 Subject: [PATCH 07/13] Add Results.EnterInteractive() to enter REPL mode from CLI commands 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. --- src/Repl.Core/CoreReplApp.cs | 83 ++++++++++++------- src/Repl.Core/EnterInteractiveResult.cs | 7 ++ src/Repl.Core/Results.cs | 11 +++ .../Given_EnterInteractiveResult.cs | 82 ++++++++++++++++++ 4 files changed, 154 insertions(+), 29 deletions(-) create mode 100644 src/Repl.Core/EnterInteractiveResult.cs create mode 100644 src/Repl.IntegrationTests/Given_EnterInteractiveResult.cs diff --git a/src/Repl.Core/CoreReplApp.cs b/src/Repl.Core/CoreReplApp.cs index fbefa79..c29a5f2 100644 --- a/src/Repl.Core/CoreReplApp.cs +++ b/src/Repl.Core/CoreReplApp.cs @@ -551,30 +551,29 @@ private async ValueTask ExecuteMatchedCommandAndMaybeEnterInteractiveAsync( return 1; } - var exitCode = match.Route.Command.IsProtocolPassthrough - ? await ExecuteProtocolPassthroughCommandAsync(match, globalOptions, serviceProvider, cancellationToken) - .ConfigureAwait(false) - : await ExecuteMatchedCommandAsync( - match, - globalOptions, - serviceProvider, - scopeTokens: null, - cancellationToken) - .ConfigureAwait(false); if (match.Route.Command.IsProtocolPassthrough) { - return exitCode; + return await ExecuteProtocolPassthroughCommandAsync(match, globalOptions, serviceProvider, cancellationToken) + .ConfigureAwait(false); } - if (exitCode != 0 || !ShouldEnterInteractive(globalOptions, allowAuto: false)) + var (exitCode, enterInteractive) = await ExecuteMatchedCommandAsync( + match, + globalOptions, + serviceProvider, + scopeTokens: null, + cancellationToken) + .ConfigureAwait(false); + + if (enterInteractive || (exitCode == 0 && ShouldEnterInteractive(globalOptions, allowAuto: false))) { - return exitCode; + var matchedPathLength = globalOptions.RemainingTokens.Count - match.RemainingTokens.Count; + var matchedPathTokens = globalOptions.RemainingTokens.Take(matchedPathLength).ToArray(); + var interactiveScope = GetDeepestContextScopePath(matchedPathTokens); + return await RunInteractiveSessionAsync(interactiveScope, serviceProvider, cancellationToken).ConfigureAwait(false); } - var matchedPathLength = globalOptions.RemainingTokens.Count - match.RemainingTokens.Count; - var matchedPathTokens = globalOptions.RemainingTokens.Take(matchedPathLength).ToArray(); - var interactiveScope = GetDeepestContextScopePath(matchedPathTokens); - return await RunInteractiveSessionAsync(interactiveScope, serviceProvider, cancellationToken).ConfigureAwait(false); + return exitCode; } private async ValueTask ExecuteProtocolPassthroughCommandAsync( @@ -585,13 +584,14 @@ private async ValueTask ExecuteProtocolPassthroughCommandAsync( { if (ReplSessionIO.IsSessionActive) { - return await ExecuteMatchedCommandAsync( + var (exitCode, _) = await ExecuteMatchedCommandAsync( match, globalOptions, serviceProvider, scopeTokens: null, cancellationToken) .ConfigureAwait(false); + return exitCode; } using var protocolScope = ReplSessionIO.SetSession( @@ -601,13 +601,14 @@ private async ValueTask ExecuteProtocolPassthroughCommandAsync( commandOutput: Console.Out, error: Console.Error, isHostedSession: false); - return await ExecuteMatchedCommandAsync( + var (code, _) = await ExecuteMatchedCommandAsync( match, globalOptions, serviceProvider, scopeTokens: null, cancellationToken) .ConfigureAwait(false); + return code; } private async ValueTask HandleEmptyInvocationAsync( @@ -918,7 +919,7 @@ private async ValueTask TryHandleContextDeeplinkAsync( "Maintainability", "MA0051:Method is too long", Justification = "Execution path intentionally keeps validation, binding, middleware and rendering in one place.")] - private async ValueTask ExecuteMatchedCommandAsync( + private async ValueTask<(int ExitCode, bool EnterInteractive)> ExecuteMatchedCommandAsync( RouteMatch match, GlobalInvocationOptions globalOptions, IServiceProvider serviceProvider, @@ -939,7 +940,7 @@ private async ValueTask ExecuteMatchedCommandAsync( globalOptions.OutputFormat, cancellationToken) .ConfigureAwait(false); - return 1; + return (1, false); } var parsedOptions = InvocationOptionParser.Parse( @@ -955,7 +956,7 @@ private async ValueTask ExecuteMatchedCommandAsync( globalOptions.OutputFormat, cancellationToken) .ConfigureAwait(false); - return 1; + return (1, false); } var matchedPathLength = globalOptions.RemainingTokens.Count - match.RemainingTokens.Count; var matchedPathTokens = globalOptions.RemainingTokens.Take(matchedPathLength).ToArray(); @@ -982,7 +983,7 @@ private async ValueTask ExecuteMatchedCommandAsync( { _ = await RenderOutputAsync(contextFailure, globalOptions.OutputFormat, cancellationToken) .ConfigureAwait(false); - return 1; + return (1, false); } await TryRenderCommandBannerAsync(match.Route.Command, globalOptions.OutputFormat, serviceProvider, cancellationToken) @@ -1000,11 +1001,22 @@ await TryRenderCommandBannerAsync(match.Route.Command, globalOptions.OutputForma .ConfigureAwait(false); } + if (result is EnterInteractiveResult enterInteractive) + { + if (enterInteractive.Payload is not null) + { + _ = await RenderOutputAsync(enterInteractive.Payload, globalOptions.OutputFormat, cancellationToken, scopeTokens is not null) + .ConfigureAwait(false); + } + + return (0, true); + } + var normalizedResult = ApplyNavigationResult(result, scopeTokens); ExecutionObserver?.OnResult(normalizedResult); var rendered = await RenderOutputAsync(normalizedResult, globalOptions.OutputFormat, cancellationToken, scopeTokens is not null) .ConfigureAwait(false); - return rendered ? ComputeExitCode(normalizedResult) : 1; + return (rendered ? ComputeExitCode(normalizedResult) : 1, false); } catch (OperationCanceledException) { @@ -1014,7 +1026,7 @@ await TryRenderCommandBannerAsync(match.Route.Command, globalOptions.OutputForma { _ = await RenderOutputAsync(Results.Validation(ex.Message), globalOptions.OutputFormat, cancellationToken) .ConfigureAwait(false); - return 1; + return (1, false); } catch (Exception ex) { @@ -1026,11 +1038,11 @@ await TryRenderCommandBannerAsync(match.Route.Command, globalOptions.OutputForma globalOptions.OutputFormat, cancellationToken) .ConfigureAwait(false); - return 1; + return (1, false); } } - private async ValueTask RenderTupleResultAsync( + private async ValueTask<(int ExitCode, bool EnterInteractive)> RenderTupleResultAsync( ITuple tuple, List? scopeTokens, GlobalInvocationOptions globalOptions, @@ -1038,10 +1050,23 @@ private async ValueTask RenderTupleResultAsync( { var isInteractive = scopeTokens is not null; var exitCode = 0; + var enterInteractive = false; for (var i = 0; i < tuple.Length; i++) { var element = tuple[i]; + + // EnterInteractiveResult: extract payload (if any) and signal interactive entry. + if (element is EnterInteractiveResult eir) + { + enterInteractive = true; + element = eir.Payload; + if (element is null) + { + continue; + } + } + var isLast = i == tuple.Length - 1; // Navigation results: only apply navigation on the last element. @@ -1058,7 +1083,7 @@ private async ValueTask RenderTupleResultAsync( if (!rendered) { - return 1; + return (1, false); } if (isLast) @@ -1067,7 +1092,7 @@ private async ValueTask RenderTupleResultAsync( } } - return exitCode; + return (exitCode, enterInteractive); } private static int ComputeExitCode(object? result) diff --git a/src/Repl.Core/EnterInteractiveResult.cs b/src/Repl.Core/EnterInteractiveResult.cs new file mode 100644 index 0000000..f7a3ed0 --- /dev/null +++ b/src/Repl.Core/EnterInteractiveResult.cs @@ -0,0 +1,7 @@ +namespace Repl; + +/// +/// Signals that the process should enter interactive REPL mode after rendering the payload. +/// +/// Optional result payload to render before entering interactive mode. +public sealed record EnterInteractiveResult(object? Payload); diff --git a/src/Repl.Core/Results.cs b/src/Repl.Core/Results.cs index b042cc9..424fe8f 100644 --- a/src/Repl.Core/Results.cs +++ b/src/Repl.Core/Results.cs @@ -93,4 +93,15 @@ public static IReplResult Cancelled(string message) => /// An explicit exit result. public static IExitResult Exit(int code, object? payload = null) => new ExitResult(code, payload); + + /// + /// Signals that the process should enter interactive REPL mode after command completion. + /// When returned from a CLI one-shot command, the process renders the optional payload + /// and then enters the interactive REPL loop instead of exiting. + /// Also works as the last element of a tuple return. + /// + /// Optional payload to render before entering interactive mode. + /// An enter-interactive result. + public static EnterInteractiveResult EnterInteractive(object? payload = null) => + new(payload); } diff --git a/src/Repl.IntegrationTests/Given_EnterInteractiveResult.cs b/src/Repl.IntegrationTests/Given_EnterInteractiveResult.cs new file mode 100644 index 0000000..15a4370 --- /dev/null +++ b/src/Repl.IntegrationTests/Given_EnterInteractiveResult.cs @@ -0,0 +1,82 @@ +namespace Repl.IntegrationTests; + +[TestClass] +[DoNotParallelize] +public sealed class Given_EnterInteractiveResult +{ + [TestMethod] + [Description("Results.EnterInteractive() enters interactive mode after CLI command.")] + public void When_CommandReturnsEnterInteractive_Then_ProcessEntersInteractiveMode() + { + var sut = ReplApp.Create(); + sut.Map("setup", () => Results.EnterInteractive()); + + var output = ConsoleCaptureHelper.CaptureWithInput("exit\n", () => sut.Run(["setup", "--no-logo"])); + + output.ExitCode.Should().Be(0); + output.Text.Should().Contain("> "); + } + + [TestMethod] + [Description("Results.EnterInteractive(payload) renders payload then enters interactive mode.")] + public void When_CommandReturnsEnterInteractiveWithPayload_Then_PayloadIsRenderedAndInteractiveModeStarts() + { + var sut = ReplApp.Create(); + sut.Map("setup", () => Results.EnterInteractive("Setup complete")); + + var output = ConsoleCaptureHelper.CaptureWithInput("exit\n", () => sut.Run(["setup", "--no-logo"])); + + output.ExitCode.Should().Be(0); + output.Text.Should().Contain("Setup complete"); + output.Text.Should().Contain("> "); + } + + [TestMethod] + [Description("EnterInteractive as last tuple element enters interactive mode after rendering prior elements.")] + public void When_TupleLastElementIsEnterInteractive_Then_PriorElementsRenderedAndInteractiveModeStarts() + { + var sut = ReplApp.Create(); + sut.Map("setup", () => (Results.Ok("Step 1 done"), Results.EnterInteractive())); + + var output = ConsoleCaptureHelper.CaptureWithInput("exit\n", () => sut.Run(["setup", "--no-logo"])); + + output.ExitCode.Should().Be(0); + output.Text.Should().Contain("Step 1 done"); + output.Text.Should().Contain("> "); + } + + [TestMethod] + [Description("EnterInteractive with payload as last tuple element renders all payloads then enters interactive.")] + public void When_TupleLastElementIsEnterInteractiveWithPayload_Then_AllPayloadsRendered() + { + var sut = ReplApp.Create(); + sut.Map("setup", () => (Results.Ok("Phase 1"), Results.EnterInteractive("Phase 2"))); + + var output = ConsoleCaptureHelper.CaptureWithInput("exit\n", () => sut.Run(["setup", "--no-logo"])); + + output.ExitCode.Should().Be(0); + output.Text.Should().Contain("Phase 1"); + output.Text.Should().Contain("Phase 2"); + output.Text.Should().Contain("> "); + } + + [TestMethod] + [Description("Results.EnterInteractive() factory creates correct type.")] + public void When_CallingEnterInteractiveFactory_Then_ReturnsCorrectType() + { + var result = Results.EnterInteractive(); + + result.Should().BeOfType(); + result.Payload.Should().BeNull(); + } + + [TestMethod] + [Description("Results.EnterInteractive(payload) preserves the payload.")] + public void When_CallingEnterInteractiveFactoryWithPayload_Then_PayloadIsPreserved() + { + var result = Results.EnterInteractive("hello"); + + result.Should().BeOfType(); + result.Payload.Should().Be("hello"); + } +} From 74ea885dcc40c8a597e8b1dac9104b9e81c073ee Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Thu, 12 Mar 2026 23:34:01 -0400 Subject: [PATCH 08/13] Document rich prompts, mnemonics, ITerminalInfo, and EnterInteractive 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. --- docs/commands.md | 6 ++ docs/interaction.md | 101 ++++++++++++++++++++++++++++ samples/05-hosting-remote/README.md | 4 ++ 3 files changed, 111 insertions(+) diff --git a/docs/commands.md b/docs/commands.md index 91ee957..fbdc399 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -186,6 +186,7 @@ Handlers can return any type. The framework renders the return value through the | `IReplResult` | Structured result with kind prefix (`Results.Ok`, `Error`, `NotFound`...) | | `ReplNavigationResult` | Renders payload and navigates scope (`Results.NavigateUp`, `NavigateTo`) | | `IExitResult` | Renders optional payload and sets process exit code (`Results.Exit`) | +| `EnterInteractiveResult` | Renders optional payload and enters interactive REPL mode (`Results.EnterInteractive`) | | `void` / `null` | No output | ### Result factory helpers @@ -201,6 +202,8 @@ Results.Cancelled("user declined") // cancellation Results.NavigateUp(payload) // navigate up one scope level Results.NavigateTo("client/42", payload) // navigate to explicit scope Results.Exit(0, payload) // explicit exit code +Results.EnterInteractive() // enter interactive REPL after command +Results.EnterInteractive(payload) // render payload then enter interactive REPL ``` ### Multiple return values (tuples) @@ -220,6 +223,7 @@ Tuple semantics: - each element is rendered as a separate output block - navigation results (`NavigateUp`, `NavigateTo`) are only applied on the **last** element +- `EnterInteractive` as the last element enters interactive mode after rendering prior elements - exit code is determined by the last element - null elements are silently skipped - nested tuples are not flattened — use a flat tuple instead @@ -228,6 +232,8 @@ Tuple semantics: Handlers can use `IReplInteractionChannel` for guided prompts (text, choice, confirmation, secret, multi-choice), progress reporting, and status messages. Extension methods add enum prompts, numeric input, validated text, and more. +When the terminal supports ANSI and key reads, choice and multi-choice prompts automatically upgrade to rich arrow-key menus with mnemonic shortcuts. Labels using the `_X` underscore convention get keyboard shortcuts (e.g. `"_Abort"` → press `A`). + See the full guide: [interaction.md](interaction.md) ## Ambient commands diff --git a/docs/interaction.md b/docs/interaction.md index 2ccaab1..382a5c5 100644 --- a/docs/interaction.md +++ b/docs/interaction.md @@ -328,3 +328,104 @@ var color = await channel.DispatchAsync( ``` 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. + +--- + +## Rich interactive prompts + +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`/`Down` to navigate, `Enter` to confirm, `Esc` to cancel). Mnemonic shortcut keys select items directly. +- **Multi-choice**: checkbox-style menu (`Up`/`Down` to navigate, `Space` to toggle, `Enter` to confirm with min/max validation, `Esc` to cancel). + +The upgrade is transparent — command handlers call the same `AskChoiceAsync` / `AskMultiChoiceAsync` API; the framework selects the best rendering mode automatically. + +### Fallback chain + +The interaction pipeline evaluates handlers in this order: + +1. **Prefill** (`--answer:*`) — always checked first. +2. **User handlers** — `IReplInteractionHandler` implementations registered via DI. +3. **Built-in rich handler** (`RichPromptInteractionHandler`) — renders arrow-key menus when ANSI + key reader are available. +4. **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. + +--- + +## Mnemonic shortcuts + +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) | + +### Rendering + +- **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`. + +### Auto-assignment + +When a label has no explicit `_` marker, the framework auto-assigns a shortcut: + +1. First unique letter of the display text. +2. If taken, scan remaining letters. +3. If all letters are taken, assign digits `1`–`9`. + +### Example + +```csharp +var index = await channel.AskChoiceAsync( + "action", "How to proceed?", + ["_Abort", "_Retry", "_Fail"], + defaultIndex: 0); +``` + +--- + +## `ITerminalInfo` + +The `ITerminalInfo` service exposes terminal capabilities for custom `IReplInteractionHandler` implementations. It is registered automatically by the framework and available via DI. + +```csharp +public interface ITerminalInfo +{ + bool IsAnsiSupported { get; } + bool CanReadKeys { get; } + (int Width, int Height)? WindowSize { get; } + AnsiPalette? Palette { get; } +} +``` + +### Usage in a custom handler + +```csharp +public class MyHandler(ITerminalInfo terminal) : IReplInteractionHandler +{ + public ValueTask 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: + +```csharp +var app = ReplApp.Create(services => +{ + services.AddSingleton(); +}); +``` + +The framework injects `ITerminalInfo` automatically — no manual registration required. diff --git a/samples/05-hosting-remote/README.md b/samples/05-hosting-remote/README.md index abe0ae1..1dc9cc1 100644 --- a/samples/05-hosting-remote/README.md +++ b/samples/05-hosting-remote/README.md @@ -26,6 +26,9 @@ Browser terminal (xterm.js / VT-compatible, raw mode) | Session names | `who` | Quick list of connected session identifiers | | Session details | `sessions` | Transport, remote peer, terminal, screen, connected/idle durations | | Runtime diagnostics | `status` | Screen, terminal identity/capabilities, transport, runtime | +| Terminal capabilities | `debug` | Structured status rows showing ANSI support, key reading, window size, palette | +| Interactive configuration | `configure` | Multi-choice interactive menu (rich arrow-key menu or text fallback) | +| Maintenance actions | `maintenance` | Single-choice interactive menu with mnemonic shortcuts (`_Abort`, `_Retry`, `_Fail`) | ## Run @@ -78,4 +81,5 @@ send hello - `status` reflects metadata of the current REPL session. - Telnet mode performs NAWS/terminal-type negotiation automatically in the browser client script. - This sample intentionally mixes in-band and out-of-band metadata paths for demonstration. +- Use `--ansi:never` to force Plain mode (no ANSI), which demonstrates the text fallback for interactive prompts. - Canonical support matrix and precedence order live in [`docs/terminal-metadata.md`](../../docs/terminal-metadata.md). From e0d4310076d368d8af43713aaa49d2f817479ab8 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Thu, 12 Mar 2026 23:54:44 -0400 Subject: [PATCH 09/13] Fix PR review findings: secret mask default, Esc cancellation, and ANSI 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 --- src/Repl.Core/ConsoleInteractionChannel.cs | 4 ++-- .../Public/ReplInteractionChannelExtensions.cs | 11 ++++++++--- .../RichPromptInteractionHandler.Rendering.cs | 4 ++-- .../Interaction/RichPromptInteractionHandler.cs | 10 +++++++--- src/Repl.Tests/Given_ShellCompletionRuntime.cs | 4 ++-- 5 files changed, 21 insertions(+), 12 deletions(-) diff --git a/src/Repl.Core/ConsoleInteractionChannel.cs b/src/Repl.Core/ConsoleInteractionChannel.cs index dead774..97b490f 100644 --- a/src/Repl.Core/ConsoleInteractionChannel.cs +++ b/src/Repl.Core/ConsoleInteractionChannel.cs @@ -400,13 +400,13 @@ await _presenter.PresentAsync( else { line = await ReadSecretWithCountdownAsync( - options.Timeout.Value, options?.Mask, ct) + options.Timeout.Value, options.Mask, ct) .ConfigureAwait(false); } } else { - line = await ReadSecretLineAsync(options?.Mask, ct).ConfigureAwait(false); + line = await ReadSecretLineAsync(options?.Mask ?? '*', ct).ConfigureAwait(false); } if (string.IsNullOrEmpty(line)) diff --git a/src/Repl.Core/Interaction/Public/ReplInteractionChannelExtensions.cs b/src/Repl.Core/Interaction/Public/ReplInteractionChannelExtensions.cs index 3bea5c8..73c22b9 100644 --- a/src/Repl.Core/Interaction/Public/ReplInteractionChannelExtensions.cs +++ b/src/Repl.Core/Interaction/Public/ReplInteractionChannelExtensions.cs @@ -224,16 +224,21 @@ private static string BuildNumberPrompt( T? defaultValue, AskNumberOptions? options) where T : struct, INumber { - if (options?.Min is null && options?.Max is null) + if (options is null) + { + return prompt; + } + + if (options.Min is null && options.Max is null) { return prompt; } var sb = new StringBuilder(prompt); sb.Append(" ("); - sb.Append(options?.Min?.ToString() ?? ".."); + sb.Append(options.Min?.ToString() ?? ".."); sb.Append(".."); - sb.Append(options?.Max?.ToString() ?? ""); + sb.Append(options.Max?.ToString() ?? ""); sb.Append(')'); return sb.ToString(); } diff --git a/src/Repl.Core/Interaction/RichPromptInteractionHandler.Rendering.cs b/src/Repl.Core/Interaction/RichPromptInteractionHandler.Rendering.cs index 807235d..e69e299 100644 --- a/src/Repl.Core/Interaction/RichPromptInteractionHandler.Rendering.cs +++ b/src/Repl.Core/Interaction/RichPromptInteractionHandler.Rendering.cs @@ -101,7 +101,7 @@ private int ReadChoiceInteractiveRemote( { ClearMenuRegion(menuLines); Out(AnsiCursorShow); - Flush(ct); + Flush(CancellationToken.None); throw; } } @@ -276,7 +276,7 @@ private int RunChoiceKeyLoopRemote( { ClearMenuRegion(menuLines, 1 + (hasError ? 1 : 0)); Out(AnsiCursorShow); - Flush(ct); + Flush(CancellationToken.None); throw; } } diff --git a/src/Repl.Core/Interaction/RichPromptInteractionHandler.cs b/src/Repl.Core/Interaction/RichPromptInteractionHandler.cs index 536f4f5..997a079 100644 --- a/src/Repl.Core/Interaction/RichPromptInteractionHandler.cs +++ b/src/Repl.Core/Interaction/RichPromptInteractionHandler.cs @@ -44,7 +44,12 @@ private async ValueTask HandleChoiceRequestAsync( var richResult = await Task.Run( () => ReadChoiceInteractiveSync(r.Prompt, r.Choices, defaultIndex, ct), ct) .ConfigureAwait(false); - return InteractionResult.Success(richResult >= 0 ? richResult : defaultIndex); + if (richResult < 0) + { + throw new OperationCanceledException("Prompt cancelled via Esc."); + } + + return InteractionResult.Success(richResult); } private async ValueTask HandleMultiChoiceRequestAsync( @@ -62,8 +67,7 @@ private async ValueTask HandleMultiChoiceRequestAsync( return InteractionResult.Success((IReadOnlyList)richResult); } - // Esc pressed → return defaults - return InteractionResult.Success((IReadOnlyList)defaults); + throw new OperationCanceledException("Prompt cancelled via Esc."); } private async ValueTask PresentPromptAsync( diff --git a/src/Repl.Tests/Given_ShellCompletionRuntime.cs b/src/Repl.Tests/Given_ShellCompletionRuntime.cs index cc4de5f..4a84337 100644 --- a/src/Repl.Tests/Given_ShellCompletionRuntime.cs +++ b/src/Repl.Tests/Given_ShellCompletionRuntime.cs @@ -113,7 +113,7 @@ public void When_StatusProfilesShareSamePath_Then_RuntimeReadsProfileContentOnce { File.Delete(profilePath); } - catch + catch (IOException) { // Best-effort cleanup for temp test files. } @@ -195,7 +195,7 @@ public async Task When_StatusIsRenderedWithAnsi_Then_AllLabelsUseCommandStyle() } finally { - try { Directory.Delete(root, recursive: true); } catch { } + try { Directory.Delete(root, recursive: true); } catch (IOException) { } } } From 1401a5948ad9996195e63926b3223d4694b10d49 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Fri, 13 Mar 2026 00:02:58 -0400 Subject: [PATCH 10/13] Expand empty catch block with comment for CodeQL --- src/Repl.Tests/Given_ShellCompletionRuntime.cs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/Repl.Tests/Given_ShellCompletionRuntime.cs b/src/Repl.Tests/Given_ShellCompletionRuntime.cs index 4a84337..ef5ea73 100644 --- a/src/Repl.Tests/Given_ShellCompletionRuntime.cs +++ b/src/Repl.Tests/Given_ShellCompletionRuntime.cs @@ -195,7 +195,14 @@ public async Task When_StatusIsRenderedWithAnsi_Then_AllLabelsUseCommandStyle() } finally { - try { Directory.Delete(root, recursive: true); } catch (IOException) { } + try + { + Directory.Delete(root, recursive: true); + } + catch (IOException) + { + // Best-effort cleanup for temp test directory. + } } } From b16fca7600874740c2bb9038fe84efa36ea2bdec Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Fri, 13 Mar 2026 00:10:15 -0400 Subject: [PATCH 11/13] Break infinite loop when prefilled answer fails validation 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. --- .../ReplInteractionChannelExtensions.cs | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/src/Repl.Core/Interaction/Public/ReplInteractionChannelExtensions.cs b/src/Repl.Core/Interaction/Public/ReplInteractionChannelExtensions.cs index 73c22b9..bfd7c9f 100644 --- a/src/Repl.Core/Interaction/Public/ReplInteractionChannelExtensions.cs +++ b/src/Repl.Core/Interaction/Public/ReplInteractionChannelExtensions.cs @@ -84,6 +84,7 @@ public static async ValueTask AskNumberAsync( ? new AskOptions(options.CancellationToken, options.Timeout) : null; var defaultText = defaultValue?.ToString(); + string? previousLine = null; while (true) { @@ -95,6 +96,8 @@ public static async ValueTask AskNumberAsync( { if (options?.Min is not null && value < options.Min.Value) { + ThrowIfRepeatedInput(line, ref previousLine, + $"Value must be at least {options.Min.Value}."); await channel.WriteStatusAsync( $"Value must be at least {options.Min.Value}.", options.CancellationToken) @@ -104,6 +107,8 @@ await channel.WriteStatusAsync( if (options?.Max is not null && value > options.Max.Value) { + ThrowIfRepeatedInput(line, ref previousLine, + $"Value must be at most {options.Max.Value}."); await channel.WriteStatusAsync( $"Value must be at most {options.Max.Value}.", options.CancellationToken) @@ -115,6 +120,8 @@ await channel.WriteStatusAsync( } var ct = options?.CancellationToken ?? default; + ThrowIfRepeatedInput(line, ref previousLine, + $"'{line}' is not a valid {typeof(T).Name}."); await channel.WriteStatusAsync($"'{line}' is not a valid {typeof(T).Name}.", ct) .ConfigureAwait(false); } @@ -144,6 +151,7 @@ public static async ValueTask AskValidatedTextAsync( { ArgumentNullException.ThrowIfNull(validate); var ct = options?.CancellationToken ?? default; + string? previousInput = null; while (true) { @@ -155,6 +163,7 @@ public static async ValueTask AskValidatedTextAsync( return input; } + ThrowIfRepeatedInput(input, ref previousInput, error); await channel.WriteStatusAsync(error, ct).ConfigureAwait(false); } } @@ -242,4 +251,16 @@ private static string BuildNumberPrompt( sb.Append(')'); return sb.ToString(); } + + private static void ThrowIfRepeatedInput( + string? current, ref string? previous, string validationMessage) + { + if (current is not null && string.Equals(current, previous, StringComparison.Ordinal)) + { + throw new InvalidOperationException( + $"Prefilled answer failed validation: {validationMessage}"); + } + + previous = current; + } } From f077cac69676c3827285c3dde78b70cf33fa49ed Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Fri, 13 Mar 2026 00:12:27 -0400 Subject: [PATCH 12/13] Preserve explicit Mask: null for no-echo secret input 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. --- src/Repl.Core/ConsoleInteractionChannel.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Repl.Core/ConsoleInteractionChannel.cs b/src/Repl.Core/ConsoleInteractionChannel.cs index 97b490f..ad0a9ec 100644 --- a/src/Repl.Core/ConsoleInteractionChannel.cs +++ b/src/Repl.Core/ConsoleInteractionChannel.cs @@ -406,7 +406,7 @@ await _presenter.PresentAsync( } else { - line = await ReadSecretLineAsync(options?.Mask ?? '*', ct).ConfigureAwait(false); + line = await ReadSecretLineAsync(options is not null ? options.Mask : '*', ct).ConfigureAwait(false); } if (string.IsNullOrEmpty(line)) From 4980cbbf67b7dcffb72aa290b85cf7341212aaa5 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Fri, 13 Mar 2026 00:18:31 -0400 Subject: [PATCH 13/13] Replace foreach filter with LINQ Any in HasAmbientCommandPrefix --- src/Repl.Core/CoreReplApp.Interactive.cs | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/src/Repl.Core/CoreReplApp.Interactive.cs b/src/Repl.Core/CoreReplApp.Interactive.cs index cf80145..8109230 100644 --- a/src/Repl.Core/CoreReplApp.Interactive.cs +++ b/src/Repl.Core/CoreReplApp.Interactive.cs @@ -1621,15 +1621,8 @@ private bool HasAmbientCommandPrefix(string token, StringComparison comparison) return true; } - foreach (var name in _options.AmbientCommands.CustomCommands.Keys) - { - if (name.StartsWith(token, comparison)) - { - return true; - } - } - - return false; + return _options.AmbientCommands.CustomCommands.Keys + .Any(name => name.StartsWith(token, comparison)); } private static bool TryClassifyTemplateSegment(