136

How to write an asynchronous Zsh prompt – Henré Botha – Medium

 6 years ago
source link: https://medium.com/@henrebotha/how-to-write-an-asynchronous-zsh-prompt-b53e81720d32
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

How to write an asynchronous Zsh prompt

Getting slow processes to play nicely with your prompt

1*Ocz1npFXZvvFnIYbjVFjVw.jpeg

Photo by Adrien Ledoux on Unsplash

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' [email protected]: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’ [email protected]: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:

  1. 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.
  2. 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.


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK