PSInquirer.psm1
|
#Requires -Version 5.1 <# .SYNOPSIS PSInquirer – interactive CLI prompts for PowerShell (5.1 and 7+). .DESCRIPTION Provides beautiful, interactive CLI prompts that work in both Windows PowerShell 5.1 (powershell.exe) and PowerShell Core 7+ (pwsh.exe) on Windows, macOS, and Linux. No external dependencies; only [System.Console] methods are used for low-level cursor/key handling. #> # ── internal helpers (not exported) ─────────────────────────────────────────── function Get-PSMajorVersion { # Thin wrapper so tests can mock the PS version check. return $PSVersionTable.PSVersion.Major } function Get-VTSupport { # Returns $true when the current environment supports VT/ANSI escape sequences. # PS 7+ always supports VT; PS 5.1 depends on terminal hints. if ((Get-PSMajorVersion) -ge 6) { return $true } if ($env:ConEmuANSI -eq 'ON') { return $true } if ($env:TERM -match 'xterm|vt100|ansi') { return $true } return $false } function Read-ConsoleKey { # Thin wrapper around [Console]::ReadKey so tests can mock key input. return [Console]::ReadKey($true) } function Get-ConsoleWidth { # Thin wrapper around [Console]::WindowWidth so tests can mock the console width. # Returns a safe default when no console is attached (e.g. in CI/headless mode). try { return [Console]::WindowWidth } catch { return 80 } } function Get-ConsoleCursorTop { # Thin wrapper around [Console]::CursorTop so tests can mock the initial row. # Returns a safe default when no console is attached (e.g. in CI/headless mode). try { return [Console]::CursorTop } catch { return 0 } } function Get-ConsoleBufferHeight { # Thin wrapper around [Console]::BufferHeight so tests can mock a small buffer. # Returns a safe default when no console is attached (e.g. in CI/headless mode). try { return [Console]::BufferHeight } catch { return 50 } } function Write-MenuRow { <# .SYNOPSIS Renders a single choice row in the interactive menu. .PARAMETER Text The choice label to display. .PARAMETER Selected When $true the row is highlighted with a '>' pointer. .PARAMETER ConsoleWidth Current console window width; used to pad lines and prevent ghost text. .PARAMETER SupportsVT When $true uses ANSI/VT escape sequences for colour; otherwise falls back to Write-Host -ForegroundColor (PS 5.1 legacy consoles). #> param ( [string] $Text, [bool] $Selected, [int] $ConsoleWidth, [bool] $SupportsVT ) # Build the full-width line so overwriting old content leaves no ghost text. # Pointer prefix: "> " for selected, " " for unselected. if ($Selected) { $prefix = '> ' } else { $prefix = ' ' } $line = "$prefix$Text" # Pad to (ConsoleWidth - 1) to avoid triggering an auto-wrap on the last # column, which would push the next row down unexpectedly on some hosts. $padWidth = [Math]::Max(0, $ConsoleWidth - 1) $line = $line.PadRight($padWidth) if ($Selected) { if ($SupportsVT) { # Bright Cyan via ANSI escape (always works in PS 7+; works in PS 5.1 # when the terminal supports VT sequences). try { [Console]::Write("`e[96m$line`e[0m") } catch { } } else { # Fallback for PS 5.1 on legacy Windows consoles without VT support. Write-Host $line -ForegroundColor Cyan -NoNewline } } else { try { [Console]::Write($line) } catch { } } } # ── public function ──────────────────────────────────────────────────────────── function Invoke-PromptList { <# .SYNOPSIS Displays an interactive single-choice menu and returns the selected item. .DESCRIPTION Renders a question followed by a navigable list of choices. The user moves the highlighted cursor with UpArrow / DownArrow and confirms with Enter. When Enter is pressed the menu is erased and the final answer is printed inline next to the original question; the chosen string is also written to the pipeline. .PARAMETER Message The question / prompt text shown above the choices. .PARAMETER Choices An array of strings representing the available options. .OUTPUTS System.String – the item that was selected by the user. .EXAMPLE $color = Invoke-PromptList -Message "What is your favorite color?" -Choices @("Red","Green","Blue","Cyan") Write-Host "You chose: $color" #> [CmdletBinding()] [OutputType([string])] param ( [Parameter(Mandatory)] [string] $Message, [Parameter(Mandatory)] [string[]] $Choices ) # Determine colour strategy once up front so every redraw is consistent. $supportsVT = Get-VTSupport $selectedIndex = 0 # Snapshot console geometry before drawing; used for cursor math on redraws. $consoleWidth = Get-ConsoleWidth # Total lines the menu occupies: 1 question line + one row per choice. $totalLines = 1 + $Choices.Count # Remember the row where the prompt begins so we can return to it when # redrawing or clearing the menu. $menuStartRow = Get-ConsoleCursorTop # Ensure there is enough room in the scroll buffer below the current cursor. # If the menu would overflow the bottom of the buffer, pre-scroll by emitting # blank lines so that every menu row has a valid, addressable position. $bufferHeight = Get-ConsoleBufferHeight $neededBottom = $menuStartRow + $totalLines if ($neededBottom -gt $bufferHeight) { $scrollBy = $neededBottom - $bufferHeight for ($lineIndex = 0; $lineIndex -lt $scrollBy; $lineIndex++) { try { [Console]::WriteLine() } catch { } } $menuStartRow = [Math]::Max(0, $bufferHeight - $totalLines) } try { # Hide the cursor to prevent flickering while we redraw. try { [Console]::CursorVisible = $false } catch { } # ── initial draw ─────────────────────────────────────────────────────── # Question line "? <message>" try { [Console]::SetCursorPosition(0, $menuStartRow) } catch { } Write-Host "? $Message" -NoNewline try { [Console]::WriteLine() } catch { } # Render each choice; the currently selected item is highlighted. for ($i = 0; $i -lt $Choices.Count; $i++) { Write-MenuRow -Text $Choices[$i] -Selected ($i -eq $selectedIndex) ` -ConsoleWidth $consoleWidth -SupportsVT $supportsVT try { [Console]::WriteLine() } catch { } } # ── interaction loop ─────────────────────────────────────────────────── $done = $false while (-not $done) { $key = Read-ConsoleKey # blocks until the user presses a key switch ($key.Key) { 'UpArrow' { # Move selection up, wrapping from the first item to the last. if ($selectedIndex -gt 0) { $selectedIndex-- } else { $selectedIndex = $Choices.Count - 1 } } 'DownArrow' { # Move selection down, wrapping from the last item to the first. if ($selectedIndex -lt ($Choices.Count - 1)) { $selectedIndex++ } else { $selectedIndex = 0 } } 'Enter' { $done = $true continue # skip the redraw below } } if (-not $done) { # Redraw only the choice rows so the question line does not flicker. # Row offset: question occupies $menuStartRow; choices start one row below. for ($i = 0; $i -lt $Choices.Count; $i++) { # Reposition cursor to the start of choice row $i. try { [Console]::SetCursorPosition(0, $menuStartRow + 1 + $i) } catch { } Write-MenuRow -Text $Choices[$i] -Selected ($i -eq $selectedIndex) ` -ConsoleWidth $consoleWidth -SupportsVT $supportsVT } } } # ── clear the menu and print the final answer ────────────────────────── # Total lines occupied: 1 (question) + Choices.Count (choice rows). # Erase every rendered line by overwriting with spaces. for ($row = $menuStartRow; $row -lt ($menuStartRow + $totalLines); $row++) { try { [Console]::SetCursorPosition(0, $row) } catch { } # Full-width blank prevents ghost characters from the previous draw. try { [Console]::Write(' ' * ([Math]::Max(0, $consoleWidth - 1))) } catch { } } # Return the cursor to the very start of the prompt line. try { [Console]::SetCursorPosition(0, $menuStartRow) } catch { } # Print the answered prompt: "? <message> <answer>" with the answer in green. Write-Host "? $Message " -NoNewline if ($supportsVT) { try { [Console]::WriteLine("`e[32m$($Choices[$selectedIndex])`e[0m") } catch { } } else { Write-Host $Choices[$selectedIndex] -ForegroundColor Green } } finally { # Always restore the cursor – even if the user presses Ctrl+C. try { [Console]::CursorVisible = $true } catch { } } # Output the selected value to the pipeline. return $Choices[$selectedIndex] } |