How to write an asynchronous Zsh prompt
Getting slow processes to play nicely with your prompt
I recently came across Sindre Sorhus’s wonderful Pure prompt, which has a pretty cool feature: it checks whether you have unpushed/unpulled Git commits, and it does so asynchronously.
Pretty magical! I wanted to add a Vagrant status indicator to my prompt to show me when Vagrant is up, but vagrant status
is quite slow (~6s on my machine), so I thought this would be a good opportunity to learn about asynchronous prompt tomfoolery.
zsh-async
At the core of this is the library zsh-async, which was written by Mathias Fredriksson specifically to solve some issues with Pure. (Mathias is now a maintainer for Pure, unsurprisingly.) It makes it easy to register async tasks & process the results.
A nice way to handle installation is to add it to our .zshrc
file, conditional upon the directory existing. This way it will install on first use. We’ll install to a folder .zsh-async
in our home directory.
if [[ ! -a ~/.zsh-async ]]; then
git clone -b 'v1.5.2' git@github.com:mafredri/zsh-async ~/.zsh-async
fi
source ~/.zsh-async/async.zsh
A note on versions: you may note I’ve specified the version tag -b 'v1.5.2'
instead of just grabbing the latest master
. I’ve done this for safety’s sake; you never know when the project gets updated with some horrible bug (not to mention the possibility of malware).
Configure zsh-async
Now we need to configure our background worker. We’ll define a function that sets a global variable to a string depending on whether Vagrant is running in the current directory, and then register that function to call once when we start a new session (& then to call itself when it finishes as well, so that it loops infinitely).
Annoyingly, for all this to work, we need to wrap vagrant status
in a function so that we can set the working directory when we call it (otherwise it will keep checking the initial directory where we started our session). So do that.
vagrant_status() {
VAGRANT_CWD=$1 vagrant status
}
We need to initialise zsh-async. We need to start a worker process, and pass the -n
flag to it to make it notify us of job completion. We’ll then also register a callback function to be run when the job completes, and finally kick off the job, passing the current working directory to our vagrant_status
wrapper.
async_initasync_start_worker vagrant_prompt_worker -nasync_register_callback vagrant_prompt_worker completed_callbackasync_job vagrant_prompt_worker vagrant_status $(pwd)
Finally, we need a function to process the results of the job. We’ll use it to toggle a string variable’s value based on whether vagrant status
says the box is running or not.
completed_callback() {
local output=$@
if [[ $output =~ 'running' ]]; then
H_PROMPT_VAGRANT_UP='vagrant up'
else
H_PROMPT_VAGRANT_UP=''
fi
async_job vagrant_prompt_worker vagrant_status $(pwd)
}
Putting it all together, in order:
# Install zsh-async if it’s not present
if [[ ! -a ~/.zsh-async ]]; then
git clone -b ‘v1.5.2’ git@github.com:mafredri/zsh-async ~/.zsh-async
fi
source ~/.zsh-async/async.zsh# Initialize zsh-async
async_init# Start a worker that will report job completion
async_start_worker vagrant_prompt_worker -n# Wrap vagrant status in a function, so we can pass in the working directory
vagrant_status() {
VAGRANT_CWD=$1 vagrant status
}# Define a function to process the result of the job
completed_callback() {
local output=$@
if [[ $output =~ 'running' ]]; then
H_PROMPT_VAGRANT_UP='vagrant up'
else
H_PROMPT_VAGRANT_UP=''
fi
async_job vagrant_prompt_worker vagrant_status $(pwd)
}# Register our callback function to run when the job completes
async_register_callback vagrant_prompt_worker completed_callback# Start the job
async_job vagrant_prompt_worker vagrant_status $(pwd)
Configure the prompt
Now we just need to add some bits to our prompt to make use of our $H_PROMPT_VAGRANT_UP
variable.
For the purposes of this demonstration, I’m going to assume we have a prompt like this:
date_string="%D{%Y-%m-%d %H:%M:%S}"
username="%n"
path_string="%3c"
precmd() {
print -rP '${date_string} ${username} ${path_string}'
}
PROMPT='» '
It looks like this in practice:
2017–11–21 17:38:51 henrebotha ~/dev/dotfiles
» echo 'command goes here'
The first thing we want to do is to add our variable to our prompt.
print -rP '${date_string} ${username} ${path_string} $H_PROMPT_VAGRANT_UP'
At this point, our prompt will include the value of $H_PROMPT_VAGRANT_UP
as it is when the prompt is refreshed (i.e. when we start a new session, or when a command finishes executing). However, it’s not yet going to update automagically in place, which is what we’re after!
That we will achieve by using Zsh’s built-in TMOUT
mechanism. The feature seems to be built for security purposes and the like (e.g. logging you out if you take too long to input a command), but we can co-opt it for this. We’ll define a callback TRAPALRM
, which we’ll use to refresh the prompt in place every $TMOUT
seconds.
TMOUT=1
TRAPALRM() { zle reset-prompt }
We’re so close to done. Two issues now rear their heads:
- The part of the prompt containing the Vagrant status is not actually getting refreshed, because it is a
precmd
and not part of the$PROMPT
variable. - The date string in our prompt now updates once a second, which makes it useless for scrolling back and seeing when we were doing what. We’d rather it stay static until a command is executed.
We can solve issue #1 by rejecting the use of precmd
, and instead just putting everything in PROMPT
with a literal line break. And we can solve issue #2 by setting the value of $date_string
in the precmd
call (using the date
util, instead of Zsh’s built-in prompt date tool), so that it is not updated when reset-prompt
happens.
All together now:
TMOUT=1
TRAPALRM() { zle reset-prompt }username="%n"
path_string="%3c"
precmd() {
date_string=$(date +'%Y-%m-%d %H:%M:%S')
}
PROMPT='${date_string} ${username} ${path_string} $H_PROMPT_VAGRANT_UP
» '
This is what it looks like when we’re not in a directory with a Vagrant box:
2017–11–22 09:53:31 henrebotha ~/dev/dotfiles
» echo 'command goes here'
But if we cd
into a directory with a running Vagrant box, and we wait a few seconds:
2017–11–22 09:53:31 henrebotha ~/dev/absolver-deck-builder vagrant up
» echo 'command goes here'
Success!
Here’s an asciinema recording that shows the concept in action a bit better.
Where to from here?
This concept is very easy to extend to other applications. You could use it to check for other running services, such as Docker or Redis. You could also use it, as Pure does, to check for unsynced Git pushes/pulls. Or maybe you come up with a new application entirely! Let me know in the comments.