Bash quick select prompter utility

In this blog post I’ll showcase flexible utility script prompter.bash that I wrote. It is a vim-which-key inspired shortcut utility but for a terminal.

First there is a two gifs showcasing the script followed with more detailed Git example. At the end there is usage instructions with full sources.

Gif showcases

In following gif the keys pressed after enter are a, a, b.

Toy example of prompter.bash.

This is variation what will be shown in the next section.

Utilizing prompter.bash for git.

Git example

Following invocation of prompter.bash (arguments are explained in Usage):

$ /path/to/prompter.bash \
    b "branches # git branch" \
    t "status # git status" \
    s "summary # git log --oneline 5; git status" \
    ls "short log 20 # git log --oneline -n 20" \
    ll "log 5 # git log -n 5" \
    dd "diff # git diff" \
    di "indexed/staged diff # git diff --staged" \
    h "stash list # git stash list"

which can be shortened with e.g. Bash alias gitp, will result in:

$ gitp
Type to select:
    ll: log 5
    ls: short log 20
    dd: diff
    di: indexed/staged diff
    t: status
    s: summary
    h: stash list
    b: branches

and terminal will wait for input and update immediatly when any key is pressed. For example pressing t, will result in a executiong of git status after clearing the screen.

However if the typed key does not result in unique command, user is asked for another one, but with delimited options. For example if after:

$ gitp
Type to select:
    ll: log 5
    ls: short log 20
    dd: diff
    di: indexed/staged diff
    t: status
    s: summary
    h: stash list
    b: branches

user types l, then options are updated to:

$ gitp
Type to select:
    l: log 5
    s: short log 20

after which the user can choose either one by typing l or s.

One useful thing is to call gitp inside a loop:

function infinite_gitp() {
    while true; do
        /path/to/prompter.bash \
            b "branches # git branch" \
            t "status # git status" \
            s "summary # git log --oneline 5; git status" \
            ls "short log 20 # git log --oneline -n 20" \
            ll "log 5 # git log -n 5" \
            dd "diff # git diff" \
            di "indexed/staged diff # git diff --staged" \
            h "stash list # git stash list"
        echo
    done

which will result in a convinient way of quering information of git repository on the fly. See the second gif example for variation of this.

Usage

Usage: prompter [key1 cmd1 [key2 cmd2 [ ... ]]

       Prompter will query keystrokes (x1, x2, ...) until word 'x1x2...':

           A) does not match beginning of any key.
           B) matches uniquely to beginning of 'keyN'.

       In case of:

           A) prompter exits with code 1.
           B) prompter executes 'cmdN'.

       All 'cmdN' are stripped up to first # character.
       This can be used as description for the command.

       If 'key1 cmd1' is missing, usage is printed with exit code 0.

Source

Version 2024-04-26.

#!/usr/bin/bash

function print_usage() {
    echo "Usage: prompter [key1 cmd1 [key2 cmd2 [ ... ]]"
    echo
    echo "       Prompter will query keystrokes (x1, x2, ...) until word 'x1x2...':"
    echo
    echo "           A) does not match beginning of any key."
    echo "           B) matches uniquely to beginning of 'keyN'."
    echo
    echo "       In case of:"
    echo
    echo "           A) prompter exits with code 1."
    echo "           B) prompter executes 'cmdN'."
    echo
    echo "       All 'cmdN' are stripped up to first # character."
    echo "       This can be used as description for the command."
    echo
    echo "       If 'key1 cmd1' is missing, usage is printed with exit code 0."
}

# Associative array of (keyN, cmdN) pairs.
declare -A opts

if (( ${#*} > 1 )); then
    # Take from
    for (( i=1; i < ${#*}; i+=2)); do
        (( j=i+1 ))
        key="${!i}"
        cmd="${!j}"
        opts["$key"]="$cmd"
    done
else
    print_usage
    exit 0
fi

# Array of the keys.
declare -a viable_opts="( ${!opts[*]} )"

currently_typed=""

# Remove all opts from viable_opts that do not match what is in currently_typed.
function update_viable_opts() {
    local -a new_viable_opts
    for opt in "${viable_opts[@]}"; do
        [[ "$opt" =~ ^"$currently_typed".*$ ]] && new_viable_opts+=( "$opt" )
    done
    viable_opts=( "${new_viable_opts[@]}" )
}

function print_viable_opts() {
    echo "Type to select:"
    for opt in "${viable_opts[@]}"; do
        # Remove everything from first # onwards.
        echo "    ${opt/#$currently_typed/}: ${opts[$opt]/%#*/}"
    done
}

function check_for_matched_opts() {
    local num_of_viable_opts="${#viable_opts[@]}"
    if [[ "$num_of_viable_opts" == 0 ]]; then
        echo "Nothing found!"
        exit 1
    elif [[ "$num_of_viable_opts" == 1 ]]; then
        local opt="${viable_opts[0]}"
        # Remove everything up to first #.
        local cmd="${opts[${opt}]/#*#/}"

        if [[ -v PROMPTER_DEBUG ]]; then
            echo "found: ${opt}"
            echo "executing: $cmd"
        fi

        # Clear screen before executing the command.
        tput home
        tput ed

        $cmd
        exit
    fi
}

while true; do
    # Will print amount of viable options + 1 rows.
    print_viable_opts
    (( lines_printed = "${#viable_opts[@]}" + 1 ))

    read -s -r -n 1 pressed_key
    currently_typed+="$pressed_key"

    tput cuu "$lines_printed" # Move up.
    tput ed                   # Clear what is below.

    update_viable_opts
    check_for_matched_opts
done