Customizing my shell: From bash to zsh to fish

Oct 31, 2022 20:00 · 1882 words · 9 minute read

When I am coding, I use the terminal a lot. Over the time, I have tried out different popular shells1: bash, zsh, and fish. For around one year now, I have settled for the fish shell. In this post, I want to explain how I got there, why I am very happy with fish, and how I have configured it.

This is how my shell looks right now:

fish screenshot

If you are not sure which shell is the default on your system, look at the output of this command2:

1
echo $SHELL

If you are using Linux, this most likely will be bash. On macOS, the default is zsh3.

The beginning: Bash

When I started using the terminal on Linux, I just used what was there: Bash. Basically all tutorials in the internet assume that you are using it, so copy-pasting commands from StackOverflow just worked.

Over time, I realized I was typing too much. I learned about the shortcut Ctrl+R to do a reverse/backwards search in history (I could not live without this shortcut anymore…). But looking at the shells of other students, I saw that their shells (a) looked nicer, and (b) automatically suggested command completions as they were typing. I wanted this, too! This is how it looks like:

More customization & auto-completion: zsh (oh-my-zsh)

I learned that auto-suggestions were a plugin for oh-my-zsh, a customization framework for zsh. So I switched to zsh with oh-my-zsh and installed the zsh-autosuggestions plugin.

Bonus: oh-my-zsh is bundled with a lot of other plugins & themes, and you can simply enable them. For example, there is the z plugin: With the new command z, you can quickly jump to frequently-used directories by just typing parts of the directory path:

Also, its default theme, “robbyrussell”, is pretty great. It not just looks very nice, it is also useful: For example, it displays whether the current directory is tracked by git, and shows the name of the branch you are currently at.

robbyrussel theme in action

⚠️ Caution: Syntax differences between bash and zsh

By switching from bash to zsh, you need to be aware that the command syntax is not 100% the same. The most important difference I observed is related to filename expansion, also called “globbing”.

Take this command:

1
echo bl*

If you execute this command in a directory with two files or folders called bla and blub, the output for bash and zsh will be the same:

1
2
echo bl*
bla blub

But if you execute this command in a directory without any matching file (e.g. an empty directory), the output will be different:

1
2
3
4
5
6
7
# Bash
echo bl*
bl*

# zsh
echo bl*
zsh: no matches found: bl*

If no files match the expression, bash will simply pass the glob expression as text to the command. zsh instead will fail the whole execution without executing the command at all.

I think zsh’s behaviour is pretty reasonable. But some commands found in the internet will expect the glob expression to get passed to the command. So the command won’t work in zsh.

The fastest solution: Enclosing the expression with quotes, so no expansion will take place, but the expression gets passed as a string:

1
echo "blu*"

This works in both bash and zsh. Here are some StackOverflow discussions where this was the issue: One related to rsync, and one related to rm.

Works Out of the Box: Switching to fish

My setup with zsh & oh-my-zsh worked well for me for a couple of years and I still recommend it without hesitation. But then I got to know the fish shell. Fish’s main advantage for me is: It comes with much more features out of the box, and does many things smarter, without any extra package or configuration.

Autosuggest built-in

Take the autosuggest feature for example. For zsh, we had to install oh-my-zsh and the the zsh-autosuggest plugin. For fish, the feature is already built-in! In fact, the description of the zsh-autosuggest plugin is “Fish-like autosuggestions for zsh”. So that’s that. Moreover, the autosuggest is better than zsh’s: Fish’s autosuggest will only suggest commands that actually succeeded, zsh’s autosuggest does not take that into account.

Two more features sold Fish for me: Smart tab completions & syntax highlight to detect potential errors.

Smart tab completions

Fish’s tab completions are much smarter and helpful than the ones in bash or zsh. See this screenshot - in all shells, I have typed git com, followed by a Tab:

tab completion compared

As you can see, bash did not complete my command at all. I likely could install a package for that (maybe bash-completion?), but there was no completion out of the box. Zsh did complete the word commit, but did not offer any help besides that. Fish did complete commit as well, and in addition, it mentions that there are multiple commands that start with commit: commit and commits-since, including a short description of each one. Especially interesting: The commits-since command is not part of the normal git installation, but is available because I have git-extras installed, a collection of useful utilities for git. So fish is aware of these extra utility commands, and suggests them as well. How cool is that!? 😀

Fish goes one more step further. It can complete command arguments, too, including descriptions of what an argument does:

fish tab completion of arguments

I am pretty sure I could somehow configure bash or zsh to provide me with argument completions, too - but with fish, it just works out of the box. As far as I understand it, fish does that by parsing a commands man pages, and automatically generating the tab completions from that - without the need for extra packages that provide completions for specific commands.

Syntax highlight to detect potential errors

Another neat feature in fish is its syntax highlighting. If a command has an error (e.g. because you have a typo in the tool you want to call, or because a file path does not exist), it marks the command in red:

fish syntax highlighting

My additional configuration for fish

Fish works really well out of the box. Over the time, I still have configured it a bit to fit my needs:

  • I have installed the oh-my-fish framework - I simply did that to get the “robbyrussell” theme that I knew & liked from oh-my-zsh. I haven’t used any other feature of oh-my-fish yet.
  • I have installed fisher, a plugin manager for fish (oh-my-fish can be used as a plugin manager, too, but fisher seems to be more popular). With fisher, I have installed these two plugins:
  • Backwards-search: While I like fish’s autosuggestions a lot, I do not like their approach to backwards-searching commands. In fish, you are supposed to start typing a command, and then use the up & down arrow keys to navigate between suggestions from your history that start the same way. And this is the issue: If you don’t remember the beginning of a command, but just some part in-between, this search does not work - the backwards-searches in bash & zsh do Therefore, I have installed fzf. Amongst other features, this adds backwards-search with Ctrl+R4. The search is even better than the ones built-in into bash & zsh: You’ll see a list of results while you type, not just the top-match. And the search matching algorithm is fuzzy, so e.g. gmiup will match the command git commit -m "Update".
    • UPDATE 2022-11-18: I was wrong - fish’s backwards-search does not just match the start of a command, but will look for the search string everywhere - so just like bash & zsh. The fzf extension still makes backwards-search much better than the default, so I still recommend it.
    • UPDATE 2023-01-12: With version 3.6.0, fish added support for Ctrl+R backwards-search - and this search is pretty awesome. Like with fzf, it shows a list of results. An advantage: The entries in the list are syntax-highlighted. The only downside: It doesn’t do fuzzy matching (yet?) - this is the only reason I’ll stay with fzf’s backwards-search.

⚠️ Caution: Even larger syntax differences

One thing to keep in mind with fish: While zsh is pretty close to bash regarding syntax and how to configure it (.bashrc & .zshrc), fish does more things differently. One thing that I have to look up regularly: Settings environment variables.

Most often, you will want to add directories to the PATH variable. For that, there is a special command, fish_add_path, that does that & persists the change accross sessions:

1
2
3
4
5
6
7
8
fish_add_path /opt/mycoolthing/bin

# The element will be added to a list `fish_user_paths`. This list is the first part of PATH. This is how you can check the variable contents:
set --show fish_user_paths
echo $PATH

# To remove an element from fish_user_paths (adjust `INDEX` with the index shown with `set --show`)
set --erase fish_user_paths[INDEX]

For other cases of setting environment variables, it is very helpful to read into fish’s different “variables scopes”, and then closely read the documentation of the set command.

One more thing to keep in mind: Fish’s config files are located in ~/.config/fish, not directly in ~. Most important is the config.fish file, that gets executed for every new session. So, this file is roughly similar to bash’s .bashrc and zsh’s .zshrc.

Learning fish

Regarding resources on learning to use fish, I can recommend the Fish shell playground (includes an in-browser terminal to play around with fish!), and the fish documentation and help pages. Good to know: By executing the command help in your fish shell, it will open the fish manual in a browser window. This also works with specific fish commands, e.g. help set. To see the documentation in the terminal, use man [command].

Conclusion

So, this is the story on how I ended up with my current shell setup. I am very happy with it - by using fish, most of the features come out of the box, and features like autosuggest & smart tab-completion really make it feel that the shell is helping me throughout my day. I hope that this post helps you in improving your shell setup, and helps you become more productive in the terminal! 🐠


  1. Difference between shell & terminal, via superuser.com: A shell is a program that processes commands and returns output. A terminal is a wrapper program which runs a shell. Terminals used to be devices, but nowadays a terminal is purely abstracted in software. ↩︎

  2. It is important to note that this command just outputs your default shell, not necessarily the shell you are currently using. For this case, the command ps -p "$$" can be used. Via stackoverflow↩︎

  3. zsh is the default on macOS since macOS 10.15 (Catalina), around ~2019. ↩︎

  4. After installing fzf, remember to activate the keybindings. E.g. when installing with Homebrew: $(brew --prefix)/opt/fzf/install. I skipped this part in the docs when I first installed fzf, and it took me a lot of time until I got keybindings to work…To remove the keybindings again, remove the call to fzf_key_bindings in ~/.config/fish/functions/fish_user_key_bindings.fish↩︎