lib/Spinner.ps1

function New-Spinner {
    <#
    .SYNOPSIS
        Starts an animated spinner in a background runspace. The spinner
        renders directly to [Console] until Stop-Spinner is called. Output
        is enqueued via Write-SpinnerLine and printed between spinner ticks
        so the spinner persists across multiple tasks without flicker.

        Returns $null when output isn't going to an interactive console
        (stdout redirected, -Disabled set, or running under CI). Downstream
        callers handle a $null spinner by printing output directly to the
        Information stream.
    #>

    [CmdletBinding()]
    param(
        [string] $InitialMessage = 'Working...',

        # Force the spinner off (caller already decided, e.g. -NoSpinner).
        [switch] $Disabled
    )

    if ($Disabled) { return $null }

    # Don't draw a spinner when output isn't going to an interactive console.
    if ([Console]::IsOutputRedirected) { return $null }

    # GitHub Actions / most CI runners set these. IsOutputRedirected doesn't
    # always trip in Actions' pwsh steps, so the spinner would otherwise fill
    # the log with carriage-return noise.
    if ($env:CI -or $env:GITHUB_ACTIONS -or $env:TF_BUILD) { return $null }

    $shared = [hashtable]::Synchronized(@{
        Stop        = $false
        Message     = $InitialMessage
        OutputLines = [System.Collections.Concurrent.ConcurrentQueue[string]]::new()
        UseColor    = [bool]$script:UseColor
    })

    $rs = [runspacefactory]::CreateRunspace()
    $rs.Open()
    $rs.SessionStateProxy.SetVariable('shared', $shared)

    $ps = [powershell]::Create().AddScript({
        $esc    = [char]27
        $glyphs = '|', '/', '-', '\'
        $i = 0
        $lastVisibleLen = 0

        try {
            while (-not $shared.Stop) {
                # Drain queued output, clearing the spinner line first.
                $line = $null
                while ($shared.OutputLines.TryDequeue([ref]$line)) {
                    if ($lastVisibleLen -gt 0) {
                        [Console]::Write("`r" + (' ' * $lastVisibleLen) + "`r")
                        $lastVisibleLen = 0
                    }
                    [Console]::WriteLine($line)
                }

                # Redraw the spinner with the current message. Track the
                # visible length (ANSI escapes don't count) so we can pad
                # if the message shrinks between ticks.
                $g           = $glyphs[$i % $glyphs.Length]
                $msg         = $shared.Message
                $visibleText = "[$g] $msg"
                $visibleLen  = $visibleText.Length
                $styled      = if ($shared.UseColor) {
                    "$esc[36m[$g]$esc[0m $msg"
                }
                else {
                    $visibleText
                }
                $padding     = if ($visibleLen -lt $lastVisibleLen) {
                    ' ' * ($lastVisibleLen - $visibleLen)
                }
                else { '' }
                [Console]::Write("`r$styled$padding")
                $lastVisibleLen = $visibleLen

                Start-Sleep -Milliseconds 100
                $i++
            }
        }
        finally {
            # Erase the spinner line and flush any remaining output.
            if ($lastVisibleLen -gt 0) {
                [Console]::Write("`r" + (' ' * $lastVisibleLen) + "`r")
            }
            $line = $null
            while ($shared.OutputLines.TryDequeue([ref]$line)) {
                [Console]::WriteLine($line)
            }
        }
    })
    $ps.Runspace = $rs
    $handle = $ps.BeginInvoke()

    [PSCustomObject]@{
        Shared   = $shared
        PS       = $ps
        Runspace = $rs
        Handle   = $handle
    }
}

function Set-SpinnerMessage {
    <# .SYNOPSIS Updates the active spinner's message. #>
    [CmdletBinding()]
    param(
        [Parameter()]
        $Spinner,

        [Parameter(Mandatory)]
        [string] $Message
    )
    if ($null -eq $Spinner) { return }
    $Spinner.Shared.Message = $Message
}

function Write-SpinnerLine {
    <#
    .SYNOPSIS
        Queues a line for the spinner to print between ticks. Falls back to
        Write-Information when no spinner is active.
    #>

    [CmdletBinding()]
    param(
        [Parameter()]
        $Spinner,

        [Parameter(Mandatory)]
        [AllowEmptyString()]
        [string] $Line
    )
    if ($null -eq $Spinner) {
        Write-Information $Line
        return
    }
    $Spinner.Shared.OutputLines.Enqueue($Line)
}

function Stop-Spinner {
    <# .SYNOPSIS Stops the spinner and disposes its runspace. #>
    [CmdletBinding()]
    param(
        [Parameter()]
        $Spinner
    )
    if ($null -eq $Spinner) { return }
    $Spinner.Shared.Stop = $true
    $Spinner.PS.EndInvoke($Spinner.Handle) | Out-Null
    $Spinner.PS.Dispose()
    $Spinner.Runspace.Close()
    $Spinner.Runspace.Dispose()
}