private/Menu.ps1

# FR-018: interactive checklist menu. Supports two equivalent ways to select an item -
# a direct shortcut keypress (case-insensitive) or Up/Down arrow navigation + Enter. Renders
# the banner (FR-022) above the list on every screen, not only once.

function Enter-OctaAltScreen {
    <#
        Switches to the terminal's alternate screen buffer (the same xterm mechanism vim/htop/
        less use for full-screen apps) - measured directly (not guessed): entering it drops
        BufferHeight to match the visible window (no scrollback), and repeated clear+redraw
        cycles inside it leave WindowTop at a fixed 0, unlike plain Clear-Host or a bare
        `Esc[2J`+`Esc[H` sequence on the MAIN buffer, which was measured to move WindowTop to
        follow the cursor instead of staying put - that's what made every arrow-key press look
        like the terminal was jumping/scrolling. Windows 11's terminals process this VT sequence
        by default, no extra native-API call needed.
    #>

    $esc = [char]27
    Write-Host "$esc[?1049h" -NoNewline
}

function Exit-OctaAltScreen {
    <#
        Restores the terminal's main screen buffer exactly as it was before Enter-OctaAltScreen -
        the user's prior scrollback/content is untouched, same as leaving vim/htop/less.
    #>

    $esc = [char]27
    Write-Host "$esc[?1049l" -NoNewline
}

function Clear-OctaScreen {
    $esc = [char]27
    Write-Host "$esc[H$esc[2J" -NoNewline
}

function Get-OctaTruncated {
    <#
        Truncates text to fit a given width, appending an ellipsis when it doesn't - category
        descriptions can run past 150 characters (e.g. background-activity's), which wrapped
        unpredictably mid-item on a narrow/compact terminal and broke the one-line-per-item
        layout the menu depends on. Never returns a negative-length substring.
    #>

    param(
        [Parameter(Mandatory)][AllowEmptyString()][string]$Text,
        [Parameter(Mandatory)][int]$MaxWidth
    )
    # ponytail: plain ASCII "..." instead of a single Unicode ellipsis character - found the hard
    # way that a non-ASCII char in a .ps1 file without a UTF-8 BOM gets misread as three garbled
    # characters under Windows PowerShell 5.1's default (system codepage) file parsing, an own
    # Pester test caught it (truncated length came out 22, not 20) before it ever reached a
    # user's screen as visible mojibake.
    if ($MaxWidth -le 0) { return '' }
    if ($Text.Length -le $MaxWidth) { return $Text }
    if ($MaxWidth -le 3) { return $Text.Substring(0, $MaxWidth) }
    return $Text.Substring(0, $MaxWidth - 3) + '...'
}

function Show-OctaMenu {
    <#
        $Items: array of @{ Key = '1'; Label = 'Telemetry'; Description = '...' }
        Returns the Key of the chosen item.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][object[]]$Items,
        [Parameter(Mandatory)][string]$Header,
        [string]$NavHint = 'Up/Down to navigate - Enter to select - or press the number/letter directly'
    )

    # ponytail: check interactivity up front, not just via the ReadKey try/catch below - some
    # non-interactive hosts (PS Direct/WinRM remoting) don't throw on ReadKey, they just hang
    # forever waiting for a keystroke that can never arrive. Test-OctaInteractiveConsole catches
    # that case; the try/catch remains as a second guard for hosts that do throw instead.
    if (-not (Test-OctaInteractiveConsole)) {
        throw "Octa's interactive menu needs a real console (Up/Down + Enter, or a direct keypress). This session has none - use the exported cmdlets directly instead (Invoke-OctaCategory, Invoke-OctaQuickClean, Show-OctaDashboard, etc.) for non-interactive/automated use."
    }

    $selectedIndex = 0
    $labelWidth = 22
    $prefixWidth = 4 + $labelWidth # pointer(1) + space(1) + key(1) + 2 spaces = 4, label = $labelWidth

    Enter-OctaAltScreen
    try {
        while ($true) {
            $width = 80
            try { $width = $Host.UI.RawUI.WindowSize.Width } catch { }
            if ($width -lt 40 -or $width -gt 300) { $width = 80 }
            $descWidth = [Math]::Max(0, $width - $prefixWidth - 1)

            Clear-OctaScreen
            Show-OctaBanner
            Write-Host $Header
            Write-Host ''
            for ($i = 0; $i -lt $Items.Count; $i++) {
                $item = $Items[$i]
                $pointer = if ($i -eq $selectedIndex) { '>' } else { ' ' }
                $desc = Get-OctaTruncated -Text $item.Description -MaxWidth $descWidth
                Write-Host ("{0} {1} {2,-$labelWidth}{3}" -f $pointer, $item.Key.ToString().ToUpperInvariant(), $item.Label, $desc)
            }
            Write-Host ''
            Write-Host (Get-OctaTruncated -Text $NavHint -MaxWidth $width)

            # ponytail: ReadKey throws in a non-interactive host (no console attached - a
            # scheduled task, a remote session, redirected output). Without this guard the caught
            # exception was a non-terminating error, so the loop kept redrawing forever instead of
            # stopping - fail fast with a clear message instead.
            try {
                $key = [Console]::ReadKey($true)
            }
            catch [System.InvalidOperationException] {
                throw "Octa's interactive menu needs a real console (Up/Down + Enter, or a direct keypress). This session has none - use the exported cmdlets directly instead (Invoke-OctaCategory, Invoke-OctaQuickClean, Show-OctaDashboard, etc.) for non-interactive/automated use."
            }
            switch ($key.Key) {
                'UpArrow' { $selectedIndex = ($selectedIndex - 1 + $Items.Count) % $Items.Count }
                'DownArrow' { $selectedIndex = ($selectedIndex + 1) % $Items.Count }
                'Enter' { return $Items[$selectedIndex].Key }
                default {
                    $typed = $key.KeyChar.ToString().ToLowerInvariant()
                    $match = $Items | Where-Object { $_.Key.ToString().ToLowerInvariant() -eq $typed }
                    if ($match) { return $match[0].Key }
                }
            }
        }
    }
    finally {
        Exit-OctaAltScreen
    }
}