File: TerminalInteractionCommands.cs
Web Access
Project: src\playground\Terminals\Terminals.AppHost\Terminals.AppHost.csproj (Terminals.AppHost)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.Globalization;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
 
// AppHost-owned terminals and terminal interactions are experimental.
#pragma warning disable ASPIRETERMINAL001
 
namespace Terminals.AppHost;
 
/// <summary>
/// Commands that exercise <see cref="IInteractionService.PromptTerminalAsync"/> — a dialog whose terminal is owned by the
/// AppHost itself rather than orchestrated by Aspire.
/// </summary>
/// <remarks>
/// This is the counterpart to <c>WithTerminal()</c>. With <c>WithTerminal()</c> the terminal is attached to a resource
/// DCP already runs, and the PTY lives in a separate Aspire.TerminalHost process. Here the AppHost spawns and owns the
/// process, and the session is tunneled to the browser over the dashboard's existing gRPC connection. That makes it
/// possible to shell into things Aspire does not orchestrate — the <c>docker exec</c> commands below are the
/// motivating example.
/// </remarks>
internal static class TerminalInteractionCommands
{
    /// <summary>The upper limit the number guess dialog starts on.</summary>
    private const int DefaultUpperLimit = 100;
 
    /// <summary>How long to pause between guesses so the game is watchable rather than instantaneous.</summary>
    private static readonly TimeSpan s_guessInterval = TimeSpan.FromSeconds(2);
 
    /// <summary>How long to wait for the game to print a prompt or a reply before giving up.</summary>
    private static readonly TimeSpan s_promptTimeout = TimeSpan.FromSeconds(30);
 
    /// <summary>
    /// Adds a command that opens an interactive shell running as a child process of the AppHost.
    /// </summary>
    /// <remarks>
    /// Nothing about this shell is tied to <paramref name="resource"/>; commands just need a host resource to hang off.
    /// </remarks>
    [AspireExportIgnore(Reason = "Uses interaction service callbacks and command handlers that are not ATS-compatible.")]
    public static IResourceBuilder<T> WithAppHostShellCommand<T>(this IResourceBuilder<T> resource) where T : IResource
    {
        return resource.WithCommand(
            "terminal-interaction-shell",
            "Open shell (interaction terminal)",
            executeCommand: async commandContext =>
            {
                var interactionService = commandContext.Services.GetRequiredService<IInteractionService>();
                var terminalService = commandContext.Services.GetRequiredService<TerminalService>();
 
                // The caller owns the terminal: it starts it, and disposes it here rather than the dialog doing so.
                await using var terminal = terminalService.CreateTerminal(new TerminalLaunchOptions
                {
                    Title = "Shell",
                    Executable = OperatingSystem.IsWindows() ? "cmd.exe" : "/bin/bash",
                    Arguments = OperatingSystem.IsWindows() ? [] : ["-i", "-l"],
                    Placement = TerminalPlacement.Dialog
                });
 
                terminal.Start();
 
                var result = await interactionService.PromptTerminalAsync(
                    "This shell is a child process of the AppHost. Cancel when finished; this command then disposes the terminal.",
                    terminal,
                    new TerminalInteractionOptions { Title = "AppHost shell", PrimaryButtonText = "Cancel" },
                    cancellationToken: commandContext.CancellationToken);
 
                return result.Canceled
                    ? CommandResults.Failure("Canceled")
                    : CommandResults.Success();
            });
    }
 
    /// <summary>
    /// Adds a command that shells into this container with <c>docker exec -it &lt;container&gt; /bin/sh</c>.
    /// </summary>
    [AspireExportIgnore(Reason = "Uses interaction service callbacks and command handlers that are not ATS-compatible.")]
    public static IResourceBuilder<ContainerResource> WithContainerShellCommand(this IResourceBuilder<ContainerResource> container)
    {
        return container.WithCommand(
            "terminal-interaction-docker",
            "Shell into container (docker exec)",
            executeCommand: commandContext => ExecIntoContainerAsync(
                commandContext,
                ResolveContainerName(container.Resource),
                ["/bin/sh"],
                title: $"Shell into '{container.Resource.Name}'",
                message: $"Runs `docker exec -it {ResolveContainerName(container.Resource)} /bin/sh` from the AppHost process."));
    }
 
    /// <summary>
    /// Adds a command that opens a Node REPL inside this container with <c>docker exec -it &lt;container&gt; node</c>.
    /// </summary>
    /// <remarks>
    /// The Node REPL is a readline app, so it exercises cursor addressing, history, and tab completion across the
    /// tunnel in a way a plain shell prompt does not.
    /// </remarks>
    [AspireExportIgnore(Reason = "Uses interaction service callbacks and command handlers that are not ATS-compatible.")]
    public static IResourceBuilder<ContainerResource> WithNodeReplCommand(this IResourceBuilder<ContainerResource> container)
    {
        return container.WithCommand(
            "terminal-interaction-node",
            "Node REPL (docker exec)",
            executeCommand: commandContext => ExecIntoContainerAsync(
                commandContext,
                ResolveContainerName(container.Resource),
                ["node"],
                title: "Node REPL",
                message: $"Runs `docker exec -it {ResolveContainerName(container.Resource)} node` from the AppHost process."));
    }
 
    /// <summary>
    /// Opens an interaction terminal whose process is <c>docker exec -it</c> into <paramref name="containerName"/>.
    /// </summary>
    /// <remarks>
    /// This is the motivating scenario for AppHost-owned terminals: shelling into a container in the app model without
    /// Aspire orchestrating the exec itself. <c>-it</c> is required so docker allocates a TTY on the container side;
    /// Aspire supplies the PTY on this side.
    /// </remarks>
    private static async Task<ExecuteCommandResult> ExecIntoContainerAsync(
        ExecuteCommandContext commandContext,
        string containerName,
        string[] command,
        string title,
        string message)
    {
        var interactionService = commandContext.Services.GetRequiredService<IInteractionService>();
        var terminalService = commandContext.Services.GetRequiredService<TerminalService>();
 
        // The caller owns the terminal: it starts it, and disposes it here rather than the dialog doing so.
        await using var terminal = terminalService.CreateTerminal(new TerminalLaunchOptions
        {
            Title = title,
            Executable = "docker",
            Arguments = ["exec", "-it", containerName, .. command],
            Placement = TerminalPlacement.Dialog
        });
 
        terminal.Start();
 
        var result = await interactionService.PromptTerminalAsync(
            message,
            terminal,
            new TerminalInteractionOptions { Title = title, PrimaryButtonText = "Cancel" },
            cancellationToken: commandContext.CancellationToken);
 
        return result.Canceled
            ? CommandResults.Failure("Canceled")
            : CommandResults.Success();
    }
 
    /// <summary>
    /// Adds a command that opens a dock terminal shelled into this container and drives it with the automation API.
    /// </summary>
    /// <remarks>
    /// This is the counterpart to the terminal interaction commands above. Instead of a modal dialog bound to a single
    /// dialog lifetime, the terminal becomes a tab in the dashboard's terminal dock (backtick shortcut) that outlives the command
    /// that created it. It also exercises <c>AspireTerminal</c>'s automation surface — send input, wait for output,
    /// read the screen — which is how AppHost code can script a terminal it owns.
    /// </remarks>
    [AspireExportIgnore(Reason = "Uses TerminalService and command handlers that are not ATS-compatible.")]
    public static IResourceBuilder<ContainerResource> WithDockShellCommand(this IResourceBuilder<ContainerResource> container)
    {
        return container.WithCommand(
            "terminal-dock-shell",
            "Shell into container (terminal dock)",
            executeCommand: async commandContext =>
            {
                var containerName = ResolveContainerName(container.Resource);
                var terminalService = commandContext.Services.GetRequiredService<TerminalService>();
 
                // Not disposed here on purpose: the tab is meant to outlive the command. The user closes it from the
                // dock, and TerminalService tears down anything still open when the AppHost shuts down.
                var terminal = terminalService.CreateTerminal(new TerminalLaunchOptions
                {
                    Title = container.Resource.Name,
                    Executable = "docker",
                    Arguments = ["exec", "-it", containerName, "/bin/sh"]
                });
 
                // Reveals the dock in every connected browser and switches it to this tab.
                terminal.Start();
                terminal.Show();
 
                try
                {
                    // Automation: type a command and wait for its output. The workload starts on the first automation
                    // call even if nobody has attached a browser yet.
                    await terminal.SendTextAsync("echo aspire-dock-ready\r", commandContext.CancellationToken);
                    await terminal.WaitForTextAsync("aspire-dock-ready", TimeSpan.FromSeconds(10), commandContext.CancellationToken);
                }
                catch (TimeoutException)
                {
                    return CommandResults.Failure("Terminal did not respond to automated input.");
                }
 
                return CommandResults.Success();
            });
    }
 
    /// <summary>
    /// Adds a command that opens a local PowerShell session in the terminal dock.
    /// </summary>
    /// <remarks>
    /// The PowerShell process is launched directly by the AppHost-owned Hex1b terminal, so this command provides a
    /// debugging control that does not depend on Docker or DCP's PTY implementation.
    /// </remarks>
    [AspireExportIgnore(Reason = "Uses TerminalService and command handlers that are not ATS-compatible.")]
    public static IResourceBuilder<ContainerResource> WithPowerShellDockCommand(this IResourceBuilder<ContainerResource> container)
    {
        return container.WithCommand(
            "terminal-dock-powershell",
            "Open PowerShell (terminal dock)",
            executeCommand: commandContext =>
            {
                var terminalService = commandContext.Services.GetRequiredService<TerminalService>();
 
                // The terminal remains open until the user closes its dock tab or the AppHost shuts down.
                var terminal = terminalService.CreateTerminal(new TerminalLaunchOptions
                {
                    Title = "PowerShell",
                    Executable = "pwsh.exe",
                    Arguments = ["-NoLogo"]
                });
 
                terminal.Start();
                terminal.Show();
 
                return Task.FromResult(CommandResults.Success());
            });
    }
 
    /// <summary>
    /// Adds a command that opens a local Command Prompt session in the terminal dock.
    /// </summary>
    /// <remarks>
    /// The Command Prompt process is launched directly by the AppHost-owned Hex1b terminal, so this command provides
    /// another Windows shell for testing terminal behavior without Docker or DCP's PTY implementation.
    /// </remarks>
    [AspireExportIgnore(Reason = "Uses TerminalService and command handlers that are not ATS-compatible.")]
    public static IResourceBuilder<ContainerResource> WithCommandPromptDockCommand(this IResourceBuilder<ContainerResource> container)
    {
        return container.WithCommand(
            "terminal-dock-command-prompt",
            "Open Command Prompt (terminal dock)",
            executeCommand: commandContext =>
            {
                var terminalService = commandContext.Services.GetRequiredService<TerminalService>();
 
                // The terminal remains open until the user closes its dock tab or the AppHost shuts down.
                var terminal = terminalService.CreateTerminal(new TerminalLaunchOptions
                {
                    Title = "Command Prompt",
                    Executable = "cmd.exe"
                });
 
                terminal.Start();
                terminal.Show();
 
                return Task.FromResult(CommandResults.Success());
            });
    }
 
    /// <summary>
    /// Adds a command that plays a terminal-based guessing game by driving the process from AppHost code.
    /// </summary>
    /// <remarks>
    /// <para>
    /// This is the "automate an interactive prompt" scenario. Plenty of tools an AppHost needs to invoke are only
    /// available as interactive console programs — they log in, prompt for confirmation, ask which subscription to
    /// use — and there is no API to call instead. A <see cref="IInteractionService.PromptTerminalAsync"/> dialog plus
    /// <see cref="AspireTerminal"/>'s automation members lets AppHost code answer those prompts itself while the
    /// human watches it happen, and step in whenever it cannot.
    /// </para>
    /// <para>
    /// The flow is: prompt for the game's upper limit, open a terminal running <c>numberguess.cs</c>, then bisect —
    /// type a guess, read the reply back off the screen, halve the range — until the number is found. The dialog is
    /// then closed from code and replaced with the answer.
    /// </para>
    /// </remarks>
    [AspireExportIgnore(Reason = "Uses TerminalService, interaction service callbacks, and command handlers that are not ATS-compatible.")]
    public static IResourceBuilder<T> WithNumberGuessCommand<T>(this IResourceBuilder<T> resource) where T : IResource
    {
        return resource.WithCommand(
            "terminal-number-guess",
            "Number guess (automated terminal)",
            executeCommand: async commandContext =>
            {
                var interactionService = commandContext.Services.GetRequiredService<IInteractionService>();
                var terminalService = commandContext.Services.GetRequiredService<TerminalService>();
 
                var limitResult = await interactionService.PromptInputsAsync(
                    "Number guess",
                    "Pick an upper limit. The AppHost will then play the game itself by typing into a terminal and reading the replies back off the screen.",
                    [
                        new InteractionInput
                        {
                            Name = "limit",
                            Label = "Upper limit",
                            InputType = InputType.Number,
                            Value = DefaultUpperLimit.ToString(CultureInfo.InvariantCulture),
                            Required = true
                        }
                    ],
                    cancellationToken: commandContext.CancellationToken);
 
                if (limitResult.Canceled)
                {
                    return CommandResults.Failure("Canceled");
                }
 
                // The dialog's number input only guarantees "a number", so clamp rather than trust it. Below 2 there
                // is nothing to bisect, and the upper bound just keeps the game short enough to sit and watch.
                if (!int.TryParse(limitResult.Data["limit"].Value, CultureInfo.InvariantCulture, out var limit))
                {
                    limit = DefaultUpperLimit;
                }
                limit = Math.Clamp(limit, 2, 1_000_000);
 
                // The command owns the terminal for its whole life: it starts it, drives the game through the
                // handle, and disposes it once the answer has been shown.
                await using var terminal = terminalService.CreateTerminal(BuildNumberGuessLaunchOptions(limit));
 
                // Start before the dialog rather than letting the first attach do it, so `dotnet run --file` is
                // already compiling the script while the dialog is being raised.
                terminal.Start();
 
                var number = 0;
                var attempts = 0;
                try
                {
                    // Work begins after the dialog is published and is joined before the caller disposes the
                    // terminal. Both the cancel button and command cancellation reach all automation calls.
                    var result = await interactionService.PromptTerminalAsync(
                        $"Guessing a number between 1 and {limit}. Every keystroke below is being typed by the AppHost.",
                        terminal,
                        new TerminalInteractionOptions
                        {
                            Title = "Number guess",
                            PrimaryButtonText = "Cancel",
                            Work = async context =>
                            {
                                (number, attempts) = await PlayNumberGuessAsync(terminal, limit, context.CancellationToken);
                                // Leave the winning line visible before successful work completion closes the dialog.
                                await Task.Delay(TimeSpan.FromSeconds(2), context.CancellationToken);
                            }
                        },
                        commandContext.CancellationToken);
 
                    if (result.Canceled)
                    {
                        return CommandResults.Failure("Canceled");
                    }
                }
                catch (OperationCanceledException) when (commandContext.CancellationToken.IsCancellationRequested)
                {
                    return CommandResults.Failure("Canceled");
                }
                catch (Exception ex)
                {
                    // Unexpected. Surface the message in the dialog, but log the full exception too: the failure is
                    // otherwise reduced to a one-line string with no stack trace, which is the hardest kind of
                    // demo failure to diagnose.
                    commandContext.Services.GetRequiredService<ILoggerFactory>()
                        .CreateLogger(nameof(TerminalInteractionCommands))
                        .LogError(ex, "The number guess automation failed unexpectedly.");
 
                    return CommandResults.Failure(ex.Message);
                }
 
                await interactionService.PromptMessageBoxAsync(
                    "Number guess",
                    $"Found it. The number was {number}, in {attempts} {(attempts == 1 ? "guess" : "guesses")}.",
                    cancellationToken: commandContext.CancellationToken);
 
                return CommandResults.Success();
            });
    }
 
    /// <summary>
    /// Adds a command that automates a <b>resource</b> terminal — one attached to a resource by
    /// <c>WithTerminal()</c>, whose PTY lives in a separate Aspire.TerminalHost process.
    /// </summary>
    /// <remarks>
    /// <para>
    /// This is the counterpart to <see cref="WithNumberGuessCommand"/>, which automates a terminal the AppHost owns.
    /// Here the AppHost is not the owner: it joins the resource's existing terminal as an additional viewer, types
    /// into it, and reads the result back off the shared screen. Whatever a human has open in the dashboard stays
    /// open and sees the same output, because the AppHost joins as a secondary and so never resizes the grid out
    /// from under them.
    /// </para>
    /// <para>
    /// The terminal is addressed by the resource name and replica index rather than by an opaque id, which is what
    /// makes it nameable across the resource's terminal host being recycled.
    /// </para>
    /// </remarks>
    [AspireExportIgnore(Reason = "Uses TerminalService and command handlers that are not ATS-compatible.")]
    public static IResourceBuilder<T> WithAutomateResourceTerminalCommand<T>(this IResourceBuilder<T> resource, string targetResourceName) where T : IResource
    {
        return resource.WithCommand(
            "terminal-automate-resource",
            "Type into this resource's terminal",
            executeCommand: async commandContext =>
            {
                var interactionService = commandContext.Services.GetRequiredService<IInteractionService>();
                var terminalService = commandContext.Services.GetRequiredService<TerminalService>();
 
                var terminalId = $"resource:{targetResourceName}:0";
 
                if (!terminalService.TryGetTerminal(terminalId, out var terminal))
                {
                    return CommandResults.Failure($"No terminal is registered for '{terminalId}'.");
                }
 
                // A marker rather than a fixed string: the shell echoes the command line before it echoes the
                // output, so a fixed string would match the echo of the command itself and the wait would succeed
                // before anything had actually run.
                var marker = Guid.NewGuid().ToString("N")[..8];
 
                try
                {
                    await terminal.SendTextAsync($"echo apphost-was-here-{marker}\n", commandContext.CancellationToken);
                    await terminal.WaitForTextAsync($"apphost-was-here-{marker}\r\n", TimeSpan.FromSeconds(15), commandContext.CancellationToken);
                }
                catch (Exception ex) when (ex is TimeoutException or InvalidOperationException)
                {
                    return CommandResults.Failure(ex.Message);
                }
 
                await interactionService.PromptMessageBoxAsync(
                    "Resource terminal automation",
                    $"Typed into `{terminalId}` from the AppHost and read the reply back off the screen. Open that resource's terminal to see the line.",
                    cancellationToken: commandContext.CancellationToken);
 
                return CommandResults.Success();
            });
    }
 
    /// <summary>
    /// Plays <c>numberguess.cs</c> to completion by bisecting, and returns the number found and how many guesses it took.
    /// </summary>
    /// <remarks>
    /// Bisection needs at most ceil(log2(limit)) guesses, so the loop is bounded by construction. The guard on an
    /// exhausted range only fires if the game stops answering consistently, which would otherwise spin forever.
    /// </remarks>
    private static async Task<(int Number, int Attempts)> PlayNumberGuessAsync(AspireTerminal terminal, int limit, CancellationToken cancellationToken)
    {
        // Generous: this is the first automation call, so it is what starts the workload, and a cold
        // `dotnet run --file` has to compile the script before the game prints anything.
        await terminal.WaitForTextAsync($"between 1 and {limit}", TimeSpan.FromMinutes(2), cancellationToken);
 
        var low = 1;
        var high = limit;
 
        for (var attempt = 1; low <= high; attempt++)
        {
            await terminal.WaitForTextAsync($"Guess #{attempt}: ", s_promptTimeout, cancellationToken);
 
            // The whole point of the demo is watching it play, so slow it down to human speed.
            await Task.Delay(s_guessInterval, cancellationToken);
 
            var guess = low + ((high - low) / 2);
            await terminal.SendTextAsync($"{guess.ToString(CultureInfo.InvariantCulture)}\r", cancellationToken);
 
            switch (await ReadReplyAsync(terminal, attempt, guess, cancellationToken))
            {
                case NumberGuessReply.Correct:
                    return (guess, attempt);
                case NumberGuessReply.TooLow:
                    low = guess + 1;
                    break;
                case NumberGuessReply.TooHigh:
                    high = guess - 1;
                    break;
            }
        }
 
        throw new InvalidOperationException("The game ruled out every number in the range without accepting a guess.");
    }
 
    /// <summary>
    /// Waits for the game's reply to a guess and reads it off the terminal screen.
    /// </summary>
    /// <remarks>
    /// The script tags each reply with its attempt number — <c>&gt;&gt; #3: 42 is too high</c> — so this can match on
    /// the whole reply rather than a prefix. That matters: waiting for <c>"#3: 42 is "</c> and then reading the screen
    /// would race the rest of the line being written. Polling for one of the three complete replies has no such race,
    /// and the attempt number keeps an earlier reply still on screen from being misread as this one.
    /// </remarks>
    private static async Task<NumberGuessReply> ReadReplyAsync(AspireTerminal terminal, int attempt, int guess, CancellationToken cancellationToken)
    {
        var prefix = $">> #{attempt.ToString(CultureInfo.InvariantCulture)}: {guess.ToString(CultureInfo.InvariantCulture)} is ";
        var deadline = DateTime.UtcNow + s_promptTimeout;
 
        while (true)
        {
            var screen = terminal.GetScreenText();
 
            if (screen.Contains(prefix + "correct", StringComparison.Ordinal))
            {
                return NumberGuessReply.Correct;
            }
 
            if (screen.Contains(prefix + "too low", StringComparison.Ordinal))
            {
                return NumberGuessReply.TooLow;
            }
 
            if (screen.Contains(prefix + "too high", StringComparison.Ordinal))
            {
                return NumberGuessReply.TooHigh;
            }
 
            if (DateTime.UtcNow >= deadline)
            {
                throw new TimeoutException($"The game did not reply to guess #{attempt} ({guess}) within {s_promptTimeout.TotalSeconds} seconds.");
            }
 
            await Task.Delay(TimeSpan.FromMilliseconds(100), cancellationToken);
        }
    }
 
    /// <summary>
    /// Builds the launch options for the <c>numberguess.cs</c> file-based app.
    /// </summary>
    /// <remarks>
    /// The script is copied next to the AppHost binary (see the <c>Scripts\</c> item group in the project file) so it
    /// can be found without knowing where the source tree is. <c>DOTNET_HOST_PATH</c> is preferred over a bare
    /// <c>dotnet</c> so the game runs on the same SDK as the AppHost when one is pinned; file-based apps need .NET 10
    /// or later, which whatever is first on <c>PATH</c> may not be.
    /// </remarks>
    private static TerminalLaunchOptions BuildNumberGuessLaunchOptions(int limit)
    {
        var scriptPath = Path.Combine(AppContext.BaseDirectory, "Scripts", "numberguess.cs");
        var dotnet = Environment.GetEnvironmentVariable("DOTNET_HOST_PATH") is { Length: > 0 } hostPath ? hostPath : "dotnet";
 
        return new TerminalLaunchOptions
        {
            Title = "Number guess",
            Placement = TerminalPlacement.Dialog,
            Executable = dotnet,
            Arguments = ["run", "--file", scriptPath, "--", limit.ToString(CultureInfo.InvariantCulture)]
        };
    }
 
    /// <summary>
    /// Resolves the name docker knows this container by.
    /// </summary>
    /// <remarks>
    /// Without <c>WithContainerName</c>, DCP appends a random suffix to the resource name, so the resource name alone
    /// would not be a valid <c>docker exec</c> target. These playground containers set an explicit name; the fallback
    /// only exists so a misconfigured resource surfaces a docker error rather than throwing here.
    /// </remarks>
    private static string ResolveContainerName(ContainerResource container)
    {
        return container.TryGetLastAnnotation<ContainerNameAnnotation>(out var annotation)
            ? annotation.Name
            : container.Name;
    }
 
    /// <summary>
    /// The game's answer to a single guess.
    /// </summary>
    private enum NumberGuessReply
    {
        TooLow,
        TooHigh,
        Correct
    }
}