Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,13 @@
<Company>gui-cs</Company>
<Copyright>Copyright (c) gui-cs and contributors</Copyright>

<Version>0.1.0-develop</Version>
<Version>0.2.0-develop</Version>
<PackageProjectUrl>https://github.com/gui-cs/cli</PackageProjectUrl>
<RepositoryUrl>https://github.com/gui-cs/cli</RepositoryUrl>
<RepositoryType>git</RepositoryType>
<PackageLicenseFile>LICENSE</PackageLicenseFile>

<TerminalGuiVersion Condition="'$(TerminalGuiVersion)' == ''">2.4.1-develop.11</TerminalGuiVersion>
<TerminalGuiVersion Condition="'$(TerminalGuiVersion)' == ''">2.4.3</TerminalGuiVersion>

<!-- SourceLink & symbol packages -->
<PublishRepositoryUrl>true</PublishRepositoryUrl>
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ dotnet run --project tests/Terminal.Gui.Cli.IntegrationTests
dotnet run --project tests/Terminal.Gui.Cli.SmokeTests

# Try the example app
dotnet run --project examples/Terminal.Gui.Cli.ExampleApp -- greet --initial "World" --json
dotnet run --project examples/greet -- greet --initial "World" --json
```

## Status
Expand Down
4 changes: 2 additions & 2 deletions examples/greet/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,11 +41,11 @@ greet --version
## Building

```bash
dotnet build examples/Terminal.Gui.Cli.Greet/Terminal.Gui.Cli.Greet.csproj
dotnet build examples/greet/Terminal.Gui.Cli.Greet.csproj
```

## Running

```bash
dotnet run --project examples/Terminal.Gui.Cli.Greet -- greet --initial "World"
dotnet run --project examples/greet -- greet --initial "World"
```
31 changes: 31 additions & 0 deletions specs/library-spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,35 @@ Public API additions must keep the following contracts aligned with implementati
- Output and metadata: `JsonEnvelope`, `ResultWriter`, `OpenCliWriter`, `ExitCodes`, `TypeNames`, `TerminalEscapeSanitizer`, and `MarkdownRenderer`.
- Input helper: `InputCommandRunner`.

## Result value JSON serialization

The `--json` envelope serializes `CommandResult.Value` through the source-generated
`CliJsonContext` (constitution C4). That built-in context only resolves the library's own
value types, so consumer commands that return custom result types must supply a
source-generated resolver:

- `CliHostOptions.ResultJsonResolver` (`IJsonTypeInfoResolver?`) — a consumer
`JsonSerializerContext` (or any resolver) registered on the host.
- `JsonEnvelope.ToJson(IJsonTypeInfoResolver?)` and the optional `resultJsonResolver`
parameter on `ResultWriter.Write` thread that resolver through serialization.

The resolver is combined with `CliJsonContext` via `JsonTypeInfoResolver.Combine`, keeping
the path reflection-free and AOT-compatible. When `ResultJsonResolver` is null, envelope
values remain restricted to the built-in value types.

## Default command dispatch

`CliHostOptions.DefaultCommand` (`string?`) names the alias to invoke when args do not
resolve to a registered command. When set, `CliHost` routes to that command in three cases:

- Args fail to parse (instead of a usage error).
- Args are empty (which otherwise maps to root `Help`).
- The leading token is not a recognized command alias.

In each case the host re-parses `[DefaultCommand, ..args]` against the resolved default
command, so bare positional args and unrecognized options are retried as args to it. If
`DefaultCommand` names an alias that is not registered, the host emits
`Default command '<name>' is not registered.` and returns a usage error. When
`DefaultCommand` is null, the original parse/usage-error behavior is preserved.

`CommandResult` and `CommandResult<T>` intentionally live together in `CommandResult.cs`. `ICliCommand<TValue>` intentionally lives in `ICliCommandGeneric.cs`; do not use angle brackets in filenames.
86 changes: 77 additions & 9 deletions src/Terminal.Gui.Cli/CliHost.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
using System.Reflection;
using System.Text;
using Terminal.Gui.App;
using Terminal.Gui.Drivers;

namespace Terminal.Gui.Cli;

Expand Down Expand Up @@ -49,6 +51,13 @@ public async Task<int> RunAsync (

if (initialParse.RootFlag is { } rootFlag)
{
// When a DefaultCommand is set and args are empty (which maps to Help),
// run the default command instead of showing help.
if (rootFlag == ArgParser.RootFlag.Help && args.Length == 0 && _options.DefaultCommand is not null)
{
return await RunWithDefaultCommandAsync (args, cancellationToken, stdout, stderr);
}

WriteRootFlag (rootFlag, stdout);
return ExitCodes.Ok;
}
Expand Down Expand Up @@ -117,6 +126,11 @@ private async Task<int> ExecuteCommandAsync (
TextWriter stdout,
TextWriter stderr)
{
// Capture whether each writer is the real console before Terminal.Gui runs. Changing
// Console.OutputEncoding replaces Console.Out/Error, so this comparison is only valid now.
var stdoutIsConsole = ReferenceEquals (stdout, Console.Out);
var stderrIsConsole = ReferenceEquals (stderr, Console.Error);

if (runOptions.Initial is not null && !command.TryValidateInitial (runOptions.Initial, runOptions))
{
stderr.WriteLine ("Invalid --initial value.");
Expand Down Expand Up @@ -147,9 +161,18 @@ private async Task<int> ExecuteCommandAsync (
return ExitCodes.Cancelled;
}

if (catResult is not null)
if (catResult is { } cat)
{
return ExitCodes.FromResult (catResult.Value);
// RenderCatAsync writes its own rendered output for successful results. For
// non-success results it produced no output, so surface the diagnostic (to stderr
// in plain text, or the error envelope under --json) instead of exiting silently.
if (cat.Status is not (CommandStatus.Ok or CommandStatus.NoResult))
{
ResultWriter.Write (cat, runOptions.JsonOutput, stdout, stderr, runOptions.OutputPath,
_options.ResultJsonResolver);
}

return ExitCodes.FromResult (cat);
}
}

Expand All @@ -164,7 +187,32 @@ private async Task<int> ExecuteCommandAsync (
result = CreateCancelledResult ();
}

if (!ResultWriter.Write (result, runOptions.JsonOutput, stdout, stderr, runOptions.OutputPath))
// Terminal.Gui may change Console.OutputEncoding during its session (e.g. to UTF-8 for
// rendering). After shutdown, the encoding might be restored to OEM or left as UTF-8.
// Either way, console writer references captured before TG ran are now stale
// (Console.Out is replaced whenever OutputEncoding changes). Ensure UTF-8 and re-acquire
// the current Console.Out/Error so Unicode content (box-drawing, etc.) renders correctly.
// Caller-supplied writers are left untouched — only real console writers go stale.
if (stdoutIsConsole || stderrIsConsole)
{
if (Console.OutputEncoding.CodePage != Encoding.UTF8.CodePage)
{
Console.OutputEncoding = Encoding.UTF8;
}

if (stdoutIsConsole)
{
stdout = Console.Out;
}

if (stderrIsConsole)
{
stderr = Console.Error;
}
}

if (!ResultWriter.Write (result, runOptions.JsonOutput, stdout, stderr, runOptions.OutputPath,
_options.ResultJsonResolver))
{
return ExitCodes.UsageError;
}
Expand All @@ -181,18 +229,38 @@ private static CommandResult CreateCancelledResult ()
null);
}

/// <summary>
/// Creates, initializes, and disposes a headless ANSI-driver Terminal.Gui application around
/// <paramref name="render" />. Centralizes the Terminal.Gui lifecycle here (constitution C1) so
/// helpers such as <see cref="MarkdownRenderer" /> never call lifecycle entrypoints directly.
/// </summary>
internal static void RunHeadlessRender (int width, int height, Action<IApplication> render)
{
var previousDriverIO = Environment.GetEnvironmentVariable ("DisableRealDriverIO");
Environment.SetEnvironmentVariable ("DisableRealDriverIO", "1");
IApplication app = Application.Create ();

try
{
app.Init (DriverRegistry.Names.ANSI);
app.Driver?.SetScreenSize (width, height);
render (app);
}
finally
{
app.Dispose ();
Environment.SetEnvironmentVariable ("DisableRealDriverIO", previousDriverIO);
}
}

private async Task<CommandResult> RunWithTerminalGuiAsync (ICliCommand command, CommandRunOptions runOptions,
CancellationToken cancellationToken)
{
var useInline = command.Kind == CommandKind.Input && !runOptions.Fullscreen;
Application.AppModel = useInline ? AppModel.Inline : AppModel.FullScreen;

using IApplication app = Application.Create ();
app.AppModel = useInline ? AppModel.Inline : AppModel.FullScreen;

if (!useInline)
{
app.Init ();
}
app.Init ();

return await command.RunAsync (app, runOptions.Initial, runOptions, cancellationToken);
}
Expand Down
8 changes: 8 additions & 0 deletions src/Terminal.Gui.Cli/CliHostOptions.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.Reflection;
using System.Text.Json.Serialization.Metadata;

namespace Terminal.Gui.Cli;

Expand Down Expand Up @@ -28,6 +29,13 @@ public sealed class CliHostOptions
/// <summary>Assembly used to resolve embedded resources. Null falls back to <see cref="Assembly.GetEntryAssembly" />.</summary>
public Assembly? ResourceAssembly { get; set; }

/// <summary>
/// Source-generated JSON resolver for command result values written to the <c>--json</c> envelope.
/// It is combined with the library's built-in envelope context so consumer result types serialize
/// without reflection. Null restricts envelope values to the library's built-in value types.
/// </summary>
public IJsonTypeInfoResolver? ResultJsonResolver { get; set; }

/// <summary>Consumer-defined global options parsed into <see cref="CommandRunOptions.Extensions" />.</summary>
public List<GlobalOptionDescriptor> GlobalOptions { get; } = [];

Expand Down
5 changes: 0 additions & 5 deletions src/Terminal.Gui.Cli/InputCommandRunner.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,6 @@ public static async Task<CommandResult<TValue>> RunAsync<TControl, TRawResult, T
ArgumentNullException.ThrowIfNull (options);
ArgumentNullException.ThrowIfNull (resultMapper);

if (!app.Initialized)
{
app.Init ();
}

wrapper.Title = options.Title ?? defaultTitle;
await app.RunAsync (wrapper, cancellationToken);
return resultMapper (wrapper.Result);
Expand Down
23 changes: 22 additions & 1 deletion src/Terminal.Gui.Cli/JsonEnvelope.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Text.Json.Serialization.Metadata;

namespace Terminal.Gui.Cli;

Expand Down Expand Up @@ -48,7 +49,27 @@ public static JsonEnvelope NoResult ()
/// <summary>Serializes using the source-generated JSON context.</summary>
public string ToJson ()
{
return JsonSerializer.Serialize (this, CliJsonContext.Default.JsonEnvelope);
return ToJson (null);
}

/// <summary>
/// Serializes the envelope using the source-generated context. When <paramref name="resultResolver" /> is
/// provided it is combined with the built-in context so consumer-defined <see cref="Value" /> types resolve
/// without reflection.
/// </summary>
public string ToJson (IJsonTypeInfoResolver? resultResolver)
{
if (resultResolver is null)
{
return JsonSerializer.Serialize (this, CliJsonContext.Default.JsonEnvelope);
}

JsonSerializerOptions options = new (CliJsonContext.Default.Options)
{
TypeInfoResolver = JsonTypeInfoResolver.Combine (CliJsonContext.Default, resultResolver)
};

return JsonSerializer.Serialize (this, options.GetTypeInfo (typeof (JsonEnvelope)));
}
}

Expand Down
65 changes: 29 additions & 36 deletions src/Terminal.Gui.Cli/MarkdownRenderer.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,4 @@
using System.Text;
using Terminal.Gui.App;
using Terminal.Gui.Drivers;
using Terminal.Gui.ViewBase;
using Terminal.Gui.Views;

Expand Down Expand Up @@ -62,46 +60,41 @@ public static void RenderToAnsi (string markdown, TextWriter output)
height = 24;
}

var previousDriverIO = Environment.GetEnvironmentVariable ("DisableRealDriverIO");
Environment.SetEnvironmentVariable ("DisableRealDriverIO", "1");
IApplication app = Application.Create ();

try
{
app.Init (DriverRegistry.Names.ANSI);
app.Driver?.SetScreenSize (width, height);

Markdown markdownView = new ()
// CliHost owns the Terminal.Gui lifecycle (constitution C1); this helper only
// performs view layout and drawing inside the callback.
CliHost.RunHeadlessRender (width, height, app =>
{
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);
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 ();
Environment.SetEnvironmentVariable ("DisableRealDriverIO", previousDriverIO);

if (previousEncoding is not null)
{
Console.OutputEncoding = previousEncoding;
Expand Down
6 changes: 4 additions & 2 deletions src/Terminal.Gui.Cli/ResultWriter.cs
Original file line number Diff line number Diff line change
@@ -1,16 +1,18 @@
using System.Text.Json.Serialization.Metadata;

namespace Terminal.Gui.Cli;

/// <summary>Formats command results to stdout, stderr, or an output file.</summary>
public static class ResultWriter
{
/// <summary>Writes <paramref name="result" /> and returns false when output file creation fails.</summary>
public static bool Write (CommandResult result, bool jsonOutput, TextWriter stdout, TextWriter stderr,
string? outputPath = null)
string? outputPath = null, IJsonTypeInfoResolver? resultJsonResolver = null)
{
ArgumentNullException.ThrowIfNull (stdout);
ArgumentNullException.ThrowIfNull (stderr);

var text = jsonOutput ? ToEnvelope (result).ToJson () : ToPlainText (result);
var text = jsonOutput ? ToEnvelope (result).ToJson (resultJsonResolver) : ToPlainText (result);
var writeToOutput = result.Status is CommandStatus.Ok or CommandStatus.NoResult;
TextWriter writer = result.Status == CommandStatus.Error && !jsonOutput ? stderr : stdout;

Expand Down
Loading
Loading