I have previously written about how I use rlselect as a replacement for Ctrl+R in Bash.

It works by creating a key binding in Bash for Ctrl+R that invokes rlselect instead of the default Bash interactive history search command. rlselect looks something like this:

Screenshot of rlselect showing two entries, hello
and world, with hello selected.

If you press tab, the current selection is inserted at the prompt. If you press enter, the current selection is executed. This is the same behavior as the default Ctrl+R.

The mechanism for this stopped working in recent Linux kernel versions. I figured out how to solve it and in this blog post I explain how.

Old Mechanism

When rlselect is invoked from Ctrl+R, it is invoked with the --tab and --action flags. The first flag allows the tab key to be used to select a line and the second makes rlselect print the action taken on the first line before to the selection.

Here is an example where enter is pressed when “hello” is selected:

$ (echo hello; echo world) | rlselect --tab --action
enter
hello

Here is an example where tab is pressed when “world” is selected:

$ (echo hello; echo world) | rlselect --tab --action
tab
world

Here is an example where Ctrl+G is pressed:

$ (echo hello; echo world) | rlselect --tab --action
ctrl-g

Ctrl+G aborts, so no selection is printed on the second line.

To feed this output to the prompt, TIOCSTI is used. It simulates that you type characters in the terminal. The full script that Ctrl+R invokes looks like this:

set -e

result=$(tac ~/.bash_history | rlselect --tab --action -- "$@")

python - "$result" << EOF
import fcntl
import sys
import termios

action, selection = sys.argv[1].split("\n", 1)

if action != "tab":
    selection += "\n"

for ch in selection:
    fcntl.ioctl(sys.stdout.fileno(), termios.TIOCSTI, ch)
EOF

The last part is where TIOCSTI is used to simulate that you press the keys of the selection. Unless tab is pressed, it appends a newline to the selection to simulate that Enter is pressed.

The Bash configuration looks like this:

if [[ $- =~ .*i.* ]]; then bind '"\C-r": "\C-a rlselect-history \C-j"'; fi

Here is how it works:

  • Ctrl+R is bound to a series of keystrokes.
  • First Ctrl+A is simulated which takes the cursor to the beginning of the line.
  • Then <space>rlselect-history<space> is typed.
  • Then Ctrl+J is simulated which means accept the current line. Or execute it. The initial space entered in the previous step ensures that the rlselect-history command does not end up in the history. The moving of the cursor to the beginning of the line ensures that anything typed at the prompt is passed as an argument to rlselect-history.

(This configuration also makes the text rlselect-history ... appear in the terminal. The new mechanism makes that go away.)

This mechanism stopped working in recent Linux kernel versions because TIOCSTI can not be used like this. There is apparently security issues with TIOCSTI and it is now only allowed as root.

New Mechanism

The new Bash configuration for Ctrl+R behavior that I came up with looks like this:

rlselect-history() {
    local action
    local selection
    {
        read action
        read selection
    } < <(tac ~/.bash_history | rlselect --tab --action -- "${READLINE_LINE}")
    if [ "$action" = "tab" ]; then
        READLINE_LINE="${selection}"
        READLINE_POINT=${#READLINE_LINE}
        bind '"\C-x2":' # Bind Ctrl+x+2 to do nothing
    elif [ "$action" = "enter" ]; then
        READLINE_LINE="${selection}"
        READLINE_POINT=${#READLINE_LINE}
        bind '"\C-x2": accept-line' # Bind Ctrl+x+2 to accept line
    else
        bind '"\C-x2":' # Bind Ctrl+x+2 to do nothing
    fi
}

if [[ $- =~ .*i.* ]]; then
    # Bind history command to Ctrl+x+1 followed by Ctrl+x+2:
    bind '"\C-r": "\C-x1\C-x2"'
    # Bind Ctrl+x+1 to execute rlselect-history which does two things:
    # 1. Sets READLINE_*
    # 2. Binds Ctrl+x+2 to either accept line or do nothing.
    bind -x '"\C-x1": rlselect-history'
fi

Let’s break this down.

  • Ctrl+R is bound to a series of keystrokes.

  • First Ctrl+X+1 is simulated.

  • Then Ctrl+X+2 is simulated.

  • Ctrl+X+1 is bound to execute the command rlselect-history. The -x to bind ensures that the variables READLINE_* can be set. From man bash on set -x:

    Cause shell-command to be executed whenever keyseq is entered. When shell-command is executed, the shell sets the READLINE_LINE variable to the contents of the readline line buffer and the READLINE_POINT and READLINE_MARK variables […] If the executed command changes the value of any of READLINE_LINE, READLINE_POINT, or READLINE_MARK, those new values will be reflected in the editing state.

  • rlselect-history is defined as a Bash function which allows it to reconfigure the key binding for Ctrl+X+2. Depending on if the current selection should be executed or not, it binds Ctrl+X+2 to either accept-line or nothing.

So the new mechanism relies on using two extra key bindings: Ctrl+X+1 and Ctrl+X+2. I chose them because I don’t use them otherwise. But they can be any two key bindings.

The trick to finding this solution for me was understanding Bash key bindings. This answer on StackOverflow writes the following:

With bind, you can bind keys to do one of three things, but no combination of them:

  • Execute a readline command: bind '"key": command'
  • Execute a series of keystrokes: bind '"key":"keystrokes"'
  • Execute a shell command: bind -x '"key": shell-command'

That made me understand that you can not call accept-line from within rlselect-history because it is executed in the context of bind -x, and readline commands can only be executed in the context of bind '"key": command'.

Resources

Here are some resources that talks about the problem with TIOCSTI that helped me: