Antoine Kalmbach

Been there, done that

Shell history and browser history, I could do without one but not the other! I have no shame in admitting I’m very reliant on my shell history for getting things done on the command line. Ctrl+R, augmented via fzf, has become one of many secondary brains of mine.

But there’s one curious thing about it! Shell history has no practical way of filtering results besides string matching. That’s where fzf comes in, it’s an amazing fuzzy finder. The nicest part about it is that you can use it as a backend to complete just about anything! Seriously, just pipe stuff to fzf and you have a fast, fuzzy completion UI for anything line-oriented.

Yet, I’d like yet another layer: semantic “bookmarking” of history entries, so that I can memorize an important command for later. What is particularly important, I want to be able to “tag” it with something concise, instead of having to rely on a fuzzy match of a part of the command to find it.

bt and dt in action

So, for instance, git commit --no-verify --amend could be saved as git verify amend or something like that. To this end, I came up with a couple of commands, bt, short for been there, and dt, short for done that. What they do precisely is the following:

  1. bt prompts for a command in your history and asks to save it with another name
  2. dt then recalls a command from this named history

So, for instance, if you end up writing git commit --no-verify --amend often, or something like that, you can write that once, then write bt, pick that command, and then enter git commit noverify or some other short hand, then pick it handily using dt.

Using fzf’s preview capabilities, it shows the command to be executed and the description of the command in a separate preview window, so you always know what’s going to happen.

All possible with a couple of short bash functions! bt stores data in a simple tabulated list in your home directory, so it’s nothing complicated. Giving an argument to bt just saves that command directly, without prompting. You can have bt and dt too, just dump this into your .bash_profile:

bt() {
    if [[ -z "$1" ]]; then
        CMD=$(history 10 | fzf --header="Choose a command to save" --no-sort --tac | cut -d' ' -f 5-)
    else
        CMD=$1
    fi

    [[ -z "$CMD" ]] && echo "Aborted" && return 2
    echo "Saving \"${CMD}\"..."
    read -p "Name of command: " NAME
    [[ -z "$NAME" ]] && echo "No name given, aborting" && return 1
    LONG=$(read -p "Longer description (optional): ")

    if [[ ! -d "${HOME}/.config/btdt" ]]; then
        mkdir -p "${HOME}/.config/btdt"
    fi

    DATA="${BTDT_DATA:-${HOME}/.config/btdt/data}"

    if [[ ! -f $DATA ]]; then
        touch $DATA
    fi
    
    result="${CMD}\t${NAME}\t${LONG}\n"
    printf "$result" >> $DATA
}

dt() {
    local data="${BTDT_DATA:-${HOME}/.config/btdt/data}"
    local cmd=$(cat $data | fzf -d'\t' --with-nth 2 --preview='echo -e "\033[1m"{1}"\033[0m""\n\n"{3}' | cut -f1)
    echo "${cmd}"
    eval "${cmd}"
}

The cool thing about fzf’s --with-nth argument is that it can display one thing in the preview and return another as the result. --with-nth 2 tells it to use the second element that it’s filtering, so applying that to foo\tbar\tbaz would return bar, but the preview elements {1} and {3} will contain foo and baz respectively, and will be shown to the user in the preview.

I tried if something like this already existed, but I couldn’t find anything. That’s weird, this can’t be a truly original idea, can it? I think there are graphical tools for this purpose, but this is about 20 lines of bash, so it’s relatively straightforward.

I’m not the most experienced bash programmer, so I don’t know if eval is the right way to “evaluate something out of a pipe”, but hey, it seems to work! It only works with bash, and you’ll need fzf, and I have no idea if it works with other shells, so suggestions are welcome.

Previous: This website is now hosted on sourcehut