From 6ab8f359a58a6f7c9a77f8bf578931d95f726de0 Mon Sep 17 00:00:00 2001 From: Tig Date: Sun, 24 May 2026 15:57:01 -0600 Subject: [PATCH 1/7] feat: help TUI viewer, markdown rendering, rename ExampleApp to greet - HelpCommand.RunAsync launches a TUI Markdown viewer (Runnable + Markdown + StatusBar) matching clet's pattern, instead of returning raw text in CommandResult - MetadataHelpProvider.GetRootHelp emits markdown (tables, headers) instead of plain text - CliHost.WriteRootFlag passes --help output through MarkdownRenderer.RenderToAnsi for ANSI-styled stdout - Rename examples/Terminal.Gui.Cli.ExampleApp to examples/greet with AssemblyName=greet - Add README.md for the greet example app - Add visual UI tests (CommandUiHarness + golden-file assertions) ported from clet's CletUiHarness pattern - Add MetadataHelpProviderTests and CliHost help rendering tests Closes #5 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CLAUDE.md | 2 +- Terminal.Gui.Cli.slnx | 2 +- .../GreetCommand.cs | 2 +- .../InfoCommand.cs | 2 +- .../Program.cs | 6 +- examples/greet/README.md | 51 ++++ .../Resources/agent-guide.md | 14 +- .../Terminal.Gui.Cli.Greet.csproj} | 6 +- src/Terminal.Gui.Cli/CliHost.cs | 5 +- src/Terminal.Gui.Cli/HelpCommand.cs | 33 +- src/Terminal.Gui.Cli/MetadataHelpProvider.cs | 28 +- .../AssemblyAttributes.cs | 16 + .../CommandUiHarness.cs | 287 ++++++++++++++++++ .../Goldens/help.ans | 18 ++ .../HelpCommandUiTests.cs | 118 +++++++ .../Terminal.Gui.Cli.IntegrationTests.csproj | 11 + tests/Terminal.Gui.Cli.Tests/CliHostTests.cs | 39 +++ .../MetadataHelpProviderTests.cs | 64 ++++ 18 files changed, 672 insertions(+), 32 deletions(-) rename examples/{Terminal.Gui.Cli.ExampleApp => greet}/GreetCommand.cs (97%) rename examples/{Terminal.Gui.Cli.ExampleApp => greet}/InfoCommand.cs (98%) rename examples/{Terminal.Gui.Cli.ExampleApp => greet}/Program.cs (69%) create mode 100644 examples/greet/README.md rename examples/{Terminal.Gui.Cli.ExampleApp => greet}/Resources/agent-guide.md (79%) rename examples/{Terminal.Gui.Cli.ExampleApp/Terminal.Gui.Cli.ExampleApp.csproj => greet/Terminal.Gui.Cli.Greet.csproj} (67%) create mode 100644 tests/Terminal.Gui.Cli.IntegrationTests/AssemblyAttributes.cs create mode 100644 tests/Terminal.Gui.Cli.IntegrationTests/CommandUiHarness.cs create mode 100644 tests/Terminal.Gui.Cli.IntegrationTests/Goldens/help.ans create mode 100644 tests/Terminal.Gui.Cli.IntegrationTests/HelpCommandUiTests.cs create mode 100644 tests/Terminal.Gui.Cli.Tests/MetadataHelpProviderTests.cs diff --git a/CLAUDE.md b/CLAUDE.md index 63c2c87..0df42db 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -21,7 +21,7 @@ If guidance conflicts, follow `specs/constitution.md`. - `tests/Terminal.Gui.Cli.Tests` — unit tests - `tests/Terminal.Gui.Cli.IntegrationTests` — integration tests - `tests/Terminal.Gui.Cli.SmokeTests` — smoke tests -- `examples/Terminal.Gui.Cli.ExampleApp` — sample console app +- `examples/greet` — sample console app ## Build and test diff --git a/Terminal.Gui.Cli.slnx b/Terminal.Gui.Cli.slnx index 2a828b3..f78cb7c 100644 --- a/Terminal.Gui.Cli.slnx +++ b/Terminal.Gui.Cli.slnx @@ -16,7 +16,7 @@ - + diff --git a/examples/Terminal.Gui.Cli.ExampleApp/GreetCommand.cs b/examples/greet/GreetCommand.cs similarity index 97% rename from examples/Terminal.Gui.Cli.ExampleApp/GreetCommand.cs rename to examples/greet/GreetCommand.cs index f0308ca..464970a 100644 --- a/examples/Terminal.Gui.Cli.ExampleApp/GreetCommand.cs +++ b/examples/greet/GreetCommand.cs @@ -1,6 +1,6 @@ using Terminal.Gui.App; -namespace Terminal.Gui.Cli.ExampleApp; +namespace Terminal.Gui.Cli.Greet; /// An input command that prompts for a name and returns a greeting. public sealed class GreetCommand : ICliCommand diff --git a/examples/Terminal.Gui.Cli.ExampleApp/InfoCommand.cs b/examples/greet/InfoCommand.cs similarity index 98% rename from examples/Terminal.Gui.Cli.ExampleApp/InfoCommand.cs rename to examples/greet/InfoCommand.cs index b9331d2..b51e630 100644 --- a/examples/Terminal.Gui.Cli.ExampleApp/InfoCommand.cs +++ b/examples/greet/InfoCommand.cs @@ -1,6 +1,6 @@ using Terminal.Gui.App; -namespace Terminal.Gui.Cli.ExampleApp; +namespace Terminal.Gui.Cli.Greet; /// A viewer command that displays application information. public sealed class InfoCommand : IViewerCommand diff --git a/examples/Terminal.Gui.Cli.ExampleApp/Program.cs b/examples/greet/Program.cs similarity index 69% rename from examples/Terminal.Gui.Cli.ExampleApp/Program.cs rename to examples/greet/Program.cs index e241dff..4006994 100644 --- a/examples/Terminal.Gui.Cli.ExampleApp/Program.cs +++ b/examples/greet/Program.cs @@ -1,12 +1,12 @@ using System.Reflection; using Terminal.Gui.Cli; -using Terminal.Gui.Cli.ExampleApp; +using Terminal.Gui.Cli.Greet; CliHost host = new (options => { - options.ApplicationName = "example-app"; + options.ApplicationName = "greet"; options.Version = "1.0.0"; - options.AgentGuide = "Terminal.Gui.Cli.ExampleApp.agent-guide.md"; + options.AgentGuide = "Terminal.Gui.Cli.Greet.agent-guide.md"; options.AgentGuideIsResource = true; options.ResourceAssembly = Assembly.GetExecutingAssembly (); }); diff --git a/examples/greet/README.md b/examples/greet/README.md new file mode 100644 index 0000000..11ee92f --- /dev/null +++ b/examples/greet/README.md @@ -0,0 +1,51 @@ +# greet + +A sample CLI app built with `Terminal.Gui.Cli` that generates greetings. + +## Usage + +```bash +# Launch the TUI greeting prompt +greet greet + +# Provide a name directly (headless) +greet greet --initial "Alice" + +# Formal greeting style +greet greet --initial "Bob" --formal + +# JSON envelope output +greet greet --initial "World" --json + +# Show help in the TUI markdown viewer +greet help + +# Render help as ANSI markdown to stdout +greet help --cat + +# Show root help (ANSI markdown to stdout) +greet --help + +# Show version +greet --version +``` + +## Commands + +| Command | Description | +|---------|-------------| +| `greet` | Prompt for a name and return a greeting. | +| `info` | Display application information. | +| `help` | Show command help in a TUI markdown viewer. | + +## Building + +```bash +dotnet build examples/Terminal.Gui.Cli.Greet/Terminal.Gui.Cli.Greet.csproj +``` + +## Running + +```bash +dotnet run --project examples/Terminal.Gui.Cli.Greet -- greet --initial "World" +``` diff --git a/examples/Terminal.Gui.Cli.ExampleApp/Resources/agent-guide.md b/examples/greet/Resources/agent-guide.md similarity index 79% rename from examples/Terminal.Gui.Cli.ExampleApp/Resources/agent-guide.md rename to examples/greet/Resources/agent-guide.md index 4f324a3..26adb3c 100644 --- a/examples/Terminal.Gui.Cli.ExampleApp/Resources/agent-guide.md +++ b/examples/greet/Resources/agent-guide.md @@ -1,6 +1,6 @@ -# Example App Agent Guide +# Greet App Agent Guide -This document describes how AI agents should interact with `example-app`. +This document describes how AI agents should interact with `greet`. ## Available Commands @@ -17,9 +17,9 @@ An input command that prompts the user for their name and returns a greeting. **Usage:** ```bash -example-app greet --initial "World" -example-app greet --initial "World" --json -example-app greet --initial "World" --formal +greet greet --initial "World" +greet greet --initial "World" --json +greet greet --initial "World" --formal ``` ### info @@ -34,8 +34,8 @@ A viewer command that displays application information. **Usage:** ```bash -example-app info --cat -example-app info --cat --json +greet info --cat +greet info --cat --json ``` ## Framework Options diff --git a/examples/Terminal.Gui.Cli.ExampleApp/Terminal.Gui.Cli.ExampleApp.csproj b/examples/greet/Terminal.Gui.Cli.Greet.csproj similarity index 67% rename from examples/Terminal.Gui.Cli.ExampleApp/Terminal.Gui.Cli.ExampleApp.csproj rename to examples/greet/Terminal.Gui.Cli.Greet.csproj index 3737551..e19b38e 100644 --- a/examples/Terminal.Gui.Cli.ExampleApp/Terminal.Gui.Cli.ExampleApp.csproj +++ b/examples/greet/Terminal.Gui.Cli.Greet.csproj @@ -2,8 +2,8 @@ Exe - Terminal.Gui.Cli.ExampleApp - Terminal.Gui.Cli.ExampleApp + Terminal.Gui.Cli.Greet + greet false @@ -12,7 +12,7 @@ - + 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..d03f320 100644 --- a/src/Terminal.Gui.Cli/HelpCommand.cs +++ b/src/Terminal.Gui.Cli/HelpCommand.cs @@ -1,4 +1,7 @@ using Terminal.Gui.App; +using Terminal.Gui.Input; +using Terminal.Gui.ViewBase; +using Terminal.Gui.Views; namespace Terminal.Gui.Cli; @@ -37,11 +40,37 @@ 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)); + var title = options.Title ?? "Help"; + + Runnable window = new () + { + Title = title, + Width = Dim.Fill (), + Height = Dim.Fill () + }; + + Markdown markdownView = new () + { + Width = Dim.Fill (), + Height = Dim.Fill (1) + }; + + StatusBar statusBar = new ( + [ + new Shortcut (Application.GetDefaultKey (Command.Quit), "Quit", window.RequestStop) + ]); + + window.Add (markdownView, statusBar); + + 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..bc6b6ac 100644 --- a/src/Terminal.Gui.Cli/MetadataHelpProvider.cs +++ b/src/Terminal.Gui.Cli/MetadataHelpProvider.cs @@ -11,23 +11,29 @@ public sealed class MetadataHelpProvider : IHelpProvider ArgumentNullException.ThrowIfNull (registry); StringBuilder builder = new (); - builder.AppendLine ("Commands:"); + builder.AppendLine ("## Commands"); + builder.AppendLine (); + builder.AppendLine ("| Command | Description |"); + 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 ("| Option | Description |"); + builder.AppendLine ("|--------|-------------|"); + builder.AppendLine ("| `--help`, `-h` | Show help |"); + builder.AppendLine ("| `--version` | Show version |"); + builder.AppendLine ("| `--opencli` | Emit OpenCLI metadata JSON |"); + builder.AppendLine ("| `--json` | Emit JSON envelope output |"); + builder.AppendLine ("| `--initial ` | Pre-fill input value |"); + builder.AppendLine ("| `--title`, `--prompt ` | Set window title |"); + builder.AppendLine ("| `--timeout ` | Cancel after duration |"); + builder.AppendLine ("| `--cat` | Render viewer content to stdout |"); return builder.ToString (); } diff --git a/tests/Terminal.Gui.Cli.IntegrationTests/AssemblyAttributes.cs b/tests/Terminal.Gui.Cli.IntegrationTests/AssemblyAttributes.cs new file mode 100644 index 0000000..0f08f22 --- /dev/null +++ b/tests/Terminal.Gui.Cli.IntegrationTests/AssemblyAttributes.cs @@ -0,0 +1,16 @@ +using System.Runtime.CompilerServices; +using Xunit; + +[assembly: CollectionBehavior (DisableTestParallelization = true)] + +namespace Terminal.Gui.Cli.IntegrationTests; + +internal static class TestSetup +{ + [ModuleInitializer] + internal static void Init () + { + Environment.SetEnvironmentVariable ("DisableRealDriverIO", "1"); + Console.SetIn (TextReader.Null); + } +} diff --git a/tests/Terminal.Gui.Cli.IntegrationTests/CommandUiHarness.cs b/tests/Terminal.Gui.Cli.IntegrationTests/CommandUiHarness.cs new file mode 100644 index 0000000..977a4d9 --- /dev/null +++ b/tests/Terminal.Gui.Cli.IntegrationTests/CommandUiHarness.cs @@ -0,0 +1,287 @@ +using System.Reflection; +using System.Text; +using Terminal.Gui.App; +using Terminal.Gui.Drawing; +using Terminal.Gui.Drivers; +using Xunit; + +namespace Terminal.Gui.Cli.IntegrationTests; + +/// +/// In-process UI render harness for commands. Ported from clet's CletUiHarness. +/// Runs a command with the ANSI driver at a fixed screen size, captures the initial +/// render, then stops. Enables visual golden-file assertions. +/// +internal sealed class CommandUiHarness : IAsyncDisposable +{ + private readonly string? _ansiSnapshot; + private readonly IApplication _app; + private readonly Task _commandTask; + private readonly CancellationTokenSource _cts; + private readonly string? _textSnapshot; + + private CommandUiHarness ( + IApplication app, + CancellationTokenSource cts, + Task commandTask, + string? ansiSnapshot, + string? textSnapshot) + { + _app = app; + _cts = cts; + _commandTask = commandTask; + _ansiSnapshot = ansiSnapshot; + _textSnapshot = textSnapshot; + } + + public async ValueTask DisposeAsync () + { + try + { + if (!_commandTask.IsCompleted) + { + await _cts.CancelAsync (); + + try + { + await _commandTask; + } + catch (OperationCanceledException) + { + } + } + } + finally + { + _cts.Dispose (); + _app.Dispose (); + } + } + + /// Start a harness for a viewer command. + public static async Task StartViewerAsync ( + IViewerCommand viewer, + CommandRunOptions? options = null, + int width = 80, + int height = 18) + { + Application.AppModel = AppModel.FullScreen; + + IApplication app = Application.Create ().Init (DriverRegistry.Names.ANSI); + app.Driver?.SetScreenSize (width, height); + + CancellationTokenSource cts = new (); + string? ansiSnapshot = null; + string? textSnapshot = null; + var iterations = 0; + var previousHash = 0; + var sawNonEmpty = false; + var stableCount = 0; + const int stableThreshold = 2; + const int maxIterations = 50; + + EventHandler> handler = (_, _) => + { + iterations++; + ansiSnapshot = app.Driver?.ToAnsi ()?.Replace ("\r\n", "\n").Replace ("\r", "\n"); + textSnapshot = BuildTextSnapshot (app.Driver?.Contents); + + var (hash, nonEmpty) = HashContents (app.Driver?.Contents); + + if (!sawNonEmpty) + { + if (nonEmpty) + { + sawNonEmpty = true; + previousHash = hash; + stableCount = 1; + } + + return; + } + + if (hash == previousHash) + { + stableCount++; + + if (stableCount >= stableThreshold) + { + app.RequestStop (); + } + } + else + { + previousHash = hash; + stableCount = 1; + } + + if (iterations >= maxIterations) + { + app.RequestStop (); + } + }; + + app.Iteration += handler; + Task task; + + try + { + task = viewer.RunAsync (app, null, options ?? new CommandRunOptions (), cts.Token); + await task; + } + finally + { + app.Iteration -= handler; + } + + return new CommandUiHarness (app, cts, task, ansiSnapshot, textSnapshot); + } + + /// Get the rendered screen as plain text. + public string SnapshotText () + { + return _textSnapshot ?? BuildTextSnapshot (_app.Driver?.Contents); + } + + /// Get the rendered screen as ANSI with styling. + public string SnapshotAnsi () + { + return _ansiSnapshot ?? _app.Driver?.ToAnsi () ?? string.Empty; + } + + /// + /// Compare against a golden file under Goldens/. Set UPDATE_SNAPSHOTS=1 to regenerate. + /// + public void AssertMatchesAnsiGolden (string fileName) + { + var actual = SnapshotAnsi (); + var path = ResolveGoldenPath (fileName); + var regen = Environment.GetEnvironmentVariable ("UPDATE_SNAPSHOTS") is "1" or "true"; + + if (!File.Exists (path)) + { + if (regen) + { + WriteGolden (path, actual); + Assert.Fail ($"Golden created at {path}. Re-run without UPDATE_SNAPSHOTS to verify."); + } + + Assert.Fail ($"Golden not found: {path}. Run with UPDATE_SNAPSHOTS=1 to create it."); + } + + var expected = File.ReadAllText (path).Replace ("\r\n", "\n").Replace ("\r", "\n"); + + if (expected == actual) + { + return; + } + + var actualPath = path + ".actual"; + WriteGolden (actualPath, actual); + + if (regen) + { + WriteGolden (path, actual); + Assert.Fail ($"Golden updated at {path}. Re-run without UPDATE_SNAPSHOTS to verify."); + } + + Assert.Fail ($""" + Golden '{fileName}' does not match. + + Plain-text render: + --- + {SnapshotText ()} + --- + + Actual written to: {actualPath} + Expected at: {path} + """); + } + + private static string ResolveGoldenPath (string fileName) + { + var sourcePath = typeof (CommandUiHarness).Assembly + .GetCustomAttributes (typeof (AssemblyMetadataAttribute), false) + .Cast () + .FirstOrDefault (a => a.Key == "GoldensSourcePath") + ?.Value; + + if (!string.IsNullOrEmpty (sourcePath)) + { + return Path.Combine (sourcePath, fileName); + } + + var assemblyDir = Path.GetDirectoryName (typeof (CommandUiHarness).Assembly.Location)!; + return Path.Combine (assemblyDir, "Goldens", fileName); + } + + private static void WriteGolden (string path, string content) + { + Directory.CreateDirectory (Path.GetDirectoryName (path)!); + File.WriteAllText (path, content, new UTF8Encoding (false)); + } + + private static string BuildTextSnapshot (Cell[,]? contents) + { + if (contents is null) + { + return string.Empty; + } + + var rows = contents.GetLength (0); + var cols = contents.GetLength (1); + StringBuilder sb = new (rows * (cols + 1)); + + for (var r = 0; r < rows; r++) + { + var lineStart = sb.Length; + + for (var c = 0; c < cols; c++) + { + var g = contents[r, c].Grapheme; + sb.Append (string.IsNullOrEmpty (g) ? " " : g); + } + + var end = sb.Length; + + while (end > lineStart && char.IsWhiteSpace (sb[end - 1])) + { + end--; + } + + sb.Length = end; + sb.Append ('\n'); + } + + return sb.ToString (); + } + + private static (int Hash, bool NonEmpty) HashContents (Cell[,]? contents) + { + if (contents is null) + { + return (0, false); + } + + var rows = contents.GetLength (0); + var cols = contents.GetLength (1); + var hash = 17; + var nonEmpty = false; + + for (var r = 0; r < rows; r++) + { + for (var c = 0; c < cols; c++) + { + var g = contents[r, c].Grapheme; + hash = unchecked(hash * 31 + (string.IsNullOrEmpty (g) ? 0 : g.GetHashCode ())); + + if (!string.IsNullOrEmpty (g) && g != " ") + { + nonEmpty = true; + } + } + } + + return (hash, nonEmpty); + } +} diff --git a/tests/Terminal.Gui.Cli.IntegrationTests/Goldens/help.ans b/tests/Terminal.Gui.Cli.IntegrationTests/Goldens/help.ans new file mode 100644 index 0000000..b29b344 --- /dev/null +++ b/tests/Terminal.Gui.Cli.IntegrationTests/Goldens/help.ans @@ -0,0 +1,18 @@ +## Commands ▲ + █ +┌─────────┬───────────────────────────────────────────────────────────────────┐█ +│ Command │ Description │█ +├─────────┼───────────────────────────────────────────────────────────────────┤█ +│ help │ Show command help. │█ +└─────────┴───────────────────────────────────────────────────────────────────┘█ + █ +## Framework Options █ + █ +┌───────────────────────────┬─────────────────────────────────────────────────┐█ +│ Option │ Description │█ +├───────────────────────────┼─────────────────────────────────────────────────┤░ +│ --help, -h │ Show help │░ +│ --version │ Show version │░ +│ --opencli │ Emit OpenCLI metadata JSON │░ +│ --json │ Emit JSON envelope output │▼ + Esc Quit diff --git a/tests/Terminal.Gui.Cli.IntegrationTests/HelpCommandUiTests.cs b/tests/Terminal.Gui.Cli.IntegrationTests/HelpCommandUiTests.cs new file mode 100644 index 0000000..3b32fe0 --- /dev/null +++ b/tests/Terminal.Gui.Cli.IntegrationTests/HelpCommandUiTests.cs @@ -0,0 +1,118 @@ +using Terminal.Gui.App; +using Xunit; + +namespace Terminal.Gui.Cli.IntegrationTests; + +/// Visual rendering tests for the built-in HelpCommand. +public sealed class HelpCommandUiTests +{ + [Fact] + public async Task HelpCommand_InitialRender_ShowsMarkdownContent () + { + CommandRegistry registry = new (); + MetadataHelpProvider helpProvider = new (); + HelpCommand help = new (registry, helpProvider); + registry.Register (help); + + await using CommandUiHarness harness = await CommandUiHarness.StartViewerAsync (help, width: 80, height: 18); + + var text = harness.SnapshotText (); + + // The viewer should display the markdown help content + Assert.Contains ("Commands", text); + Assert.Contains ("help", text); + } + + [Fact] + public async Task HelpCommand_InitialRender_ContainsFrameworkOptions () + { + CommandRegistry registry = new (); + MetadataHelpProvider helpProvider = new (); + HelpCommand help = new (registry, helpProvider); + registry.Register (help); + + await using CommandUiHarness harness = await CommandUiHarness.StartViewerAsync (help, width: 80, height: 24); + + var text = harness.SnapshotText (); + + Assert.Contains ("Framework Options", text); + Assert.Contains ("--help", text); + } + + [Fact] + public async Task HelpCommand_InitialRender_ProducesAnsiOutput () + { + CommandRegistry registry = new (); + MetadataHelpProvider helpProvider = new (); + HelpCommand help = new (registry, helpProvider); + registry.Register (help); + + await using CommandUiHarness harness = await CommandUiHarness.StartViewerAsync (help, width: 80, height: 18); + + var ansi = harness.SnapshotAnsi (); + + // ANSI output must contain escape sequences for styling + Assert.Contains ("\x1b[", ansi); + Assert.False (string.IsNullOrWhiteSpace (ansi)); + } + + [Fact] + public async Task HelpCommand_WithSubcommand_ShowsCommandHelp () + { + CommandRegistry registry = new (); + MetadataHelpProvider helpProvider = new (); + HelpCommand help = new (registry, helpProvider); + registry.Register (help); + registry.Register (new StubCommand ("greet", "Greet someone.")); + + CommandRunOptions options = new () { Arguments = ["greet"] }; + + await using CommandUiHarness harness = await CommandUiHarness.StartViewerAsync (help, options); + + var text = harness.SnapshotText (); + + Assert.Contains ("greet", text); + Assert.Contains ("Greet someone.", text); + } + + [Fact] + public async Task HelpCommand_MatchesAnsiGolden () + { + CommandRegistry registry = new (); + MetadataHelpProvider helpProvider = new (); + HelpCommand help = new (registry, helpProvider); + registry.Register (help); + + await using CommandUiHarness harness = await CommandUiHarness.StartViewerAsync (help, width: 80, height: 18); + + harness.AssertMatchesAnsiGolden ("help.ans"); + } + + private sealed class StubCommand : ICliCommand + { + public StubCommand (string alias, string description) + { + PrimaryAlias = alias; + Aliases = [alias]; + Description = description; + } + + public string PrimaryAlias { get; } + + public IReadOnlyList Aliases { get; } + + public string Description { get; } + + public CommandKind Kind => CommandKind.Input; + + public Type ResultType => typeof (void); + + public IReadOnlyList Options { get; } = []; + + public Task RunAsync (IApplication app, string? initial, + CommandRunOptions options, CancellationToken cancellationToken) + { + throw new NotImplementedException (); + } + } +} diff --git a/tests/Terminal.Gui.Cli.IntegrationTests/Terminal.Gui.Cli.IntegrationTests.csproj b/tests/Terminal.Gui.Cli.IntegrationTests/Terminal.Gui.Cli.IntegrationTests.csproj index 7dd9131..179eaa2 100644 --- a/tests/Terminal.Gui.Cli.IntegrationTests/Terminal.Gui.Cli.IntegrationTests.csproj +++ b/tests/Terminal.Gui.Cli.IntegrationTests/Terminal.Gui.Cli.IntegrationTests.csproj @@ -15,4 +15,15 @@ + + + <_Parameter1>GoldensSourcePath + <_Parameter2>$(MSBuildProjectDirectory)\Goldens + + + + + + + diff --git a/tests/Terminal.Gui.Cli.Tests/CliHostTests.cs b/tests/Terminal.Gui.Cli.Tests/CliHostTests.cs index ae35150..646c20c 100644 --- a/tests/Terminal.Gui.Cli.Tests/CliHostTests.cs +++ b/tests/Terminal.Gui.Cli.Tests/CliHostTests.cs @@ -60,6 +60,45 @@ public async Task RunAsync_CommandCancellation_ReturnsCancelledExitCode () Assert.Equal (string.Empty, stderr.ToString ()); } + [Fact] + public async Task RunAsync_HelpFlag_RendersMarkdownAsAnsi () + { + CliHost host = new (options => + { + options.ApplicationName = "test-app"; + 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); + var output = stdout.ToString (); + // MarkdownRenderer.RenderToAnsi produces ANSI escape sequences + Assert.Contains ("\x1b[", output); + Assert.Equal (string.Empty, stderr.ToString ()); + } + + [Fact] + public async Task RunAsync_HelpCat_RendersMarkdownAsAnsi () + { + CliHost host = new (options => + { + options.ApplicationName = "test-app"; + options.Version = "1.0.0"; + }); + 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); + var output = stdout.ToString (); + Assert.Contains ("\x1b[", output); + Assert.Equal (string.Empty, stderr.ToString ()); + } + private sealed class CancellingCatCommand : IViewerCommand { public string PrimaryAlias => "cancel"; diff --git a/tests/Terminal.Gui.Cli.Tests/MetadataHelpProviderTests.cs b/tests/Terminal.Gui.Cli.Tests/MetadataHelpProviderTests.cs new file mode 100644 index 0000000..6b331df --- /dev/null +++ b/tests/Terminal.Gui.Cli.Tests/MetadataHelpProviderTests.cs @@ -0,0 +1,64 @@ +using Terminal.Gui.App; +using Xunit; + +namespace Terminal.Gui.Cli.Tests; + +public sealed class MetadataHelpProviderTests +{ + [Fact] + public void GetRootHelp_ProducesMarkdown () + { + MetadataHelpProvider provider = new (); + CommandRegistry registry = new (); + registry.Register (new StubCommand ("demo", "A demo command.")); + + var result = provider.GetRootHelp (registry); + + Assert.NotNull (result); + Assert.Contains ("## Commands", result); + Assert.Contains ("| `demo` | A demo command. |", result); + Assert.Contains ("## Framework Options", result); + Assert.Contains ("| `--help`, `-h` | Show help |", result); + } + + [Fact] + public void GetCommandHelp_ProducesMarkdown () + { + MetadataHelpProvider provider = new (); + StubCommand command = new ("test", "Test command."); + + var result = provider.GetCommandHelp (command); + + Assert.NotNull (result); + Assert.Contains ("# test", result); + Assert.Contains ("Test command.", result); + } + + private sealed class StubCommand : ICliCommand + { + public StubCommand (string alias, string description) + { + PrimaryAlias = alias; + Aliases = [alias]; + Description = description; + } + + public string PrimaryAlias { get; } + + public IReadOnlyList Aliases { get; } + + public string Description { get; } + + public CommandKind Kind => CommandKind.Input; + + public Type ResultType => typeof (void); + + public IReadOnlyList Options { get; } = []; + + public Task RunAsync (IApplication app, string? initial, + CommandRunOptions options, CancellationToken cancellationToken) + { + throw new NotImplementedException (); + } + } +} From 4ab71d84e8900a7ff2b1c68220e44b1366f98d95 Mon Sep 17 00:00:00 2001 From: Tig Date: Sun, 24 May 2026 16:19:25 -0600 Subject: [PATCH 2/7] Fix terminal encoding for ANSI rendering and add multiple help topics - Port clet's MarkdownHelpRenderer approach: set Console.OutputEncoding to UTF-8 before rendering, use a full TG ANSI driver with proper layout/draw cycle, and restore encoding afterward. Fixes garbled box-drawing chars. - Add FarewellCommand with --until option for navigation testing - Add embedded markdown help files (help.md, greet.md, farewell.md, info.md) - Configure EmbeddedMarkdownHelpProvider in greet example - Fix InfoCommand to properly launch TUI viewer (was returning text without calling app.RunAsync, causing terminal to hang until Enter pressed) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- examples/greet/FarewellCommand.cs | 47 ++++++++++ examples/greet/InfoCommand.cs | 73 ++++++++++++--- examples/greet/Program.cs | 6 +- examples/greet/Resources/Help/farewell.md | 34 +++++++ examples/greet/Resources/Help/greet.md | 35 +++++++ examples/greet/Resources/Help/help.md | 32 +++++++ examples/greet/Resources/Help/info.md | 19 ++++ examples/greet/Terminal.Gui.Cli.Greet.csproj | 4 + src/Terminal.Gui.Cli/MarkdownRenderer.cs | 97 +++++++++++++++++++- 9 files changed, 329 insertions(+), 18 deletions(-) create mode 100644 examples/greet/FarewellCommand.cs create mode 100644 examples/greet/Resources/Help/farewell.md create mode 100644 examples/greet/Resources/Help/greet.md create mode 100644 examples/greet/Resources/Help/help.md create mode 100644 examples/greet/Resources/Help/info.md diff --git a/examples/greet/FarewellCommand.cs b/examples/greet/FarewellCommand.cs new file mode 100644 index 0000000..9ef30e2 --- /dev/null +++ b/examples/greet/FarewellCommand.cs @@ -0,0 +1,47 @@ +using Terminal.Gui.App; + +namespace Terminal.Gui.Cli.Greet; + +/// An input command that says goodbye to someone. +public sealed class FarewellCommand : ICliCommand +{ + /// + public string PrimaryAlias => "farewell"; + + /// + public IReadOnlyList Aliases { get; } = ["farewell", "bye"]; + + /// + public string Description => "Say goodbye to someone."; + + /// + public CommandKind Kind => CommandKind.Input; + + /// + public Type ResultType => typeof (string); + + /// + public IReadOnlyList Options { get; } = + [ + new ("until", "u", typeof (string), "When you expect to meet again.", false, null) + ]; + + /// + public Task> RunAsync ( + IApplication app, + string? initial, + CommandRunOptions options, + CancellationToken cancellationToken) + { + var name = initial ?? "World"; + var until = options.CommandOptions.TryGetValue ("until", out var untilValue) + ? untilValue + : null; + + var farewell = until is not null + ? $"Goodbye, {name}! See you {until}." + : $"Goodbye, {name}!"; + + return Task.FromResult (new CommandResult (CommandStatus.Ok, farewell, null, null)); + } +} diff --git a/examples/greet/InfoCommand.cs b/examples/greet/InfoCommand.cs index b51e630..82ce38c 100644 --- a/examples/greet/InfoCommand.cs +++ b/examples/greet/InfoCommand.cs @@ -1,20 +1,41 @@ using Terminal.Gui.App; +using Terminal.Gui.Input; +using Terminal.Gui.ViewBase; +using Terminal.Gui.Views; namespace Terminal.Gui.Cli.Greet; -/// A viewer command that displays application information. +/// A viewer command that displays application information in a TUI markdown view. public sealed class InfoCommand : IViewerCommand { - private const string InfoText = """ - Example App v1.0.0 - A demonstration of the Terminal.Gui.Cli library. + private const string InfoMarkdown = """ + # greet — Info - This app shows how to: - - Register input and viewer commands - - Use --help, --json, --opencli, and agent-guide - - Embed an agent-guide.md resource - - Support --cat for headless content rendering - """; + **Version:** 1.0.0 + + A demonstration of the `Terminal.Gui.Cli` library. + + ## Features + + This app shows how to: + + - Register input and viewer commands + - Use `--help`, `--json`, `--opencli`, and `agent-guide` + - Embed an `agent-guide.md` resource + - Support `--cat` for headless content rendering + - Launch a TUI markdown viewer for `help` and `info` + + ## Usage + + ``` + greet [name] Greet someone (default: World) + greet --formal [name] Use a formal greeting style + greet help Browse help topics + greet help greet Help for the greet command + greet info Show this info page + greet --help Render help as ANSI to stdout + ``` + """; /// public string PrimaryAlias => "info"; @@ -29,19 +50,43 @@ A demonstration of the Terminal.Gui.Cli library. public CommandKind Kind => CommandKind.Viewer; /// - public Type ResultType => typeof (string); + public Type ResultType => typeof (void); /// public IReadOnlyList Options { get; } = []; /// - public Task RunAsync ( + public async Task RunAsync ( IApplication app, string? initial, CommandRunOptions options, CancellationToken cancellationToken) { - return Task.FromResult (new CommandResult (CommandStatus.Ok, InfoText, null, null)); + Runnable window = new () + { + Title = "Info", + Width = Dim.Fill (), + Height = Dim.Fill () + }; + + Markdown markdownView = new () + { + Width = Dim.Fill (), + Height = Dim.Fill (1) + }; + + StatusBar statusBar = new ( + [ + new Shortcut (Application.GetDefaultKey (Command.Quit), "Quit", window.RequestStop) + ]); + + window.Add (markdownView, statusBar); + + window.Initialized += (_, _) => { markdownView.Text = InfoMarkdown; }; + + await app.RunAsync (window, cancellationToken); + + return new CommandResult (CommandStatus.Ok, null, null, null); } /// @@ -51,7 +96,7 @@ public Task RunAsync ( CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull (stdout); - stdout.Write (InfoText); + MarkdownRenderer.RenderToAnsi (InfoMarkdown, stdout); return Task.FromResult (new CommandResult (CommandStatus.Ok, null, null, null)); } } diff --git a/examples/greet/Program.cs b/examples/greet/Program.cs index 4006994..3f7d76f 100644 --- a/examples/greet/Program.cs +++ b/examples/greet/Program.cs @@ -2,16 +2,20 @@ using Terminal.Gui.Cli; using Terminal.Gui.Cli.Greet; +Assembly assembly = Assembly.GetExecutingAssembly (); + CliHost host = new (options => { options.ApplicationName = "greet"; options.Version = "1.0.0"; options.AgentGuide = "Terminal.Gui.Cli.Greet.agent-guide.md"; options.AgentGuideIsResource = true; - options.ResourceAssembly = Assembly.GetExecutingAssembly (); + options.ResourceAssembly = assembly; + options.HelpProvider = new EmbeddedMarkdownHelpProvider (assembly); }); host.Registry.Register (new GreetCommand ()); +host.Registry.Register (new FarewellCommand ()); host.Registry.Register (new InfoCommand ()); return await host.RunAsync (args); diff --git a/examples/greet/Resources/Help/farewell.md b/examples/greet/Resources/Help/farewell.md new file mode 100644 index 0000000..90f5f58 --- /dev/null +++ b/examples/greet/Resources/Help/farewell.md @@ -0,0 +1,34 @@ +# farewell + +Say goodbye to someone. + +## Usage + +``` +farewell [name] +farewell --until diff --git a/src/Terminal.Gui.Cli/MarkdownRenderer.cs b/src/Terminal.Gui.Cli/MarkdownRenderer.cs index 29ab182..d11ec66 100644 --- a/src/Terminal.Gui.Cli/MarkdownRenderer.cs +++ b/src/Terminal.Gui.Cli/MarkdownRenderer.cs @@ -1,3 +1,7 @@ +using System.Text; +using Terminal.Gui.App; +using Terminal.Gui.Drivers; +using Terminal.Gui.ViewBase; using Terminal.Gui.Views; namespace Terminal.Gui.Cli; @@ -11,8 +15,95 @@ public static void RenderToAnsi (string markdown, TextWriter output) ArgumentNullException.ThrowIfNull (markdown); ArgumentNullException.ThrowIfNull (output); - var rendered = new Markdown ().RenderToAnsi (markdown, - Math.Max (1, Console.IsOutputRedirected ? 80 : Console.WindowWidth)); - output.Write (TerminalEscapeSanitizer.SanitizeRenderedOutput (rendered)); + markdown = TerminalEscapeSanitizer.Sanitize (markdown)!; + + // On Windows the default Console.OutputEncoding is the OEM code page which + // mangles Unicode box-drawing characters. Force UTF-8 for the render pass + // and restore on exit. Only mutate when writing to the real console. + Encoding? previousEncoding = null; + TextWriter target = output; + + if (ReferenceEquals (output, Console.Out) && !Console.IsOutputRedirected) + { + previousEncoding = Console.OutputEncoding; + Console.OutputEncoding = Encoding.UTF8; + target = Console.Out; + } + + int width; + + try + { + width = Console.WindowWidth; + } + catch + { + width = 0; + } + + if (width <= 0) + { + width = 80; + } + + int height; + + try + { + height = Console.WindowHeight; + } + catch + { + height = 0; + } + + if (height <= 0) + { + height = 24; + } + + Environment.SetEnvironmentVariable ("DisableRealDriverIO", "1"); + IApplication app = Application.Create (); + app.Init (DriverRegistry.Names.ANSI); + + try + { + app.Driver?.SetScreenSize (width, height); + + Markdown markdownView = new () + { + App = app, + UseThemeBackground = false, + ShowCopyButtons = false, + Width = Dim.Fill (), + Height = Dim.Fill (), + Text = markdown + }; + + markdownView.SetRelativeLayout (app.Screen.Size); + markdownView.Layout (); + + var contentHeight = markdownView.GetContentHeight (); + app.Driver?.SetScreenSize (width, contentHeight); + markdownView.SetRelativeLayout (app.Screen.Size); + markdownView.Frame = app.Screen with { X = 0, Y = 0 }; + markdownView.Layout (); + + app.Driver?.ClearContents (); + markdownView.Draw (); + + var rendered = app.Driver?.ToAnsi () ?? string.Empty; + rendered = TerminalEscapeSanitizer.SanitizeRenderedOutput (rendered); + target.WriteLine (rendered); + } + finally + { + app.Dispose (); + + if (previousEncoding is not null) + { + Console.OutputEncoding = previousEncoding; + } + } } } From f353898b21137c0e21fd82e178ece604b697aa8c Mon Sep 17 00:00:00 2001 From: Tig Date: Sun, 24 May 2026 16:28:31 -0600 Subject: [PATCH 3/7] Add default command support, help link navigation, and fix positional args - Add DefaultCommand option to CliHostOptions so bare args/options are retried as the default command (e.g. 'greet World' == 'greet greet World') - Refactor CliHost.RunAsync into DispatchCommandAsync/ExecuteCommandAsync - Add help:topic link navigation in HelpCommand via Markdown.LinkClicked - Add 'Back to main help' links in all sub-topic help files - Add help:command anchors in root help.md for topic navigation - Make GreetCommand and FarewellCommand accept positional args for name - Set greet example DefaultCommand = 'greet' Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- examples/greet/FarewellCommand.cs | 7 ++- examples/greet/GreetCommand.cs | 8 +++- examples/greet/Program.cs | 1 + examples/greet/Resources/Help/farewell.md | 2 + examples/greet/Resources/Help/greet.md | 2 + examples/greet/Resources/Help/help.md | 12 +++-- examples/greet/Resources/Help/info.md | 2 + src/Terminal.Gui.Cli/CliHost.cs | 54 ++++++++++++++++++++++- src/Terminal.Gui.Cli/CliHostOptions.cs | 6 +++ src/Terminal.Gui.Cli/HelpCommand.cs | 34 ++++++++++++++ 10 files changed, 121 insertions(+), 7 deletions(-) diff --git a/examples/greet/FarewellCommand.cs b/examples/greet/FarewellCommand.cs index 9ef30e2..5e269fc 100644 --- a/examples/greet/FarewellCommand.cs +++ b/examples/greet/FarewellCommand.cs @@ -26,6 +26,9 @@ public sealed class FarewellCommand : ICliCommand new ("until", "u", typeof (string), "When you expect to meet again.", false, null) ]; + /// + public bool AcceptsPositionalArgs => true; + /// public Task> RunAsync ( IApplication app, @@ -33,7 +36,9 @@ public Task> RunAsync ( CommandRunOptions options, CancellationToken cancellationToken) { - var name = initial ?? "World"; + var name = options.Arguments.Count > 0 + ? string.Join (" ", options.Arguments) + : initial ?? "World"; var until = options.CommandOptions.TryGetValue ("until", out var untilValue) ? untilValue : null; diff --git a/examples/greet/GreetCommand.cs b/examples/greet/GreetCommand.cs index 464970a..766d164 100644 --- a/examples/greet/GreetCommand.cs +++ b/examples/greet/GreetCommand.cs @@ -26,6 +26,9 @@ public sealed class GreetCommand : ICliCommand new ("formal", "f", typeof (bool), "Use a formal greeting style.", false, null) ]; + /// + public bool AcceptsPositionalArgs => true; + /// public Task> RunAsync ( IApplication app, @@ -33,7 +36,10 @@ public Task> RunAsync ( CommandRunOptions options, CancellationToken cancellationToken) { - var name = initial ?? "World"; + var name = options.Arguments.Count > 0 + ? string.Join (" ", options.Arguments) + : initial ?? "World"; + var formal = options.CommandOptions.TryGetValue ("formal", out var formalValue) && formalValue.Equals ("true", StringComparison.OrdinalIgnoreCase); diff --git a/examples/greet/Program.cs b/examples/greet/Program.cs index 3f7d76f..2c8480d 100644 --- a/examples/greet/Program.cs +++ b/examples/greet/Program.cs @@ -8,6 +8,7 @@ { options.ApplicationName = "greet"; options.Version = "1.0.0"; + options.DefaultCommand = "greet"; options.AgentGuide = "Terminal.Gui.Cli.Greet.agent-guide.md"; options.AgentGuideIsResource = true; options.ResourceAssembly = assembly; diff --git a/examples/greet/Resources/Help/farewell.md b/examples/greet/Resources/Help/farewell.md index 90f5f58..20e2ae7 100644 --- a/examples/greet/Resources/Help/farewell.md +++ b/examples/greet/Resources/Help/farewell.md @@ -2,6 +2,8 @@ Say goodbye to someone. +[Back to main help](help:help) + ## Usage ``` diff --git a/examples/greet/Resources/Help/greet.md b/examples/greet/Resources/Help/greet.md index 5c50167..d6ebec3 100644 --- a/examples/greet/Resources/Help/greet.md +++ b/examples/greet/Resources/Help/greet.md @@ -2,6 +2,8 @@ Prompt for a name and return a greeting. +[Back to main help](help:help) + ## Usage ``` diff --git a/examples/greet/Resources/Help/help.md b/examples/greet/Resources/Help/help.md index 4f5faa5..0753cdf 100644 --- a/examples/greet/Resources/Help/help.md +++ b/examples/greet/Resources/Help/help.md @@ -6,10 +6,10 @@ A CLI greeting application built with `Terminal.Gui.Cli`. | Command | Description | |---------|-------------| -| `greet` | Prompt for a name and return a greeting | -| `farewell` | Say goodbye to someone | -| `info` | Display application information | -| `help` | Show this help page | +| [greet](help:greet) | Prompt for a name and return a greeting | +| [farewell](help:farewell) | Say goodbye to someone | +| [info](help:info) | Display application information | +| [help](help:help) | Show this help page | ## Global Flags @@ -30,3 +30,7 @@ farewell --until tomorrow Bob help greet Help for the greet command help farewell Help for the farewell command ``` + +## See Also + +- [Agent Guide](help:agent-guide) - Machine-readable guide for AI agents diff --git a/examples/greet/Resources/Help/info.md b/examples/greet/Resources/Help/info.md index 7a3fd6c..28c19da 100644 --- a/examples/greet/Resources/Help/info.md +++ b/examples/greet/Resources/Help/info.md @@ -2,6 +2,8 @@ Display application information in a TUI markdown viewer. +[Back to main help](help:help) + ## Usage ``` diff --git a/src/Terminal.Gui.Cli/CliHost.cs b/src/Terminal.Gui.Cli/CliHost.cs index bc63beb..040ba1d 100644 --- a/src/Terminal.Gui.Cli/CliHost.cs +++ b/src/Terminal.Gui.Cli/CliHost.cs @@ -38,6 +38,11 @@ public async Task RunAsync ( if (!initialParse.Success) { + if (_options.DefaultCommand is not null) + { + return await RunWithDefaultCommandAsync (args, cancellationToken, stdout, stderr); + } + stderr.WriteLine (initialParse.Error); return ExitCodes.UsageError; } @@ -51,10 +56,49 @@ public async Task RunAsync ( if (initialParse.Alias is null || !Registry.TryResolve (initialParse.Alias, out ICliCommand? command) || command is null) { + if (_options.DefaultCommand is not null) + { + return await RunWithDefaultCommandAsync (args, cancellationToken, stdout, stderr); + } + stderr.WriteLine ($"Unknown command '{initialParse.Alias}'."); return ExitCodes.UsageError; } + return await DispatchCommandAsync (args, command, cancellationToken, stdout, stderr); + } + + private async Task RunWithDefaultCommandAsync ( + string[] args, + CancellationToken cancellationToken, + TextWriter stdout, + TextWriter stderr) + { + if (!Registry.TryResolve (_options.DefaultCommand!, out ICliCommand? defaultCmd) || defaultCmd is null) + { + stderr.WriteLine ($"Default command '{_options.DefaultCommand}' is not registered."); + return ExitCodes.UsageError; + } + + string[] adjusted = [_options.DefaultCommand!, .. args]; + ArgParser.ParseResult parse = _parser.Parse (adjusted, defaultCmd); + + if (!parse.Success || parse.Options is null) + { + stderr.WriteLine (parse.Error); + return ExitCodes.UsageError; + } + + return await ExecuteCommandAsync (defaultCmd, parse.Options, cancellationToken, stdout, stderr); + } + + private async Task DispatchCommandAsync ( + string[] args, + ICliCommand command, + CancellationToken cancellationToken, + TextWriter stdout, + TextWriter stderr) + { ArgParser.ParseResult parse = _parser.Parse (args, command); if (!parse.Success || parse.Options is null) @@ -63,8 +107,16 @@ public async Task RunAsync ( return ExitCodes.UsageError; } - CommandRunOptions runOptions = parse.Options; + return await ExecuteCommandAsync (command, parse.Options, cancellationToken, stdout, stderr); + } + private async Task ExecuteCommandAsync ( + ICliCommand command, + CommandRunOptions runOptions, + CancellationToken cancellationToken, + TextWriter stdout, + TextWriter stderr) + { if (runOptions.Initial is not null && !command.TryValidateInitial (runOptions.Initial, runOptions)) { stderr.WriteLine ("Invalid --initial value."); diff --git a/src/Terminal.Gui.Cli/CliHostOptions.cs b/src/Terminal.Gui.Cli/CliHostOptions.cs index 62c25f9..188922e 100644 --- a/src/Terminal.Gui.Cli/CliHostOptions.cs +++ b/src/Terminal.Gui.Cli/CliHostOptions.cs @@ -31,6 +31,12 @@ public sealed class CliHostOptions /// Consumer-defined global options parsed into . public List GlobalOptions { get; } = []; + /// + /// Alias of the default command to invoke when args don't match any registered command. + /// When set, bare positional args or unrecognized options are retried as args to this command. + /// + public string? DefaultCommand { get; set; } + internal IReadOnlyDictionary BuiltInReplacements => _builtInReplacements; /// Replaces a library built-in command before it is registered. diff --git a/src/Terminal.Gui.Cli/HelpCommand.cs b/src/Terminal.Gui.Cli/HelpCommand.cs index d03f320..f240fb2 100644 --- a/src/Terminal.Gui.Cli/HelpCommand.cs +++ b/src/Terminal.Gui.Cli/HelpCommand.cs @@ -59,6 +59,23 @@ public async Task RunAsync (IApplication app, string? initial, Co Height = Dim.Fill (1) }; + markdownView.LinkClicked += (_, e) => + { + if (e.Url is not null && e.Url.StartsWith ("help:", StringComparison.OrdinalIgnoreCase)) + { + var topic = e.Url["help:".Length..]; + var topicMarkdown = ResolveHelpTopic (topic); + + if (topicMarkdown is not null) + { + markdownView.Text = topicMarkdown; + window.Title = $"Help - {topic}"; + } + + e.Handled = true; + } + }; + StatusBar statusBar = new ( [ new Shortcut (Application.GetDefaultKey (Command.Quit), "Quit", window.RequestStop) @@ -94,4 +111,21 @@ private string ResolveHelp (CommandRunOptions options) return _helpProvider.GetRootHelp (_registry) ?? new MetadataHelpProvider ().GetRootHelp (_registry) ?? string.Empty; } + + private string? ResolveHelpTopic (string topic) + { + if (topic.Equals ("help", StringComparison.OrdinalIgnoreCase)) + { + return _helpProvider.GetRootHelp (_registry) ?? + new MetadataHelpProvider ().GetRootHelp (_registry); + } + + if (_registry.TryResolve (topic, out ICliCommand? command) && command is not null) + { + return _helpProvider.GetCommandHelp (command) ?? + new MetadataHelpProvider ().GetCommandHelp (command); + } + + return null; + } } From 54fb6554aa56fea6c96006f8a61bd8b8ea534a14 Mon Sep 17 00:00:00 2001 From: Tig Date: Sun, 24 May 2026 16:36:34 -0600 Subject: [PATCH 4/7] Port clet's BrowseBar with back/forward nav and syntax highlighting - Add BrowseBar (ported from clet) with back/forward history stacks, Ctrl+Left/Right shortcuts, and proper statusbar styling - Rewrite HelpCommand.RunAsync to match clet's HelpClet pattern: BrowseBar, LinkClicked handler, NavigateTo, viewport reset - Add TextMateSyntaxHighlighter for code block coloring - Add horizontal scrollbar to markdown view - Update golden file for new status bar layout Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Terminal.Gui.Cli/BrowseBar.cs | 118 +++++++++++++++++ src/Terminal.Gui.Cli/HelpCommand.cs | 122 ++++++++++++------ .../Goldens/help.ans | 36 +++--- 3 files changed, 219 insertions(+), 57 deletions(-) create mode 100644 src/Terminal.Gui.Cli/BrowseBar.cs diff --git a/src/Terminal.Gui.Cli/BrowseBar.cs b/src/Terminal.Gui.Cli/BrowseBar.cs new file mode 100644 index 0000000..618f3eb --- /dev/null +++ b/src/Terminal.Gui.Cli/BrowseBar.cs @@ -0,0 +1,118 @@ +using Terminal.Gui.Drawing; +using Terminal.Gui.Input; +using Terminal.Gui.ViewBase; +using Terminal.Gui.Views; +using Command = Terminal.Gui.Input.Command; + +namespace Terminal.Gui.Cli; + +/// +/// Back/forward navigation history for the help viewer. +/// Exposes and shortcuts for insertion into a StatusBar. +/// +internal sealed class BrowseBar +{ + private readonly Stack _backStack = new (); + private readonly Stack _forwardStack = new (); + private string? _current; + + /// Creates a browse bar starting at . + public BrowseBar (string? initialLocation) + { + _current = initialLocation; + + Back = new Shortcut + { + Title = Glyphs.LeftArrow.ToString (), + Key = Key.CursorLeft.WithCtrl, + Command = Command.Left, + Action = NavigateBack, + Enabled = false + }; + + Forward = new Shortcut + { + Title = Glyphs.RightArrow.ToString (), + Key = Key.CursorRight.WithCtrl, + Command = Command.Right, + Action = NavigateForward, + Enabled = false + }; + } + + /// The back shortcut (Ctrl+Left). + public Shortcut Back { get; } + + /// The forward shortcut (Ctrl+Right). + public Shortcut Forward { get; } + + /// Called when back/forward navigation fires. The argument is the target location key. + public Action? OnNavigate { get; init; } + + /// + /// Applies styling that must be set after the shortcuts are added to a StatusBar. + /// Call after inserting and into the bar. + /// + public void ApplyStyle () + { + Back.AlignmentModes = AlignmentModes.StartToEnd; + Back.KeyView.Visible = false; + Forward.KeyView.Visible = false; + } + + /// + /// Records a navigation from the current location to . + /// Pushes the current location onto the back stack and clears the forward stack. + /// + public void Push (string location) + { + if (_current is not null) + { + _backStack.Push (_current); + } + + _forwardStack.Clear (); + _current = location; + UpdateButtons (); + } + + private void NavigateBack () + { + if (_backStack.Count == 0) + { + return; + } + + if (_current is not null) + { + _forwardStack.Push (_current); + } + + _current = _backStack.Pop (); + OnNavigate?.Invoke (_current); + UpdateButtons (); + } + + private void NavigateForward () + { + if (_forwardStack.Count == 0) + { + return; + } + + if (_current is not null) + { + _backStack.Push (_current); + } + + _current = _forwardStack.Pop (); + OnNavigate?.Invoke (_current); + UpdateButtons (); + } + + private void UpdateButtons () + { + Back.Enabled = _backStack.Count > 0; + Forward.Enabled = _forwardStack.Count > 0; + } +} diff --git a/src/Terminal.Gui.Cli/HelpCommand.cs b/src/Terminal.Gui.Cli/HelpCommand.cs index f240fb2..cb8ebca 100644 --- a/src/Terminal.Gui.Cli/HelpCommand.cs +++ b/src/Terminal.Gui.Cli/HelpCommand.cs @@ -1,11 +1,13 @@ using Terminal.Gui.App; +using Terminal.Gui.Drawing; using Terminal.Gui.Input; using Terminal.Gui.ViewBase; using Terminal.Gui.Views; +using Command = Terminal.Gui.Input.Command; namespace Terminal.Gui.Cli; -/// Interactive TUI markdown help viewer, with --cat support for ANSI stdout. +/// Interactive TUI markdown help viewer with back/forward navigation and --cat support. public sealed class HelpCommand : IViewerCommand { private readonly IHelpProvider _helpProvider; @@ -43,8 +45,8 @@ public HelpCommand (ICommandRegistry registry, IHelpProvider helpProvider) public async Task RunAsync (IApplication app, string? initial, CommandRunOptions options, CancellationToken cancellationToken) { - var markdown = ResolveHelp (options); - var title = options.Title ?? "Help"; + var alias = options.Arguments.Count > 0 ? options.Arguments[0] : null; + var (markdown, title) = BuildHelpContent (alias); Runnable window = new () { @@ -56,34 +58,85 @@ public async Task RunAsync (IApplication app, string? initial, Co Markdown markdownView = new () { Width = Dim.Fill (), - Height = Dim.Fill (1) + Height = Dim.Fill (1), + SyntaxHighlighter = new TextMateSyntaxHighlighter () + }; + + markdownView.ViewportSettings |= ViewportSettingsFlags.HasHorizontalScrollBar; + + Shortcut statusShortcut = new (Key.Empty, title, null) { MouseHighlightStates = MouseState.None }; + + var initialKey = alias ?? "(overview)"; + BrowseBar browseBar = new (initialKey) + { + OnNavigate = NavigateTo }; markdownView.LinkClicked += (_, e) => { - if (e.Url is not null && e.Url.StartsWith ("help:", StringComparison.OrdinalIgnoreCase)) + if (e.Url is null) { - var topic = e.Url["help:".Length..]; - var topicMarkdown = ResolveHelpTopic (topic); - - if (topicMarkdown is not null) - { - markdownView.Text = topicMarkdown; - window.Title = $"Help - {topic}"; - } + return; + } + if (e.Url.StartsWith ("help:", StringComparison.OrdinalIgnoreCase)) + { + var linkTopic = e.Url["help:".Length..]; + var key = linkTopic.Equals ("help", StringComparison.OrdinalIgnoreCase) + ? "(overview)" + : linkTopic; + browseBar.Push (key); + NavigateTo (key); e.Handled = true; } }; - StatusBar statusBar = new ( + List statusItems = [ - new Shortcut (Application.GetDefaultKey (Command.Quit), "Quit", window.RequestStop) - ]); + browseBar.Back, + browseBar.Forward, + new (Application.GetDefaultKey (Command.Quit), "Quit", window.RequestStop), + statusShortcut + ]; + + StatusBar statusBar = new (statusItems) + { + AlignmentModes = AlignmentModes.IgnoreFirstOrLast + }; + browseBar.ApplyStyle (); window.Add (markdownView, statusBar); - window.Initialized += (_, _) => { markdownView.Text = markdown; }; + window.Initialized += (_, _) => + { + markdownView.Text = markdown; + + // The Markdown view may auto-scroll to a focused link after layout. + // Reset viewport on the second draw to counteract this. + var drawCount = 0; + + markdownView.DrawComplete += ResetViewport; + + void ResetViewport (object? sender, DrawEventArgs e) + { + drawCount++; + + if (drawCount >= 2) + { + markdownView.DrawComplete -= ResetViewport; + markdownView.Viewport = markdownView.Viewport with { Y = 0 }; + } + } + }; + + void NavigateTo (string key) + { + var targetAlias = key == "(overview)" ? null : key; + var (md, t) = BuildHelpContent (targetAlias); + markdownView.Text = md; + window.Title = t; + statusShortcut.Title = t; + } await app.RunAsync (window, cancellationToken); @@ -95,37 +148,28 @@ public async Task RunAsync (IApplication app, string? initial, Co CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull (stdout); - MarkdownRenderer.RenderToAnsi (ResolveHelp (options), stdout); + var alias = options.Arguments.Count > 0 ? options.Arguments[0] : null; + var (markdown, _) = BuildHelpContent (alias); + MarkdownRenderer.RenderToAnsi (markdown, stdout); return Task.FromResult (new CommandResult (CommandStatus.Ok, null, null, null)); } - private string ResolveHelp (CommandRunOptions options) - { - if (options.Arguments.Count > 0 && _registry.TryResolve (options.Arguments[0], out ICliCommand? command) && - command is not null) - { - return _helpProvider.GetCommandHelp (command) ?? - new MetadataHelpProvider ().GetCommandHelp (command) ?? string.Empty; - } - - return _helpProvider.GetRootHelp (_registry) ?? - new MetadataHelpProvider ().GetRootHelp (_registry) ?? string.Empty; - } - - private string? ResolveHelpTopic (string topic) + private (string Markdown, string Title) BuildHelpContent (string? alias) { - if (topic.Equals ("help", StringComparison.OrdinalIgnoreCase)) + if (alias is null) { - return _helpProvider.GetRootHelp (_registry) ?? - new MetadataHelpProvider ().GetRootHelp (_registry); + var rootHelp = _helpProvider.GetRootHelp (_registry) ?? + new MetadataHelpProvider ().GetRootHelp (_registry) ?? string.Empty; + return (rootHelp, "Help"); } - if (_registry.TryResolve (topic, out ICliCommand? command) && command is not null) + if (_registry.TryResolve (alias, out ICliCommand? command) && command is not null) { - return _helpProvider.GetCommandHelp (command) ?? - new MetadataHelpProvider ().GetCommandHelp (command); + var commandHelp = _helpProvider.GetCommandHelp (command) ?? + new MetadataHelpProvider ().GetCommandHelp (command) ?? string.Empty; + return (commandHelp, $"Help - {command.PrimaryAlias}"); } - return null; + return ($"# Unknown command: {alias}\n\nTry `help` to see available commands.", "Help"); } } diff --git a/tests/Terminal.Gui.Cli.IntegrationTests/Goldens/help.ans b/tests/Terminal.Gui.Cli.IntegrationTests/Goldens/help.ans index b29b344..66bc4e0 100644 --- a/tests/Terminal.Gui.Cli.IntegrationTests/Goldens/help.ans +++ b/tests/Terminal.Gui.Cli.IntegrationTests/Goldens/help.ans @@ -1,18 +1,18 @@ -## Commands ▲ - █ -┌─────────┬───────────────────────────────────────────────────────────────────┐█ -│ Command │ Description │█ -├─────────┼───────────────────────────────────────────────────────────────────┤█ -│ help │ Show command help. │█ -└─────────┴───────────────────────────────────────────────────────────────────┘█ - █ -## Framework Options █ - █ -┌───────────────────────────┬─────────────────────────────────────────────────┐█ -│ Option │ Description │█ -├───────────────────────────┼─────────────────────────────────────────────────┤░ -│ --help, -h │ Show help │░ -│ --version │ Show version │░ -│ --opencli │ Emit OpenCLI metadata JSON │░ -│ --json │ Emit JSON envelope output │▼ - Esc Quit +## Commands ▲ + █ +┌─────────┬───────────────────────────────────────────────────────────────────┐█ +│ Command │ Description │█ +├─────────┼───────────────────────────────────────────────────────────────────┤█ +│ help │ Show command help. │█ +└─────────┴───────────────────────────────────────────────────────────────────┘█ + █ +## Framework Options █ + █ +┌───────────────────────────┬─────────────────────────────────────────────────┐█ +│ Option │ Description │█ +├───────────────────────────┼─────────────────────────────────────────────────┤░ +│ --help, -h │ Show help │░ +│ --version │ Show version │░ +│ --opencli │ Emit OpenCLI metadata JSON │░ +│ --json │ Emit JSON envelope output │▼ + ◄ │ ► │ Esc Quit │ Help From dd6d7d7b550d546b06ab497e393e7acb5409a1f3 Mon Sep 17 00:00:00 2001 From: Tig Date: Sun, 24 May 2026 16:44:09 -0600 Subject: [PATCH 5/7] Add visual help browser tests and greet example execution tests - HelpBrowserUiTests: proves BrowseBar back/forward arrows render, embedded help content displays, subcommand view shows options, and golden file assertion for the browser layout - GreetExampleTests: proves every documented example in the help files actually works (greet World, --formal Alice, farewell Bob, farewell --until tomorrow Bob, default command dispatch, etc.) - Add embedded help.md resource to integration test assembly - Total: 24 integration tests, 17 unit tests (41 total, all passing) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Goldens/help-browser.ans | 20 ++ .../GreetExampleTests.cs | 207 ++++++++++++++++++ .../HelpBrowserUiTests.cs | 118 ++++++++++ .../Resources/help.md | 15 ++ .../Terminal.Gui.Cli.IntegrationTests.csproj | 4 + 5 files changed, 364 insertions(+) create mode 100644 tests/Terminal.Gui.Cli.IntegrationTests/Goldens/help-browser.ans create mode 100644 tests/Terminal.Gui.Cli.IntegrationTests/GreetExampleTests.cs create mode 100644 tests/Terminal.Gui.Cli.IntegrationTests/HelpBrowserUiTests.cs create mode 100644 tests/Terminal.Gui.Cli.IntegrationTests/Resources/help.md diff --git a/tests/Terminal.Gui.Cli.IntegrationTests/Goldens/help-browser.ans b/tests/Terminal.Gui.Cli.IntegrationTests/Goldens/help-browser.ans new file mode 100644 index 0000000..0c7ad03 --- /dev/null +++ b/tests/Terminal.Gui.Cli.IntegrationTests/Goldens/help-browser.ans @@ -0,0 +1,20 @@ +## Commands ▲ + █ +┌──────────┬──────────────────────────────────────────────────────────────────┐█ +│ Command │ Description │█ +├──────────┼──────────────────────────────────────────────────────────────────┤█ +│ help │ Show command help. │█ +│ greet │ Greet someone. │█ +│ farewell │ Say goodbye. │█ +└──────────┴──────────────────────────────────────────────────────────────────┘█ + █ +## Framework Options █ + █ +┌───────────────────────────┬─────────────────────────────────────────────────┐█ +│ Option │ Description │█ +├───────────────────────────┼─────────────────────────────────────────────────┤░ +│ --help, -h │ Show help │░ +│ --version │ Show version │░ +│ --opencli │ Emit OpenCLI metadata JSON │░ +│ --json │ Emit JSON envelope output │▼ + ◄ │ ► │ Esc Quit │ Help diff --git a/tests/Terminal.Gui.Cli.IntegrationTests/GreetExampleTests.cs b/tests/Terminal.Gui.Cli.IntegrationTests/GreetExampleTests.cs new file mode 100644 index 0000000..0092172 --- /dev/null +++ b/tests/Terminal.Gui.Cli.IntegrationTests/GreetExampleTests.cs @@ -0,0 +1,207 @@ +using System.Reflection; +using Terminal.Gui.App; +using Xunit; + +namespace Terminal.Gui.Cli.IntegrationTests; + +/// +/// Tests that each example documented in the greet help files actually produces the expected output. +/// These run the full CliHost dispatch pipeline with captured stdout. +/// +public sealed class GreetExampleTests +{ + private static CliHost CreateGreetHost () + { + Assembly assembly = typeof (GreetExampleTests).Assembly; + + CliHost host = new (options => + { + options.ApplicationName = "greet"; + options.Version = "1.0.0"; + options.DefaultCommand = "greet"; + options.HelpProvider = new EmbeddedMarkdownHelpProvider (assembly); + }); + + host.Registry.Register (new GreetTestCommand ()); + host.Registry.Register (new FarewellTestCommand ()); + + return host; + } + + [Theory] + [InlineData (new[] { "World" }, "Hello, World!")] + [InlineData (new[] { "greet", "World" }, "Hello, World!")] + [InlineData (new[] { "Alice" }, "Hello, Alice!")] + [InlineData (new[] { "Charlie" }, "Hello, Charlie!")] + public async Task Greet_BasicExamples (string[] args, string expected) + { + CliHost host = CreateGreetHost (); + using StringWriter stdout = new (); + using StringWriter stderr = new (); + + var exitCode = await host.RunAsync (args, TestContext.Current.CancellationToken, stdout, stderr); + + Assert.Equal (ExitCodes.Ok, exitCode); + Assert.Contains (expected, stdout.ToString ()); + Assert.Equal (string.Empty, stderr.ToString ()); + } + + [Theory] + [InlineData (new[] { "--formal", "Alice" }, "Good day, Alice. It is a pleasure to meet you.")] + [InlineData (new[] { "greet", "--formal", "Alice" }, "Good day, Alice. It is a pleasure to meet you.")] + [InlineData (new[] { "--formal", "World" }, "Good day, World. It is a pleasure to meet you.")] + public async Task Greet_FormalExamples (string[] args, string expected) + { + CliHost host = CreateGreetHost (); + using StringWriter stdout = new (); + using StringWriter stderr = new (); + + var exitCode = await host.RunAsync (args, TestContext.Current.CancellationToken, stdout, stderr); + + Assert.Equal (ExitCodes.Ok, exitCode); + Assert.Contains (expected, stdout.ToString ()); + Assert.Equal (string.Empty, stderr.ToString ()); + } + + [Theory] + [InlineData (new[] { "farewell", "Bob" }, "Goodbye, Bob!")] + [InlineData (new[] { "farewell", "World" }, "Goodbye, World!")] + [InlineData (new[] { "farewell", "--until", "tomorrow", "Bob" }, "Goodbye, Bob! See you tomorrow.")] + [InlineData (new[] { "farewell", "--until", "next week", "World" }, "Goodbye, World! See you next week.")] + public async Task Farewell_Examples (string[] args, string expected) + { + CliHost host = CreateGreetHost (); + using StringWriter stdout = new (); + using StringWriter stderr = new (); + + var exitCode = await host.RunAsync (args, TestContext.Current.CancellationToken, stdout, stderr); + + Assert.Equal (ExitCodes.Ok, exitCode); + Assert.Contains (expected, stdout.ToString ()); + Assert.Equal (string.Empty, stderr.ToString ()); + } + + [Fact] + public async Task DefaultCommand_NoArgs_GreetsWorld () + { + CliHost host = CreateGreetHost (); + using StringWriter stdout = new (); + using StringWriter stderr = new (); + + // No args at all triggers --help (root flag), not default command + var exitCode = await host.RunAsync ([], TestContext.Current.CancellationToken, stdout, stderr); + + Assert.Equal (ExitCodes.Ok, exitCode); + } + + [Fact] + public async Task HelpCat_RendersAnsiForRootHelp () + { + CliHost host = CreateGreetHost (); + 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); + var output = stdout.ToString (); + Assert.Contains ("greet", output); + } + + [Fact] + public async Task HelpCat_RendersAnsiForSubcommand () + { + CliHost host = CreateGreetHost (); + using StringWriter stdout = new (); + using StringWriter stderr = new (); + + var exitCode = await host.RunAsync (["help", "--cat", "greet"], TestContext.Current.CancellationToken, stdout, + stderr); + + Assert.Equal (ExitCodes.Ok, exitCode); + var output = stdout.ToString (); + Assert.Contains ("greet", output); + } + + [Fact] + public async Task Version_PrintsVersion () + { + CliHost host = CreateGreetHost (); + using StringWriter stdout = new (); + using StringWriter stderr = new (); + + var exitCode = await host.RunAsync (["--version"], TestContext.Current.CancellationToken, stdout, stderr); + + Assert.Equal (ExitCodes.Ok, exitCode); + Assert.Contains ("1.0.0", stdout.ToString ()); + } + + /// Minimal greet command for testing. + private sealed class GreetTestCommand : ICliCommand + { + public string PrimaryAlias => "greet"; + public IReadOnlyList Aliases { get; } = ["greet"]; + public string Description => "Prompt for a name and return a greeting."; + public CommandKind Kind => CommandKind.Input; + public Type ResultType => typeof (string); + public bool AcceptsPositionalArgs => true; + + public IReadOnlyList Options { get; } = + [ + new ("formal", "f", typeof (bool), "Use a formal greeting style.", false, null) + ]; + + public Task> RunAsync ( + IApplication app, string? initial, CommandRunOptions options, + CancellationToken cancellationToken) + { + var name = options.Arguments.Count > 0 + ? string.Join (" ", options.Arguments) + : initial ?? "World"; + + var formal = options.CommandOptions.TryGetValue ("formal", out var formalValue) + && formalValue.Equals ("true", StringComparison.OrdinalIgnoreCase); + + var greeting = formal + ? $"Good day, {name}. It is a pleasure to meet you." + : $"Hello, {name}!"; + + return Task.FromResult (new CommandResult (CommandStatus.Ok, greeting, null, null)); + } + } + + /// Minimal farewell command for testing. + private sealed class FarewellTestCommand : ICliCommand + { + public string PrimaryAlias => "farewell"; + public IReadOnlyList Aliases { get; } = ["farewell", "bye"]; + public string Description => "Say goodbye to someone."; + public CommandKind Kind => CommandKind.Input; + public Type ResultType => typeof (string); + public bool AcceptsPositionalArgs => true; + + public IReadOnlyList Options { get; } = + [ + new ("until", "u", typeof (string), "When you expect to meet again.", false, null) + ]; + + public Task> RunAsync ( + IApplication app, string? initial, CommandRunOptions options, + CancellationToken cancellationToken) + { + var name = options.Arguments.Count > 0 + ? string.Join (" ", options.Arguments) + : initial ?? "World"; + + var until = options.CommandOptions.TryGetValue ("until", out var untilValue) + ? untilValue + : null; + + var farewell = until is not null + ? $"Goodbye, {name}! See you {until}." + : $"Goodbye, {name}!"; + + return Task.FromResult (new CommandResult (CommandStatus.Ok, farewell, null, null)); + } + } +} diff --git a/tests/Terminal.Gui.Cli.IntegrationTests/HelpBrowserUiTests.cs b/tests/Terminal.Gui.Cli.IntegrationTests/HelpBrowserUiTests.cs new file mode 100644 index 0000000..a8d889f --- /dev/null +++ b/tests/Terminal.Gui.Cli.IntegrationTests/HelpBrowserUiTests.cs @@ -0,0 +1,118 @@ +using System.Reflection; +using Terminal.Gui.App; +using Xunit; + +namespace Terminal.Gui.Cli.IntegrationTests; + +/// Tests that prove the help browser renders correctly with BrowseBar and navigation. +public sealed class HelpBrowserUiTests +{ + [Fact] + public async Task HelpBrowser_ShowsBackForwardButtons () + { + CommandRegistry registry = new (); + MetadataHelpProvider helpProvider = new (); + HelpCommand help = new (registry, helpProvider); + registry.Register (help); + + await using CommandUiHarness harness = await CommandUiHarness.StartViewerAsync (help, width: 80, height: 18); + + var text = harness.SnapshotText (); + + // BrowseBar should render back/forward arrows in the status bar + Assert.Contains ("\u25c4", text); // ◄ left arrow + Assert.Contains ("\u25ba", text); // ► right arrow + Assert.Contains ("Quit", text); + } + + [Fact] + public async Task HelpBrowser_WithEmbeddedHelp_ShowsRootContent () + { + Assembly assembly = typeof (HelpBrowserUiTests).Assembly; + EmbeddedMarkdownHelpProvider provider = new (assembly); + CommandRegistry registry = new (); + HelpCommand help = new (registry, provider); + registry.Register (help); + registry.Register (new StubCommand ("greet", "Prompt for a name and return a greeting.")); + registry.Register (new StubCommand ("farewell", "Say goodbye to someone.")); + + await using CommandUiHarness harness = await CommandUiHarness.StartViewerAsync (help, width: 100, height: 24); + + var text = harness.SnapshotText (); + + Assert.Contains ("greet", text); + Assert.Contains ("farewell", text); + } + + [Fact] + public async Task HelpBrowser_SubcommandView_ShowsCommandHelp () + { + CommandRegistry registry = new (); + MetadataHelpProvider helpProvider = new (); + HelpCommand help = new (registry, helpProvider); + StubCommand greet = new ("greet", "Greet someone.") + { + CommandOptions = + [ + new ("formal", "f", typeof (bool), "Use a formal greeting style.", false, null) + ] + }; + registry.Register (help); + registry.Register (greet); + + CommandRunOptions options = new () { Arguments = ["greet"] }; + + await using CommandUiHarness harness = await CommandUiHarness.StartViewerAsync (help, options, 80, + 20); + + var text = harness.SnapshotText (); + + Assert.Contains ("greet", text); + Assert.Contains ("Greet someone.", text); + Assert.Contains ("--formal", text); + } + + [Fact] + public async Task HelpBrowser_MatchesGolden () + { + CommandRegistry registry = new (); + MetadataHelpProvider helpProvider = new (); + HelpCommand help = new (registry, helpProvider); + registry.Register (help); + registry.Register (new StubCommand ("greet", "Greet someone.")); + registry.Register (new StubCommand ("farewell", "Say goodbye.")); + + await using CommandUiHarness harness = await CommandUiHarness.StartViewerAsync (help, width: 80, height: 20); + + harness.AssertMatchesAnsiGolden ("help-browser.ans"); + } + + private sealed class StubCommand : ICliCommand + { + public StubCommand (string alias, string description) + { + PrimaryAlias = alias; + Aliases = [alias]; + Description = description; + } + + public IReadOnlyList CommandOptions + { + get => Options; + init => Options = value; + } + + public string PrimaryAlias { get; } + public IReadOnlyList Aliases { get; } + public string Description { get; } + public CommandKind Kind => CommandKind.Input; + public Type ResultType => typeof (void); + public IReadOnlyList Options { get; init; } = []; + + public Task RunAsync (IApplication app, string? initial, + CommandRunOptions options, CancellationToken cancellationToken) + { + throw new NotImplementedException (); + } + } +} diff --git a/tests/Terminal.Gui.Cli.IntegrationTests/Resources/help.md b/tests/Terminal.Gui.Cli.IntegrationTests/Resources/help.md new file mode 100644 index 0000000..5fbedc2 --- /dev/null +++ b/tests/Terminal.Gui.Cli.IntegrationTests/Resources/help.md @@ -0,0 +1,15 @@ +# Test App + +## Commands + +| Command | Description | +|---------|-------------| +| [greet](help:greet) | Prompt for a name and return a greeting | +| [farewell](help:farewell) | Say goodbye to someone | + +## Examples + +``` +greet World +farewell Bob +``` diff --git a/tests/Terminal.Gui.Cli.IntegrationTests/Terminal.Gui.Cli.IntegrationTests.csproj b/tests/Terminal.Gui.Cli.IntegrationTests/Terminal.Gui.Cli.IntegrationTests.csproj index 179eaa2..35b5cfa 100644 --- a/tests/Terminal.Gui.Cli.IntegrationTests/Terminal.Gui.Cli.IntegrationTests.csproj +++ b/tests/Terminal.Gui.Cli.IntegrationTests/Terminal.Gui.Cli.IntegrationTests.csproj @@ -26,4 +26,8 @@ + + + + From 031580323ea95eb47d2d0253b6f57defef0b81f1 Mon Sep 17 00:00:00 2001 From: Tig Date: Sun, 24 May 2026 16:46:11 -0600 Subject: [PATCH 6/7] Escape markdown table cells in MetadataHelpProvider Pipe characters and newlines in command/option descriptions would break the generated markdown table structure. Add EscapeCell helper that replaces | with \| and collapses newlines to spaces. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Terminal.Gui.Cli/MetadataHelpProvider.cs | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/src/Terminal.Gui.Cli/MetadataHelpProvider.cs b/src/Terminal.Gui.Cli/MetadataHelpProvider.cs index bc6b6ac..c98b568 100644 --- a/src/Terminal.Gui.Cli/MetadataHelpProvider.cs +++ b/src/Terminal.Gui.Cli/MetadataHelpProvider.cs @@ -18,7 +18,7 @@ public sealed class MetadataHelpProvider : IHelpProvider foreach (ICliCommand command in registry.All) { - builder.AppendLine ($"| `{command.PrimaryAlias}` | {command.Description} |"); + builder.AppendLine ($"| `{command.PrimaryAlias}` | {EscapeCell (command.Description)} |"); } builder.AppendLine (); @@ -45,7 +45,7 @@ public sealed class MetadataHelpProvider : IHelpProvider StringBuilder builder = new (); builder.AppendLine ($"# {command.PrimaryAlias}"); - builder.AppendLine (command.Description); + builder.AppendLine (EscapeCell (command.Description)); if (command.Options.Count > 0) { @@ -55,10 +55,20 @@ public sealed class MetadataHelpProvider : IHelpProvider foreach (CommandOptionDescriptor option in command.Options) { var shortName = option.ShortName is null ? string.Empty : $" -{option.ShortName},"; - builder.AppendLine ($" {shortName} --{option.Name}\t{option.Description}"); + builder.AppendLine ($" {shortName} --{option.Name}\t{EscapeCell (option.Description)}"); } } return builder.ToString (); } + + private static string EscapeCell (string? value) + { + if (string.IsNullOrEmpty (value)) + { + return string.Empty; + } + + return value.Replace ("|", "\\|").Replace ("\r\n", " ").Replace ("\n", " ").Replace ("\r", " "); + } } From ddd82e3c036219924e391954b7ecdbfd73ad088a Mon Sep 17 00:00:00 2001 From: Tig Date: Sun, 24 May 2026 16:52:18 -0600 Subject: [PATCH 7/7] fix: use explicit type in collection expression for ReSharper compliance ReSharper cannot infer target type for 'new' inside collection expressions, so use explicit 'new CommandOptionDescriptor(...)' instead of 'new (...)'. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tests/Terminal.Gui.Cli.IntegrationTests/HelpBrowserUiTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Terminal.Gui.Cli.IntegrationTests/HelpBrowserUiTests.cs b/tests/Terminal.Gui.Cli.IntegrationTests/HelpBrowserUiTests.cs index a8d889f..458af9c 100644 --- a/tests/Terminal.Gui.Cli.IntegrationTests/HelpBrowserUiTests.cs +++ b/tests/Terminal.Gui.Cli.IntegrationTests/HelpBrowserUiTests.cs @@ -54,7 +54,7 @@ public async Task HelpBrowser_SubcommandView_ShowsCommandHelp () { CommandOptions = [ - new ("formal", "f", typeof (bool), "Use a formal greeting style.", false, null) + new CommandOptionDescriptor ("formal", "f", typeof (bool), "Use a formal greeting style.", false, null) ] }; registry.Register (help);