diff --git a/docs/best-practices.md b/docs/best-practices.md index c80bd69..9bf3f90 100644 --- a/docs/best-practices.md +++ b/docs/best-practices.md @@ -292,4 +292,19 @@ Use Spectre.Console for rich UI — prompts auto-upgrade transparently: app.UseSpectreConsole(); // existing IReplInteractionChannel calls render as Spectre prompts ``` +When Spectre owns the screen, treat that as a separate rendering surface. Use `IAnsiConsole` for one-shot renderables and prompts, but do not mix a full-screen/live Spectre UI with regular REPL feedback on the same surface. If your app enters a TUI or live display flow, resolve `SpectreInteractionPresenter` from DI and capture interaction output for the duration of that scope: + +```csharp +app.Map("dashboard", static async ( + SpectreInteractionPresenter presenter, + IReplIoContext io, + CancellationToken ct) => +{ + using var capture = presenter.BeginCapture(io.Error); + await RunDashboardAsync(ct); +}); +``` + +That keeps status/progress/problem events out of the main Spectre surface and avoids terminal control sequences fighting with your TUI. + See also: [Modules](module-presence.md) | [Route System](route-system.md) | [MCP Overview](mcp-overview.md) | [Testing](testing-toolkit.md) | [Configuration](configuration-reference.md) diff --git a/docs/configuration-reference.md b/docs/configuration-reference.md index 0349956..fed67fe 100644 --- a/docs/configuration-reference.md +++ b/docs/configuration-reference.md @@ -12,7 +12,7 @@ app.Options(o => }); ``` -See also: [Commands](commands.md) | [Shell Completion](shell-completion.md) | [Interaction](interaction.md) +See also: [Commands](commands.md) | [Shell Completion](shell-completion.md) | [Interaction](interaction.md) | [Progress](progress.md) ## ReplOptions @@ -136,6 +136,7 @@ These options are configured through `app.Options(...)`. Repl does not currently - `DefaultProgressLabel` (`string`, default: `"Progress"`) — Default label for progress indicators. - `ProgressTemplate` (`string`, default: `"{label}: {percent:0}%"`) — Progress display template. Supports placeholders: `{label}`, `{percent}`, `{percent:0}`, `{percent:0.0}`. +- `AdvancedProgressMode` (`AdvancedProgressMode`, default: `Auto`) — Controls whether compatible hosts emit advanced terminal progress sequences. See [Progress](progress.md#advanced-terminal-progress). - `PromptFallback` (`PromptFallback`, default: `UseDefault`) — Behavior when interactive prompts are unavailable. ## ShellCompletionOptions diff --git a/docs/interaction.md b/docs/interaction.md index c2aedc4..d977218 100644 --- a/docs/interaction.md +++ b/docs/interaction.md @@ -150,6 +150,8 @@ await channel.PressAnyKeyAsync("Press any key to continue...", cancellationToken ## Progress reporting +This section summarizes the portable progress APIs. See [Progress](progress.md) for the full rendering model, `OSC 9;4` behavior, MCP mapping, and TUI guidance. + Handlers inject `IProgress` to report progress. The framework creates the appropriate adapter automatically. ### Simple percentage: `IProgress` @@ -289,6 +291,8 @@ var app = ReplApp.Create(services => 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). +If you use `Repl.Spectre`, the package now registers `SpectreInteractionPresenter` as the default presenter when no custom presenter already exists. That implementation wraps the built-in console behavior and also supports temporary capture for screen-owned flows. + The presenter receives strongly-typed semantic events: | Event type | When emitted | @@ -383,6 +387,28 @@ public class SpectreInteractionHandler : IReplInteractionHandler 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. +## Spectre and screen ownership + +`IAnsiConsole.Write(...)` is great for one-shot renderables, banners, and prompt-driven flows. It is not a good fit for a full-screen or continuously refreshed TUI if normal REPL feedback is still writing to the same terminal surface. + +In particular: + +- Do not mix a Spectre live display / full-screen surface with normal REPL status or progress output on the same writer. +- `OSC 9;4` progress is terminal feedback for CLI-style execution, not a rendering primitive for TUIs. +- If your app temporarily owns the screen, capture interaction output away from the main surface. + +With `Repl.Spectre`, use `SpectreInteractionPresenter.BeginCapture(...)`: + +```csharp +var presenter = services.GetRequiredService(); +var io = services.GetRequiredService(); + +using var capture = presenter.BeginCapture(io.Error); +await RunFullScreenDashboardAsync(cancellationToken); +``` + +You can capture to any `IReplInteractionPresenter` or to a plain `TextWriter`. For application handlers, prefer a session-aware sink such as `IReplIoContext.Error` or a custom presenter registered by your host. The `TextWriter` overload emits plain text only — no ANSI styling, no line rewriting, and no `OSC 9;4`. + ### Custom request types Apps can define their own `InteractionRequest` subtypes for app-specific controls: @@ -525,7 +551,38 @@ With this setup: - `AskConfirmationAsync` renders as a `ConfirmationPrompt` - `AskTextAsync` renders as a `TextPrompt` - `AskSecretAsync` renders as a `TextPrompt.Secret()` -- Collections returned from handlers render as bordered Spectre tables +- Collections returned from handlers render as lightweight Spectre tables + +### Output formats + +`UseSpectreConsole()` registers the `spectre` output format and makes it the default. You can still switch per-command: + +- `--spectre` selects the Spectre renderer +- `--human` switches back to the standard text renderer +- `--output:` remains the canonical format selector + +`--help` respects the selected format: + +- `human` renders the classic text help +- `spectre` renders dedicated Spectre help +- `json/xml/yaml/markdown` keep the structured help pipeline + +### Presenter capture for future TUIs + +When a command temporarily owns the terminal surface, capture interaction events explicitly: + +```csharp +app.Map("dashboard", static async ( + SpectreInteractionPresenter presenter, + IReplIoContext io, + CancellationToken ct) => +{ + using var capture = presenter.BeginCapture(io.Error); + await RunDashboardAsync(ct); +}); +``` + +This is the intended integration point for future TUI tooling. Command handlers remain unchanged — the upgrade from built-in prompts to Spectre prompts is transparent. diff --git a/docs/mcp-reference.md b/docs/mcp-reference.md index 2890ef8..89e5b4d 100644 --- a/docs/mcp-reference.md +++ b/docs/mcp-reference.md @@ -187,7 +187,7 @@ app.UseMcpServer(o => o.InteractivityMode = InteractivityMode.PrefillThenElicita > **Why this matters:** Console-style writes blur the boundary between result data, progress, logs, and protocol traffic. In MCP, this ranges from confusing agent behavior to protocol corruption. -`WriteProgressAsync` maps to MCP progress notifications. `WriteStatusAsync` maps to log messages (`level: info`): +`WriteProgressAsync` maps to MCP progress notifications. `WriteStatusAsync` maps to log messages (`level: info`). See [Progress](progress.md#mcp) for the centralized progress model across console, hosted sessions, Spectre, and MCP: ```csharp app.Map("import", async (IReplInteractionChannel interaction, CancellationToken ct) => diff --git a/docs/output-system.md b/docs/output-system.md index f65a4c5..fc7becb 100644 --- a/docs/output-system.md +++ b/docs/output-system.md @@ -15,6 +15,7 @@ The active output format is resolved in this order: | Format | Description | |------------|--------------------------------------| | `human` | Plain text, intended for terminals. | +| `spectre` | Lightweight Spectre.Console rendering for terminals. | | `json` | JSON serialization. | | `xml` | XML serialization. | | `yaml` | YAML serialization. | @@ -22,7 +23,19 @@ The active output format is resolved in this order: ### Format aliases -The alias `yml` resolves to `yaml`. Additional aliases can be registered. +The built-in aliases are: + +- `--human` -> `human` +- `--json` -> `json` +- `--xml` -> `xml` +- `--yaml` / `--yml` -> `yaml` +- `--markdown` -> `markdown` + +When `Repl.Spectre` is enabled, it also registers: + +- `--spectre` -> `spectre` + +Additional aliases can be registered. ## Custom Transformers @@ -69,6 +82,14 @@ The startup banner is controlled by: The banner is only rendered when the active format is in the `BannerFormats` set and `BannerEnabled` is `true`. +## Help output + +`--help` uses the active output format: + +1. `human` renders the classic text help. +2. `spectre` renders dedicated Spectre help. +3. Structured formats (`json`, `xml`, `yaml`, `markdown`) use the machine-readable help pipeline. + ## Render Width The output width used for wrapping and table layout is resolved as: diff --git a/docs/progress.md b/docs/progress.md new file mode 100644 index 0000000..f829b6f --- /dev/null +++ b/docs/progress.md @@ -0,0 +1,217 @@ +# Progress + +Progress in Repl is semantic feedback from a command handler to the active host. A handler reports that work is moving forward; the host decides whether that becomes a console line, terminal taskbar progress, MCP progress notifications, hosted-session feedback, or a captured side stream. + +Use progress for user-facing execution feedback. Do not use it for logs, command results, or long-lived TUI rendering primitives. + +## Quick Choice + +| Need | Use | +|---|---| +| Simple percent complete | `IProgress` | +| Label, current/total, state, or details | `IProgress` | +| Direct async control from a handler | `IReplInteractionChannel` progress helpers | +| MCP-only progress/message control | `IMcpFeedback` | +| Spectre live/TUI progress bars | `IAnsiConsole.Progress(...)`, with Repl feedback captured away from the main surface | + +## Simple Progress + +Handlers can request `IProgress`. The framework creates the adapter automatically. + +```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"; +}); +``` + +`IProgress` is connected to `IReplInteractionChannel.WriteProgressAsync(...)`. Reporting `42` is equivalent to sending a normal progress event with the configured default label and `Percent = 42`. + +## Structured Progress + +Use `IProgress` when the progress update needs a label, computed percentage, state, unit, or details. + +```csharp +app.Map("import", async (IProgress progress, CancellationToken ct) => +{ + var total = 100; + for (var i = 1; i <= total; i++) + { + progress.Report(new ReplProgressEvent( + "Importing contacts", + Current: i, + Total: total, + Unit: "contacts")); + + await Task.Delay(25, ct); + } + + return "done"; +}); +``` + +`ReplProgressEvent.ResolvePercent()` uses `Percent` when set. Otherwise, it computes a percentage from `Current` and `Total` when both are available. + +## Channel Helpers + +Use `IReplInteractionChannel` when the handler is already async and you want explicit control. + +```csharp +app.Map("import", async (IReplInteractionChannel channel, CancellationToken ct) => +{ + await channel.WriteProgressAsync("Preparing import", 10, ct); + + await channel.WriteIndeterminateProgressAsync( + "Waiting for remote review", + "The agent is still processing.", + ct); + + await channel.WriteWarningProgressAsync( + "Retrying duplicate check", + percent: 55, + details: "The remote worker timed out once.", + ct); + + await channel.WriteErrorProgressAsync( + "Import failed", + percent: 80, + details: "The final retry window was exhausted.", + ct); + + await channel.ClearProgressAsync(ct); +}); +``` + +Available states: + +| State | Meaning | +|---|---| +| `Normal` | Regular progress update | +| `Warning` | Work is continuing, but the user should pay attention | +| `Error` | The current workflow has entered an error state | +| `Indeterminate` | Work is active but there is no meaningful percentage yet | +| `Clear` | Clear any visible progress indicator | + +`percent: null` does not mean indeterminate. Use `WriteIndeterminateProgressAsync(...)` or `State = ReplProgressState.Indeterminate` explicitly. + +## Rendering Pipeline + +All portable progress APIs converge on the same semantic pipeline: + +```text +IProgress +IProgress +IReplInteractionChannel progress helpers + | + v +WriteProgressRequest / ReplProgressEvent + | + v +Host presenter or transport +``` + +The built-in hosts render that semantic event differently: + +| Host | Behavior | +|---|---| +| Console/default host | Text fallback, optional in-place rewriting, optional advanced terminal progress | +| Spectre presenter | Same semantic event, with explicit capture support for TUI/live surfaces | +| MCP | `notifications/progress`, plus warning/error message notifications for structured states | +| Hosted sessions | Session-aware output and terminal capabilities drive rendering | + +The framework clears visible progress automatically when a command completes, fails, or is cancelled. + +## Advanced Terminal Progress + +The console presenter can emit `OSC 9;4` progress sequences in addition to normal text feedback. These sequences are useful for terminal taskbar progress or hosted terminal integrations. + +`InteractionOptions.AdvancedProgressMode` controls this behavior: + +| Value | Behavior | +|---|---| +| `Auto` | Emit advanced progress only for known-compatible terminals or sessions advertising progress support | +| `Always` | Emit advanced progress whenever the host can write terminal control sequences | +| `Never` | Disable advanced progress and keep text-only rendering | + +Advanced progress is emitted only when the console presenter can safely write terminal control sequences. It is disabled for protocol passthrough and for terminal multiplexer sessions such as `tmux` and `screen` in automatic mode. + +`OSC 9;4` is additional feedback. It does not replace the text progress line. + +## Text Labels And TUI Surfaces + +Console progress has a text fallback. That fallback includes a label, so it can break a full-screen TUI or a Spectre live display if both write to the same terminal surface. + +When a command temporarily owns the screen, capture regular Repl interaction feedback away from the main TUI surface: + +```csharp +app.Map("dashboard", static async ( + SpectreInteractionPresenter presenter, + IReplIoContext io, + CancellationToken ct) => +{ + using var capture = presenter.BeginCapture(io.Error); + await RunDashboardAsync(ct); +}); +``` + +The `TextWriter` capture overload emits plain text only. It does not emit ANSI styling, line rewriting, or `OSC 9;4`. + +For a custom TUI, another option is to capture to an `IReplInteractionPresenter` that ignores `ReplProgressEvent` or renders it inside an app-owned panel. + +## Repl Progress Vs Spectre Progress + +`IProgress`, `IProgress`, and `IReplInteractionChannel` are portable Repl feedback APIs. They work across console, hosted sessions, and MCP. + +`Spectre.Console.Progress` is a Spectre rendering primitive. Use it when your command owns a Spectre surface and you want Spectre's progress bar UI. It is not automatically connected to Repl progress events. + +If you use Spectre live displays or progress bars, avoid sending normal Repl progress to the same writer unless you capture or redirect it. + +## Configuration + +Progress display is configured through `ReplOptions.Interaction`: + +```csharp +app.Options(o => +{ + o.Interaction.DefaultProgressLabel = "Sync"; + o.Interaction.ProgressTemplate = "[{label}] {percent:0.0}%"; + o.Interaction.AdvancedProgressMode = AdvancedProgressMode.Auto; +}); +``` + +`ProgressTemplate` supports: + +| Placeholder | Example | +|---|---| +| `{label}` | `Sync` | +| `{percent}` | `12.5` | +| `{percent:0}` | `13` | +| `{percent:0.0}` | `12.5` | + +## MCP + +Portable progress should normally use `IReplInteractionChannel`. In MCP mode, Repl maps those calls to MCP feedback: + +| Repl API | MCP behavior | +|---|---| +| `WriteProgressAsync("Label", 40)` | `notifications/progress` with `progress = 40`, `total = 100` | +| `WriteIndeterminateProgressAsync(...)` | `notifications/progress` with a message and no `total` | +| `WriteWarningProgressAsync(...)` | `notifications/progress` plus a warning-level message notification | +| `WriteErrorProgressAsync(...)` | `notifications/progress` plus an error-level message notification | + +Use `IMcpFeedback` only when the command is intentionally MCP-specific and needs direct control over MCP notifications. + +## Rules Of Thumb + +- Return command results as handler return values, not as progress. +- Use progress for transient execution feedback the current user should see. +- Use `ILogger` for operator diagnostics and centralized logs. +- Use `IProgress` for simple percent updates. +- Use structured progress or channel helpers for warning/error/indeterminate states. +- In TUI or Spectre live surfaces, capture or redirect Repl progress so text labels do not fight the app-owned screen. diff --git a/src/Repl.Core/CoreReplApp.Execution.cs b/src/Repl.Core/CoreReplApp.Execution.cs index 02e1f48..f2b0147 100644 --- a/src/Repl.Core/CoreReplApp.Execution.cs +++ b/src/Repl.Core/CoreReplApp.Execution.cs @@ -734,6 +734,18 @@ internal async ValueTask RenderHelpAsync( return true; } + if (_options.Output.TryBuildHelpOutput( + requestedFormat, + discoverableRoutes, + discoverableContexts, + globalOptions.RemainingTokens, + _options.Parsing, + _options.AmbientCommands, + out var customHelpOutput)) + { + return await RenderOutputAsync(customHelpOutput, requestedFormat, cancellationToken).ConfigureAwait(false); + } + var machineHelp = HelpTextBuilder.BuildModel( discoverableRoutes, discoverableContexts, diff --git a/src/Repl.Core/Help/HelpRenderCommand.cs b/src/Repl.Core/Help/HelpRenderCommand.cs new file mode 100644 index 0000000..6ce268e --- /dev/null +++ b/src/Repl.Core/Help/HelpRenderCommand.cs @@ -0,0 +1,10 @@ +namespace Repl; + +internal sealed record HelpRenderCommand( + string Name, + string Description, + string Usage, + IReadOnlyList Aliases, + IReadOnlyList Arguments, + IReadOnlyList Options, + IReadOnlyList Answers); diff --git a/src/Repl.Core/Help/HelpRenderDocument.cs b/src/Repl.Core/Help/HelpRenderDocument.cs new file mode 100644 index 0000000..d6b91a3 --- /dev/null +++ b/src/Repl.Core/Help/HelpRenderDocument.cs @@ -0,0 +1,9 @@ +namespace Repl; + +internal sealed record HelpRenderDocument( + string Scope, + bool IsCommandHelp, + IReadOnlyList Commands, + IReadOnlyList Scopes, + IReadOnlyList GlobalOptions, + IReadOnlyList GlobalCommands); diff --git a/src/Repl.Core/Help/HelpRenderEntry.cs b/src/Repl.Core/Help/HelpRenderEntry.cs new file mode 100644 index 0000000..8db93f1 --- /dev/null +++ b/src/Repl.Core/Help/HelpRenderEntry.cs @@ -0,0 +1,3 @@ +namespace Repl; + +internal sealed record HelpRenderEntry(string Name, string Description); diff --git a/src/Repl.Core/Help/HelpTextBuilder.Rendering.cs b/src/Repl.Core/Help/HelpTextBuilder.Rendering.cs index 8a8af41..24eea17 100644 --- a/src/Repl.Core/Help/HelpTextBuilder.Rendering.cs +++ b/src/Repl.Core/Help/HelpTextBuilder.Rendering.cs @@ -63,27 +63,8 @@ private static string BuildSingleCommandHelp(RouteDefinition route, bool useAnsi private static string BuildArgumentSection(RouteDefinition route, bool useAnsi, AnsiPalette palette) { - var dynamicSegments = route.Template.Segments.OfType().ToList(); - if (dynamicSegments.Count == 0) - { - return string.Empty; - } - - var handlerParams = route.Command.Handler.Method.GetParameters(); - var rows = new List(); - foreach (var segment in dynamicSegments) - { - var param = handlerParams.FirstOrDefault(p => - !string.IsNullOrWhiteSpace(p.Name) - && string.Equals(p.Name, segment.Name, StringComparison.OrdinalIgnoreCase)); - var desc = param?.GetCustomAttribute()?.Description; - if (desc is not null) - { - rows.Add([FormatDynamicSegment(segment), desc]); - } - } - - if (rows.Count == 0) + var rows = BuildArgumentRows(route); + if (rows.Length == 0) { return string.Empty; } @@ -97,8 +78,8 @@ private static string BuildArgumentSection(RouteDefinition route, bool useAnsi, { builder.AppendLine(); builder.Append(useAnsi - ? $" {AnsiText.Apply(row[0], palette.CommandStyle)} {AnsiText.Apply(row[1], palette.DescriptionStyle)}" - : $" {row[0]} {row[1]}"); + ? $" {AnsiText.Apply(row.Name, palette.CommandStyle)} {AnsiText.Apply(row.Description, palette.DescriptionStyle)}" + : $" {row.Name} {row.Description}"); } return builder.ToString(); @@ -106,7 +87,8 @@ private static string BuildArgumentSection(RouteDefinition route, bool useAnsi, private static string BuildAnswerSection(RouteDefinition route, bool useAnsi, AnsiPalette palette) { - if (route.Command.Answers.Count == 0) + var rows = BuildAnswerRows(route); + if (rows.Length == 0) { return string.Empty; } @@ -116,14 +98,12 @@ private static string BuildAnswerSection(RouteDefinition route, bool useAnsi, An builder.Append(useAnsi ? AnsiText.Apply("Answers:", palette.SectionStyle) : "Answers:"); - foreach (var answer in route.Command.Answers) + foreach (var row in rows) { - var token = $"--answer:{answer.Name}"; - var desc = answer.Description ?? $"({answer.Type})"; builder.AppendLine(); builder.Append(useAnsi - ? $" {AnsiText.Apply(token, palette.CommandStyle)} {AnsiText.Apply(desc, palette.DescriptionStyle)}" - : $" {token} {desc}"); + ? $" {AnsiText.Apply(row.Name, palette.CommandStyle)} {AnsiText.Apply(row.Description, palette.DescriptionStyle)}" + : $" {row.Name} {row.Description}"); } return builder.ToString(); @@ -131,31 +111,7 @@ private static string BuildAnswerSection(RouteDefinition route, bool useAnsi, An private static string BuildOptionSection(RouteDefinition route, bool useAnsi, AnsiPalette palette) { - var parameters = route.Command.Handler.Method.GetParameters() - .Where(parameter => !string.IsNullOrWhiteSpace(parameter.Name)) - .ToDictionary(parameter => parameter.Name!, StringComparer.OrdinalIgnoreCase); - var groupProperties = new Dictionary(StringComparer.OrdinalIgnoreCase); - foreach (var methodParam in route.Command.Handler.Method.GetParameters()) - { - if (!Attribute.IsDefined(methodParam.ParameterType, typeof(ReplOptionsGroupAttribute), inherit: true)) - { - continue; - } - - var defaultInstance = CreateOptionsGroupDefault(methodParam.ParameterType); - foreach (var prop in GetOptionsGroupProperties(methodParam.ParameterType) - .Where(prop => prop.CanWrite && !groupProperties.ContainsKey(prop.Name))) - { - groupProperties[prop.Name] = (prop, defaultInstance); - } - } - - var optionRows = route.OptionSchema.Parameters.Values - .Where(parameter => parameter.Mode != ReplParameterMode.ArgumentOnly) - .Select(parameter => BuildOptionRow(route.OptionSchema, parameter, parameters, groupProperties)) - .Where(row => row is not null) - .Select(row => row!) - .ToArray(); + var optionRows = BuildOptionRows(route); if (optionRows.Length == 0) { return string.Empty; @@ -170,8 +126,8 @@ private static string BuildOptionSection(RouteDefinition route, bool useAnsi, An { builder.AppendLine(); builder.Append(useAnsi - ? $" {AnsiText.Apply(row[0], palette.CommandStyle)} {AnsiText.Apply(row[1], palette.DescriptionStyle)}" - : $" {row[0]} {row[1]}"); + ? $" {AnsiText.Apply(row.Name, palette.CommandStyle)} {AnsiText.Apply(row.Description, palette.DescriptionStyle)}" + : $" {row.Name} {row.Description}"); } return builder.ToString(); @@ -237,6 +193,70 @@ or OptionSchemaTokenKind.ValueAlias return [left, right]; } + private static HelpRenderEntry[] BuildArgumentRows(RouteDefinition route) + { + var dynamicSegments = route.Template.Segments.OfType().ToList(); + if (dynamicSegments.Count == 0) + { + return []; + } + + var handlerParams = route.Command.Handler.Method.GetParameters(); + var rows = new List(); + foreach (var segment in dynamicSegments) + { + var param = handlerParams.FirstOrDefault(p => + !string.IsNullOrWhiteSpace(p.Name) + && string.Equals(p.Name, segment.Name, StringComparison.OrdinalIgnoreCase)); + var desc = param?.GetCustomAttribute()?.Description; + if (desc is not null) + { + rows.Add(new HelpRenderEntry(FormatDynamicSegment(segment), desc)); + } + } + + return [.. rows]; + } + + private static HelpRenderEntry[] BuildAnswerRows(RouteDefinition route) + { + return [.. + route.Command.Answers.Select(answer => + new HelpRenderEntry( + $"--answer:{answer.Name}", + answer.Description ?? $"({answer.Type})")), + ]; + } + + private static HelpRenderEntry[] BuildOptionRows(RouteDefinition route) + { + var parameters = route.Command.Handler.Method.GetParameters() + .Where(parameter => !string.IsNullOrWhiteSpace(parameter.Name)) + .ToDictionary(parameter => parameter.Name!, StringComparer.OrdinalIgnoreCase); + var groupProperties = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var methodParam in route.Command.Handler.Method.GetParameters()) + { + if (!Attribute.IsDefined(methodParam.ParameterType, typeof(ReplOptionsGroupAttribute), inherit: true)) + { + continue; + } + + var defaultInstance = CreateOptionsGroupDefault(methodParam.ParameterType); + foreach (var prop in GetOptionsGroupProperties(methodParam.ParameterType) + .Where(prop => prop.CanWrite && !groupProperties.ContainsKey(prop.Name))) + { + groupProperties[prop.Name] = (prop, defaultInstance); + } + } + + return route.OptionSchema.Parameters.Values + .Where(parameter => parameter.Mode != ReplParameterMode.ArgumentOnly) + .Select(parameter => BuildOptionRow(route.OptionSchema, parameter, parameters, groupProperties)) + .Where(row => row is not null) + .Select(row => new HelpRenderEntry(row![0], row[1])) + .ToArray(); + } + private static bool IsDefaultForType(object value, Type type) { if (type == typeof(bool)) diff --git a/src/Repl.Core/Help/HelpTextBuilder.cs b/src/Repl.Core/Help/HelpTextBuilder.cs index efc66f6..32e46e5 100644 --- a/src/Repl.Core/Help/HelpTextBuilder.cs +++ b/src/Repl.Core/Help/HelpTextBuilder.cs @@ -17,7 +17,8 @@ internal static partial class HelpTextBuilder ["--interactive", "Force interactive mode."], ["--no-interactive", "Prevent interactive mode."], ["--no-logo", "Disable banner rendering."], - ["--output:", "Set output format (for example json, yaml, xml, markdown)."], + ["--output:", "Set output format (for example human, json, yaml, xml, markdown)."], + ["--human", "Select standard text output."], ["--answer:[=value]", "Provide prompt answers in non-interactive execution."], ]; @@ -51,6 +52,46 @@ public static HelpDocumentModel BuildModel( return new HelpDocumentModel(scope, commands, DateTimeOffset.UtcNow); } + public static HelpRenderDocument BuildRenderModel( + IReadOnlyList routes, + IReadOnlyList contexts, + IReadOnlyList scopeTokens, + ParsingOptions parsingOptions, + AmbientCommandOptions? ambientOptions = null) + { + ArgumentNullException.ThrowIfNull(routes); + ArgumentNullException.ThrowIfNull(contexts); + ArgumentNullException.ThrowIfNull(scopeTokens); + ArgumentNullException.ThrowIfNull(parsingOptions); + + var visibleRoutes = routes + .Where(route => !route.Command.IsHidden) + .ToArray(); + var scope = scopeTokens.Count == 0 ? "root" : string.Join(' ', scopeTokens); + if (TryGetCommandHelpRoutes(visibleRoutes, scopeTokens, parsingOptions, out var commandHelpRoutes)) + { + return new HelpRenderDocument( + scope, + IsCommandHelp: true, + Commands: commandHelpRoutes.Select(CreateRenderCommand).ToArray(), + Scopes: [], + GlobalOptions: [], + GlobalCommands: []); + } + + var effectiveAmbientOptions = ambientOptions ?? new AmbientCommandOptions(); + var matchingRoutes = visibleRoutes + .Where(route => MatchesPrefix(route.Template, scopeTokens, parsingOptions)) + .ToArray(); + return new HelpRenderDocument( + scope, + IsCommandHelp: false, + Commands: BuildScopeCommandEntries(matchingRoutes, contexts, scopeTokens, parsingOptions), + Scopes: BuildScopeContextEntries(contexts, scopeTokens, parsingOptions), + GlobalOptions: BuildGlobalOptionEntries(parsingOptions), + GlobalCommands: BuildGlobalCommandEntries(effectiveAmbientOptions)); + } + private static HelpCommandModel[] BuildGroupedCommandModels( RouteDefinition[] matchingRoutes, IReadOnlyList contexts, @@ -217,6 +258,83 @@ private static HelpCommandModel CreateCommandModel(RouteDefinition route) Aliases: route.Command.Aliases.ToArray()); } + private static HelpRenderCommand CreateRenderCommand(RouteDefinition route) + { + var displayTemplate = FormatRouteTemplate(route.Template); + return new HelpRenderCommand( + Name: displayTemplate, + Description: route.Command.Description ?? "No description.", + Usage: displayTemplate, + Aliases: route.Command.Aliases.ToArray(), + Arguments: BuildArgumentRows(route), + Options: BuildOptionRows(route), + Answers: BuildAnswerRows(route)); + } + + private static HelpRenderCommand[] BuildScopeCommandEntries( + RouteDefinition[] matchingRoutes, + IReadOnlyList contexts, + IReadOnlyList scopeTokens, + ParsingOptions parsingOptions) + { + var nextIndex = scopeTokens.Count; + return matchingRoutes + .Where(route => route.Template.Segments.Count > nextIndex) + .GroupBy(route => route.Template.Segments[nextIndex].RawText, StringComparer.OrdinalIgnoreCase) + .OrderBy(group => group.Key, StringComparer.OrdinalIgnoreCase) + .Select(group => + { + var hasScopedContext = contexts.Any(context => + MatchesPrefix(context.Template, scopeTokens, parsingOptions) + && context.Template.Segments.Count > nextIndex + && string.Equals(context.Template.Segments[nextIndex].RawText, group.Key, StringComparison.OrdinalIgnoreCase)); + var hasTerminalCommand = group.Any(route => route.Template.Segments.Count == nextIndex + 1); + if (hasScopedContext && !hasTerminalCommand) + { + return null; + } + + var display = hasTerminalCommand + ? group.Key + : BuildScopedCommandDisplay(group, nextIndex, hasScopedContext); + var description = ResolveScopeDescription(group, contexts, scopeTokens, parsingOptions, nextIndex); + var usage = string.Join(' ', scopeTokens.Append(group.Key)); + var aliases = group + .SelectMany(route => route.Command.Aliases) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + return new HelpRenderCommand( + Name: display, + Description: description, + Usage: usage, + Aliases: aliases, + Arguments: [], + Options: [], + Answers: []); + }) + .Where(command => command is not null) + .Select(command => command!) + .ToArray(); + } + + private static HelpRenderEntry[] BuildScopeContextEntries( + IReadOnlyList contexts, + IReadOnlyList scopeTokens, + ParsingOptions parsingOptions) => + BuildScopeContextRows(contexts, scopeTokens, parsingOptions) + .Select(row => new HelpRenderEntry(row[0], row[1])) + .ToArray(); + + private static HelpRenderEntry[] BuildGlobalOptionEntries(ParsingOptions parsingOptions) => + BuildGlobalOptionRows(parsingOptions) + .Select(row => new HelpRenderEntry(row[0], row[1])) + .ToArray(); + + private static HelpRenderEntry[] BuildGlobalCommandEntries(AmbientCommandOptions ambientOptions) => + BuildGlobalCommandRows(ambientOptions) + .Select(row => new HelpRenderEntry(row[0], row[1])) + .ToArray(); + private static RouteDefinition[] OrderCommandHelpRoutes(RouteDefinition[] routes) => routes .OrderBy(route => route.Template.Template, StringComparer.OrdinalIgnoreCase) diff --git a/src/Repl.Core/HelpOutputFactory.cs b/src/Repl.Core/HelpOutputFactory.cs new file mode 100644 index 0000000..df19826 --- /dev/null +++ b/src/Repl.Core/HelpOutputFactory.cs @@ -0,0 +1,8 @@ +namespace Repl; + +internal delegate object HelpOutputFactory( + IReadOnlyList routes, + IReadOnlyList contexts, + IReadOnlyList scopeTokens, + ParsingOptions parsingOptions, + AmbientCommandOptions ambientOptions); diff --git a/src/Repl.Core/OutputOptions.cs b/src/Repl.Core/OutputOptions.cs index c90da02..b315960 100644 --- a/src/Repl.Core/OutputOptions.cs +++ b/src/Repl.Core/OutputOptions.cs @@ -11,6 +11,8 @@ public sealed class OutputOptions new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary _aliases = new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary _helpOutputFactories = + new(StringComparer.OrdinalIgnoreCase); private Func _resolveHostAnsiSupport = static () => true; /// @@ -34,6 +36,7 @@ public OutputOptions() _aliases["yaml"] = "yaml"; _aliases["yml"] = "yaml"; _aliases["markdown"] = "markdown"; + _aliases["human"] = "human"; } /// @@ -147,6 +150,35 @@ public void AddAlias(string alias, string format) internal bool TryResolveAlias(string alias, out string format) => _aliases.TryGetValue(alias, out format!); + internal void AddHelpOutputFactory(string format, HelpOutputFactory factory) + { + format = string.IsNullOrWhiteSpace(format) + ? throw new ArgumentException("Format cannot be empty.", nameof(format)) + : format; + ArgumentNullException.ThrowIfNull(factory); + + _helpOutputFactories[format] = factory; + } + + internal bool TryBuildHelpOutput( + string format, + IReadOnlyList routes, + IReadOnlyList contexts, + IReadOnlyList scopeTokens, + ParsingOptions parsingOptions, + AmbientCommandOptions ambientOptions, + out object? output) + { + if (_helpOutputFactories.TryGetValue(format, out var factory)) + { + output = factory(routes, contexts, scopeTokens, parsingOptions, ambientOptions); + return true; + } + + output = null; + return false; + } + internal void SetHostAnsiSupportResolver(Func resolver) { ArgumentNullException.ThrowIfNull(resolver); diff --git a/src/Repl.Core/Session/InteractiveSession.cs b/src/Repl.Core/Session/InteractiveSession.cs index 818a5ad..147502b 100644 --- a/src/Repl.Core/Session/InteractiveSession.cs +++ b/src/Repl.Core/Session/InteractiveSession.cs @@ -282,9 +282,12 @@ internal async ValueTask TryHandleAmbientCommandAsync( var token = inputTokens[0]; if (CoreReplApp.IsHelpToken(token)) { - var helpPath = scopeTokens.Concat(inputTokens.Skip(1)).ToArray(); - var helpText = app.BuildHumanHelp(helpPath); - await ReplSessionIO.Output.WriteLineAsync(helpText).ConfigureAwait(false); + var helpTokens = scopeTokens.Concat(inputTokens.Skip(1)).ToArray(); + var globalOptions = GlobalOptionParser.Parse( + helpTokens, + app.OptionsSnapshot.Output, + app.OptionsSnapshot.Parsing); + _ = await app.RenderHelpAsync(globalOptions, cancellationToken).ConfigureAwait(false); return AmbientCommandOutcome.Handled; } diff --git a/src/Repl.Defaults/ReplApp.cs b/src/Repl.Defaults/ReplApp.cs index dd64df3..4bf0e0e 100644 --- a/src/Repl.Defaults/ReplApp.cs +++ b/src/Repl.Defaults/ReplApp.cs @@ -690,6 +690,8 @@ private static void EnsureDefaultServices(IServiceCollection services, CoreReplA services.TryAddSingleton(); services.TryAddSingleton(); services.TryAddSingleton(); + services.TryAddSingleton(_ => core.OptionsSnapshot.Interaction); + services.TryAddSingleton(_ => core.OptionsSnapshot.Output); services.TryAddSingleton( _ => new ConsoleTerminalInfo(core.OptionsSnapshot.Output)); services.TryAddSingleton(_ => core.GlobalOptionsAccessor); diff --git a/src/Repl.IntegrationTests/Given_HelpDiscovery.cs b/src/Repl.IntegrationTests/Given_HelpDiscovery.cs index 5eb65e0..53c3382 100644 --- a/src/Repl.IntegrationTests/Given_HelpDiscovery.cs +++ b/src/Repl.IntegrationTests/Given_HelpDiscovery.cs @@ -1,4 +1,5 @@ using ComponentDescriptionAttribute = System.ComponentModel.DescriptionAttribute; +using Repl.Spectre; namespace Repl.IntegrationTests; @@ -21,6 +22,8 @@ public void When_RequestingRootHelp_Then_HiddenCommandsAreExcluded() output.Text.Should().NotContain("debug"); output.Text.Should().Contain("Global Commands:"); output.Text.Should().Contain("help [path]"); + output.Text.Should().Contain("human, json, yaml, xml, markdown"); + output.Text.Should().NotContain("markdown, spectre"); output.Text.Should().NotContain("? [path]"); output.Text.Should().NotContain("history [--limit ]"); output.Text.Should().NotContain("complete --target "); @@ -217,6 +220,23 @@ public void When_InteractiveInputIsQuestionMark_Then_HelpIsRendered() output.Text.Should().Contain("contact list"); } + [TestMethod] + [Description("Regression guard: verifies interactive help uses the active output format so Spectre defaults are respected.")] + public void When_InteractiveHelpAndSpectreIsDefault_Then_SpectreHelpIsRendered() + { + var sut = ReplApp.Create(services => services.AddSpectreConsole()) + .UseSpectreConsole() + .UseDefaultInteractive(); + sut.Map("contact list", () => "ok").WithDescription("List contacts"); + + var output = ConsoleCaptureHelper.CaptureWithInput("?\nexit\n", () => sut.Run(Array.Empty())); + + output.ExitCode.Should().Be(0); + output.Text.Should().Contain("contact"); + output.Text.Should().Contain("Global Commands"); + output.Text.Should().NotContain("Global Commands:"); + } + [TestMethod] [Description("Regression guard: verifies partial command help with dynamic continuation so that command usage is rendered instead of scoped/global command list.")] public void When_RequestingHelpForLiteralPrefixWithDynamicArguments_Then_CommandUsageIsRendered() @@ -404,4 +424,46 @@ private enum HelpMode Fast, Slow, } + + [TestMethod] + [Description("Regression guard: verifies Spectre help uses a dedicated renderer so command help keeps the expected sections.")] + public void When_RequestingCommandHelpInSpectre_Then_DedicatedHelpSectionsAreRendered() + { + var sut = ReplApp.Create(services => services.AddSpectreConsole()) + .UseSpectreConsole(); + sut.Map( + "render {target}", + ([ComponentDescriptionAttribute("Target to render")] string target, + [ReplOption(Aliases = ["-m"])] HelpMode mode = HelpMode.Fast) => $"{target}:{mode}") + .WithDescription("Render a target") + .WithAlias("draw") + .WithAnswer("confirm", "Confirmation answer"); + + var output = ConsoleCaptureHelper.Capture(() => sut.Run(["render", "--help", "--spectre", "--no-logo"])); + + output.ExitCode.Should().Be(0); + output.Text.Should().Contain("Usage"); + output.Text.Should().Contain("Description"); + output.Text.Should().Contain("Aliases"); + output.Text.Should().Contain("Arguments"); + output.Text.Should().Contain("Options"); + output.Text.Should().Contain("Answers"); + output.Text.Should().Contain("Render a target"); + output.Text.Should().NotContain("\"scope\":"); + } + + [TestMethod] + [Description("Regression guard: verifies --human overrides the Spectre default so the classic text help stays available.")] + public void When_RequestingHelpWithHumanAliasWhileSpectreIsDefault_Then_ClassicHelpIsRendered() + { + var sut = ReplApp.Create(services => services.AddSpectreConsole()) + .UseSpectreConsole(); + sut.Map("contact list", () => "ok").WithDescription("List contacts"); + + var output = ConsoleCaptureHelper.Capture(() => sut.Run(["--help", "--human", "--no-logo"])); + + output.ExitCode.Should().Be(0); + output.Text.Should().Contain("Global Commands:"); + output.Text.Should().Contain("contact"); + } } diff --git a/src/Repl.IntegrationTests/Given_OutputFormatting.cs b/src/Repl.IntegrationTests/Given_OutputFormatting.cs index 7ef4c31..cdfd10d 100644 --- a/src/Repl.IntegrationTests/Given_OutputFormatting.cs +++ b/src/Repl.IntegrationTests/Given_OutputFormatting.cs @@ -1,4 +1,5 @@ using System.ComponentModel.DataAnnotations; +using Repl.Spectre; namespace Repl.IntegrationTests; @@ -417,6 +418,70 @@ public void When_NonInteractiveJsonOutputAndAnsiEnabled_Then_JsonPayloadRemainsP output.Text.Should().NotContain("\u001b["); } + [TestMethod] + [Description("Regression guard: verifies Spectre becomes the default output format and renders objects without boxed chrome.")] + public void When_UsingSpectreConsole_Then_ObjectOutputStaysLightweight() + { + var sut = ReplApp.Create(services => services.AddSpectreConsole()) + .UseSpectreConsole(); + sut.Map("contact show", () => new Contact(42, "Alice")); + + var output = ConsoleCaptureHelper.Capture(() => sut.Run(["contact", "show", "--no-logo"])); + + output.ExitCode.Should().Be(0); + output.Text.Should().Contain("Id"); + output.Text.Should().Contain("Name"); + output.Text.Should().Contain("42"); + output.Text.Should().Contain("Alice"); + output.Text.Should().NotContain("╭"); + output.Text.Should().NotContain("│"); + } + + [TestMethod] + [Description("Regression guard: verifies Spectre output uses configured render width so PreferredWidth applies consistently.")] + public void When_SpectreOutputAndPreferredRenderWidthIsConfigured_Then_TableRowsFitWithinWidth() + { + const int width = 36; + var sut = ReplApp.Create(services => services.AddSpectreConsole()) + .UseSpectreConsole(); + sut.Options(options => + { + options.Output.PreferredWidth = width; + options.Output.FallbackWidth = width; + }); + sut.Map("contact list", () => new[] + { + new ContactRow("Alice Martin", "alice.martin.super.long@example.com"), + new ContactRow("Bob Tremblay", "bob@example.com"), + }); + + var output = ConsoleCaptureHelper.Capture(() => sut.Run(["contact", "list", "--no-logo"])); + var lines = output.Text + .Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + + output.ExitCode.Should().Be(0); + lines.Should().OnlyContain(line => line.Length <= width); + output.Text.Should().Contain("ong@example.com"); + } + + [TestMethod] + [Description("Regression guard: verifies --human remains available even when Spectre is the default output format.")] + public void When_UsingHumanAliasWithSpectreDefault_Then_ClassicHumanTransformerIsUsed() + { + var sut = ReplApp.Create(services => services.AddSpectreConsole()) + .UseSpectreConsole(); + sut.Map("contact show", () => new Contact(42, "Alice")); + + var output = ConsoleCaptureHelper.Capture(() => sut.Run(["contact", "show", "--human", "--no-logo"])); + + output.ExitCode.Should().Be(0); + output.Text.TrimEnd().Should().Be( + string.Join( + Environment.NewLine, + "Id : 42", + "Name: Alice")); + } + private sealed record Contact(int Id, string Name); private sealed record ContactNote(string Note); diff --git a/src/Repl.IntegrationTests/Repl.IntegrationTests.csproj b/src/Repl.IntegrationTests/Repl.IntegrationTests.csproj index 75040dc..7ff3c71 100644 --- a/src/Repl.IntegrationTests/Repl.IntegrationTests.csproj +++ b/src/Repl.IntegrationTests/Repl.IntegrationTests.csproj @@ -13,6 +13,7 @@ + diff --git a/src/Repl.Spectre/README.md b/src/Repl.Spectre/README.md index 51cb271..f3f4181 100644 --- a/src/Repl.Spectre/README.md +++ b/src/Repl.Spectre/README.md @@ -1,13 +1,14 @@ # Repl.Spectre -Spectre.Console integration for Repl Toolkit. Provides rich interactive prompts, injectable `IAnsiConsole`, and beautiful table rendering. +Spectre.Console integration for Repl Toolkit. Provides rich interactive prompts, injectable `IAnsiConsole`, a lightweight Spectre output format, and an interaction presenter that can capture feedback during screen-owned flows. ## Features - **Rich prompts** — `SelectionPrompt`, `MultiSelectionPrompt`, `ConfirmationPrompt`, `TextPrompt`, and secret input via Spectre.Console - **IAnsiConsole injection** — use `IAnsiConsole` as a command parameter to render tables, trees, panels, and other Spectre renderables -- **Table output** — the `"spectre"` output format renders collections as bordered Spectre tables with `[Display]` attribute support +- **Lightweight output** — the `"spectre"` output format renders objects, results, help, and collections with less chrome than the default Spectre widgets - **Banner support** — inject `IAnsiConsole` into `WithBanner()` callbacks for rich startup banners (FigletText, Markup, etc.) +- **Capture support** — `SpectreInteractionPresenter.BeginCapture(...)` redirects REPL feedback away from a screen-owned Spectre surface - **Configurable capabilities** — `SpectreConsoleOptions` to control Unicode rendering for different terminal environments ## Setup @@ -15,7 +16,7 @@ Spectre.Console integration for Repl Toolkit. Provides rich interactive prompts, ```csharp var app = ReplApp.Create(services => { - services.AddSpectreConsole(); // DI: IAnsiConsole + SpectreInteractionHandler + services.AddSpectreConsole(); // DI: IAnsiConsole + SpectreInteractionHandler + SpectreInteractionPresenter }) .UseSpectreConsole(); // Output transformer + banner format + UTF-8 encoding ``` @@ -24,14 +25,14 @@ Two calls, two concerns: | Method | Scope | What it does | |--------|-------|-------------| -| `AddSpectreConsole()` | `IServiceCollection` | Registers `IAnsiConsole` (transient) and `SpectreInteractionHandler` in DI | -| `UseSpectreConsole()` | `ReplApp` | Registers `"spectre"` output transformer, sets it as default, enables banners, configures UTF-8 | +| `AddSpectreConsole()` | `IServiceCollection` | Registers `IAnsiConsole`, `SpectreInteractionHandler`, and `SpectreInteractionPresenter` in DI | +| `UseSpectreConsole()` | `ReplApp` | Registers `"spectre"` output transformer, sets it as default, adds `--spectre`, enables banners, configures UTF-8 | ## Usage ### Auto-rendered tables -Return a collection from a command — the output transformer renders it as a bordered Spectre table: +Return a collection from a command — the output transformer renders it as a lightweight Spectre table: ```csharp app.Map("list", (IContactStore store) => store.All()); @@ -54,6 +55,16 @@ app.Map("report", (IAnsiConsole console) => Works with all Spectre renderables: `Table`, `Tree`, `Panel`, `BarChart`, `Calendar`, `FigletText`, `Progress`, `Status`, and more. +### Format switching + +`UseSpectreConsole()` makes `spectre` the default output format. You can still switch per-command: + +- `--spectre` selects the Spectre renderer +- `--human` switches back to the standard text renderer +- `--output:` remains the canonical selector + +`--help` respects the selected format as well, so `--spectre --help` uses Spectre help while `--human --help` returns the classic text help. + ### Transparent prompt upgrade `IReplInteractionChannel` calls are automatically rendered as Spectre prompts: @@ -68,6 +79,31 @@ Works with all Spectre renderables: `Table`, `Tree`, `Panel`, `BarChart`, `Calen No Spectre-specific code in handlers — the same handler works with or without the Spectre package. +### Capture feedback during screen-owned flows + +If your command temporarily owns the terminal surface, do not mix that full-screen/live Spectre rendering with regular REPL status/progress output on the same writer. Instead, capture interaction feedback explicitly: + +```csharp +app.Map("dashboard", static async ( + SpectreInteractionPresenter presenter, + IReplIoContext io, + CancellationToken ct) => +{ + using var capture = presenter.BeginCapture(io.Error); + await RunDashboardAsync(ct); +}); +``` + +The `TextWriter` overload emits plain text only. In application handlers, prefer a session-aware sink such as `IReplIoContext.Error`. Reserve raw writers for host/tooling code that already owns the transport surface. + +You can also capture to a custom presenter: + +```csharp +using var capture = presenter.BeginCapture(myPresenter); +``` + +This is the intended integration point for future TUI tooling. + ### Banner with `IAnsiConsole` Use `IAnsiConsole` in banner callbacks for rich startup output: diff --git a/src/Repl.Spectre/SpectreHumanOutputTransformer.cs b/src/Repl.Spectre/SpectreHumanOutputTransformer.cs index 27cfdbd..1e3b451 100644 --- a/src/Repl.Spectre/SpectreHumanOutputTransformer.cs +++ b/src/Repl.Spectre/SpectreHumanOutputTransformer.cs @@ -1,5 +1,6 @@ using System.ComponentModel; using System.ComponentModel.DataAnnotations; +using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.Globalization; using System.IO; @@ -8,11 +9,23 @@ namespace Repl.Spectre; /// -/// Output transformer that renders values using Spectre.Console renderables -/// (bordered tables, panels, grids) for rich terminal output. +/// Output transformer that renders values using light Spectre.Console layouts. /// internal sealed class SpectreHumanOutputTransformer : IOutputTransformer { + private readonly Func _resolveRenderSettings; + + public SpectreHumanOutputTransformer() + : this(DefaultResolveRenderSettings) + { + } + + public SpectreHumanOutputTransformer(Func resolveRenderSettings) + { + ArgumentNullException.ThrowIfNull(resolveRenderSettings); + _resolveRenderSettings = resolveRenderSettings; + } + /// public string Name => "spectre"; @@ -26,62 +39,134 @@ public ValueTask TransformAsync(object? value, CancellationToken cancell return ValueTask.FromResult(string.Empty); } - if (value is IReplResult replResult) + return ValueTask.FromResult(value switch + { + HelpRenderDocument help => RenderHelp(help), + IReplResult replResult => RenderReplResult(replResult), + string text => text, + System.Collections.IEnumerable enumerable => RenderEnumerable(enumerable), + _ when TryRenderObject(value, out var objectText) => objectText, + _ => Convert.ToString(value, CultureInfo.InvariantCulture) ?? string.Empty, + }); + } + + private string RenderHelp(HelpRenderDocument help) + { + if (help.IsCommandHelp) + { + return help.Commands.Count == 1 + ? RenderSingleCommandHelp(help.Commands[0]) + : RenderCommandList(help.Commands, "Commands"); + } + + var sections = new List(); + if (help.Commands.Count > 0) { - return ValueTask.FromResult(RenderReplResult(replResult)); + sections.Add(new Markup("[bold]Commands[/]")); + sections.Add(BuildCommandsTable(help.Commands)); } - if (value is string text) + if (help.Scopes.Count > 0) { - return ValueTask.FromResult(text); + AppendSpacer(sections); + sections.Add(new Markup("[bold]Scopes[/]")); + sections.Add(BuildEntryTable(help.Scopes)); } - if (value is System.Collections.IEnumerable enumerable) + if (help.GlobalOptions.Count > 0) { - return ValueTask.FromResult(RenderEnumerable(enumerable)); + AppendSpacer(sections); + sections.Add(new Markup("[bold]Global Options[/]")); + sections.Add(BuildEntryTable(help.GlobalOptions)); } - if (TryRenderObject(value, out var objectText)) + if (help.GlobalCommands.Count > 0) { - return ValueTask.FromResult(objectText); + AppendSpacer(sections); + sections.Add(new Markup("[bold]Global Commands[/]")); + sections.Add(BuildEntryTable(help.GlobalCommands)); } - return ValueTask.FromResult( - Convert.ToString(value, CultureInfo.InvariantCulture) ?? string.Empty); + return RenderToString(new Rows(sections)); } - private static string RenderReplResult(IReplResult result) + private string RenderSingleCommandHelp(HelpRenderCommand command) { - var prefix = result.Kind.ToLowerInvariant() switch - { - "text" => string.Empty, - "success" => "[green]Success[/]", - "error" => "[red]Error[/]", - "validation" => "[yellow]Validation[/]", - "not_found" => "[yellow]Not found[/]", - "cancelled" => "[grey]Cancelled[/]", - _ => "[blue]Result[/]", + var sections = new List + { + BuildLabelValueGrid( + ("Usage", command.Usage), + ("Description", command.Description)), }; - var message = string.IsNullOrWhiteSpace(prefix) - ? Markup.Escape(result.Message) - : $"{prefix}: {Markup.Escape(result.Message)}"; + if (command.Aliases.Count > 0) + { + sections.Add(new Text(string.Empty)); + sections.Add(BuildLabelValueGrid(("Aliases", string.Join(", ", command.Aliases)))); + } - if (result.Details is null) + if (command.Arguments.Count > 0) + { + AppendSpacer(sections); + sections.Add(new Markup("[bold]Arguments[/]")); + sections.Add(BuildEntryTable(command.Arguments)); + } + + if (command.Options.Count > 0) + { + AppendSpacer(sections); + sections.Add(new Markup("[bold]Options[/]")); + sections.Add(BuildEntryTable(command.Options)); + } + + if (command.Answers.Count > 0) { - return RenderToString(new Markup(message)); + AppendSpacer(sections); + sections.Add(new Markup("[bold]Answers[/]")); + sections.Add(BuildEntryTable(command.Answers)); } - var detailText = RenderValue(result.Details); - if (string.IsNullOrWhiteSpace(detailText)) + return RenderToString(new Rows(sections)); + } + + private string RenderCommandList(IReadOnlyList commands, string title) + { + var sections = new List + { + new Markup($"[bold]{Markup.Escape(title)}[/]"), + BuildCommandsTable(commands), + }; + return RenderToString(new Rows(sections)); + } + + private string RenderReplResult(IReplResult result) + { + var statusMarkup = result.Kind.ToLowerInvariant() switch + { + "text" => Markup.Escape(result.Message), + "success" => $"[green]Success[/]: {Markup.Escape(result.Message)}", + "error" => $"[red]Error[/]: {Markup.Escape(result.Message)}", + "validation" => $"[yellow]Validation[/]: {Markup.Escape(result.Message)}", + "not_found" => $"[yellow]Not found[/]: {Markup.Escape(result.Message)}", + "cancelled" => $"[grey]Cancelled[/]: {Markup.Escape(result.Message)}", + _ => $"[blue]Result[/]: {Markup.Escape(result.Message)}", + }; + + if (result.Details is null) { - return RenderToString(new Markup(message)); + return RenderToString(new Markup(statusMarkup)); } - return RenderToString(new Markup(message)) + Environment.NewLine + detailText; + var details = RenderValueRenderable(result.Details, nested: false); + return RenderToString(new Rows(new IRenderable[] + { + new Markup(statusMarkup), + new Text(string.Empty), + details, + })); } - private static string RenderEnumerable(System.Collections.IEnumerable enumerable) + private string RenderEnumerable(System.Collections.IEnumerable enumerable) { var items = enumerable.Cast().ToArray(); if (items.Length == 0) @@ -99,8 +184,7 @@ private static string RenderEnumerable(System.Collections.IEnumerable enumerable { return string.Join( Environment.NewLine, - items.Select(item => - Convert.ToString(item, CultureInfo.InvariantCulture) ?? string.Empty)); + items.Select(item => Convert.ToString(item, CultureInfo.InvariantCulture) ?? string.Empty)); } var members = GetDisplayMembers(firstNonNull.GetType()); @@ -108,23 +192,51 @@ private static string RenderEnumerable(System.Collections.IEnumerable enumerable { return string.Join( Environment.NewLine, - items.Select(item => - Convert.ToString(item, CultureInfo.InvariantCulture) ?? string.Empty)); + items.Select(item => Convert.ToString(item, CultureInfo.InvariantCulture) ?? string.Empty)); + } + + return RenderToString(BuildObjectTable(items, members)); + } + + private bool TryRenderObject(object value, out string text) + { + var members = GetDisplayMembers(value.GetType()); + if (members.Length == 0) + { + text = string.Empty; + return false; } - return RenderTable(items, members); + text = RenderToString(BuildObjectGrid(value, members)); + return true; } - private static string RenderTable(object?[] items, DisplayMember[] members) + private Grid BuildObjectGrid(object value, IReadOnlyList members) + { + var grid = new Grid(); + grid.AddColumn(new GridColumn().NoWrap().PadRight(2)); + grid.AddColumn(new GridColumn()); + + foreach (var member in members) + { + var memberValue = member.Property.GetValue(value); + grid.AddRow( + new Markup($"[bold]{Markup.Escape(member.Label)}[/]:"), + RenderValueRenderable(memberValue, nested: true, member)); + } + + return grid; + } + + private static Table BuildObjectTable(object?[] items, IReadOnlyList members) { var table = new Table() - .Border(TableBorder.Rounded) - .BorderColor(Color.Grey); + .Border(TableBorder.None) + .Collapse(); foreach (var member in members) { - table.AddColumn(new TableColumn(Markup.Escape(member.Label)) - .NoWrap()); + table.AddColumn(new TableColumn($"[bold]{Markup.Escape(member.Label)}[/]")); } foreach (var item in items) @@ -135,77 +247,120 @@ private static string RenderTable(object?[] items, DisplayMember[] members) continue; } - var cells = new IRenderable[members.Length]; - for (var i = 0; i < members.Length; i++) + var cells = new IRenderable[members.Count]; + for (var i = 0; i < members.Count; i++) { var memberValue = members[i].Property.GetValue(item); - var text = RenderScalar(memberValue, members[i]); - cells[i] = new Markup(Markup.Escape(text)); + cells[i] = new Text(RenderInlineValue(memberValue, members[i])); } table.AddRow(cells); } - return RenderToString(table); + return table; } - private static bool TryRenderObject(object value, out string text) + private static Table BuildCommandsTable(IReadOnlyList commands) { - var members = GetDisplayMembers(value.GetType()); - if (members.Length == 0) + var table = new Table() + .Border(TableBorder.None) + .Collapse(); + table.AddColumn(new TableColumn("[bold]Command[/]")); + table.AddColumn(new TableColumn("[bold]Description[/]")); + + foreach (var command in commands) { - text = string.Empty; - return false; + table.AddRow( + new Text(command.Name), + new Text(string.IsNullOrWhiteSpace(command.Description) ? "No description." : command.Description)); } + return table; + } + + private static Table BuildEntryTable(IReadOnlyList entries) + { + var table = new Table() + .Border(TableBorder.None) + .Collapse(); + table.AddColumn(new TableColumn("[bold]Name[/]")); + table.AddColumn(new TableColumn("[bold]Description[/]")); + + foreach (var entry in entries) + { + table.AddRow(new Text(entry.Name), new Text(entry.Description)); + } + + return table; + } + + private static Grid BuildLabelValueGrid(params (string Label, string Value)[] rows) + { var grid = new Grid(); grid.AddColumn(new GridColumn().NoWrap().PadRight(2)); grid.AddColumn(new GridColumn()); - - foreach (var member in members) + foreach (var row in rows) { - var memberValue = member.Property.GetValue(value); - if (memberValue is System.Collections.IEnumerable collectionValue - && memberValue is not string) - { - var rendered = RenderEnumerable(collectionValue); - grid.AddRow( - new Markup($"[bold]{Markup.Escape(member.Label)}[/]:"), - new Markup(Markup.Escape(rendered))); - } - else - { - var rendered = RenderScalar(memberValue, member); - grid.AddRow( - new Markup($"[bold]{Markup.Escape(member.Label)}[/]:"), - new Markup(Markup.Escape(rendered))); - } + grid.AddRow( + new Markup($"[bold]{Markup.Escape(row.Label)}[/]:"), + new Text(row.Value)); } - text = RenderToString(grid); - return true; + return grid; } - private static string RenderScalar(object? value, DisplayMember? member) + private static void AppendSpacer(List sections) + { + if (sections.Count > 0) + { + sections.Add(new Text(string.Empty)); + } + } + + private IRenderable RenderValueRenderable( + object? value, + bool nested, + DisplayMember? member = null) { if (value is null) { - return member?.NullDisplayText ?? string.Empty; + return new Text(member?.NullDisplayText ?? string.Empty); } if (value is string text) { - return text; + return new Text(text); } - return Convert.ToString(value, CultureInfo.InvariantCulture) ?? string.Empty; + if (value is System.Collections.IEnumerable enumerable) + { + var lines = RenderNestedEnumerableLines(enumerable); + return lines.Count switch + { + 0 => new Text(string.Empty), + 1 => new Text(lines[0]), + _ => new Rows(lines.Select(line => (IRenderable)new Text(line))), + }; + } + + if (nested && TryRenderInlineObject(value, out var inline)) + { + return new Text(inline); + } + + if (TryRenderObject(value, out var objectText)) + { + return new Text(objectText); + } + + return new Text(Convert.ToString(value, CultureInfo.InvariantCulture) ?? string.Empty); } - private static string RenderValue(object? value) + private static string RenderInlineValue(object? value, DisplayMember? member = null) { if (value is null) { - return string.Empty; + return member?.NullDisplayText ?? string.Empty; } if (value is string text) @@ -215,49 +370,111 @@ private static string RenderValue(object? value) if (value is System.Collections.IEnumerable enumerable) { - return RenderEnumerable(enumerable); + var lines = RenderNestedEnumerableLines(enumerable); + return lines.Count == 0 ? string.Empty : string.Join("; ", lines); } - if (TryRenderObject(value, out var objectText)) + if (TryRenderInlineObject(value, out var inline)) { - return objectText; + return inline; } return Convert.ToString(value, CultureInfo.InvariantCulture) ?? string.Empty; } - private static string RenderToString(IRenderable renderable) + private static bool TryRenderInlineObject(object value, out string text) { -#pragma warning disable MA0045 // StringWriter is disposed synchronously; no async benefit here. - using var writer = new StringWriter(); -#pragma warning restore MA0045 + var members = GetDisplayMembers(value.GetType()); + if (members.Length == 0) + { + text = string.Empty; + return false; + } - var width = 120; - if (ReplSessionIO.WindowSize is { } size && size.Width > 0) + text = string.Join( + "; ", + members.Select(member => + { + var rendered = RenderInlineValue(member.Property.GetValue(value), member); + return string.IsNullOrWhiteSpace(rendered) + ? string.Empty + : $"{member.Label}: {rendered}"; + }).Where(rendered => !string.IsNullOrWhiteSpace(rendered))); + return !string.IsNullOrWhiteSpace(text); + } + + private static List RenderNestedEnumerableLines(System.Collections.IEnumerable enumerable) + { + var items = enumerable.Cast().ToArray(); + if (items.Length == 0) + { + return []; + } + + if (items.All(item => item is null)) { - width = size.Width; + return []; } - else + + var lines = new List(items.Length); + foreach (var item in items) { - try + if (item is null) { - var consoleWidth = Console.WindowWidth; - if (consoleWidth > 0) - { - width = consoleWidth; - } + continue; } - catch (Exception ex) when (ex is IOException or PlatformNotSupportedException or InvalidOperationException) + + var rendered = RenderInlineValue(item); + if (!string.IsNullOrWhiteSpace(rendered)) { - // Width may be unavailable in redirected output. + lines.Add($"- {rendered}"); } } - var console = SessionAnsiConsole.CreateForWriter(writer, width); + return lines; + } + + private string RenderToString(IRenderable renderable) + { +#pragma warning disable MA0045 + using var writer = new StringWriter(); +#pragma warning restore MA0045 + var console = SessionAnsiConsole.CreateForWriter(writer, ResolveRenderWidth()); console.Write(renderable); return writer.ToString().TrimEnd(); } + private int ResolveRenderWidth() => _resolveRenderSettings().Width; + + private static HumanRenderSettings DefaultResolveRenderSettings() => + new(ResolveFallbackRenderWidth(), UseAnsi: false, Palette: new DefaultAnsiPaletteProvider().Create(ThemeMode.Dark)); + + private static int ResolveFallbackRenderWidth() + { + if (ReplSessionIO.WindowSize is { } size && size.Width > 0) + { + return size.Width; + } + + try + { + var consoleWidth = Console.WindowWidth; + if (consoleWidth > 0) + { + return consoleWidth; + } + } + catch (Exception ex) when (ex is IOException or PlatformNotSupportedException or InvalidOperationException) + { + Trace.TraceInformation( + "Could not resolve console width for Spectre rendering. {0}: {1}", + ex.GetType().Name, + ex.Message); + } + + return 120; + } + private static bool IsSimpleValue(Type type) => type.IsPrimitive || type.IsEnum diff --git a/src/Repl.Spectre/SpectreInteractionPresenter.cs b/src/Repl.Spectre/SpectreInteractionPresenter.cs new file mode 100644 index 0000000..734a3ed --- /dev/null +++ b/src/Repl.Spectre/SpectreInteractionPresenter.cs @@ -0,0 +1,164 @@ +using System.Globalization; +using Repl.Interaction; + +namespace Repl.Spectre; + +/// +/// Spectre-aware interaction presenter that supports explicit output capture +/// when an application temporarily owns the terminal surface. +/// +public sealed class SpectreInteractionPresenter : IReplInteractionPresenter +{ + private readonly IReplInteractionPresenter _fallback; + private readonly AsyncLocal _capture = new(); + + /// + /// Creates a presenter backed by the default console interaction presenter. + /// + public SpectreInteractionPresenter(InteractionOptions options, OutputOptions outputOptions) + { + ArgumentNullException.ThrowIfNull(options); + ArgumentNullException.ThrowIfNull(outputOptions); + _fallback = new ConsoleReplInteractionPresenter(options, outputOptions); + } + + internal SpectreInteractionPresenter(IReplInteractionPresenter fallback) + { + _fallback = fallback ?? throw new ArgumentNullException(nameof(fallback)); + } + + /// + /// Redirects interaction events to the provided sink for the current async flow. + /// Dispose the returned scope to restore the previous sink. + /// + public IDisposable BeginCapture(IReplInteractionPresenter sink) + { + ArgumentNullException.ThrowIfNull(sink); + var previous = _capture.Value; + _capture.Value = new CaptureScope(sink, previous); + return new CaptureLease(_capture, previous); + } + + /// + /// Redirects interaction events to a plain text writer for the current async flow. + /// The writer sink never emits ANSI control sequences or OSC progress messages. + /// + public IDisposable BeginCapture(TextWriter writer) + { + ArgumentNullException.ThrowIfNull(writer); + return BeginCapture(new PlainTextCapturePresenter(writer)); + } + + /// + public ValueTask PresentAsync(ReplInteractionEvent evt, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(evt); + var active = _capture.Value; + return active?.Sink.PresentAsync(evt, cancellationToken) + ?? _fallback.PresentAsync(evt, cancellationToken); + } + + private sealed record CaptureScope( + IReplInteractionPresenter Sink, + CaptureScope? Previous); + + private sealed class CaptureLease( + AsyncLocal state, + CaptureScope? previous) : IDisposable + { + private readonly AsyncLocal _state = state; + private readonly CaptureScope? _previous = previous; + private bool _disposed; + + public void Dispose() + { + if (_disposed) + { + return; + } + + _state.Value = _previous; + _disposed = true; + } + } + + private sealed class PlainTextCapturePresenter(TextWriter writer) : IReplInteractionPresenter + { + private readonly TextWriter _writer = writer; + + public async ValueTask PresentAsync(ReplInteractionEvent evt, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(evt); + cancellationToken.ThrowIfCancellationRequested(); + + switch (evt) + { + case ReplStatusEvent status: + await _writer.WriteLineAsync(status.Text).ConfigureAwait(false); + break; + + case ReplNoticeEvent notice: + await _writer.WriteLineAsync(notice.Text).ConfigureAwait(false); + break; + + case ReplWarningEvent warning: + await _writer.WriteLineAsync($"Warning: {warning.Text}").ConfigureAwait(false); + break; + + case ReplProblemEvent problem: + var header = string.IsNullOrWhiteSpace(problem.Code) + ? $"Problem: {problem.Summary}" + : $"Problem [{problem.Code}]: {problem.Summary}"; + await _writer.WriteLineAsync(header).ConfigureAwait(false); + if (!string.IsNullOrWhiteSpace(problem.Details)) + { + await _writer.WriteLineAsync(problem.Details).ConfigureAwait(false); + } + + break; + + case ReplPromptEvent prompt: + await _writer.WriteAsync($"{prompt.PromptText}: ").ConfigureAwait(false); + break; + + case ReplProgressEvent progress: + await _writer.WriteLineAsync(FormatProgress(progress)).ConfigureAwait(false); + break; + + case ReplClearScreenEvent: + break; + } + } + + private static string FormatProgress(ReplProgressEvent progress) + { + if (progress.State == ReplProgressState.Clear) + { + return string.Empty; + } + + var percent = progress.ResolvePercent(); + var label = string.IsNullOrWhiteSpace(progress.Label) ? "Progress" : progress.Label; + if (progress.State == ReplProgressState.Indeterminate) + { + return string.IsNullOrWhiteSpace(progress.Details) + ? $"Progress: {label}" + : $"Progress: {label}: {progress.Details}"; + } + + var prefix = progress.State switch + { + ReplProgressState.Warning => "Warning progress", + ReplProgressState.Error => "Error progress", + _ => "Progress", + }; + + var text = percent is null + ? $"{prefix}: {label}" + : $"{prefix}: {label}: {percent.Value.ToString("0.###", CultureInfo.InvariantCulture)}%"; + return string.IsNullOrWhiteSpace(progress.Details) + ? text + : $"{text}: {progress.Details}"; + } + } +} diff --git a/src/Repl.Spectre/SpectreReplExtensions.cs b/src/Repl.Spectre/SpectreReplExtensions.cs index b719094..2c7145f 100644 --- a/src/Repl.Spectre/SpectreReplExtensions.cs +++ b/src/Repl.Spectre/SpectreReplExtensions.cs @@ -1,6 +1,7 @@ using System.Text; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; +using Repl.Interaction; namespace Repl.Spectre; @@ -18,6 +19,8 @@ public static class SpectreReplExtensions public static IServiceCollection AddSpectreConsole(this IServiceCollection services) { ArgumentNullException.ThrowIfNull(services); + services.TryAddSingleton(); + services.TryAddSingleton(sp => sp.GetRequiredService()); services.AddSingleton(); services.TryAddTransient(_ => SessionAnsiConsole.Create()); return services; @@ -46,7 +49,20 @@ public static ReplApp UseSpectreConsole(this ReplApp app, Action { - o.Output.AddTransformer("spectre", new SpectreHumanOutputTransformer()); + o.Output.AddTransformer("spectre", new SpectreHumanOutputTransformer(o.Output.ResolveHumanRenderSettings)); + o.Output.AddHelpOutputFactory( + "spectre", + static (routes, contexts, scopeTokens, parsingOptions, ambientOptions) => + HelpTextBuilder.BuildRenderModel( + routes, + contexts, + scopeTokens, + parsingOptions, + ambientOptions)); + if (!o.Output.TryResolveAlias("spectre", out _)) + { + o.Output.AddAlias("spectre", "spectre"); + } o.Output.DefaultFormat = "spectre"; o.Output.BannerFormats.Add("spectre"); }); diff --git a/src/Repl.SpectreTests/Given_SpectreInteractionPresenter.cs b/src/Repl.SpectreTests/Given_SpectreInteractionPresenter.cs new file mode 100644 index 0000000..2a8a531 --- /dev/null +++ b/src/Repl.SpectreTests/Given_SpectreInteractionPresenter.cs @@ -0,0 +1,79 @@ +using System.Text; +using Repl.Interaction; + +namespace Repl.SpectreTests; + +[TestClass] +public sealed class Given_SpectreInteractionPresenter +{ + [TestMethod] + [Description("Regression guard: verifies TextWriter capture emits plain text without ANSI or OSC control sequences.")] + public async Task When_CapturingToTextWriter_Then_OutputRemainsPlainText() + { + var writer = new StringWriter(new StringBuilder()); + var presenter = new SpectreInteractionPresenter(new RecordingPresenter()); + + using (presenter.BeginCapture(writer)) + { + await presenter.PresentAsync(new ReplProgressEvent("Downloading", Percent: 42.5), CancellationToken.None); + await presenter.PresentAsync(new ReplProblemEvent("Boom", "Something happened", "oops"), CancellationToken.None); + await presenter.PresentAsync(new ReplClearScreenEvent(), CancellationToken.None); + } + + var text = writer.ToString(); + text.Should().Contain("Progress: Downloading: 42.5%"); + text.Should().Contain("Problem [oops]: Boom"); + text.Should().Contain("Something happened"); + text.Should().NotContain("\u001b["); + text.Should().NotContain("]9;4;"); + } + + [TestMethod] + [Description("Regression guard: verifies nested capture scopes restore the previous sink when the inner scope completes.")] + public async Task When_CaptureScopesAreNested_Then_PreviousSinkIsRestored() + { + var fallback = new RecordingPresenter(); + var outer = new RecordingPresenter(); + var inner = new RecordingPresenter(); + var presenter = new SpectreInteractionPresenter(fallback); + + using (presenter.BeginCapture(outer)) + { + await presenter.PresentAsync(new ReplStatusEvent("outer-1"), CancellationToken.None); + + using (presenter.BeginCapture(inner)) + { + await presenter.PresentAsync(new ReplStatusEvent("inner"), CancellationToken.None); + } + + await presenter.PresentAsync(new ReplStatusEvent("outer-2"), CancellationToken.None); + } + + await presenter.PresentAsync(new ReplStatusEvent("fallback"), CancellationToken.None); + + outer.Events.Should().ContainInOrder("outer-1", "outer-2"); + inner.Events.Should().ContainSingle().Which.Should().Be("inner"); + fallback.Events.Should().ContainSingle().Which.Should().Be("fallback"); + } + + private sealed class RecordingPresenter : IReplInteractionPresenter + { + public List Events { get; } = []; + + public ValueTask PresentAsync(ReplInteractionEvent evt, CancellationToken cancellationToken) + { + Events.Add(evt switch + { + ReplStatusEvent status => status.Text, + ReplNoticeEvent notice => notice.Text, + ReplWarningEvent warning => warning.Text, + ReplPromptEvent prompt => prompt.PromptText, + ReplProblemEvent problem => problem.Summary, + ReplProgressEvent progress => progress.Label, + ReplClearScreenEvent => "clear", + _ => evt.GetType().Name, + }); + return ValueTask.CompletedTask; + } + } +}