Writing Your Own Simple Tab-Completions for Bash and Zsh

Li Haoyi, 7 August 2025

Shell tab-completions can be very handy, but setting them up is complicated by the fact that half your users would be using Bash-on-Linux, while the other half will be using Zsh-on-OSX, each of which has different tab-completion APIs. Furthermore, most users exploring an unfamiliar CLI tool using tab completion appreciate showing a description along with each completion so they can read what it is, but that’s normally only available on Zsh and not on Bash.

But with some work, you can make your tab-completions work on both shells, including nice quality-of-life features like completion descriptions. This blog post will explore how it can be done, based on our recent experience implementing this in the Mill build tool version 1.0.3, providing the great tab-completion experience you see below in a way that works across both common shells. Hopefully based on this, you will know enough and have enough reference examples to set up Bash and Zsh completions for your own command-line tooling.

CompletionDescriptions
CompletionDescriptions2

Basic Tab Completion

The basic way tab-completion works in shells like Bash or Zsh is to register a handler function that is called when a user presses <TAB> at the command line. This handler function is then given the words currently written, and the index of the word the user’s cursor is currently over. From this information, the completion function generates a list of strings that are possible completions for the word at that index, and return it to the shell. At a glance, this looks something like:

_generate_foo_completions() {
  local idx=$1; shift
  local words=( "$@" )
  local current_word=${words[idx]}

  local array=(apple apricot banana cherry durian)
  for elem in "${array[@]}"; do
    if [[ $elem == "$current_word"* ]]; then echo "$elem"; fi
  done
}

_complete_foo_bash() {
  local raw=($(_generate_foo_completions "$COMP_CWORD" "${COMP_WORDS[@]}"))
  COMPREPLY=( "${raw[@]}" )
}

_complete_foo_zsh() {
  local -a raw
  raw=($(_generate_foo_completions "$CURRENT" "${words[@]}"))
  compadd -- $raw
}

if [ -n "${ZSH_VERSION:-}" ]; then
  autoload -Uz compinit
  compinit
  compdef _complete_foo_zsh foo
elif [ -n "${BASH_VERSION:-}" ]; then
  complete -F _complete_foo_bash foo
fi
  • _generate_foo_completions is a dummy function used for demonstration purposes that prints out a hardcoded set of completions, but in a real scenario would be the logic that generates completions for your specific app or CLI tool.

  • _complete_foo_bash and _complete_foo_zsh are the shell-specific completion functions that pass the current word to _generate_foo_completions and wire up the results to each shell’s unique completion APIs. Bash completion functions need to set the COMPREPLY environment variable, while Zsh completion functions need to call compadd (or one of the other similar functions)

  • This example snippet would typically be put (or sourceed) in your ~/.bashrc, ~/.bash_profile, and ~/.zshrc so the if/elif/fi block at the bottom registers the relevant hooks when the shell starts. These hook into tab-completion whenever foo is the first word at the prompt.

For example, the Mill build tool provides a ./mill mill.tabcomplete/install builtin that automatically updates these files and instructs the user to restart the shell or source the relevant script to begin using completions:

$ ./mill mill.tabcomplete/install
[1/1] mill.tabcomplete.TabCompleteModule.install
Writing to /Users/lihaoyi/.cache/mill/download/mill-completion.sh
Writing to /Users/lihaoyi/.bash_profile
Writing to /Users/lihaoyi/.zshrc
Writing to /Users/lihaoyi/.bashrc
Please restart your shell or `source ~/.cache/mill/download/mill-completion.sh` to enable completions

Although the Shell syntax can be very finnicky, e.g. passing arrays to as function arguments via "${words[@]}", the actual underlying logic here isn’t too complicated. _complete_foo_bash and _complete_foo_zsh take the local variables from the shell, pass it to _generate_foo_completions that uses them to return the possible completions, and passes the completions back to the shell via COMPREPLY or compadd.

You can try this out live by pasting it into your Bash or Zsh shell and typing foo <TAB> or foo a<TAB>. Note that you don’t actually need a foo command installed:

$ foo <TAB>
apple    apricot  banana   cherry   durian

$ foo a<TAB>
apple    apricot

That’s all you need to get a basic tab-completer working. In real usage"

  • foo would be the name of the command the user would invoke your CLI program with (e.g. mill)

  • _generate_foo_completions would be your bespoke logic to print out a line-separated list of completions. This could be a hard-coded list for programs that change infrequently, or it could actually invoke your binary and ask it what completions are available for the given input (what mill does).

  • While this example only looks up words[idx] to try and find a prefix match for the current word, the completer is allowed to use the entirety of words to decide what completions to offer, e.g. based on what flags or command-names are present in that array

Note that when you register completion hooks for foo in Bash and Zsh, they apply to commands like ./foo as well. This is handy for programs like Mill, Maven, or Gradle which typically use a ./mill Bootstrap Script to run:

$ ./foo a<TAB>
apple    apricot

Zsh Completion Descriptions

The completions above work and provide a basic level of assistance for users of your CLI, but it would be nice for users if they could also see a description of each command they could complete in the terminal, as is done in the Mill build tool:

CompletionDescriptions

To do this, we can make _generate_foo_completions generate an array of longer strings containing both the completion and a description. Bash does not support completion descriptions by default so we trim off the description, but in Zsh we pass both the trimmed completion-words as well as the raw words and descriptions to compadd -d raw — $trimmed as two parallel arrays.

_generate_foo_completions() {
  local idx=$1; shift
  local words=( "$@" )
  local current_word=${words[idx]}

  local array=(
    "apple: a common fruit"
    "apricot: sour fruit with a large stone"
    "banana: starchy and high in potassium"
    "cherry: small and sweet with a large pit"
    "durian: stinky spiky fruit"
  )
  for elem in "${array[@]}"; do
    if [[ $elem == "$current_word"* ]]; then echo "$elem"; fi
  done
}

_complete_foo_bash() {
  local IFS=$'\n'
  local raw=($(_generate_foo_completions "$COMP_CWORD" "${COMP_WORDS[@]}"))
  local trimmed=()
  for d in "${raw[@]}"; do trimmed+=( "${d%%:*}" ); done

  COMPREPLY=( "${trimmed[@]}" )
}

_complete_foo_zsh() {
  local -a raw trimmed
  local IFS=$'\n'
  raw=($(_generate_foo_completions "$CURRENT" "${words[@]}"))

  for d in $raw; do trimmed+=( "${d%%:*}" ); done
  compadd -d raw -- $trimmed
}

if [ -n "${ZSH_VERSION:-}" ]; then
  autoload -Uz compinit
  compinit
  compdef _complete_foo_zsh foo
elif [ -n "${BASH_VERSION:-}" ]; then
  complete -F _complete_foo_bash foo
fi

Zsh would then display the raw lines including both the completion-word as well as the descriptions when displaying the completion options, but use the trimmed lines which only contain the completion-words when completing the line

$ foo a<TAB>
$ foo ap

$ foo ap<TAB>
apple: a common fruit                          apricot: sour fruit with a large stone

$ foo app<TAB>
$ foo apple

However in this scenario the descriptions are entirely ignored by Bash. Because Bash does not have a concept of tab-complete descriptions, in Bash we only pass the trimmed word-completions to COMPREPLY and discard the raw lines containing the descriptions.

Hacking Bash Completion Descriptions

To make Bash show completion "descriptions", we can take advantage of the fact that the completions are generated dynamically every time we call _generate_foo_completions, and Bash and Zsh only inserts text that is a common prefix to all completion options

$ foo a<TAB>
$ foo ap

Therefore, if we have multiple differing word-completions, we can actually append whatever we want to the right of those words in _generate_foo_completions! This "appended text" will be shown to users if there are multiple completions available, but since the word-completions differ, Bash will never insert the entire word, and thus never insert the appended text either.

The code below implements this: if there is only one completion we trim off the description following the : off as normal, but if there’s more than one completion we leave the description intact for the user to see

_complete_foo_bash() {
  local IFS=$'\n'
  local raw=($(_generate_foo_completions "$COMP_CWORD" "${COMP_WORDS[@]}"))
  local trimmed=()
  if (( ${#raw[@]} == 1 )); then
    trimmed=( "${raw[0]%%:*}" )
  else
    trimmed=( "${raw[@]}" )
  fi

  COMPREPLY=( "${trimmed[@]}" )
}

Now when I use autocomplete in Bash, I can see the descriptions for each item, but when the tab-completion actually completes the token it only completes the word itself and does not include the description!

$ foo <TAB>
apple: a common fruit                     cherry: small and sweet with a large pit
apricot: sour fruit with a large stone    durian: stinky spiky fruit
banana: starchy and high in potassium

$ foo a<TAB>
$ foo ap

$ foo ap<TAB>
apple: a common fruit                   apricot: sour fruit with a large stone


$ foo app<TAB>
$ foo apple

In this section, we only needed to make changes to the _complete_foo_bash function, as the Zsh completion logic in _complete_foo_zsh is completely unchanged.

Showing Single-Completion Descriptions

The last quality of life feature we will add is the ability to show completion descriptions when tabbing on a complete word:

$ foo apple<TAB>

For example, the Mill build tool does this so if you’re not sure what a flag or command does, you can press <TAB> on it to see more details:

CompletionSingleDescription

Tab-completion is a common way to explore unfamiliar APIs, and just because someone finished writing a flat or command doesn’t mean they aren’t curious about what it does! But while Zsh tab-completion displays descriptions when multiple options match the prefix, and we managed to hack Bash tab-completion to do the same thing, neither displays any information if the word you are tab-completing is already complete.

This behavior can be annoying, if the user wants to see the description, they will need to first:

  • Delete enough characters to make the token match multiple completions

  • Press <TAB>

  • Visually scan the multiple completions printed to find the word description they care about

  • Type back in all the missing characters so they can run the command

To solve this, we can hack Bash and Zsh to print tab-completion descriptions even if the token is already a complete word. We do this by checking if the token is a complete word, and if so adding a second "dummy" completion: this makes the tab-completion ambiguous, which cases Bash and Zsh to print out the completions and descriptions for the user to see.

Doing this in _complete_foo_bash looks like the following:

_complete_foo_bash() {
  local IFS=$'\n'
  local raw=($(_generate_foo_completions "$COMP_CWORD" "${COMP_WORDS[@]}"))
  local trimmed=()
  trimmed+=( "${raw[@]}" )

  if (( ${#raw[@]} == 1 )); then
    trimmed+=( "${raw[0]%%:*}" )
  fi

  COMPREPLY=( "${trimmed[@]}" )
}

Instead of checking the length of raw to decide whether we add a trimmed and non-trimmed lines to trimmed, we now instead always add the non-trimmed lines that contain the completion descriptions, and in the case where there’s only one line we then add an additional word-only completion with the description trimmed off.

This means that all completions are ambiguous and will print the description - even completions with a single real choice - but the additional trimmed line when there is only 1 real choice ensures that the description text never gets inserted into the user’s command

In Zsh, this can be similarly done via:

_complete_foo_zsh() {
  local -a raw trimmed
  local IFS=$'\n'
  raw=($(_generate_foo_completions "$CURRENT" "${words[@]}"))

  for d in $raw; do trimmed+=( "${d%%:*}" ); done
  if (( ${#raw} == 1 )); then
    trimmed+=( "${raw[1]}" )
    raw+=( "${trimmed[1]}" )
  fi

  compadd -d raw -- $trimmed
}

The change here is similar to the Bash snippet above: when the number of completions is 1, we add an additional completion to make it ambiguous so Zsh prints the description. But because Zsh expects to pass two parallel arrays of descriptions and tokens to compadd, our if block needs to append items to both trimmed and raw.

Using this, it now looks like

$ foo apple<TAB>
apple                  apple: a common fruit

Although the UI is not quite perfect - the word apple gets duplicated twice - this nevertheless achieves the original goal of letting users <TAB> on an already-completed flag or command to see the description or documentation for that word.

Conclusion

At this point, our final code looks like this:

_generate_foo_completions() {
  local idx=$1; shift
  local words=( "$@" )
  local current_word=${words[idx]}

  local array=(
    "apple: a common fruit"
    "apricot: sour fruit with a large stone"
    "banana: starchy and high in potassium"
    "cherry: small and sweet with a large pit"
    "durian: stinky spiky fruit"
  )
  for elem in "${array[@]}"; do
    if [[ $elem == "$current_word"* ]]; then echo "$elem"; fi
  done
}

_complete_foo_bash() {
  local IFS=$'\n'
  local raw=($(_generate_foo_completions "$COMP_CWORD" "${COMP_WORDS[@]}"))
  local trimmed=()
  trimmed+=( "${raw[@]}" )

  if (( ${#raw[@]} == 1 )); then
    trimmed+=( "${raw[0]%%:*}" )
  fi

  COMPREPLY=( "${trimmed[@]}" )
}

_complete_foo_zsh() {
  local -a raw trimmed
  local IFS=$'\n'
  raw=($(_generate_foo_completions "$CURRENT" "${words[@]}"))

  for d in $raw; do trimmed+=( "${d%%:*}" ); done
  if (( ${#raw} == 1 )); then
    trimmed+=( "${raw[1]}" )
    raw+=( "${trimmed[1]}" )
  fi

  compadd -d raw -- $trimmed
}

if [ -n "${ZSH_VERSION:-}" ]; then
  autoload -Uz compinit
  compinit
  compdef _complete_foo_zsh foo
elif [ -n "${BASH_VERSION:-}" ]; then
  complete -F _complete_foo_bash foo
fi

And can be used in both Bash or Zsh to provide an identical user experience:

  • Showing possible tab-completions when there are multiple available

  • Showing command or flag descriptions (even though this is not natively supported by Bash)

  • Performing partial or entire-word completions

  • Showing the description or documentation when <TAB>ing on an already-completed word

$ foo <TAB>
apple: a common fruit                     banana: starchy and high in potassium     durian: stinky spiky fruit
apricot: sour fruit with a large stone    cherry: small and sweet with a large pit

$ foo a<TAB>
$ foo ap

$ foo ap<TAB>
apple: a common fruit                   apricot: sour fruit with a large stone

$ foo app<TAB>
$ foo apple

$ foo apple<TAB>
apple                  apple: a common fruit

The actual docs for each shell’s tab-completion system contains a lot more detail (e.g. 72 pages for Zsh!), and there are definitely many different ways you can set up your tab-completion scripts. This blog post just aims to provide the simplest working example that works in both Bash and Zsh, so hopefully you can understand it well enough to integrate into your own projects.