zerowidth positive lookahead

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 on wip: or todo: 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 prevents jj 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. The sed 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 the my_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

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.