Farewell zsh, switching fully to fish
I had actually heard the name fish a long time ago. Back when I first started using oh-my-zsh, I learned that many of the handy things in oh-my-zsh—such as autosuggestions that show gray suggestions based on history, syntax highlighting as you type, and smarter completions—were actually recreations on zsh of features fish had built in.
Later I switched from oh-my-zsh to zinit, all for speed. zinit really is fast, but to achieve that speed, my config was full of wait, lucid, atinit and the like, and completions even required special timing for compinit at the right moment. I had to maintain it every so often, and the mental overhead was heavy—I could never remember what I had been trying to do 😭.
A while ago, I happened to see that fish has been completely rewritten in Rust since 4.0. I always have a bit more fondness for things written in Rust, so I installed it and gave it a try. All I can say is that it truly lives up to its reputation—fish already had, without any plugins, the features I had spent years tinkering to configure on zsh. So within a few days, I switched my default shell over entirely.
Works out of the box
A freshly installed fish, with no configuration at all, already has these features.
- Autosuggestions. Based on history and files in the current directory, it shows suggestions in gray as you type; press the right arrow to accept, while
Alt + →accepts only one word. - Syntax highlighting. Existing commands are one color, nonexistent ones are marked in red immediately, and valid paths are underlined.
- Completions. For many commands, fish parses completions automatically from the man page, so even argument descriptions are included—just press Tab to see them.
- Startup speed. There's no need for the whole lazy-loading setup; it opens instantly.
In zsh, these required zsh-autosuggestions, zsh-syntax-highlighting, zsh-completions, and fzf-tab respectively, and I also had to carefully arrange their loading order.
Installation
My Mac is now managed with nix-darwin, so fish is installed by it too. nix-darwin's programs.fish.enable installs fish and sets up the Nix environment, while environment.shells adds the binary to /etc/shells.
{ pkgs, ... }:
{
programs.fish.enable = true;
environment.shells = [ pkgs.fish ];
}
darwin-rebuild switch then use chsh to switch the default shell over.
chsh -s /run/current-system/sw/bin/fish
Here I use /run/current-system/sw/bin/fish, rather than a specific /nix/store path, because it's a link that always points to the current system version; when fish is upgraded later, there's no need to run chsh.
Configuration file structure
fish's configuration all lives under ~/.config/fish, and unlike zsh, which stuffs everything into a single .zshrc, fish itself encourages splitting the configuration up.
~/.config/fish/
├── config.fish # 主配置
├── conf.d/ # 启动时会自动加载的片段
│ ├── 05-env.fish
│ ├── 10-colors.fish
│ ├── 20-abbrs.fish
│ └── 30-integrations.fish
├── functions/ # 一个文件一个函数,用到的时候才加载
│ ├── dog.fish
│ ├── git-amend-date.fish
│ ├── list_node_modules.fish
│ └── yy.fish
└── fish_plugins # fisher 的插件列表
conf.d files are loaded in filename order, so I prefixed them with numbers. functions is fish's autoload directory; fish only reads the corresponding file when a function is called for the first time. So no matter how many functions you write, startup speed won't be slowed down. This is similar to the way I split modules in zinit before, except that fish supports it natively.
config.fish
The main configuration is mainly PATH and a few tool initializations.
set -g fish_greeting
fish_add_path -g /run/current-system/sw/bin /nix/var/nix/profiles/default/bin
fish_add_path -g $HOME/bin $HOME/go/bin $HOME/.local/bin $HOME/.cargo/bin
if test -x /opt/homebrew/bin/brew
fish_add_path -g /opt/homebrew/bin /opt/homebrew/sbin
end
set -gx LANG C.UTF-8
set -gx LC_ALL C.UTF-8
set -gx FZF_DEFAULT_COMMAND 'fd --type file --hidden --no-ignore'
if status is-interactive
set -gx GPG_TTY (tty)
if type -q mise
mise activate fish | source
end
if type -q starship
starship init fish | source
end
if type -q zoxide
zoxide init fish | source
end
if type -q direnv
direnv hook fish | source
end
end
set -g fish_greeting turns off the greeting that fish prints on every startup. fish_add_path automatically deduplicates and simply skips paths that don't exist—much cleaner than writing line after line of export PATH=... in zsh.
status is-interactive is used to check whether the current shell is interactive. Only interactive shells need to initialize things like starship and zoxide; when running scripts with fish, this can be skipped. type -q checks whether a command exists, so even on a machine without starship installed, it won't throw an error.
Variables and aliases
fish variables are set with set; -g is a global variable, and -x means exported as an environment variable, so in zsh export FOO=bar becomes set -gx FOO bar.
As for aliases, besides alias, fish also has abbr, which stands for abbreviation.
status is-interactive; or return
abbr -a fzf fzfp
abbr -a cat bat
abbr -a c clear
alias ls 'eza --group-directories-first'
alias la 'eza -la --group-directories-first'
alias ll 'eza -l --group-directories-first'
alias l 'eza -1 --group-directories-first'
abbr and alias differ in that when you type cat and press space, it expands directly on the command line into bat. So the command that actually runs, and the command left in history, are both the full expanded command, and others can understand it when they watch my terminal recordings. I now generally use abbr, while for something like ls where I just want to add a few default arguments, I still use alias.
Porting zsh functions over
fish's syntax is incompatible with bash and zsh, so all the old functions had to be rewritten. Fortunately most functions aren't long, so it was quick to change them. Take my dog function, which checks whether the current DNS is Surge; in zsh it looked like this.
DOG_SURGE_DNS="198.18.0.2"
DOG_DEFAULT_DNS="https://dns.alidns.com/dns-query"
function dog() {
if [[ "$@" == *@* ]]; then
doggo "$@"
else
current_dns=$(cat /etc/resolv.conf | grep '^nameserver' | awk '{print $2}' | head -n 1)
if [ "$current_dns" != "$DOG_SURGE_DNS" ]; then
doggo "$@"
else
doggo "$@" @"$DOG_DEFAULT_DNS"
fi
fi
}
After switching to fish, it goes in functions/dog.fish.
function dog --description 'Run doggo with Surge-aware DNS fallback'
set -l dog_surge_dns 198.18.0.2
set -l dog_default_dns https://dns.alidns.com/dns-query
if string match -rq '@' -- "$argv"
doggo $argv
return
end
set -l current_dns (awk '/^nameserver/ { print $2; exit }' /etc/resolv.conf)
if test "$current_dns" != "$dog_surge_dns"
doggo $argv
else
doggo $argv @"$dog_default_dns"
end
end
A few obvious differences. Arguments are $argv, not $@. Command substitution uses parentheses (...), not $(...). if and function both end with end, and there are no fi or braces. For string handling there is a built-in string command; string match -rq performs a regex match and only returns the result without printing it. Also, fish variables are not split on spaces by default, so there's no need to add quotes everywhere like in zsh.
A function can also take a --description, which will be shown during completion; when you look back after a while, you'll know what the function does.
Colors
fish's default colors are a bit different from the zsh-syntax-highlighting setup I used before. I'd been using that color scheme for so many years that suddenly changing it felt unfamiliar. So in conf.d/10-colors.fish I adjusted the highlight colors to look roughly the same as on the zsh side.
status is-interactive; or return
set -g fish_color_command green
set -g fish_color_keyword yellow
set -g fish_color_quote yellow
set -g fish_color_error red --bold
set -g fish_color_comment brblack --bold
set -g fish_color_autosuggestion brblack
set -g fish_color_valid_path --underline
Commands are green, typos are bold red, and autosuggestions are dark gray—exactly the same as before, so after switching over I can barely feel any difference.
Plugins
Although fish's built-in features are already enough, there are still a few things I want. For fish's plugin manager, I use fisher, which is very simple; the plugin list is written in fish_plugins one per line.
jorgebucaran/fisher
PatrickF1/fzf.fish
jhillyerd/plugin-git
xxxbrian/fzf-tab.fish
After editing this file, run fisher update and it will install or remove plugins according to the list.
fzf.fish
fzf.fish adds a set of fzf key bindings to fish for searching history, files, git log, git status, processes, and variables. I rebound them to match my own habits.
if type -q fzf_configure_bindings
fzf_configure_bindings --history=\cr --directory=\e\cf --git_log=\e\cl --git_status=\e\cs --processes=\e\cp --variables=\cv
end
The most frequently used one is Ctrl + R for searching history, the same as using fzf in zsh.
plugin-git
plugin-git brings the set of abbreviations from oh-my-zsh's git plugin over to fish; gst, gco, gcmsg are all still there. Moreover, they are implemented in fish with abbr, so after you type them they expand directly into full git commands.
fzf-tab.fish
In zsh I relied heavily on fzf-tab: when pressing Tab, I used fzf to choose completions, and it could preview files and directories. In fish, the corresponding plugin is fifc, which works similarly and also supports various previews.
However, the terminal I use now is Ghostty, and fifc has some issues previewing images in Ghostty—for example, images remain on the screen, and switching between image and text previews flickers. So I forked it and changed it to xxxbrian/fzf-tab.fish, mainly to make image previews use the kitty graphics protocol supported by Ghostty, clear the previous image before switching previews, and make media file previews much smoother.
It only needs an editor to be set, used to open files directly during completion. I made it prefer Helix, falling back to Neovim if it's unavailable.
if not set -q fifc_editor
if type -q hx
set -gx fifc_editor hx
else if type -q nvim
set -gx fifc_editor nvim
end
end
POSIX compatibility
The most commonly mentioned drawback of fish is that it isn't POSIX-compatible; much of bash and zsh syntax can't be used directly in fish. For example, a line copied from the internet like export FOO=bar && some_command, or a loop with for ... do ... done will throw an error when pasted into fish.
But this has affected me much less than I imagined. Script files already have their own shebang; a script with #!/bin/bash still gets handed off to bash when run from fish, so it's completely unaffected. What I normally encounter is only the stuff I type temporarily on the command line. And these days, slightly more complex pipelines and scripts are mostly written by AI; I just need to tell it that I use fish, or have it write a bash script and then run that, so it isn't a big problem. When I really need it, temporarily opening a bash or zsh is just a single command.
Now
fish is now my default shell. I haven't deleted my zsh and zinit configuration either; I've kept it around in case I need to switch back someday. But after using fish for the past few days, it seems there's no need for that.
Looking back, from oh-my-zsh to zinit, I spent a lot of time making zsh faster and more usable, while fish was already like this from the start. The configuration files have also gone from that pile of zi ice to a few clear, small files.