Migrating from iTerm2 + nvm to Ghostty + fnm
I’ve been working in a terminal long enough to have written a Terminal 101 series about it back in 2020. For most of that time my setup barely changed. iTerm2 for the window, oh-my-zsh for the prompt, nvm for Node versions. It worked, so I never looked at it.
With AI my workflow changed, new tools emerged and I’m testing new ones every day. These days I use T3 Code, Claude Code and Codex running in several projects at once. Suddenly every millisecond of shell startup gets multiplied by a lot of shells, and a tab bar full of identical titles stops being a minor annoyance.
So I replaced both tools. nvm became fnm, iTerm2 became Ghostty and VSCode was replaced by Zed.
Why I dropped nvm
I was shocked when I started using Zed for the first time (maybe we really should rewrite everything in Rust). I couldn’t believe that opening an editor can be instant. You click, it’s there. Zero wait.
And then I opened a new terminal tab.
After Zed, my terminal felt slow as hell. New tab, wait. Split pane, wait. Agent spawns a shell, wait. It was never something I’d noticed before, because I had nothing to compare it to. Zed gave me a new baseline, and my shell was nowhere near it.
So I started digging. If you want to check your own setup, two commands get you most of the way:
# How long does an interactive shell take to start?
time zsh -i -c exit
# Which part of .zshrc is slow? Add `zmodload zsh/zprof` as the first line
# of .zshrc and `zprof` as the last, then open a new tab.
The culprit wasn’t oh-my-zsh or my plugins. It was nvm.
nvm is a shell script. A big one: nvm.sh in v0.40.3 is 4,661 lines, and zsh has to parse and run it every time you open a new shell. On top of that, if you want nvm to switch versions when you cd into a project, you paste a load-nvmrc hook from the README into your .zshrc, which calls nvm again on startup and on every directory change.
I had never thought of my Node version manager as something with a cost. It was just there, in every .zshrc I’d set up for years. Then I went looking for a faster alternative and found one rewritten in (wait for it) Rust. Of course it was.
Why fnm
fnm (Fast Node Manager) does the same job as nvm, but it’s a single Rust binary instead of a shell script. The commands are almost identical, so there’s nothing new to learn.
- Startup cost is close to zero.
fnm envprints a few environment variables and exits. On my M1 Pro it takes 14 ms, and half of that is just launching zsh. - Auto-switching is built in. One flag,
--use-on-cd, replaces the wholeload-nvmrchook. - It reads
.nvmrcand.node-version. Existing projects keep working, and so do the ones that use.node-versionfor Cloudflare or Netlify.
I prefer fnm because it gives me everything I used nvm for, with no startup cost and no copy-pasted hooks in my .zshrc.
nvm vs fnm: startup time
I measured both on the same machine (MacBook Pro M1 Pro, zsh 5.9), median of 20 runs. Each test starts a clean zsh -f and only loads the version manager, so the numbers show the cost of nvm or fnm alone.
| What runs on shell start | Median |
|---|---|
Empty zsh -f (baseline) |
7 ms |
fnm env --use-on-cd |
14 ms |
source nvm.sh |
635 ms |
source nvm.sh + nvm use default |
1161 ms |
Yes, that’s over a full second. Just loading nvm costs 635 ms, roughly 45 times more than fnm. Add nvm use default and you wait over a second before the prompt even shows up. In every tab, every split, and every shell an agent opens.
My full interactive shell, with oh-my-zsh, autosuggestions, syntax highlighting and fnm, now opens in a median of 197 ms. With nvm, the version manager alone took three times that.
nvm vs fnm: features
| nvm | fnm | |
|---|---|---|
| Written in | Shell script | Rust (single binary) |
| Install on macOS | curl install script | brew install fnm |
Reads .nvmrc |
Yes | Yes |
Reads .node-version |
No | Yes |
Switch version on cd |
Hook copied from README | --use-on-cd flag |
| Windows | Separate nvm-windows tool | Same tool |
One thing worth knowing if your team mixes tools. nvm-windows reads neither .nvmrc nor .node-version, so Windows users still run nvm use 24 by hand. If you need one file that covers fnm, nvm and most hosting platforms, use .nvmrc.
Migrating from nvm to fnm
The whole migration took me about ten minutes.
1. Write down what you have. List your installed versions and global packages before you delete anything, because globals don’t carry over.
nvm ls
npm ls -g --depth=0
2. Remove nvm. If you used the official install script, delete the folder and the NVM_DIR lines (plus any load-nvmrc hook) from your .zshrc. If you installed it with Homebrew, use brew uninstall nvm instead.
rm -rf ~/.nvm
3. Install fnm.
brew install fnm
4. Hook it into your shell. This is the only line Node needs in my .zshrc:
# Node — fnm (replaces nvm; reads .nvmrc, auto-switches on cd)
eval "$(fnm env --use-on-cd --version-file-strategy=recursive --shell zsh)"
--version-file-strategy=recursive makes fnm walk up parent folders to find the nearest .nvmrc or .node-version. That matters in monorepos, where you often cd into a package that doesn’t have its own version file.
5. Install your versions and set a default.
fnm install 24
fnm install 22
fnm default 24
6. Reinstall global packages. Like nvm, fnm keeps global packages per Node version. After switching to a new version, run npm i -g again for the CLIs you need, like @shopify/cli.
Gotcha: old Node versions on Apple Silicon
One of my projects still runs on Node 12. On an M1, fnm install 12 didn’t give me a working Node, because Node versions before 16 have no native Apple Silicon builds. The fix is to install the Intel build and let Rosetta run it:
softwareupdate --install-rosetta --agree-to-license
fnm install 12 --arch x64
It’s slower than native, but for a legacy project that only needs to build, it’s fine.
Bonus: keep every LTS line up to date
I also added two small helpers to my .zshrc (if you’re new to shell config, my post on terminal aliases covers the basics). node-lts lists the latest release of every LTS line and marks which ones I have installed or need to update. node-lts-update installs the latest patch for every major version I already use.
# Latest release of each Node LTS line, marked if installed
node-lts() {
local installed
installed=$(fnm ls | grep -oE 'v[0-9]+\.[0-9]+\.[0-9]+' | sort -u)
fnm ls-remote --lts | awk '{print $1, $2}' | tail -r | awk '!seen[$2]++' | tail -r \
| while read -r ver name; do
local major=${ver%%.*}
local have=$(echo "$installed" | grep "^$major\." | tail -1)
if [ "$have" = "$ver" ]; then
printf ' \033[32m✓\033[0m %-10s %s\n' "$ver" "$name"
elif [ -n "$have" ]; then
printf ' \033[33m↑\033[0m %-10s %s (installed: %s → fnm install %s)\n' "$ver" "$name" "$have" "${major#v}"
else
printf ' %-10s %s\n' "$ver" "$name"
fi
done
}
# Update every installed LTS line to its latest patch
node-lts-update() {
fnm ls | grep -oE 'v[0-9]+\.' | sort -u | while read -r major; do
fnm install "${major#v}"
done
}
Why I left iTerm2 for Ghostty
iTerm2 is a great app, and I used it for years. Then it got AI features, and with them came more settings, more menus and more stuff I never asked for. The app I opened a hundred times a day started to feel bloated.
I’m tired of bloat. Not everything needs AI (Siri AI anyone?). I want my terminal to open fast and draw text, that’s it.
Ghostty is a newer terminal from Mitchell Hashimoto, the co-founder of HashiCorp. On macOS it’s a native app, with the window and tabs built in SwiftUI and the text drawn on the GPU. What sold me:
- It’s fast. Opening tabs, scrolling long logs and dumping thousands of lines of agent output all feel instant.
- Config is one text file. Everything lives in
~/.config/ghostty/config, which I can read, diff and copy to a new machine. - The defaults are good. My config file has one real setting in it, and you’ll see it below.
- It looks like a Mac app. Native tabs, native splits, native fullscreen.
Ghostty gotchas (and how I fixed them)
Switching wasn’t completely painless. These are the three things I ran into.
“Error opening terminal: xterm-ghostty”
This one hit me the first day. I SSHed into one of our servers, ran htop, and got:
user@server:~$ htop
Error opening terminal: xterm-ghostty.
Same for nano and sudo systemctl edit. Ghostty sets TERM=xterm-ghostty, and your servers don’t have a terminfo entry for it, so any full-screen program refuses to start.
For a one-off, override TERM for that command:
TERM=xterm-256color htop
To fix it on a single server for good, copy Ghostty’s terminfo entry over from your Mac:
infocmp -x xterm-ghostty | ssh forge@your-server -- tic -x -
The real fix, and the one I’d recommend, is a single line in your Ghostty config. Ghostty 1.2 added two SSH shell integration features: ssh-terminfo copies the terminfo entry to each server you connect to, and ssh-env sets TERM=xterm-256color for SSH sessions so programs still work where it can’t. Both are off by default because they work by wrapping your ssh command through Ghostty’s shell integration:
# ~/.config/ghostty/config
shell-integration-features = ssh-terminfo,ssh-env
Since adding that, I haven’t seen the error on any server.
AI agents hijacking tab titles
With six projects open, I need each tab to show which folder it’s in. Claude Code sets the terminal title to whatever it’s doing, so every tab ended up with a similar task description, and I couldn’t tell which one was which. This wasn’t Ghostty’s fault. iTerm2 had the exact same problem.
Two changes fixed it. First, tell Claude Code to stop touching the title in ~/.claude/settings.json:
{
"env": {
"CLAUDE_CODE_DISABLE_TERMINAL_TITLE": "1"
}
}
Second, Ghostty names a tab after the running command, so a tab running Claude would just say claude. A tiny wrapper in .zshrc sets the title to the current directory right before Claude starts:
# Keep the terminal tab title as PWD while Claude Code runs
claude() { print -Pn "\e]2;%~\a"; command claude "$@" }
Now every tab shows its folder, like ~/Developer/selectr-api, whatever is running inside it.
No plugins (yet)
I wanted vertical tabs grouped by repo, a bit like the sidebar in the Codex app. Ghostty can’t do that today, because it has no plugin or extension system. Mitchell has said a scripting API is planned. In the meantime, Ghostty 1.3 added AppleScript support for listing and controlling windows, tabs and splits, which is enough to build a small companion app if you really want one. I decided I didn’t need it that badly.
Should you switch?
If you have a working setup and open a few shells a day, you won’t feel much difference. But if your machine runs AI agents, monorepos and SSH sessions all day, both migrations pay for themselves on day one.
fnm is the easier call. It takes ten minutes, reads your existing .nvmrc files, and removes the slowest line from your .zshrc. Ghostty takes a bit more adjusting, mostly the xterm-ghostty error, but one config line fixes it for good.
So next time a new tab takes a moment too long to show a prompt, look at your .zshrc. There’s a good chance nvm is the reason.