Search code examples
powershellread-host

Why does Read-Host require to hit enter 2 times enter before sending result?


I have a simeple loop to read for text and also detect the escape ESC [0x1b = chr(27)] key, to quit.

The Expected Behaviour

<Enter loop> 

<Enter any string, such as "AT" and hit Return>
# Some Output
<Repeat above OR>, 
...
<Hit the ESC (Escape) key to exit loop>

<Exit Loop> 

Actual Behaviour

I have to:

  • Hit [Enter] button 2 times, after entering a string and before getting any output.
  • ESC key to exit loop does not seem to work.

The Code:

        do {
            $key = if ($host.UI.RawUI.KeyAvailable) { $host.UI.RawUI.ReadKey('NoEcho, IncludeKeyDown') }
            if ($port.IsOpen) {
                $at = Read-Host
                $port.Write("${at}`r")
            } else {
                Write-Host -Fo Yellow "[INFO] Port was Closed!"
                break
            }
    
        } until ($key.VirtualKeyCode -eq 27)    # Repeat until a 'ESC'
    }


Q: How Can I fix the above to get the intended functionality?

(Why do I need to hit enter 2 times before the input string is processed?)


Experimenting, this one-liner is behaving very weird...

while (1) { if($host.UI.RawUI.ReadKey('IncludeKeyDown').VirtualKeyCode -eq 81) { break };$s=''; $s=Read-Host; if ($s -ne "w") { Write-Host ": $s" -Non | Out-Host } else { "Hit W!"}  }

If you want to list the names and codes of all available keys, you can use:

1..255| ForEach-Object { '{0} = {1}' -f $_, [System.Windows.Forms.Keys]$_ }

# <output>
...
16 = ShiftKey
17 = ControlKey
18 = Menu
19 = Pause
...

Solution

  • The following solution preserves the usual Read-Host command-line editing experience, so that editing the string being typed by the user is possible:

    $wshell = (New-Object -ComObject WScript.Shell) # For sending keystrokes
    while ($true) {
      # Solicit an initial keypress without echoing it.
      $c = $host.UI.RawUI.ReadKey('NoEcho,IncludeKeyDown').Character
      if ($c -eq [char] 27) { # ESC pressed? Exit the loop.
        'You pressed ESC - Quitting'; break
      } 
      elseif ($c -in '', "`r") {
        # Ignore keypresses that don't translate into printable characters
        # as well as an initial Enter keypress with nothing to submit.
        continue
      }
      # Send the character as a keypress to Read-Host to prefill its edit buffer...
      $wshell.SendKeys("{$c}")
      # ... and solicit a line of input, as usual.
      $userInput = Read-Host
      # Process the input.
      Write-Host -Fo DarkGreen "[\r] Sending: " -NoNewline
      Write-Host -Fo DarkYellow $userInput
    }
    
    • The above waits for a single, initial keypress.

      • If it turns out to be ESC, the loop is exited.
      • If it turns out to be a keypress that doesn't produce a printable character or produces Enter, it is ignored, given that there's nothing to submit.
    • Otherwise:

      • The initially typed character is sent as a keystroke to the keyboard buffer, where Read-Host will pick it up; that is, the edit buffer will be prefilled with the typed character, and it will appear as if the user had just typed that character into the prompt.

      • Caveat: Due to use of New-Object -ComObject WScript.Shell, this solution is Windows-only and isn't fully robust: Keeping Shift held down in order to type a sequence of uppercase letters doesn't work reliably - for certain characters, such as o, everything from the 2nd character unexpectedly remains lowercase[1] - see the next section for a potential workaround IF you have WSL installed.

        • On Linux, you could replace the keystroke-sending + Read-Host call more elegantly with (syntax assumes PowerShell 7.3+):

           $userInput = bash -c ('read -e -i ''{0}''; printf %s "$REPLY"' -f ($c -eq "'" ? "'\''" : $c)) || $(break)
          
        • On macOS, this doesn't work, unfortunately, and there's no simple workaround.

    Note:

    • Pressing Ctrl-C exits too, but not just the loop, but invariably the entire script (call stack); while you cannot prevent that, you can perform cleanup operations and/or write to any of PowerShell's output streams other than the success output stream in the finally block of an enclosing try / catch / finally statement.

    • Pressing Esc to exit the loop only works before entering the Read-Host prompt; that is, once you've typed a printable character, Ctrl-C is your only option to directly abort the prompt - along with your script and all its callers.

      • As of PowerShell 7.3.4, there is no mechanism that would allow you to respond to keypresses in a Read-Host prompt in realtime, and pressing ESC invariably just clears what has been typed so far, without leaving the prompt.

    Workaround for the .SendKeys() problem on Windows, assuming you have WSL installed:

    # Switch the console encoding (temporarily) to UTF-8, so that
    # PowerShell interprets WSL output correctly.
    $prevEnc = [Console]::OutputEncoding; [Console]::OutputEncoding = [Text.Utf8Encoding]::new()
    
    while ($true) {
      # Solicit an initial keypress without echoing it.
      $c = ($host.UI.RawUI.ReadKey('NoEcho,IncludeKeyDown')).Character
      if ($c -eq [char] 27) { # ESC pressed? Exit the loop.
        'You pressed ESC - Quitting'; break
      } 
      elseif ($c -in '', "`r") {
        # Ignore keypresses that don't translate into printable characters
        # as well as an initial Enter keypress with nothing to submit.
        continue
      }
      # Use Bash, via WSL, to prompt for the input, taking advantage of its ability
      # to pre-fill the edit buffer
      $userInput = wsl -e bash -c ('read -e -i ''{0}''; printf %s "$REPLY"' -f $(if ($c -eq "'") { "'\''" } else { $c }))
      if ($LASTEXITCODE) { 
        # Implies that Ctrl-C was pressed.
        # Exit the loop. Note that this is NOT the same as pressing Ctrl-C
        # with Read-Host, which terminates the entire script and its call stack.
        break 
      } 
      # Process the input.
      Write-Host -Fo DarkGreen "[\r] Sending: " -NoNewline
      Write-Host -Fo DarkYellow $userInput
    }
    
    # Restore the previous console endoding, if needed.
    [Console]::OutputEncoding = $prevEnc
    

    [1] Seemingly, the simulation of keyboard input, which involves programmatically pressing and releasing the Shift key, can interfere with properly detecting the physical keyboard state.