PoshInteractive.psm1

function Invoke-InteractivePipeline {
    <#
    .SYNOPSIS
    This PowerShell function provides a set of handy pipeline tools for interactive sessions.
 
    .DESCRIPTION
    This tool can do stuff like an on demand progress indicator, pausing pipeline, stepping up and down the pipeline, and exiting pipeline cleanly
 
    .PARAMETER InputObject
    The pipeline object to send on downstream
 
    .PARAMETER TotalCount
    The number of total items to be iterated over. This can be used to calculate SecondsLeft and PercentComplete for Write-Progress
 
    .PARAMETER Pause
    Begin by starting at the command screen
 
    .PARAMETER ShowProgress
    Enable updating via Write-Progress for each object that is iterated over
 
    .PARAMETER StatusMessage
    A custom scriptblock to run when showing progress. Some parameters are passed into the scriptblock via $args variable.
 
    Available parameters in $args are:
    Existing: The default status message
    InputObject: The current item loaded in the pipeline
    TotalCount: The number of expected objects down the pipeline
 
    .EXAMPLE
    1..100 | iip | %{ sleep -mil 100 }
 
    # Press P or ? to pause execution and enter command mode
 
    .EXAMPLE
    1..100 | iip -ShowProgress -TotalCount 100 | %{ sleep -mil 100 }
 
    # Tells the command how many iterations to expect because that is an unknown in pipeline exection
 
    .EXAMPLE
    Interactive (1..100) -ShowProgress | %{ sleep -mil 100 }
 
    # Same as prior example, but can determine object count due to no pipeline
 
    .EXAMPLE
    Interactive (1..100) -ShowProgress -StatusMessage {"{0} - {1}" -f $args.Existing,$args.InputObject} | %{ sleep -mil 100 }
 
    Append the InputObject onto the Write-Progress Status parameter.
 
    .EXAMPLE
    Interactive (1..100) -ShowProgress -StatusMessage {"{0}/{1} - {2}" -f $args.CurrentCount,$args.TotalCount,$args.InputObject} | %{ sleep -mil 100 }
 
    Override the Write-Progress Status parameter with a custom output text
 
    .EXAMPLE
    Interactive (1..100) -ShowProgress | %{ sleep -mil 50 ;$_/2} | iip | measure -Average
 
    # Press P to pause, then C to show current object, then <Right> to move to the other Invoke-InteractivePipeline (Interactive/iip), then press C to show current object.
    # Notice that if you press N and then C, you are still only seeing the objects as they pass the second instance of iip in the pipeline.
 
    .NOTES
    This function unrolls any input arrays! You have been warned.
 
    #>

    [Alias('iip', 'interactive')]
    # [CmdletBinding(PositionalBinding = $false)]
    [CmdletBinding()]
    param(
        [Parameter(ValueFromPipeline)]
        $InputObject,

        # [Parameter(Position = 0)]
        [ValidateRange(1, [int]::MaxValue)]
        [int] $TotalCount,

        [switch] $Pause,
        [switch] $ShowProgress,

        [ScriptBlock]$StatusMessage
    )
    begin {
        function Write-ProgressReport {
            $write_progress_splat.Status = "Currently on item #$current"
            if ($TotalCount) {
                $write_progress_splat.Status += " (of $TotalCount)"
                $write_progress_splat.PercentComplete = $current / $TotalCount * 100
                if ($write_progress_splat.PercentComplete -gt 100) {
                    $write_progress_splat.PercentComplete = 100
                    $write_progress_splat.SecondsRemaining = 0
                }
                else {
                    $write_progress_splat.SecondsRemaining = ($timer.Elapsed / ($current / $TotalCount) - $timer.Elapsed ).TotalSeconds
                    $timeleft = [timespan]::new(0, 0, $write_progress_splat.SecondsRemaining)
                    $time_string = ""
                    if ($timeleft.Days) { $time_string += "{0}d" -f $timeleft.Days }
                    if ($timeleft.Hours) { $time_string += "{0}h" -f $timeleft.Hours }
                    if ($timeleft.Minutes) { $time_string += "{0}m" -f $timeleft.Minutes }
                    if ($timeleft.Seconds) { $time_string += "{0}s" -f $timeleft.Seconds }
                    $write_progress_splat.Status += " (remaining:$time_string)"
                }
            }
            if ($StatusMessage) {
                $write_progress_splat.Status = (
                    Invoke-Command -ScriptBlock $StatusMessage -ArgumentList @{
                        Existing     = $write_progress_splat.Status
                        InputObject  = $item
                        CurrentCount = $current
                        TotalCount   = $TotalCount
                    }
                )
            }
            Write-Progress @write_progress_splat
        }

        if ($InputObject) { $TotalCount = $InputObject.count }
        $current = 0
        $write_progress_splat = @{
            Activity = "Processing pipeline objects"
        }
        if (-not $script:activeID) { $script:activeID = 1 }
        if (-not $script:scheduler) { $script:scheduler = @{} }

        # Use a hashtable with one key per command to ensure stuff is reset for each command
        $HistoryId = $MyInvocation.HistoryId
        $script:scheduler.$HistoryId++
        $myID = $script:scheduler.$HistoryId
        # First occurance of this in a pipeline should clear any leftover pauses
        if ($myID -eq 1) { $script:Paused = $false }
        # If this occurance should start paused, set to paused
        if ($Pause) { $script:Paused = $true }

        $timer = [System.Diagnostics.Stopwatch]::StartNew()
    }

    process {
        foreach ($item in $InputObject) {
            $current++

            if ([Console]::KeyAvailable -or $script:Paused) {
                $timer.Stop()
                if ($myID -eq $script:activeID) {
                    . Invoke-InteractiveCommand
                    while ([Console]::KeyAvailable) { [void][Console]::ReadKey($true) }
                }
                $timer.Start()
            }
            elseif ($ShowProgress) {
                Write-ProgressReport
            }

            $item
        }
    }

    end {
        if ($script:scheduler.$HistoryId -lt $script:activeID) {
            $script:activeID = 1
        }
    }
}

function Assert-CommandStopperInitialized {
    [CmdletBinding()]
    param()
    end {
        if ('UtilityProfile.CommandStopper' -as [type]) {
            return
        }

        Add-Type -TypeDefinition '
            using System;
            using System.ComponentModel;
            using System.Linq.Expressions;
            using System.Management.Automation;
            using System.Management.Automation.Internal;
            using System.Reflection;
            namespace UtilityProfile
            {
                [EditorBrowsable(EditorBrowsableState.Never)]
                [Cmdlet(VerbsLifecycle.Stop, "UpstreamCommand")]
                public class CommandStopper : PSCmdlet
                {
                    private static readonly Func<PSCmdlet, Exception> s_creator;
                    static CommandStopper()
                    {
                        ParameterExpression cmdlet = Expression.Parameter(typeof(PSCmdlet), "cmdlet");
                        s_creator = Expression.Lambda<Func<PSCmdlet, Exception>>(
                            Expression.New(
                                typeof(PSObject).Assembly
                                    .GetType("System.Management.Automation.StopUpstreamCommandsException")
                                    .GetConstructor(
                                        BindingFlags.Public | BindingFlags.Instance,
                                        null,
                                        new Type[] { typeof(InternalCommand) },
                                        null),
                                cmdlet),
                            "NewStopUpstreamCommandsException",
                            new ParameterExpression[] { cmdlet })
                            .Compile();
                    }
                    [Parameter(Position = 0, Mandatory = true)]
                    [ValidateNotNull]
                    public Exception Exception { get; set; }
                    [Hidden, EditorBrowsable(EditorBrowsableState.Never)]
                    public static void Stop(PSCmdlet cmdlet)
                    {
                        var exception = s_creator(cmdlet);
                        cmdlet.SessionState.PSVariable.Set("__exceptionToThrow", exception);
                        var variable = GetOrCreateVariable(cmdlet, "__exceptionToThrow");
                        object oldValue = variable.Value;
                        try
                        {
                            variable.Value = exception;
                            ScriptBlock.Create("& $ExecutionContext.InvokeCommand.GetCmdletByTypeName([UtilityProfile.CommandStopper]) $__exceptionToThrow")
                                .GetSteppablePipeline(CommandOrigin.Internal)
                                .Begin(false);
                        }
                        finally
                        {
                            variable.Value = oldValue;
                        }
                    }
                    private static PSVariable GetOrCreateVariable(PSCmdlet cmdlet, string name)
                    {
                        PSVariable result = cmdlet.SessionState.PSVariable.Get(name);
                        if (result != null)
                        {
                            return result;
                        }
                        result = new PSVariable(name, null);
                        cmdlet.SessionState.PSVariable.Set(result);
                        return result;
                    }
                    protected override void BeginProcessing()
                    {
                        throw Exception;
                    }
                }
            }'

    }
}
function Invoke-InteractiveCommand {
    # This function gets run in the local scope via dot source:
    # It edits variables directly.
    do {
        if (-not [Console]::KeyAvailable) {
            Write-Host "Processing paused by function. Press any command key (?/ /s/#/p/e/,/./n/c/j/d/other):"
        }
        $keyInfo = [Console]::ReadKey($true)

        if ($keyInfo.KeyChar -eq '?') {
            @(
                "q - Quit"
                "<space> - Write-Progress"
                "s - Toggle Write-Progress"
                "# - Enter total count for Write-Progress"
                "p - Pause processing"
                "e - Enter nested prompt"
                "<Right>/. - Move active Interactive down the pipeline (to the right)"
                "<Left>/, - Move active Interactive up the pipeline (to the left)"
                "<Down>/n - Next item"
                "c - Current item display"
                "j - Current item display (JSON)"
                "d - Debug info"
                "? - This help text"
                "Anything else - continue processing"
            ) | Out-String | Write-Host
            $script:Paused = $true
        }
        elseif ($keyInfo.KeyChar -eq 'q') {
            Assert-CommandStopperInitialized
            [UtilityProfile.CommandStopper]::Stop($PSCmdlet)
        }
        elseif ($keyInfo.KeyChar -eq ' ') {
            $script:Paused = $false
            Write-ProgressReport
        }
        elseif ($keyInfo.KeyChar -eq 's') {
            if ($ShowProgress) { $ShowProgress = $false }
            else {
                $ShowProgress = $true
            }
        }
        elseif ($keyInfo.KeyChar -eq '#') {
            [int]$TotalCount = Read-Host "TotalCount"
        }
        elseif ($keyInfo.KeyChar -eq 'p') {
            Write-Host "Processing paused. Press any command key to run that command or any other key to continue..."
            # [void][Console]::ReadKey($true)
            $script:Paused = $true
        }
        elseif ($keyInfo.KeyChar -eq 'e') {
            Write-Host "use `$item, `$PSItem, or `$Inputobject to access current item"
            $host.EnterNestedPrompt()
        }
        elseif ($keyInfo.Key -in 37, 'OemComma') {
            $script:Paused = $false
            $script:continuePause = $true
            if ($script:activeID -gt 1) {
                $script:activeID--
            }
        }
        elseif ($keyInfo.Key -in 39, 'OemPeriod') {
            $script:Paused = $false
            $script:continuePause = $true
            if ($script:scheduler.$HistoryId -gt $script:activeID) {
                $script:activeID++
            }
        }
        elseif ($keyInfo.Key -in 40, 'n') {
            $script:Paused = $false
            $script:continuePause = $true
        }
        elseif ($keyInfo.KeyChar -eq 'c') {
            $InputObject | Out-String | Write-Host
        }
        elseif ($keyInfo.KeyChar -eq 'j') {
            if ($ENV:INTERACTIVE_JSON_DEPTH) { $jsonDepth = $ENV:INTERACTIVE_JSON_DEPTH }
            else { $jsonDepth = 2 }
            $InputObject | ConvertTo-Json -Depth $jsonDepth | Out-String | Write-Host
        }
        elseif ($keyInfo.KeyChar -eq 'd') {
            "Item number: {0}, value: {1}, interactives count: {2}, active invocation: {3}" -f @(
                $current
                $item
                $script:scheduler.$HistoryId
                $script:activeID
            ) | Write-Host
        }
        else {
            $script:Paused = $false
        }

    } while ($script:Paused)

    # Enable skipping out of current loop but stop next active occurence.
    if ($script:continuePause) {
        $script:Paused = $true
        $script:continuePause = $false
    }
}