Skip to content

Arrow key inputs are ignored when using MSYS bash with non-Mintty terminals #903

@yayugu

Description

@yayugu

I found the default MSYS ruby's irb is broken on Windows Terminal and VSCode terminal. It ignores all arrow keys (←→↑↓) so it couldn't move cursor or reuse prev-history.

After the investigation, I found Reline's logic to decide the IO gate (Reline::ANSI or Reline::Windows) is incorrect. It only correctly detect msys_tty? == true when using Mintty.

Step to reproduce

  • Windows 11 25H2
  1. Install MSYS2
  2. Setup MSYS2 for Windows Terminal (see the official doc https://www.msys2.org/docs/terminals/ )
  3. pacman -Sy ruby
  4. Run irb and try to use arrow keys

Possible solutions

Here are some ideas (I'm not familiar with neither terminal techs nor windows system, though):

  1. Respect $TERM. That env var (like TERM="xterm-256color") is only available on UNIX-like console. Probably safe to use Reline::ANSI instead of Reline::Windows.
  2. Reline::Windows also allows unix-style arrow keys input. I'm not sure about the difference. But it seems they aren't conflict and safe to allow both.
  3. A new env var for switching behavior. This is not a great solution but, it's good workaround if we can define and pass the env var to change the behavior with RELINE_IO=dump/ansi/windows

Debug script:

#!/usr/bin/env ruby
# frozen_string_literal: true

require "rbconfig"

def print_env
  puts "Ruby"
  puts "  RUBY_VERSION=#{RUBY_VERSION}"
  puts "  RUBY_PLATFORM=#{RUBY_PLATFORM}"
  puts "  ruby=#{RbConfig.ruby}"
  puts "  host_os=#{RbConfig::CONFIG["host_os"]}"
  puts "  target_os=#{RbConfig::CONFIG["target_os"]}"
  puts
  puts "TTY"
  puts "  STDIN.tty?=#{STDIN.tty?}"
  puts "  STDOUT.tty?=#{STDOUT.tty?}"
  puts
  puts "Environment"
  %w[
    TERM
    COLORTERM
    MSYSTEM
    MSYS
    WT_SESSION
    WT_PROFILE_ID
    ConEmuPID
    MINTTY_SHORTCUT
    SHELL
  ].each do |name|
    puts "  #{name}=#{ENV[name].inspect}"
  end
end

def printable_byte(byte)
  if byte.between?(32, 126)
    byte.chr
  else
    case byte
    when 7 then "BEL"
    when 8 then "BS"
    when 9 then "TAB"
    when 10 then "LF"
    when 13 then "CR"
    when 27 then "ESC"
    when 127 then "DEL"
    else "."
    end
  end
end

def run_raw
  require "io/console"

  print_env
  puts
  puts "Raw byte mode"
  puts "  Press keys to see bytes. Try Up, Down, Left, Right, Backspace, Enter."
  puts "  Press Ctrl-C to quit."
  puts

  STDOUT.sync = true
  buffer = []

  STDIN.raw do |io|
    loop do
      byte = io.getbyte
      break if byte == 3

      buffer << byte
      printf "%-5s 0x%02X  %3d  %s\n", "byte", byte, byte, printable_byte(byte)

      if byte == 65 && buffer[-3, 3] == [27, 91, 65]
        puts "      recognized sequence: ESC [ A  (cursor up)"
      elsif byte == 66 && buffer[-3, 3] == [27, 91, 66]
        puts "      recognized sequence: ESC [ B  (cursor down)"
      elsif byte == 67 && buffer[-3, 3] == [27, 91, 67]
        puts "      recognized sequence: ESC [ C  (cursor right)"
      elsif byte == 68 && buffer[-3, 3] == [27, 91, 68]
        puts "      recognized sequence: ESC [ D  (cursor left)"
      elsif byte == 65 && buffer[-3, 3] == [27, 79, 65]
        puts "      recognized sequence: ESC O A  (application cursor up)"
      elsif byte == 66 && buffer[-3, 3] == [27, 79, 66]
        puts "      recognized sequence: ESC O B  (application cursor down)"
      elsif byte == 67 && buffer[-3, 3] == [27, 79, 67]
        puts "      recognized sequence: ESC O C  (application cursor right)"
      elsif byte == 68 && buffer[-3, 3] == [27, 79, 68]
        puts "      recognized sequence: ESC O D  (application cursor left)"
      end
    end
  end
end

def run_timing
  require "io/console"

  print_env
  puts
  puts "Raw byte timing mode"
  puts "  Press Up/Down a few times. Press Ctrl-C to quit."
  puts "  This checks whether ESC and the following bytes arrive with suspicious gaps."
  puts

  STDOUT.sync = true
  previous = Process.clock_gettime(Process::CLOCK_MONOTONIC)

  STDIN.raw do |io|
    loop do
      byte = io.getbyte
      now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
      delta_ms = ((now - previous) * 1000).round(3)
      previous = now

      break if byte == 3

      printf "dt=%9.3f ms  0x%02X  %3d  %s\r\n", delta_ms, byte, byte, printable_byte(byte)
    end
  end
end

def run_stty
  print_env
  puts
  puts "stty -a"
  system("stty", "-a")
end

def run_gate
  require "reline"
  require "reline/io/windows"

  print_env
  puts
  puts "Reline IO gate"

  io = Reline::IO.decide_io_gate
  puts "  decide_io_gate.class=#{io.class}"
  puts "  win?=#{io.win?}" if io.respond_to?(:win?)

  if io.respond_to?(:msys_tty?)
    begin
      puts "  msys_tty?=#{io.msys_tty?}"
    rescue StandardError => e
      puts "  msys_tty? error=#{e.class}: #{e.message}"
    end
  else
    puts "  msys_tty?=(method not available)"
  end

  begin
    windows = Reline::Windows.new
    puts
    puts "Windows stdin handle details"
    print_windows_handle_details(windows)
  rescue StandardError => e
    puts "  Could not inspect Windows handle: #{e.class}: #{e.message}"
  end

  begin
    require "reline/io/ansi"
    puts
    puts "Reline::ANSI default cursor bindings"
    puts "  Up ESC [ A => #{Reline::ANSI::ANSI_CURSOR_KEY_BINDINGS["A"].inspect}"
    puts "  Down ESC [ B => #{Reline::ANSI::ANSI_CURSOR_KEY_BINDINGS["B"].inspect}"
  rescue StandardError => e
    puts "  Could not inspect ANSI bindings: #{e.class}: #{e.message}"
  end
end

def print_windows_handle_details(_windows)
  api = Reline::Windows::Win32API
  get_std_handle = api.new("kernel32", "GetStdHandle", ["L"], "L")
  get_file_type = api.new("kernel32", "GetFileType", ["L"], "L")
  get_file_info = api.new("kernel32", "GetFileInformationByHandleEx", ["L", "I", "P", "L"], "I")

  std_input_handle = -10
  file_type_disk = 0x0001
  file_type_char = 0x0002
  file_type_pipe = 0x0003
  file_name_info = 2

  handle = get_std_handle.call(std_input_handle)
  file_type = get_file_type.call(handle)
  type_name = case file_type
              when file_type_disk then "FILE_TYPE_DISK"
              when file_type_char then "FILE_TYPE_CHAR"
              when file_type_pipe then "FILE_TYPE_PIPE"
              else "unknown"
              end

  puts "  GetStdHandle(STD_INPUT_HANDLE)=0x#{handle.to_s(16)}"
  puts "  GetFileType(stdin)=0x#{file_type.to_s(16)} #{type_name}"

  unless file_type == file_type_pipe
    puts "  pipe_name=(not a pipe, so Reline::Windows#msys_tty? returns false)"
    return
  end

  bufsize = 1024
  buffer = "\0" * bufsize
  result = get_file_info.call(handle, file_name_info, buffer, bufsize - 2)
  if result == 0
    puts "  pipe_name=(GetFileInformationByHandleEx failed)"
    return
  end

  length = buffer[0, 4].unpack1("L")
  name = buffer[4, length].encode(Encoding::UTF_8, Encoding::UTF_16LE, invalid: :replace)
  puts "  pipe_name=#{name.inspect}"
  puts "  matches_msys_pty?=#{!!(name =~ /(msys-|cygwin-).*-pty/)}"
end

def clean_inputrc_for_reline
  <<~INPUTRC
    set editing-mode emacs
    set keymap emacs
    set keyseq-timeout 0
    "\\e[A": ed-prev-history
    "\\e[B": ed-next-history
    "\\e[C": ed-next-char
    "\\e[D": ed-prev-char
    "\\eOA": ed-prev-history
    "\\eOB": ed-next-history
    "\\eOC": ed-next-char
    "\\eOD": ed-prev-char
  INPUTRC
end

def clean_inputrc_for_readline
  <<~INPUTRC
    set editing-mode emacs
    set keymap emacs
    set keyseq-timeout 0
    "\\e[A": previous-history
    "\\e[B": next-history
    "\\e[C": forward-char
    "\\e[D": backward-char
    "\\eOA": previous-history
    "\\eOB": next-history
    "\\eOC": forward-char
    "\\eOD": backward-char
  INPUTRC
end

def with_clean_inputrc(kind)
  require "tmpdir"

  Dir.mktmpdir("ruby-inputrc-") do |dir|
    inputrc = File.join(dir, "inputrc")
    content = kind == :readline ? clean_inputrc_for_readline : clean_inputrc_for_reline
    File.write(inputrc, content)
    ENV["INPUTRC"] = inputrc
    puts "Using temporary INPUTRC=#{inputrc}"
    puts content
    puts
    yield
  end
end

def run_reline
  require "reline"

  print_env
  puts
  puts "Reline mode"
  puts "  Type a few lines, then try Up/Down history."
  puts "  Press Ctrl-D on an empty line to quit."
  puts

  i = 0
  while (line = Reline.readline("reline#{i += 1}> ", true))
    p line
  end
end

def run_reline_clean
  with_clean_inputrc(:reline) do
    load_reline_and_run
  end
end

def load_reline_and_run
  require "reline"

  print_env
  begin
    core = Reline.respond_to?(:core) ? Reline.core : nil
    config = core&.respond_to?(:config) ? core.config : nil
    if config
      puts
      puts "Reline config"
      %i[keyseq_timeout editing_mode inputrc_path].each do |name|
        value =
          if config.respond_to?(name)
            config.public_send(name)
          elsif config.instance_variable_defined?(:"@#{name}")
            config.instance_variable_get(:"@#{name}")
          else
            "(unavailable)"
          end
        puts "  #{name}=#{value.inspect}"
      end
    end
  rescue StandardError => e
    warn "Could not inspect Reline config: #{e.class}: #{e.message}"
  end

  puts
  puts "Reline mode"
  puts "  Type a few lines, then try Up/Down history."
  puts "  Press Ctrl-D on an empty line to quit."
  puts

  i = 0
  while (line = Reline.readline("reline#{i += 1}> ", true))
    p line
  end
end

def run_readline
  begin
    require "readline"
  rescue LoadError => e
    warn "Could not load readline: #{e.class}: #{e.message}"
    exit 2
  end

  print_env
  puts
  puts "Readline mode"
  puts "  Readline::VERSION=#{Readline.const_defined?(:VERSION) ? Readline::VERSION : "(no VERSION constant)"}"
  puts "  Type a few lines, then try Up/Down history."
  puts "  Press Ctrl-D on an empty line to quit."
  puts

  i = 0
  while (line = Readline.readline("readline#{i += 1}> ", true))
    p line
  end
end

def run_readline_clean
  with_clean_inputrc(:readline) do
    run_readline
  end
end

def usage
  warn <<~USAGE
    Usage:
      ruby terminal_input_probe.rb env
      ruby terminal_input_probe.rb raw
      ruby terminal_input_probe.rb timing
      ruby terminal_input_probe.rb stty
      ruby terminal_input_probe.rb gate
      ruby terminal_input_probe.rb reline
      ruby terminal_input_probe.rb reline-clean
      ruby terminal_input_probe.rb readline
      ruby terminal_input_probe.rb readline-clean

    Suggested order:
      1. env
      2. raw
      3. timing
      4. gate
      5. reline-clean
      6. readline-clean
  USAGE
end

case ARGV.fetch(0, "help")
when "env"
  print_env
when "raw"
  run_raw
when "timing"
  run_timing
when "stty"
  run_stty
when "gate"
  run_gate
when "reline"
  load_reline_and_run
when "reline-clean"
  run_reline_clean
when "readline"
  run_readline
when "readline-clean"
  run_readline_clean
else
  usage
  exit 1
end

Windows Terminal:

Ruby
  RUBY_VERSION=3.4.8
  RUBY_PLATFORM=x86_64-cygwin
  ruby=/usr/bin/ruby.exe
  host_os=cygwin
  target_os=cygwin

TTY
  STDIN.tty?=true
  STDOUT.tty?=true

Environment
  TERM="xterm-256color"
  COLORTERM=nil
  MSYSTEM="MSYS"
  MSYS="winsymlinks:nativestrict"
  WT_SESSION="3be8b3a2-0619-4392-af71-591b5bfc38e5"
  WT_PROFILE_ID="{71160544-14d8-4194-af25-d05feeac7233}"
  ConEmuPID=nil
  MINTTY_SHORTCUT=nil
  SHELL="/usr/bin/zsh"

Reline IO gate
  decide_io_gate.class=Reline::Windows
  win?=true
  msys_tty?=false

Windows stdin handle details
  GetStdHandle(STD_INPUT_HANDLE)=0x1d8
  GetFileType(stdin)=0x2 FILE_TYPE_CHAR
  pipe_name=(not a pipe, so Reline::Windows#msys_tty? returns false)

Reline::ANSI default cursor bindings
  Up ESC [ A => [:ed_prev_history, {}]
  Down ESC [ B => [:ed_next_history, {}]

Mintty:

$ ruby terminal_input_probe.rb gate
Ruby
  RUBY_VERSION=3.4.8
  RUBY_PLATFORM=x86_64-cygwin
  ruby=/usr/bin/ruby.exe
  host_os=cygwin
  target_os=cygwin

TTY
  STDIN.tty?=true
  STDOUT.tty?=true

Environment
  TERM="xterm-256color"
  COLORTERM=nil
  MSYSTEM="MSYS"
  MSYS=nil
  WT_SESSION=nil
  WT_PROFILE_ID=nil
  ConEmuPID=nil
  MINTTY_SHORTCUT=nil
  SHELL="/usr/bin/bash"

Reline IO gate
  decide_io_gate.class=Reline::ANSI
  win?=false
  msys_tty?=(method not available)

Windows stdin handle details
  GetStdHandle(STD_INPUT_HANDLE)=0x4b4
  GetFileType(stdin)=0x3 FILE_TYPE_PIPE
  pipe_name="\\msys-dd50a72ab4668b33-pty0-from-master"
  matches_msys_pty?=true

Reline::ANSI default cursor bindings
  Up ESC [ A => [:ed_prev_history, {}]
  Down ESC [ B => [:ed_next_history, {}]

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions