diff --git a/docs/commands.md b/docs/commands.md index 47fa946..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,10 +223,19 @@ 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 +## 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. + +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 These commands are handled by the runtime (not by your mapped routes): @@ -241,6 +253,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/docs/interaction.md b/docs/interaction.md new file mode 100644 index 0000000..382a5c5 --- /dev/null +++ b/docs/interaction.md @@ -0,0 +1,431 @@ +# 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. + +### 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. + +--- + +## 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/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/samples/04-interactive-ops/ContactStore.cs b/samples/04-interactive-ops/ContactStore.cs index ca045ba..e96e9d7 100644 --- a/samples/04-interactive-ops/ContactStore.cs +++ b/samples/04-interactive-ops/ContactStore.cs @@ -1,5 +1,31 @@ +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, +} + +[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 7118652..51ad0ec 100644 --- a/samples/04-interactive-ops/Program.cs +++ b/samples/04-interactive-ops/Program.cs @@ -6,6 +6,14 @@ // - 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) +// - 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) @@ -35,9 +43,24 @@ 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 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", @@ -216,6 +239,99 @@ 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}."); + }); + + // 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", + [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/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/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/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). 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/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/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.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 7c3d7d5..ad0a9ec 100644 --- a/src/Repl.Core/ConsoleInteractionChannel.cs +++ b/src/Repl.Core/ConsoleInteractionChannel.cs @@ -1,16 +1,56 @@ 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; + } + + /// + 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. /// @@ -25,6 +65,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 +85,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,37 +129,106 @@ public async ValueTask AskChoiceAsync( } } - var choiceDisplay = string.Join('/', choices.Select((c, i) => - i == effectiveDefaultIndex ? c.ToUpperInvariant() : c.ToLowerInvariant())); + var dispatched = await TryDispatchAsync( + new AskChoiceRequest(name, prompt, choices, defaultIndex, options), effectiveCt).ConfigureAwait(false); + if (dispatched.Handled) + { + return (int)dispatched.Value!; + } + + return await ReadChoiceTextFallbackAsync(name, prompt, choices, effectiveDefaultIndex, effectiveCt, options?.Timeout) + .ConfigureAwait(false); + } + + 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( name, $"{prompt} [{choiceDisplay}]", kind: "choice", - effectiveCt, - options?.Timeout, - defaultLabel: choices[effectiveDefaultIndex]) + ct, + timeout, + 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)}."), - effectiveCt) + new ReplStatusEvent($"Invalid choice '{line}'. Please enter one of: {string.Join(", ", displayChoices)}."), + ct) .ConfigureAwait(false); } } - private static int MatchChoice(IReadOnlyList choices, string input) + 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); + + /// + /// 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++) @@ -151,6 +276,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")}]", @@ -197,6 +329,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}]"; @@ -212,279 +351,260 @@ public async ValueTask AskTextAsync( return line; } - 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 async ValueTask ReadPromptLineAsync( + public async ValueTask AskSecretAsync( string name, string prompt, - string kind, - CancellationToken cancellationToken, - TimeSpan? timeout = null, - string? defaultLabel = null) + AskSecretOptions? options = null) { - await _presenter.PresentAsync( - new ReplPromptEvent(name, prompt, kind), - cancellationToken) - .ConfigureAwait(false); + _ = ValidateName(name); + prompt = string.IsNullOrWhiteSpace(prompt) + ? throw new ArgumentException("Prompt cannot be empty.", nameof(prompt)) + : prompt; + var effectiveCt = ResolveToken(options?.CancellationToken); + effectiveCt.ThrowIfCancellationRequested(); - if (timeout is null || timeout.Value <= TimeSpan.Zero) + if (_options.TryGetPrefilledAnswer(name, out var prefilledSecret)) { - return await ReadLineWithEscAsync(cancellationToken).ConfigureAwait(false); + return prefilledSecret ?? string.Empty; } - // Timeout path — redirected input: simple read with timer-based timeout. - if (Console.IsInputRedirected || ReplSessionIO.IsSessionActive) + var dispatched = await TryDispatchAsync( + new AskSecretRequest(name, prompt, options), effectiveCt).ConfigureAwait(false); + if (dispatched.Handled) { - return await ReadWithTimeoutRedirectedAsync(cancellationToken, timeout.Value) - .ConfigureAwait(false); + return (string)dispatched.Value!; } - // 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); + return await ReadSecretLoopAsync(name, prompt, options, effectiveCt).ConfigureAwait(false); } - private async ValueTask ReadWithTimeoutRedirectedAsync( - CancellationToken cancellationToken, - TimeSpan timeout) + private async ValueTask ReadSecretLoopAsync( + string name, string prompt, AskSecretOptions? options, CancellationToken ct) { - 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) + var allowEmpty = options?.AllowEmpty ?? false; + while (true) { - return null; - } - } + await _presenter.PresentAsync( + new ReplPromptEvent(name, prompt, "secret"), + ct) + .ConfigureAwait(false); - private async ValueTask ReadLineWithCountdownAsync( - TimeSpan timeout, - string? defaultLabel, - CancellationToken cancellationToken) - { - using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - using var _ = _timeProvider.CreateTimer( - static state => + string? line; + if (options?.Timeout is not null && options.Timeout.Value > TimeSpan.Zero) { - try { ((CancellationTokenSource)state!).Cancel(); } - catch (ObjectDisposedException) { /* CTS disposed before timer fired. */ } - }, - timeoutCts, timeout, Timeout.InfiniteTimeSpan); + if (Console.IsInputRedirected || ReplSessionIO.IsSessionActive) + { + line = await ReadWithTimeoutRedirectedAsync(ct, options.Timeout.Value) + .ConfigureAwait(false); + } + else + { + line = await ReadSecretWithCountdownAsync( + options.Timeout.Value, options.Mask, ct) + .ConfigureAwait(false); + } + } + else + { + line = await ReadSecretLineAsync(options is not null ? options.Mask : '*', ct).ConfigureAwait(false); + } - var result = await Task.Run( - () => ReadLineWithCountdownSync(timeout, defaultLabel, timeoutCts.Token, cancellationToken), - cancellationToken) - .ConfigureAwait(false); + if (string.IsNullOrEmpty(line)) + { + if (allowEmpty) + { + return HandleMissingAnswer(string.Empty, "secret"); + } - if (result.Escaped) - { - throw new OperationCanceledException("Prompt cancelled via Esc.", cancellationToken); - } + await _presenter.PresentAsync( + new ReplStatusEvent("A value is required."), + ct) + .ConfigureAwait(false); + continue; + } - return result.Line; + return 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) + public async ValueTask ClearScreenAsync(CancellationToken cancellationToken) { - ConsoleInputGate.Gate.Wait(externalCt); - try - { - return ReadLineWithCountdownCore(timeout, defaultLabel, timeoutCt, externalCt); - } - finally + cancellationToken.ThrowIfCancellationRequested(); + + var dispatched = await TryDispatchAsync(new ClearScreenRequest(), cancellationToken) + .ConfigureAwait(false); + if (dispatched.Handled) { - ConsoleInputGate.Gate.Release(); + return; } + + await _presenter.PresentAsync(new ReplClearScreenEvent(), cancellationToken) + .ConfigureAwait(false); } - private static ConsoleLineReader.ReadResult ReadLineWithCountdownCore( - TimeSpan timeout, - string? defaultLabel, - CancellationToken timeoutCt, - CancellationToken externalCt) + public async ValueTask> AskMultiChoiceAsync( + string name, + string prompt, + IReadOnlyList choices, + IReadOnlyList? defaultIndices = null, + AskMultiChoiceOptions? options = null) { - 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; - } + ValidateMultiChoiceArgs(name, ref prompt, ref choices, defaultIndices); - continue; - } + var effectiveCt = ResolveToken(options?.CancellationToken); + effectiveCt.ThrowIfCancellationRequested(); - Thread.Sleep(15); + var effectiveDefaults = defaultIndices ?? []; + var minSelections = options?.MinSelections ?? 0; + var maxSelections = options?.MaxSelections; - // Update countdown display (only when user hasn't started typing). - if (!userTyping && remaining > 0) + if (_options.TryGetPrefilledAnswer(name, out var prefilledMulti) && !string.IsNullOrWhiteSpace(prefilledMulti)) + { + var parsed = ParseMultiChoiceInput(prefilledMulti, choices); + if (parsed is not null && IsValidSelection(parsed, minSelections, maxSelections)) { - (remaining, lastSuffix, lastTickMs) = TickCountdown( - remaining, defaultLabel, lastSuffix, lastTickMs); + return parsed; } } - // Timeout or cancellation — clean up and signal default. - if (lastSuffix.Length > 0) + var dispatched = await TryDispatchAsync( + new AskMultiChoiceRequest(name, prompt, choices, defaultIndices, options), effectiveCt).ConfigureAwait(false); + if (dispatched.Handled) { - EraseInline(lastSuffix.Length); + return (IReadOnlyList)dispatched.Value!; } - Console.WriteLine(); - return new ConsoleLineReader.ReadResult(Line: null, Escaped: false); + var choiceDisplay = FormatMultiChoiceDisplay(choices, effectiveDefaults); + var defaultLabel = FormatMultiChoiceDefaultLabel(effectiveDefaults); + + return await ReadMultiChoiceTextFallbackAsync( + name, prompt, choices, effectiveDefaults, choiceDisplay, defaultLabel, + minSelections, maxSelections, effectiveCt, options?.Timeout).ConfigureAwait(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) + private static void ValidateMultiChoiceArgs( + string name, ref string prompt, ref IReadOnlyList choices, IReadOnlyList? defaultIndices) { - var key = Console.ReadKey(intercept: true); + _ = 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; + } - if (key.Key == ConsoleKey.Escape) + foreach (var idx in defaultIndices) { - if (buffer.Length > 0) + if (idx < 0 || idx >= choices.Count) { - EraseInline(buffer.Length); + throw new ArgumentOutOfRangeException(nameof(defaultIndices)); } - - return new ConsoleLineReader.ReadResult(Line: null, Escaped: true); } + } - if (key.Key == ConsoleKey.Enter) - { - Console.WriteLine(); - return new ConsoleLineReader.ReadResult(buffer.ToString(), Escaped: false); - } + 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; - if (key.Key == ConsoleKey.Backspace) + private async ValueTask> ReadMultiChoiceTextFallbackAsync( + string name, string prompt, IReadOnlyList choices, + IReadOnlyList effectiveDefaults, string choiceDisplay, string? defaultLabel, + int minSelections, int? maxSelections, CancellationToken ct, TimeSpan? timeout) + { + while (true) { - if (buffer.Length > 0) + var line = await ReadPromptLineAsync( + name, $"{prompt}\r\n {choiceDisplay}\r\n Enter numbers (comma-separated)", + kind: "multi-choice", ct, timeout, defaultLabel: defaultLabel) + .ConfigureAwait(false); + + if (string.IsNullOrWhiteSpace(line)) { - buffer.Remove(buffer.Length - 1, 1); - Console.Write("\b \b"); + return HandleMissingAnswer(effectiveDefaults, "multi-choice"); } - return null; - } + 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 (key.KeyChar != '\0') - { - buffer.Append(key.KeyChar); - Console.Write(key.KeyChar); - } + 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 null; + return selected; + } } - private static (int Remaining, string Suffix, long LastTickMs) TickCountdown( - int remaining, - string? defaultLabel, - string lastSuffix, - long lastTickMs) + private static int[]? ParseMultiChoiceInput(string input, IReadOnlyList choices) { - var now = Environment.TickCount64; - if (now - lastTickMs < 1000) + var parts = input.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + if (parts.Length == 0) { - return (remaining, lastSuffix, lastTickMs); + return null; } - remaining--; - EraseInline(lastSuffix.Length); - - if (remaining > 0) + var result = new List(parts.Length); + foreach (var part in parts) { - lastSuffix = FormatCountdownSuffix(remaining, defaultLabel); - Console.Write(lastSuffix); - } - else - { - lastSuffix = string.Empty; + // 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 (remaining, lastSuffix, now); + return result.Distinct().Order().ToArray(); } - private static void EraseInline(int length) - { - Console.Write(new string('\b', length) + new string(' ', length) + new string('\b', length)); - } + private static bool IsValidSelection(int[] selected, int min, int? max) => + selected.Length >= min && (max is null || selected.Length <= max.Value); - private static async ValueTask ReadLineWithEscAsync(CancellationToken ct) + private CancellationToken ResolveToken(CancellationToken? explicitToken) { - var result = await ConsoleLineReader.ReadLineAsync(ct).ConfigureAwait(false); - if (result.Escaped) - { - throw new OperationCanceledException("Prompt cancelled via Esc.", ct); - } - - return result.Line; + var ct = explicitToken ?? default; + return ct != default ? ct : _commandToken; } - /// - /// 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 static string ValidateName(string name) => + string.IsNullOrWhiteSpace(name) + ? throw new ArgumentException("Prompt name cannot be empty.", nameof(name)) + : name; + + private CancellationToken ResolveToken(AskOptions? options) => + ResolveToken(options?.CancellationToken); private T HandleMissingAnswer(T fallbackValue, string promptKind) { 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/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/CoreReplApp.Interactive.cs b/src/Repl.Core/CoreReplApp.Interactive.cs index cc25400..8109230 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,13 @@ 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; + } + + return _options.AmbientCommands.CustomCommands.Keys + .Any(name => name.StartsWith(token, comparison)); } private static bool TryClassifyTemplateSegment( diff --git a/src/Repl.Core/CoreReplApp.cs b/src/Repl.Core/CoreReplApp.cs index 646cfca..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) @@ -1360,7 +1385,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/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/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/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/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/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/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/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/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/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/IReplInteractionChannel.cs b/src/Repl.Core/Interaction/Public/IReplInteractionChannel.cs index 5dbb216..b7661f4 100644 --- a/src/Repl.Core/Interaction/Public/IReplInteractionChannel.cs +++ b/src/Repl.Core/Interaction/Public/IReplInteractionChannel.cs @@ -71,4 +71,51 @@ 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); + + /// + /// Clears the terminal screen. + /// + /// 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.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/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/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/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.Core/Interaction/Public/ReplInteractionChannelExtensions.cs b/src/Repl.Core/Interaction/Public/ReplInteractionChannelExtensions.cs new file mode 100644 index 0000000..bfd7c9f --- /dev/null +++ b/src/Repl.Core/Interaction/Public/ReplInteractionChannelExtensions.cs @@ -0,0 +1,266 @@ +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(); + string? previousLine = null; + + 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) + { + 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) + .ConfigureAwait(false); + continue; + } + + 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) + .ConfigureAwait(false); + continue; + } + + return value; + } + + 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); + } + } + + /// + /// 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; + string? previousInput = null; + + while (true) + { + var input = await channel.AskTextAsync(name, prompt, defaultValue, options) + .ConfigureAwait(false); + var error = validate(input); + if (error is null) + { + return input; + } + + ThrowIfRepeatedInput(input, ref previousInput, error); + 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 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(".."); + sb.Append(options.Max?.ToString() ?? ""); + 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; + } +} 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.Core/Interaction/RichPromptInteractionHandler.Rendering.cs b/src/Repl.Core/Interaction/RichPromptInteractionHandler.Rendering.cs new file mode 100644 index 0000000..e69e299 --- /dev/null +++ b/src/Repl.Core/Interaction/RichPromptInteractionHandler.Rendering.cs @@ -0,0 +1,633 @@ +namespace Repl; + +/// +/// Interactive arrow-key menu rendering for AskChoice and AskMultiChoice. +/// +internal sealed partial class RichPromptInteractionHandler +{ + 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(CancellationToken.None); + 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(CancellationToken.None); + 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"); + } + } + + private static bool IsValidSelection(int[] selected, int min, int? max) => + selected.Length >= min && (max is null || selected.Length <= max.Value); + + // ---------- I/O routing ---------- + + 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/Interaction/RichPromptInteractionHandler.cs b/src/Repl.Core/Interaction/RichPromptInteractionHandler.cs new file mode 100644 index 0000000..997a079 --- /dev/null +++ b/src/Repl.Core/Interaction/RichPromptInteractionHandler.cs @@ -0,0 +1,95 @@ +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); + if (richResult < 0) + { + throw new OperationCanceledException("Prompt cancelled via Esc."); + } + + return InteractionResult.Success(richResult); + } + + 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); + } + + throw new OperationCanceledException("Prompt cancelled via Esc."); + } + + 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.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.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.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..95d4e0b 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) => @@ -42,4 +44,26 @@ 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 ClearScreenAsync(CancellationToken cancellationToken) => + _inner.ClearScreenAsync(cancellationToken); + + public ValueTask> AskMultiChoiceAsync( + string name, + string prompt, + IReadOnlyList choices, + 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.Defaults/ReplApp.cs b/src/Repl.Defaults/ReplApp.cs index 295ad85..fb9d07d 100644 --- a/src/Repl.Defaults/ReplApp.cs +++ b/src/Repl.Defaults/ReplApp.cs @@ -592,9 +592,17 @@ private SessionOverlayServiceProvider CreateSessionOverlay(IServiceProvider exte [typeof(IReplIoContext)] = new LiveReplIoContext(), }; + 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, + presenterInstance, + allHandlers, external.GetService(typeof(TimeProvider)) as TimeProvider); defaults[typeof(IReplInteractionChannel)] = channel; defaults[typeof(IReplSessionInfo)] = new LiveSessionInfo(); @@ -602,6 +610,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 @@ -626,13 +640,23 @@ 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()))); + presenterSvc, + allHandlers, + sp.GetService()); + })); 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.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.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"); + } +} diff --git a/src/Repl.IntegrationTests/Given_InteractionChannel.cs b/src/Repl.IntegrationTests/Given_InteractionChannel.cs index a495275..e01a100 100644 --- a/src/Repl.IntegrationTests/Given_InteractionChannel.cs +++ b/src/Repl.IntegrationTests/Given_InteractionChannel.cs @@ -152,4 +152,245 @@ 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 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() + { + 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.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, +} 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. /// 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(); + } +} 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..da44562 --- /dev/null +++ b/src/Repl.Tests/Given_RichPrompts.cs @@ -0,0 +1,271 @@ +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.Handler.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.Handler.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.Handler.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.Handler.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.Handler.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.Handler.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.Handler.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.Handler.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.Handler.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.Handler.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.Handler.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.Handler.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.Handler.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 handler = new RichPromptInteractionHandler(outputOptions); + return new TestContext(handler, scope); + } + + private sealed class TestContext(RichPromptInteractionHandler handler, IDisposable scope) : IDisposable + { + public RichPromptInteractionHandler Handler { get; } = handler; + + 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.Tests/Given_ShellCompletionRuntime.cs b/src/Repl.Tests/Given_ShellCompletionRuntime.cs index d52524a..ef5ea73 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. } @@ -157,6 +157,55 @@ 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 (IOException) + { + // Best-effort cleanup for temp test directory. + } + } + } + private static ShellCompletionRuntime CreateRuntime( ReplOptions? options = null, Func? resolveCandidates = null, 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); + } }