An async zsh jujutsu prompt with p10k
How to add an async jujutsu
segment to your powerlevel10k prompt in zsh
My zsh configuration uses the powerlevel10k plugin to render the shell prompt. While the p10k project is no longer maintained, it’s still a good choice due to its easy configuration and performance. The git integration uses a system called gitstatus
to load git status into the prompt asynchronously, which makes a noticeable difference to responsiveness in large repositories. Unfortunately, because of its tight integration and private APIs, the gitstatus
piece of the prompt is difficult to modify or extend for new VCS systems.
I’ve been using the jujutsu VCS in colocated mode. This lets my editor continue to use git status to show file changes. However, since jj
leaves git in a “detached HEAD” state, git status prompts are no longer very informative. I’ve figured out a way to add a separate jj-specific prompt segment, including an async version, to show details about the current jj
change:
~/jj-prompt pzpu 397c (no description) a-bookmark ⇡1 +1 ±1 -1 ↻1
❯
This prompt shows:
- the current working directory
- the current change id
- the current change’s git SHA
- an indicator that there’s no description for the change yet. This shows
(empty)
when applicable, and also picks up onwip:
ortodo:
in descriptions to show an extra label. - the nearest bookmark
a-bookmark
and the number of changes (1) between@
and the bookmark. - the number of added, modified, removed, and renamed files as seen by
jj
.
None of this information is strictly necessary, but the “(empty)” or “(no description)” in particular are helpful. The rest is fun to see, and I figured out how to make it asynchronous to show these the extra details without affecting the prompt’s performance.
Simple synchronous prompt
In p10k.zsh
(as generated by the p10k configure
command), it’s easy to define your own segments. Let’s start with a simple example showing the current change ID:
prompt_my_jj() {
local current_workspace change_id display
command -v jj >/dev/null 2>&1 || return
current_workspace=$(jj workspace root 2>/dev/null) || return
change_id=$(jj log --ignore-working-copy \
--no-graph --limit 1 --color always \
--revisions @ -T 'change_id.shortest(4)')
display=$(echo "$change_id" | sed 's/\x1b\[[0-9;]*m/%{&%}/g')
p10k segment -t "$display"
}
# and add the this jj segment to the powerlevel prompt:
typeset -g POWERLEVEL9K_LEFT_PROMPT_ELEMENTS=(
# ...
context # user@hostname
my_jj # jj version control status
# ...
)
Piece by piece, this function:
- checks that the current working directory is in a
jj
workspace - fetches the current change with
jj
--ignore-working-copy
preventsjj
from taking a new snapshot, it only shows the state of what it currently knows.--no-graph
disables the visual graph rendering--limit 1
ensures we only get the first change in the revisions--revisions @
gets only the current change--color always
forces ANSI colors so we can use jj’s color formatting for the prompt output-T 'change_id.shortest(4)'
defines a template to fetch the first four characters of the change id.
sed 's/\x1b\[[0-9;]*m/%{&%}/g'
:jj
is emitting colors with ANSI color escape codes, but the prompt doesn’t know to ignore those extra characters for width calculations. Thesed
regex finds\x1b\[0;]
ANSI escapes and wraps them in%{ %}
, telling the zsh prompt expansion to treat them as literal escape sequences and thus zero width, so they don’t affect width calculations.- Finally,
p10k segment -t "$display"
defines the content of themy_jj
segment, rendering the current change:
~/jj-prompt spxl
❯
Making it async
We can display basic information quickly, but anything complex will make the prompt unresponsive.
Fortunately, there is a zsh plugin called zsh-async. This plugin lets us define asynchronous jobs, call them, and handle the callback when the job completes. Here’s an example of using this for CI status, and another for docker compose status.
I’m using antigen for zsh plugins, so I’ve added it to my .zshrc
. The @main
suffix is important: antigen assumes master
is the default branch name, which is incorrect for this plugin.
antigen bundle mafredri/zsh-async@main
Initial async setup
First we register global variables. The display string is for the prompt segment, and will be updated asynchronously. The current workspace tracks which workspace we’re in so we can clear the segment if we change to a different workspace. If we didn’t do this, we might temporarily show stale information in the prompt from the previous workspace.
typeset -g _my_jj_display=""
typeset -g _my_jj_workspace=""
Next, we define the worker and callbacks.
# This function is what gets called asynchronously.
# It's given the current workspace as its argument, and outputs the
# formatted prompt segment.
#
# The workspace argument is required, as the async worker can't see
# the global $_my_jj_workspace.
#
# --repository specifies which workspace to operate in.
_my_jj_async() {
local workspace=$1
local change_id
change_id=$(jj log --repository "$workspace" --ignore-working-copy \
--no-graph --limit 1 --color always \
--revisions @ -T 'change_id.shortest(3)')
display=$(echo "$change_id" | sed 's/\x1b\[[0-9;]*m/%{&%}/g')
}
# This callback is called with the result of the async function.
_my_jj_callback() {
local job_name=$1 exit_code=$2 output=$3 execution_time=$4 stderr=$5 next_pending=$6
if [[ $exit_code == 0 ]]; then
_my_jj_display=$output
else
_my_jj_display="$output %F{red}$stderr%f"
fi
# This is the dynamic piece: once we have the display data, tell p10k to
# redraw the prompt in-place.
p10k display -r
}
Then, initialize the plugin and callbacks. We explicitly stop and restart the worker and re-register the callback so we can handle a full reload of the config.
async_init
async_stop_worker _my_jj_worker 2>/dev/null
async_start_worker _my_jj_worker
async_unregister_callback _my_jj_worker 2>/dev/null
async_register_callback _my_jj_worker _my_jj_callback
The prompt itself tracks the current workspace and registers the segment with p10k. The difference from before is that the segment value is in single quotes with a -e
flag: this tells p10k
to reinterpret the segment each time it’s rendered, allowing the dynamic update to get the latest from $_my_jj_display
.
prompt_my_jj() {
local workspace
command -v jj >/dev/null 2>&1 || return
workspace=$(jj workspace root 2>/dev/null) || return
# track current workspace for the async worker
if [[ $_my_jj_workspace != "$workspace" ]]; then
_my_jj_display="" # clear segment if we've moved
_my_jj_workspace="$workspace"
fi
# request async job for the current workspace
async_job _my_jj_worker _my_jj_async "$workspace"
# single quotes, we want this to be interpreted each time
p10k segment -t '$_my_jj_display' -e
}
Fancier prompt
We’ll use some revset aliases and templates defined in jjconfig.toml
to make things a little more flexible and easier to update. If you’re looking for template examples, check the built-in configs: jj config list --include-defaults --include-overridden
.
First, the revision data should show the current change, the current git SHA, whether or not it’s hidden or conflicted, and (empty)
and (no description)
when applicable:
[template-aliases]
'format_short_change_id(id)' = 'id.shortest(4)'
'format_short_commit_id(id)' = 'id.shortest(4)'
prompt = '''
separate(" ",
format_short_change_id_with_hidden_and_divergent_info(self),
format_short_commit_id(commit_id),
if(empty, label("empty", "(empty)"), ""),
if(description == "", label("description placeholder", "(no description)"), ""),
if(conflict, label("conflict", "(conflict)"), "")
)
'''
Then using this new prompt template:
revision=$(jj log --repository "$workspace" --ignore-working-copy \
--no-graph --limit 1 --color always \
--revisions @ -T 'prompt')
We’ll use a revset to find the name and distance to the nearest ancestor bookmark:
[revset-aliases]
'closest_bookmark(to)' = 'heads(::to & bookmarks())'
bookmark=$(jj log --repository "$workspace" --ignore-working-copy \
--no-graph --limit 1 --color always \
-r "closest_bookmark(@)" -T 'bookmarks.join(" ")' 2>/dev/null)
distance=$(jj log --repository "$workspace" --ignore-working-copy \
--no-graph --color never \
-r "closest_bookmark(@)..@" \
-T 'change_id ++ "\n"' 2>/dev/null | wc -l | tr -d ' ')
Finally, we can use awk
to parse and count file change statuses, and show them with some icons. This uses zsh’s prompt color highlighting syntax to set the colors.
file_status=$(jj log --repository "$workspace" --ignore-working-copy \
--no-graph --color never --revisions @ \
-T 'self.diff().files().map(|f| f.status()).join("\n")' 2>/dev/null | \
sort | uniq -c | awk '
/modified/ { parts[++i] = "%F{cyan}±" $1 "%f" }
/added/ { parts[++i] = "%F{green}+" $1 "%f" }
/removed/ { parts[++i] = "%F{red}-" $1 "%f" }
/copied/ { parts[++i] = "%F{yellow}⧉" $1 "%f" }
/renamed/ { parts[++i] = "%F{magenta}↻" $1 "%f" }
END { for (j=1; j<=i; j++) printf "%s%s", parts[j], (j<i ? " " : "") }
')
Tying it all together:
display=$revision
if [[ -n "$bookmark" ]]; then
display+=" $bookmark"
if [[ "$distance" -gt 0 ]]; then
display+=" %7F⇡${distance}"
fi
fi
if [[ -n "$file_status" ]]; then
display+=" ${file_status}"
fi
echo "$display" | sed 's/\x1b\[[0-9;]*m/%{&%}/g'
This looks like:
~/jj-prompt pzpu 0441 (no description) main ⇡2 +1 ±1 -1 ↻1
❯
Disabling the vcs
segment in jj
workspaces
The prompt examples above left out the vcs
segment, which shows a detached HEAD
status. If enabled in a colocated jj repository, it looks like this:
~/jj-prompt on @397c199a !3 lvkk aa10 (empty) (no description) main ⇡3
❯ ^^^^^^^^^ ^^
| `- git status shows changed files too
`- detached git HEAD SHA
p10k
gives us an easy way to selectively disable and enable segments. All it takes is a small addition to the “are we in a jj workspace” check:
if workspace=$(jj workspace root 2>/dev/null); then
p10k display "*/jj=show"
p10k display "*/vcs=hide"
else
p10k display "*/jj=hide"
p10k display "*/vcs=show"
return
fi
This can also be accomplished using precmd
and chpwd
hooks.
Putting it all together
Including custom labels for changes described with “wip”, “todo”, and so forth.
jjconfig.toml
:
[template-aliases]
'format_short_change_id(id)' = 'id.shortest(4)'
'format_short_commit_id(id)' = 'id.shortest(4)'
prompt = '''
separate(" ",
format_short_change_id_with_hidden_and_divergent_info(self),
format_short_commit_id(commit_id),
if(empty, label("empty", "(empty)"), ""),
if(description == "", label("description placeholder", "(no description)"), ""),
if(description.contains("megamerge"), label("mega", "(mega)"), ""),
if(description.starts_with("wip"), label("wip", "(wip)"), ""),
if(description.starts_with("todo"), label("todo", "(todo)"), ""),
if(description.starts_with("vibe"), label("vibe", "(vibe)"), ""),
if(description.starts_with("mega"), label("mega", "(mega)"), ""),
if(conflict, label("conflict", "(conflict)"), "")
)
'''
[revset-aliases]
'closest_bookmark(to)' = 'heads(::to & bookmarks())'
[colors]
wip = "yellow"
todo = "blue"
vibe = "cyan"
mega = "red"
p10k.zsh
:
typeset -g _my_jj_display=""
typeset -g _my_jj_workspace=""
prompt_my_jj() {
local workspace
command -v jj >/dev/null 2>&1 || return
if workspace=$(jj workspace root 2>/dev/null); then
p10k display "*/jj=show"
p10k display "*/vcs=hide"
else
p10k display "*/jj=hide"
p10k display "*/vcs=show"
return
fi
# track current workspace for the async worker
if [[ $_my_jj_workspace != "$workspace" ]]; then
_my_jj_display=""
_my_jj_workspace="$workspace"
fi
# request async job for the current workspace
async_job _my_jj_worker _my_jj_async "$workspace"
# note the single quotes, we want this to be interpreted each time
p10k segment -t '$_my_jj_display' -e
}
# this function is called by the async worker, and does the work
# of calculating the jj status.
_my_jj_async() {
local workspace=$1
local display revision bookmark distance
revision=$(jj log --repository "$workspace" --ignore-working-copy \
--no-graph --limit 1 --color always \
--revisions @ -T 'prompt')
bookmark=$(jj log --repository "$workspace" --ignore-working-copy \
--no-graph --limit 1 --color always \
-r "closest_bookmark(@)" -T 'bookmarks.join(" ")' 2>/dev/null)
distance=$(jj log --repository "$workspace" --ignore-working-copy \
--no-graph --color never \
-r "closest_bookmark(@)..@" \
-T 'change_id ++ "\n"' 2>/dev/null | wc -l | tr -d ' ')
file_status=$(jj log --repository "$workspace" --ignore-working-copy \
--no-graph --color never --revisions @ \
-T 'self.diff().files().map(|f| f.status()).join("\n")' 2>/dev/null | \
sort | uniq -c | awk '
/modified/ { parts[++i] = "%F{cyan}±" $1 "%f" }
/added/ { parts[++i] = "%F{green}+" $1 "%f" }
/removed/ { parts[++i] = "%F{red}-" $1 "%f" }
/copied/ { parts[++i] = "%F{yellow}⧉" $1 "%f" }
/renamed/ { parts[++i] = "%F{magenta}↻" $1 "%f" }
END { for (j=1; j<=i; j++) printf "%s%s", parts[j], (j<i ? " " : "") }
')
display=$revision
if [[ -n "$bookmark" ]]; then
display+=" $bookmark"
if [[ "$distance" -gt 0 ]]; then
display+=" %7F⇡${distance}"
fi
fi
if [[ -n "$file_status" ]]; then
display+=" ${file_status}"
fi
echo "$display" | sed 's/\x1b\[[0-9;]*m/%{&%}/g'
}
_my_jj_callback() {
local job_name=$1 exit_code=$2 output=$3 execution_time=$4 stderr=$5 next_pending=$6
if [[ $exit_code == 0 ]]; then
_my_jj_display=$output
else
_my_jj_display="$output %F{red}$stderr%f"
fi
p10k display -r
}
# finally, initialize and register the worker and callbacks.
# this unregisters first so we can easily reload everything.
async_init
async_stop_worker _my_jj_worker 2>/dev/null
async_start_worker _my_jj_worker
async_unregister_callback _my_jj_worker 2>/dev/null
async_register_callback _my_jj_worker _my_jj_callback
Related work
André Arko also used zsh-async
to implement an async jj
prompt. I look forward to incorporating some of the different features he’s implemented, including distance from a remote tracked bookmark.