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:
- Have a resultant
action
and list of options passed into the script - Handle keyboard input (text and arrow keys)
- Select an item from the list and call
action
with the selected item as an argument on Enter - Filter the available items
- 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 Down
s 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.