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 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 Write-OctaMenuRow {
    <#
        Writes exactly one menu item's line at the current cursor position - for the initial
        top-to-bottom render, or for an in-place update after the caller has already moved the
        cursor there with [Console]::SetCursorPosition. Always pads to $Width so a shorter new
        line fully overwrites whatever was on that row before, without an ANSI clear-line code.
    #>

    param(
        [Parameter(Mandatory)][object]$Item,
        [Parameter(Mandatory)][bool]$IsSelected,
        [Parameter(Mandatory)][int]$LabelWidth,
        [Parameter(Mandatory)][int]$Width
    )
    $pointer = if ($IsSelected) { '>' } else { ' ' }
    $prefixWidth = 4 + $LabelWidth # pointer(1) + space(1) + key(1) + 2 spaces = 4, label = $LabelWidth
    $descWidth = [Math]::Max(0, $Width - $prefixWidth)
    $desc = Get-OctaTruncated -Text $Item.Description -MaxWidth $descWidth
    $line = ("{0} {1} {2,-$LabelWidth}{3}" -f $pointer, $Item.Key.ToString().ToUpperInvariant(), $Item.Label, $desc)
    if ($line.Length -lt $Width) { $line = $line.PadRight($Width) }
    Write-Host $line -NoNewline
}

function Show-OctaFrame {
    <#
        Wraps a screen-drawing scriptblock (a menu screen, or an action's "> Running: X" +
        output) so every screen anchors to the same starting row and never leaves stale content
        below from a taller previous screen - this is what makes a full session (menu -> pick a
        category -> see its output -> back to the menu) feel like one fixed, updating screen
        instead of piling up in the terminal's normal scrollback history. Uses only
        [Console]::SetCursorPosition, the same mechanism already confirmed working for the menu
        itself - no ANSI/VT sequence involved.

        A screen taller than the terminal's own visible window still scrolls normally when it's
        drawn (e.g. a long Uninstaller program list) - that's ordinary, expected terminal
        behavior for content that doesn't fit, not the "history piling up between unrelated
        screens" this fixes.
    #>

    param(
        [Parameter(Mandatory)][int]$AnchorRow,
        [Parameter(Mandatory)][scriptblock]$Draw
    )

    [Console]::SetCursorPosition(0, $AnchorRow)
    $result = & $Draw
    $endRow = [Console]::CursorTop

    if ($null -ne $script:OctaLastFrameEndRow -and $endRow -lt $script:OctaLastFrameEndRow) {
        $width = 80
        try { $width = $Host.UI.RawUI.WindowSize.Width } catch { }
        if ($width -lt 40 -or $width -gt 300) { $width = 80 }
        $blank = ' ' * ($width - 1)
        for ($row = $endRow; $row -le $script:OctaLastFrameEndRow; $row++) {
            [Console]::SetCursorPosition(0, $row)
            Write-Host $blank -NoNewline
        }
        [Console]::SetCursorPosition(0, $endRow)
    }
    $script:OctaLastFrameEndRow = $endRow

    return $result
}

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

        Real user testing found two prior redraw approaches (Clear-Host, then an ANSI
        clear+home sequence, then the terminal's alternate screen buffer) all still caused
        visible flicker/scrolling on their specific terminal, even though each was verified to
        work correctly by direct measurement on the dev machine's own terminal - none of that
        is directly testable against the user's real terminal from here. This version drops
        ANSI/VT entirely: the full menu prints exactly once, top to bottom, like any normal
        command's output (no clearing at all), and each item's real on-screen row is captured
        as it's written. Arrow-key navigation then only rewrites the two affected rows via
        [Console]::SetCursorPosition - a basic Win32 console API, not an escape sequence the
        terminal has to interpret - so at most 2 of perhaps 15-20 on-screen lines are ever
        touched per keypress, and the mechanism doesn't depend on the terminal implementing any
        particular VT mode correctly.
    #>

    [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
    $width = 80
    try { $width = $Host.UI.RawUI.WindowSize.Width } catch { }
    if ($width -lt 40 -or $width -gt 300) { $width = 80 }
    $safeWidth = $width - 1 # never write the very last column - avoids a soft-wrap-triggered scroll

    Show-OctaBanner
    Write-Host (Get-OctaTruncated -Text $Header -MaxWidth $safeWidth)
    Write-Host ''
    $itemRows = @()
    for ($i = 0; $i -lt $Items.Count; $i++) {
        $itemRows += [Console]::CursorTop
        Write-OctaMenuRow -Item $Items[$i] -IsSelected ($i -eq $selectedIndex) -LabelWidth $labelWidth -Width $safeWidth
        Write-Host ''
    }
    Write-Host ''
    Write-Host (Get-OctaTruncated -Text $NavHint -MaxWidth $safeWidth)

    while ($true) {
        # 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."
        }

        $newIndex = $selectedIndex
        switch ($key.Key) {
            'UpArrow' { $newIndex = ($selectedIndex - 1 + $Items.Count) % $Items.Count }
            'DownArrow' { $newIndex = ($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 }
            }
        }

        if ($newIndex -ne $selectedIndex) {
            $oldIndex = $selectedIndex
            $selectedIndex = $newIndex
            [Console]::SetCursorPosition(0, $itemRows[$oldIndex])
            Write-OctaMenuRow -Item $Items[$oldIndex] -IsSelected $false -LabelWidth $labelWidth -Width $safeWidth
            [Console]::SetCursorPosition(0, $itemRows[$selectedIndex])
            Write-OctaMenuRow -Item $Items[$selectedIndex] -IsSelected $true -LabelWidth $labelWidth -Width $safeWidth
        }
    }
}