Public/Invoke-Spinner.ps1

function Invoke-Spinner {
    <#
    .SYNOPSIS
        Displays a CLI spinner while running a script block in the background.

    .DESCRIPTION
        Runs a ScriptBlock in a separate Runspace (thread) to allow the main thread
        to render a smooth animation. Returns the output of the ScriptBlock.

    .PARAMETER ScriptBlock
        The code to execute.

    .PARAMETER Message
        The text to display next to the spinner.

    .EXAMPLE
        $Result = Invoke-Spinner -Message "Downloading..." -ScriptBlock { Start-Sleep -Seconds 2; return "Done" }
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [ScriptBlock]$ScriptBlock,

        [Parameter(Mandatory = $false)]
        [string]$Message = "Processing...",

        [Parameter(Mandatory = $false)]
        [int]$TimeoutSeconds = 0
    )

    # Spinner Frames (Braille dots for PSCore, ASCII for Windows PowerShell)
    $Frames = @([char]0x280B, [char]0x2819, [char]0x2839, [char]0x2838, [char]0x283C, [char]0x2834, [char]0x2826, [char]0x2827, [char]0x2807, [char]0x280F)
    $Interval = 80 # Milliseconds

    # Save original cursor state
    try {
        $OriginCursor = [Console]::CursorVisible
        [Console]::CursorVisible = $false
    }
    catch {
        # Keep going if host doesn't support cursor manipulation (e.g. some CI envs)
    }

    # Setup the background runspace
    $PowerShell = [PowerShell]::Create()
    $null = $PowerShell.AddScript($ScriptBlock)

    try {
        # Start the background task
        $AsyncResult = $PowerShell.BeginInvoke()

        # Start timeout stopwatch (if a timeout was provided)
        $TimedOut = $false
        if ($TimeoutSeconds -gt 0) { $Stopwatch = [System.Diagnostics.Stopwatch]::StartNew() }

        # Animation Loop
        $FrameIndex = 0
        while (-not $AsyncResult.IsCompleted) {
            $CurrentFrame = $Frames[$FrameIndex % $Frames.Count]

            # \r returns to start of line. Write-Host handles color/formatting.
            Write-Host -NoNewline "`r$CurrentFrame $Message"

            Start-Sleep -Milliseconds $Interval
            $FrameIndex++

            # Check for timeout
            if ($TimeoutSeconds -gt 0 -and $Stopwatch.Elapsed.TotalSeconds -ge $TimeoutSeconds) {
                try { $PowerShell.Stop() } catch {}
                $TimedOut = $true
                break
            }
        }

        # End the task (if not timed out)
        if (-not $TimedOut) {
            $Output = $PowerShell.EndInvoke($AsyncResult)
        } else {
            try { $null = $PowerShell.EndInvoke($AsyncResult) } catch {}
        }

        # Check for timeout
        if ($TimedOut) {
            # Failure State for timeout
            Write-Host -NoNewline "`r"
            Write-Host -ForegroundColor Red -NoNewline ([char]0x2716 + " ")
            Write-Host "$Message"

            throw [System.TimeoutException]::new("Invoke-Spinner timed out after $TimeoutSeconds seconds")
        }

        # Check for errors in the background runspace
        if ($PowerShell.Streams.Error.Count -gt 0) {
            throw $PowerShell.Streams.Error[0]
        }

        # Success State
        Write-Host -NoNewline "`r"
        Write-Host -ForegroundColor Green -NoNewline ([char]0x2714 + " ")
        Write-Host "$Message"
        
        # Return the actual object from the scriptblock
        return $Output

    }
    catch {
        # Failure State
        Write-Host -NoNewline "`r"
        Write-Host -ForegroundColor Red -NoNewline ([char]0x2716 + " ")
        Write-Host "$Message"
        
        # Throw the inner exception if it exists, otherwise throw the current exception
        if ($_.Exception.InnerException) {
            throw $_.Exception.InnerException
        } else {
            throw $_
        }
    }
    finally {
        # Cleanup Resources
        $PowerShell.Dispose()
        try { [Console]::CursorVisible = $OriginCursor } catch {}
    }
}