diff --git a/src/Terminal.Gui.Cli/CliHost.cs b/src/Terminal.Gui.Cli/CliHost.cs index 018afe0..bc63beb 100644 --- a/src/Terminal.Gui.Cli/CliHost.cs +++ b/src/Terminal.Gui.Cli/CliHost.cs @@ -141,8 +141,9 @@ private void WriteRootFlag (ArgParser.RootFlag rootFlag, TextWriter stdout) switch (rootFlag) { case ArgParser.RootFlag.Help: - stdout.WriteLine (_helpProvider.GetRootHelp (Registry) ?? - new MetadataHelpProvider ().GetRootHelp (Registry)); + var helpMarkdown = _helpProvider.GetRootHelp (Registry) ?? + new MetadataHelpProvider ().GetRootHelp (Registry) ?? string.Empty; + MarkdownRenderer.RenderToAnsi (helpMarkdown, stdout); break; case ArgParser.RootFlag.Version: stdout.WriteLine ($"{_options.ApplicationName} {_options.Version ?? "0.0.0"}"); diff --git a/src/Terminal.Gui.Cli/HelpCommand.cs b/src/Terminal.Gui.Cli/HelpCommand.cs index f5a5fba..7b1fa41 100644 --- a/src/Terminal.Gui.Cli/HelpCommand.cs +++ b/src/Terminal.Gui.Cli/HelpCommand.cs @@ -1,4 +1,6 @@ using Terminal.Gui.App; +using Terminal.Gui.ViewBase; +using Terminal.Gui.Views; namespace Terminal.Gui.Cli; @@ -37,11 +39,34 @@ public HelpCommand (ICommandRegistry registry, IHelpProvider helpProvider) public bool AcceptsPositionalArgs => true; /// - public Task RunAsync (IApplication app, string? initial, CommandRunOptions options, + public async Task RunAsync (IApplication app, string? initial, CommandRunOptions options, CancellationToken cancellationToken) { var markdown = ResolveHelp (options); - return Task.FromResult (new CommandResult (CommandStatus.Ok, markdown, null, null)); + + Runnable window = new () + { + Title = options.Title ?? "Help", + Width = Dim.Fill (), + Height = Dim.Fill () + }; + + Markdown markdownView = new () + { + Width = Dim.Fill (), + Height = Dim.Fill () + }; + + window.Add (markdownView); + + window.Initialized += (_, _) => + { + markdownView.Text = markdown; + }; + + await app.RunAsync (window, cancellationToken); + + return new CommandResult (CommandStatus.Ok, null, null, null); } /// diff --git a/src/Terminal.Gui.Cli/MetadataHelpProvider.cs b/src/Terminal.Gui.Cli/MetadataHelpProvider.cs index a546c04..20ed767 100644 --- a/src/Terminal.Gui.Cli/MetadataHelpProvider.cs +++ b/src/Terminal.Gui.Cli/MetadataHelpProvider.cs @@ -11,23 +11,25 @@ public sealed class MetadataHelpProvider : IHelpProvider ArgumentNullException.ThrowIfNull (registry); StringBuilder builder = new (); - builder.AppendLine ("Commands:"); + builder.AppendLine ("## Commands"); + builder.AppendLine (); foreach (ICliCommand command in registry.All) { - builder.AppendLine ($" {command.PrimaryAlias}\t{command.Description}"); + builder.AppendLine ($"- `{command.PrimaryAlias}` — {command.Description}"); } builder.AppendLine (); - builder.AppendLine ("Framework options:"); - builder.AppendLine (" --help, -h"); - builder.AppendLine (" --version"); - builder.AppendLine (" --opencli"); - builder.AppendLine (" --json"); - builder.AppendLine (" --initial "); - builder.AppendLine (" --title, --prompt "); - builder.AppendLine (" --timeout "); - builder.AppendLine (" --cat"); + builder.AppendLine ("## Framework options"); + builder.AppendLine (); + builder.AppendLine ("- `--help`, `-h`"); + builder.AppendLine ("- `--version`"); + builder.AppendLine ("- `--opencli`"); + builder.AppendLine ("- `--json`"); + builder.AppendLine ("- `--initial `"); + builder.AppendLine ("- `--title`, `--prompt `"); + builder.AppendLine ("- `--timeout `"); + builder.AppendLine ("- `--cat`"); return builder.ToString (); } diff --git a/tests/Terminal.Gui.Cli.IntegrationTests/HelpCommandIntegrationTests.cs b/tests/Terminal.Gui.Cli.IntegrationTests/HelpCommandIntegrationTests.cs new file mode 100644 index 0000000..85c69b5 --- /dev/null +++ b/tests/Terminal.Gui.Cli.IntegrationTests/HelpCommandIntegrationTests.cs @@ -0,0 +1,152 @@ +using Terminal.Gui.App; +using Terminal.Gui.Drivers; +using Xunit; + +namespace Terminal.Gui.Cli.IntegrationTests; + +public sealed class HelpCommandIntegrationTests +{ + [Fact] + public async Task RunAsync_WithStopAfterFirstIteration_RendersMarkdownInViewer () + { + using IApplication app = Application.Create (); + app.Init (DriverRegistry.Names.ANSI); + app.StopAfterFirstIteration = true; + + CommandRegistry registry = new (); + MetadataHelpProvider helpProvider = new (); + HelpCommand helpCommand = new (registry, helpProvider); + registry.Register (helpCommand); + + CommandRunOptions options = new (); + + CommandResult result = await helpCommand.RunAsync (app, null, options, CancellationToken.None); + + Assert.Equal (CommandStatus.Ok, result.Status); + } + + [Fact] + public async Task RunAsync_CancellationToken_AlreadyCancelled_DoesNotHang () + { + using IApplication app = Application.Create (); + app.Init (DriverRegistry.Names.ANSI); + + CommandRegistry registry = new (); + MetadataHelpProvider helpProvider = new (); + HelpCommand helpCommand = new (registry, helpProvider); + registry.Register (helpCommand); + + CommandRunOptions options = new (); + + using CancellationTokenSource cts = new (); + await cts.CancelAsync (); + + // Should either throw OperationCanceledException or return quickly + try + { + await helpCommand.RunAsync (app, null, options, cts.Token); + } + catch (OperationCanceledException) + { + // Expected + } + } + + [Fact] + public async Task RunAsync_RendersHelpText_ContainingCommandName () + { + using IApplication app = Application.Create (); + app.Init (DriverRegistry.Names.ANSI); + app.StopAfterFirstIteration = true; + + // Set screen size for deterministic rendering + app.Driver!.SetScreenSize (80, 24); + + CommandRegistry registry = new (); + MetadataHelpProvider helpProvider = new (); + HelpCommand helpCommand = new (registry, helpProvider); + registry.Register (helpCommand); + + CommandRunOptions options = new (); + + CommandResult result = await helpCommand.RunAsync (app, null, options, CancellationToken.None); + + Assert.Equal (CommandStatus.Ok, result.Status); + + // Verify the driver rendered content containing the "help" command + var driverContents = app.Driver.ToString (); + Assert.Contains ("help", driverContents); + } + + [Fact] + public async Task RunAsync_WithSubcommandArgument_RendersCommandHelp () + { + using IApplication app = Application.Create (); + app.Init (DriverRegistry.Names.ANSI); + app.StopAfterFirstIteration = true; + + app.Driver!.SetScreenSize (80, 24); + + CommandRegistry registry = new (); + MetadataHelpProvider helpProvider = new (); + HelpCommand helpCommand = new (registry, helpProvider); + registry.Register (helpCommand); + registry.Register (new StubCommand ("greet", "Say hello.")); + + CommandRunOptions options = new () + { + Arguments = ["greet"] + }; + + CommandResult result = await helpCommand.RunAsync (app, null, options, CancellationToken.None); + + Assert.Equal (CommandStatus.Ok, result.Status); + + var driverContents = app.Driver.ToString (); + Assert.Contains ("greet", driverContents); + } + + [Fact] + public async Task RenderCatAsync_ProducesAnsiOutput () + { + CommandRegistry registry = new (); + MetadataHelpProvider helpProvider = new (); + HelpCommand helpCommand = new (registry, helpProvider); + registry.Register (helpCommand); + + CommandRunOptions options = new (); + using StringWriter stdout = new (); + + CommandResult? result = await helpCommand.RenderCatAsync (options, stdout, CancellationToken.None); + + Assert.NotNull (result); + Assert.Equal (CommandStatus.Ok, result.Value.Status); + + var output = stdout.ToString (); + + // ANSI escape sequences present + Assert.Contains ("\x1b[", output); + Assert.Contains ("help", output); + } + + private sealed class StubCommand (string alias, string description) : ICliCommand + { + public string PrimaryAlias { get; } = alias; + + public IReadOnlyList Aliases => [PrimaryAlias]; + + public string Description => description; + + public CommandKind Kind => CommandKind.Input; + + public Type ResultType => typeof (string); + + public IReadOnlyList Options { get; } = []; + + public Task RunAsync (IApplication app, string? initial, CommandRunOptions options, + CancellationToken cancellationToken) + { + return Task.FromResult (new CommandResult (CommandStatus.Ok, "ok", null, null)); + } + } +} diff --git a/tests/Terminal.Gui.Cli.IntegrationTests/TestSetup.cs b/tests/Terminal.Gui.Cli.IntegrationTests/TestSetup.cs new file mode 100644 index 0000000..bf3ccb1 --- /dev/null +++ b/tests/Terminal.Gui.Cli.IntegrationTests/TestSetup.cs @@ -0,0 +1,16 @@ +using System.Runtime.CompilerServices; + +namespace Terminal.Gui.Cli.IntegrationTests; + +/// +/// Disables real driver I/O so tests never interact with the terminal or launch processes. +/// +internal static class TestSetup +{ + [ModuleInitializer] + internal static void Init () + { + Environment.SetEnvironmentVariable ("DisableRealDriverIO", "1"); + Console.SetIn (TextReader.Null); + } +} diff --git a/tests/Terminal.Gui.Cli.Tests/CliHostTests.cs b/tests/Terminal.Gui.Cli.Tests/CliHostTests.cs index ae35150..dfb1791 100644 --- a/tests/Terminal.Gui.Cli.Tests/CliHostTests.cs +++ b/tests/Terminal.Gui.Cli.Tests/CliHostTests.cs @@ -43,6 +43,52 @@ public async Task RunAsync_AgentGuideCat_WritesLiteralWithoutStartingTui () Assert.Equal (string.Empty, stderr.ToString ()); } + [Fact] + public async Task RunAsync_HelpFlag_RendersMarkdownAsAnsi () + { + CliHost host = new (options => + { + options.ApplicationName = "sample"; + options.Version = "1.0.0"; + }); + using StringWriter stdout = new (); + using StringWriter stderr = new (); + + var exitCode = await host.RunAsync (["--help"], TestContext.Current.CancellationToken, stdout, stderr); + + Assert.Equal (ExitCodes.Ok, exitCode); + Assert.Equal (string.Empty, stderr.ToString ()); + + var output = stdout.ToString (); + + // ANSI escape sequences should be present (rendered markdown) + Assert.Contains ("\x1b[", output); + + // Should contain the command name from the registry + Assert.Contains ("help", output); + } + + [Fact] + public async Task RunAsync_HelpCat_RendersMarkdownAsAnsi () + { + CliHost host = new (); + using StringWriter stdout = new (); + using StringWriter stderr = new (); + + var exitCode = await host.RunAsync (["help", "--cat"], TestContext.Current.CancellationToken, stdout, stderr); + + Assert.Equal (ExitCodes.Ok, exitCode); + Assert.Equal (string.Empty, stderr.ToString ()); + + var output = stdout.ToString (); + + // ANSI escape sequences should be present (rendered markdown) + Assert.Contains ("\x1b[", output); + + // Should contain the command name from the registry + Assert.Contains ("help", output); + } + [Fact] public async Task RunAsync_CommandCancellation_ReturnsCancelledExitCode () { diff --git a/tests/Terminal.Gui.Cli.Tests/MetadataHelpProviderTests.cs b/tests/Terminal.Gui.Cli.Tests/MetadataHelpProviderTests.cs new file mode 100644 index 0000000..470da09 --- /dev/null +++ b/tests/Terminal.Gui.Cli.Tests/MetadataHelpProviderTests.cs @@ -0,0 +1,59 @@ +using Terminal.Gui.App; +using Xunit; + +namespace Terminal.Gui.Cli.Tests; + +public sealed class MetadataHelpProviderTests +{ + [Fact] + public void GetRootHelp_GeneratesMarkdownWithHeadings () + { + CommandRegistry registry = new (); + registry.Register (new StubCommand ("pick", "Pick something.")); + MetadataHelpProvider provider = new (); + + var result = provider.GetRootHelp (registry); + + Assert.NotNull (result); + Assert.Contains ("## Commands", result); + Assert.Contains ("## Framework options", result); + Assert.Contains ("- `pick`", result); + Assert.Contains ("`--help`", result); + } + + [Fact] + public void GetRootHelp_ListsAllRegisteredCommands () + { + CommandRegistry registry = new (); + registry.Register (new StubCommand ("alpha", "Alpha command.")); + registry.Register (new StubCommand ("beta", "Beta command.")); + MetadataHelpProvider provider = new (); + + var result = provider.GetRootHelp (registry); + + Assert.NotNull (result); + Assert.Contains ("- `alpha` — Alpha command.", result); + Assert.Contains ("- `beta` — Beta command.", result); + } + + private sealed class StubCommand (string alias, string description) : ICliCommand + { + public string PrimaryAlias { get; } = alias; + + public IReadOnlyList Aliases => [PrimaryAlias]; + + public string Description => description; + + public CommandKind Kind => CommandKind.Input; + + public Type ResultType => typeof (string); + + public IReadOnlyList Options { get; } = []; + + public Task RunAsync (IApplication app, string? initial, CommandRunOptions options, + CancellationToken cancellationToken) + { + return Task.FromResult (new CommandResult (CommandStatus.Ok, "ok", null, null)); + } + } +}