Building a basic command palette for Bash

I’ve been using tmux for a while at work, and in particular nested tmux sessions, one for each server I use regularly handled via a main session on a jump host. There are around 20 different servers I connect to for various tasks and navigating between them all via standard methods was a little tricky. I thought of the idea (which, of course, wasn’t a new thought) of having a fuzzy find so to get to box4, I could type x4<Enter>.

I had a look for anything that existed already and found marker and fzf which seemed really promising. Unfortunately, the servers I’m using didn’t meet the requirements so I wondered how tricky making something like this would be.

I’m not unfamiliar with building things in bash. I make a lot of command-line tools that my colleagues use, of varying complexity, mostly to simplify repetetive tasks and handle commonly encountered errors, so I thought I’d have a go at making something.

My requirements were:

  1. Have a resultant action and list of options passed into the script
  2. Handle keyboard input (text and arrow keys)
  3. Select an item from the list and call action with the selected item as an argument on Enter
  4. Filter the available items
  5. Update the display in place

I wanted this to be compatible with bash 3.2 and to rely on as few external tools as possible for speed and to avoid having to cater for any differences in, say, BSD vs. Linux. I’m comfortable with ANSI escape sequences and have used these extensively for various puropses but thought it would be best for me to use tput to aid portability and compatibility.

With these points in mind, I started to cobble together the features.

Initial build items

1. Have a resultant action and list of options passed into the script

This seems easy enough:

action=$1;
lines=$2;

2. Handle keyboard input (text and arrow keys) and filter the list on keypress

This seemed quite easy at first, I’ve taken input in scripts hundreds of times, read -s -n1 key will read one key into the variable $key. If I can use that in a while loop I can easily update the filter with what’s needed:

filter="";

while read -s -n1 key; do
    case $key in
        *)
            filter="$filter$key";
            echo $filter;
            ;;
    esac
done

Running that, $filter shows what I’d expect after each keypress! Now to add some listeners for and . Finding out how the terminal sees these keys is pretty straightforward, pressing Ctrl+V followed by the key you want to see will show you what the underlying sequence presented, ^[[A^[[B in this case, so thats Esc, [ and A or B. So updating the while with that sequence:

filter="";

while read -s -n1 key; do
    case $key in
        $'\e[A')
            echo "Up";
            ;;
        $'\e[B')
            echo "Down";
            ;;
        *)
            filter="$filter$key";
            echo $filter;
            ;;
    esac
done

we get…

Keyboard input isn’t so simple

…no Up or Downs on screen. This is because read returns each item in sequence, so it first gets Esc, then [ and finally A. To cater for this, we listen for each keypress in sequence and store a modifier:

filter="";
escape="";
modifier="";

while read -s -n1 key; do
    case $key in
        $'\e')
            escape=1;
            ;;
        '[')
            if [[ $escape = 1 ]]; then
                modifier=1;
            fi
            ;;
        A)
            if [[ $escape = 1 && $modifier = 1 ]]; then
                escape="";
                modifier="";
                echo "Up";
            fi
            ;;
        B)
            if [[ $escape = 1 && $modifier = 1 ]]; then
                escape="";
                modifier="";
                echo "Down";
            fi
            ;;
        *)
            filter="$filter$key";
            echo $filter;
            ;;
    esac
done

Now we are getting Up and Down as expected, but I can’t use BkSp to delete from $filter, but that’s another straightforward mapping along with the empty string to handle Enter (as read is newline terminated). I’ll also update to add some functionality to track our current index that is changed via and :

filter="";
escape="";
modifier="";
index=0;

while read -s -n1 key; do
    case $key in
        $'\e')
            escape=1;
            ;;
        '[')
            if [[ $escape = 1 ]]; then
                modifier=1;
            fi
            ;;
        A)
            if [[ $escape = 1 && $modifier = 1 ]]; then
                escape="";
                modifier="";
                index=$((index+1));
                echo "$index";
            fi
            ;;
        B)
            if [[ $escape = 1 && $modifier = 1 ]]; then
                escape="";
                modifier="";
                index=$((index-1));
                echo "$index";
            fi
            ;;
        $'\177')
            filter="${filter%?}"; # remove the last char of the filter
            ;;
        '')
            $action $item;
            exit;
            ;;
        *)
            filter="$filter$key";
            echo $filter;
            ;;
    esac
done

The main functionality is working, we can update a filter and navigate a list of items, lets work on the next item

3. Select an item from the list and call action with the selected item as an argument on Enter

I wanted a way to break a string containing newlines into individual array elements. This isn’t something I’ve had to do before, so I experimented with a few different approaches. To create an array in Bash you define it within ()s:

array=(this is an array);
echo ${array[3]} # array

Splitting directly on newlines wasn’t straightforward though. Take the following example:

lines="this is a
test of the program
and how it handles
newlines";
array=($lines);
echo ${array[3]} # expected "newlines", got "test"

this doesn’t work because Bash, by default, breaks on any whitespace. A quick search and I found this helpful post that even supplied a Bash 3.2 solution which works exactly as expected:

lines="this is a
test of the program
and how it handles
newlines";
IFS=$'\n' array=($lines);
echo ${array[3]} # newlines

So now we can call our script like this:

command-palette "echo" "item 1
item 2
item 3
item 4"

and if we update the code for the arrow keys to echo ${data[$index]} we get the expected data on each keypress:

action="$1";
lines="$2";

IFS=$'\n' data=($lines);

filter="";
escape="";
modifier="";
index=0;

while read -s -n1 key; do
    case $key in
        $'\e')
            escape=1;
            ;;
        '[')
            if [[ $escape = 1 ]]; then
                modifier=1;
            fi
            ;;
        A)
            if [[ $escape = 1 && $modifier = 1 ]]; then
                escape="";
                modifier="";
                index=$((index-1));
                echo "${data[$index]}";
            fi
            ;;
        B)
            if [[ $escape = 1 && $modifier = 1 ]]; then
                escape="";
                modifier="";
                index=$((index+1));
                echo "${data[$index]}";
            fi
            ;;
        $'\177')
            filter="${filter%?}"; # remove the last char of the filter
            ;;
        '')
            $action "${data[$index]}";
            exit;
            ;;
        *)
            filter="$filter$key";
            echo $filter;
            ;;
    esac
done

Now we can’t see the full list, but when we use the arrow keys it’s posisble to select an item and when you press Enter the first argument is called as a command with the selected list item as its argument!

4. Filter the available items

My approach for this isn’t particularly sophisticated. My plan was to intersperse $filter with *s so that it would work in a [[ $x = $y ]] conditional, I could work on ordering the results later on. To add the *s to the filter my approach was to iterate over the string appending each char, followed by * to $filterGlob. My initial approach utilised seq but I’ve since changed to a while loop:

i=0;

while [[ $i -le ${#filter} ]]; do
    filterGlob="${filterGlob}${filter:$((i++)):1}*";
done

With the glob in place, $filteredData can be updated whenever $filter is changed to contain only items that match $filterGlob. Since I’m calling this on most keypresses, it makes sense to break out into its own function:

filteredData=();

function _filterData {
    filteredData=();
    filterGlob="";

    i=0;

    while [[ $i -le ${#filter} ]]; do
        filterGlob="${filterGlob}${filter:$((i++)):1}*";
    done

    for item in "${data[@]}"; do
        if [[ $item = *$filterGlob ]]; then
            filteredData+=($item);
        fi
    done
}

With filtering in place, it’s time to render!

5. Update the display in place

As we know we’ll be updating the display in many locations, again we want to create a function. _updateDisplay needs to iterate around $filteredData, echo out the items and indicate which item is currently selected. This does enough:

function _updateDisplay {
    clear;

    echo " | $filter";

    for i in "${!filteredData[@]}"; do 
        item="${filteredData[$i]}";
        if [[ $i == $index ]]; then
            echo " * $item";
        else
            echo "   $item";
        fi
    done
}

but it’s not perfect, it completely fills up your terminal buffer every time the screen is redrawn! Enter tput.

Using tput

tput is a way to simplify triggering capabilities within the terminal, positioning the cursor, setting colours, clearing the screen, or even using an alternate screen. An alternate screen is a way to display data without moving the cursor in the underlying terminal window. You might have noticed vim or less do this, well its just a call to tput (or sending the appropriate ANSI escape sequence) away!

tput smcup puts you into an alternate screen. Running this will move you to the top of your screen and running the counterpart tput rmcup will remove you from it. We can call these in our script before our first call to _updateDisplay to ensure we don’t fill up our scrollback with numerous copies of the list we’re displaying. It’s also worth re-using the space available so it’s possible to call tput cup <line> <col> to move to the location specified, tput cup 0 0 will put you at the top of your screen in the first column. What you might notice using this, is that if you move to the top of the screen, the text on the rest of the screen still stays there, but that can be easily cleared with tput ed. There are a lot of capabilities that can be triggered via tput, man tput is a good place to start, and this is a good resource for alternative capabilities (and presumably, maximum compatibility).

A somewhat working version

An annoying bug I encountered with this when trying to use it as a finder for calling git checkout after looking at items in git branch, was that it was trying to call git\ checkout (with the space being part of the command name) which of course didn’t work. I can’t remember the StackOverflow post that helped me here, but the answer is to use an array, so storing the action via action=($1) and calling via ${action[@]} ${filteredData[$index]}. I also wanted to add case-insensitivity to the program as that seemed reasonably key. This was possible using shopt -s nocasematch before the checks in _filterData.

Putting all this together we get a pretty usable base command-palette-like functionality:

action=($1);
lines="$2";

IFS=$'\n' data=($lines);

filter="";
escape="";
modifier="";
index=0;
filteredData=("${data[@]}");

function _updateDisplay {
    tput cup 0 0;
    tput cd;

    echo " | $filter";

    for i in "${!filteredData[@]}"; do 
        item="${filteredData[$i]}";
        if [[ $i == $index ]]; then
            tput rev;
            echo " * $item   ";
            tput sgr0;
        else
            tput sgr0;
            echo "   $item";
        fi
    done
}

function _filterData {
    filteredData=();
    filterGlob="";

    i=0;

    while [[ $i -le ${#filter} ]]; do
        filterGlob="${filterGlob}${filter:$((i++)):1}*";
    done

    shopt -s nocasematch;

    for item in "${data[@]}"; do
        if [[ $item = *$filterGlob ]]; then
            filteredData+=($item);
        fi
    done
}

tput smcup;
_updateDisplay;

while read -s -n1 key; do
    case $key in
        $'\e')
            escape=1;
            ;;
        '[')
            if [[ $escape = 1 ]]; then
                modifier=1;
            fi
            ;;
        A)
            if [[ $escape = 1 && $modifier = 1 ]]; then
                escape="";
                modifier="";
                index=$((index-1));
                _updateDisplay;
            fi
            ;;
        B)
            if [[ $escape = 1 && $modifier = 1 ]]; then
                escape="";
                modifier="";
                index=$((index+1));
                _updateDisplay;
            fi
            ;;
        $'\177')
            filter="${filter%?}"; # remove the last char of the filter
            _filterData;
            _updateDisplay;
            ;;
        '')
            tput rmcup;
            ${action[@]} "${filteredData[$index]}";
            exit;
            ;;
        *)
            filter="$filter$key";
            _filterData;
            _updateDisplay;
            ;;
    esac
done

There are still many bugs, but this is the basic version I started using. I’ve since added more functionality and features and published the code on GitHub. Feel free to fork, raise issues or give me feedback.

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.