Private/Read-TimedChoice.ps1

function Read-TimedChoice {
    # A choice prompt that gives up and takes the default after a while.
    #
    # $Host.UI.PromptForChoice has no timeout, and a scheduled run blocked on a
    # question nobody is there to answer would sit until the task's execution
    # time limit killed it -- turning "ask politely" into "never update again".
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', '', Justification = 'The prompt is drawn for a person to read and answer. Sending it down the pipeline would make it the return value instead.')]
    [CmdletBinding()]
    [OutputType([int])]
    param(
        [Parameter(Mandatory)][string]   $Caption,
        [Parameter(Mandatory)][string[]] $Choice,
        [Parameter(Mandatory)][int]      $TimeoutSeconds,
        [int] $DefaultIndex = 0
    )

    Write-Host ''
    Write-Host $Caption -ForegroundColor Cyan
    for ($i = 0; $i -lt $Choice.Count; $i++) {
        $marker = if ($i -eq $DefaultIndex) { '*' } else { ' ' }
        Write-Host (" [{0}]{1} {2}" -f ($i + 1), $marker, $Choice[$i])
    }
    Write-Host ''

    $deadline = (Get-Date).AddSeconds($TimeoutSeconds)
    $lastShown = -1

    try {
        while ((Get-Date) -lt $deadline) {
            $remaining = [int][Math]::Ceiling(($deadline - (Get-Date)).TotalSeconds)
            if ($remaining -ne $lastShown) {
                # [Console]::Write, not Write-Host: transcription does not see a
                # raw console write, so the countdown redraws on screen without
                # writing a line per second into the run log.
                # The extra parentheses matter. Inside a method call the comma
                # separates arguments, so Write("..." -f $a, $b) is parsed as
                # Write(("..." -f $a), $b): the format string gets one argument
                # and .NET throws about the argument list, which the catch below
                # then reports as an unreadable keypress.
                [Console]::Write(("`rStarting in {0,3}s -- press 1-{1}, Enter for the default, or wait. " -f $remaining, $Choice.Count))
                $lastShown = $remaining
            }

            if ($Host.UI.RawUI.KeyAvailable) {
                $key = $Host.UI.RawUI.ReadKey('NoEcho,IncludeKeyDown')

                # Enter takes the default rather than being ignored. Waiting out
                # the timeout already chooses it, so making the obvious keypress
                # mean the same thing costs nothing and saves the wait. 13 is
                # VK_RETURN; the character is checked too, because a host that
                # reports no virtual key code still reports the carriage return.
                $picked = if ($key.VirtualKeyCode -eq 13 -or $key.Character -eq "`r" -or $key.Character -eq "`n") {
                    $DefaultIndex
                } else {
                    [int] $key.Character - [int] [char] '1'
                }

                if ($picked -ge 0 -and $picked -lt $Choice.Count) {
                    [Console]::Write("`r" + (' ' * 70) + "`r")
                    Write-Host ("Chose: {0}" -f $Choice[$picked]) -ForegroundColor Cyan
                    return $picked
                }
            }

            Start-Sleep -Milliseconds 150
        }
    } catch {
        # A host with no readable keyboard throws rather than returning. Taking
        # the default is the safe answer: the run proceeds.
        Write-Host ''
        Write-Warning "Could not read a keypress ($($_.Exception.Message)); taking the default."
        return $DefaultIndex
    }

    [Console]::Write("`r" + (' ' * 60) + "`r")
    Write-Host ("No answer in ${TimeoutSeconds}s; taking the default: {0}" -f $Choice[$DefaultIndex]) -ForegroundColor DarkGray
    return $DefaultIndex
}