As developers, we spend significant time in the terminal. This post documents building a modern terminal environment from scratch with three goals: speed, aesthetics, and usability. The result is a shell that starts in under 100ms, looks clean, can be set up on any new machine in minutes, and includes many handy features.
A Modular Dotfiles Architecture
Instead of one monolithic .zshrc, we created a modular structure that's easy to understand, maintain, and extend:
~/dotfiles/
├── .zshrc # Minimal - just sources modules
├── Brewfile # All Homebrew dependencies
├── install.sh # One-command setup script
├── config/
│ ├── ghostty/
│ │ └── config # Terminal config (fonts, theme, behavior)
│ ├── zsh/
│ │ ├── 01-paths.zsh # PATH configuration
│ │ ├── 02-nvm-lazy.zsh # Lazy NVM loading
│ │ ├── 03-tools.zsh # Atuin, zoxide, fzf
│ │ ├── 04-aliases.zsh # Modern CLI aliases
│ │ └── 05-completions.zsh # Shell completions
│ ├── starship.toml # Prompt configuration
│ ├── atuin/
│ │ └── config.toml # History sync settings
│ └── claude/
│ ├── statusline.sh # Claude Code status line
│ └── cmux-notify.sh # Claude Code → cmux notifications
Note: The code examples below show the essential configuration. The full dotfiles repository includes additional tools, language support, and customizations.
Part 1: The Shell Foundation
Minimal .zshrc
Keep .zshrc minimal. It sets up the config directory and sources modular files:
# ~/.zshrc - Minimal, fast shell configuration
export XDG_CONFIG_HOME="${XDG_CONFIG_HOME:-$HOME/.config}"
# Source modular configs in order
for config in "$XDG_CONFIG_HOME/zsh"/*.zsh(N); do
[[ -f "$config" ]] && source "$config"
done
# Starship prompt (must be last, only in interactive shells)
[[ $- == *i* ]] && eval "$(starship init zsh)"
This approach provides:
- Maintainability: Each concern is in its own file
- Ordering: Files are sourced alphabetically (hence the number prefixes)
- Debugging: Easy to disable a module by renaming it
PATH Configuration (01-paths.zsh)
We centralize all PATH modifications in one file:
# Homebrew
eval "$(/opt/homebrew/bin/brew shellenv)"
# pnpm
export PNPM_HOME="$HOME/Library/pnpm"
[[ ":$PATH:" != *":$PNPM_HOME:"* ]] && export PATH="$PNPM_HOME:$PATH"
# pyenv
export PYENV_ROOT="$HOME/.pyenv"
[[ -d $PYENV_ROOT/bin ]] && export PATH="$PYENV_ROOT/bin:$PATH"
if command -v pyenv &> /dev/null; then
eval "$(pyenv init -)"
fi
# Local binaries
export PATH="$HOME/.local/bin:$PATH"
# VS Code
[[ -d "/Applications/Visual Studio Code.app/Contents/Resources/app/bin" ]] && \
export PATH="$PATH:/Applications/Visual Studio Code.app/Contents/Resources/app/bin"
Part 2: Solving the NVM Speed Problem
NVM (Node Version Manager) is notoriously slow, adding ~300ms to shell startup. The solution is lazy loading - we add Node to PATH directly but only load NVM when you actually need to switch versions.
Smart Lazy Loading (02-nvm-lazy.zsh)
export NVM_DIR="$HOME/.nvm"
if [[ -d "$NVM_DIR" ]]; then
# Add default node to PATH for immediate use (fast)
NODE_VERSIONS_PATH="$NVM_DIR/versions/node"
if [[ -d "$NODE_VERSIONS_PATH" ]]; then
if [[ -f "$NVM_DIR/alias/default" ]]; then
DEFAULT_ALIAS=$(cat "$NVM_DIR/alias/default")
# Handle aliases like "20" -> "v20.14.0"
DEFAULT_VERSION=$(ls -1 "$NODE_VERSIONS_PATH" 2>/dev/null | grep "^v${DEFAULT_ALIAS}" | sort -V | tail -1)
fi
if [[ -z "$DEFAULT_VERSION" ]]; then
DEFAULT_VERSION=$(ls -1 "$NODE_VERSIONS_PATH" 2>/dev/null | sort -V | tail -1)
fi
if [[ -n "$DEFAULT_VERSION" && -d "$NODE_VERSIONS_PATH/$DEFAULT_VERSION/bin" ]]; then
export PATH="$NODE_VERSIONS_PATH/$DEFAULT_VERSION/bin:$PATH"
fi
fi
# Lazy load NVM itself only when needed
nvm() {
unset -f nvm
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"
nvm "$@"
}
fi
This gives us:
- Instant access to
node,npm,npxwithout loading NVM - Starship compatibility - it can detect the Node version for the prompt
- Full NVM functionality when you need to switch versions
Part 3: Modern CLI Tools
We replace outdated Unix tools with modern alternatives that are faster and more user-friendly.
The Brewfile (Essential Packages)
# Terminal
tap "manaflow-ai/cmux"
cask "cmux" # Terminal (Ghostty-based, built for AI agents)
# Shell & Prompt
brew "starship" # Cross-shell prompt
brew "atuin" # Shell history with sync
# Modern CLI Replacements
brew "eza" # Better ls (icons, git status)
brew "bat" # Better cat (syntax highlighting)
brew "zoxide" # Smarter cd (learns your habits)
brew "fzf" # Fuzzy finder
brew "fd" # Better find
brew "ripgrep" # Better grep
brew "git-delta" # Better git diff
brew "lazygit" # Git TUI
brew "tlrc" # Simplified man pages
# Data Processing
brew "jq" # JSON processor
brew "yq" # YAML processor
# Development Tools
brew "gh" # GitHub CLI
brew "git" # Latest git
brew "awscli" # AWS CLI
# Security & Auth
cask "1password-cli" # 1Password CLI
# Fonts (Nerd Font patched)
cask "font-fira-code-nerd-font"
cask "font-meslo-lg-nerd-font"
Tool Initialization (03-tools.zsh)
# Atuin - replaces shell history with fuzzy search + sync
if command -v atuin &> /dev/null && [[ $- == *i* ]]; then
eval "$(atuin init zsh)"
fi
# Zoxide - smart cd that learns your habits
if command -v zoxide &> /dev/null && [[ $- == *i* ]]; then
eval "$(zoxide init zsh)"
fi
# FZF - fuzzy finder
if command -v fzf &> /dev/null && [[ $- == *i* ]]; then
source <(fzf --zsh)
export FZF_DEFAULT_COMMAND='fd --type f --hidden --follow --exclude .git'
export FZF_DEFAULT_OPTS='--height 40% --layout=reverse --border'
fi
Aliases (04-aliases.zsh)
# Modern replacements
alias ls='eza --icons --group-directories-first'
alias ll='eza -la --icons --group-directories-first --git'
alias lt='eza --tree --level=2 --icons'
alias cat='bat --paging=never'
alias grep='rg'
alias find='fd'
alias diff='delta'
# Git shortcuts
alias g='git'
alias gs='git status'
alias gd='git diff'
alias lg='lazygit'
# Navigation
alias ..='cd ..'
alias ...='cd ../..'
Shell Completions (05-completions.zsh)
The final module configures tab completion with arrow-key menu navigation:
# Docker completions
[[ -d "$HOME/.docker/completions" ]] && fpath=($HOME/.docker/completions $fpath)
# Homebrew completions
if type brew &>/dev/null; then
fpath=($(brew --prefix)/share/zsh/site-functions $fpath)
fi
# Load completion list module (required for menu selection)
zmodload zsh/complist
# Completion styling (must be before compinit)
zstyle ':completion:*' menu select
zstyle ':completion:*' matcher-list 'm:{a-z}={A-Z}'
zstyle ':completion:*' list-colors "${(s.:.)LS_COLORS}"
# Initialize completion system
autoload -Uz compinit
compinit -C # -C for faster startup (uses cache)
# AWS completions (bash-style - doesn't support menu select)
if [[ -f /usr/local/bin/aws_completer ]]; then
autoload -Uz bashcompinit && bashcompinit
complete -C '/usr/local/bin/aws_completer' aws
fi
Key points:
zmodload zsh/complist- Required for arrow key navigation in menus- zstyles before compinit - Styling must be configured before initialization
menu select- Enables interactive menu selection with arrow keys- Docker/Homebrew/AWS - Adds completion for container names, packages, and AWS services
Part 4: The Starship Prompt
Starship is a fast, customizable prompt written in Rust. We configured a two-line prompt inspired by Powerlevel10k with Catppuccin colors.
Key Starship Configuration
# Performance settings
scan_timeout = 10
command_timeout = 500
# Two-line prompt format
format = """
[╭─](overlay1)\
[](fg:surface0)$os$username[](bg:blue fg:surface0)\
$directory[](fg:blue bg:yellow)\
$git_branch$git_status[](fg:yellow bg:green)\
$nodejs$python$rust$golang[](fg:green bg:sapphire)\
$docker_context[](fg:sapphire)\
$fill\
[](fg:lavender)$cmd_duration[](bg:lavender fg:surface0)\
$time[](fg:surface0)[─╮](overlay1)
[╰─](overlay1)$character"""
palette = 'catppuccin_mocha'
Nerd Font Symbol Setup
Getting Nerd Font symbols to render correctly requires the actual Unicode characters in the TOML file, not escape sequences.
We used Python to write the correct bytes:
# Fix nodejs symbol (U+E718 = Node.js icon)
content = re.sub(
r'\[nodejs\]\nsymbol = ".*?"',
'[nodejs]\nsymbol = "\ue718 "',
content
)
Part 5: Font Configuration
The Nerd Font Solution
The terminal prompt uses Nerd Font icons for visual indicators. Ghostty handles this elegantly with codepoint mapping — you set your preferred coding font as the primary family, and Ghostty falls back to a Nerd Font only for icon codepoints. No separate "non-ASCII font" setting needed.
# Primary font
font-family = MonoLisa
font-family-bold = MonoLisa
font-family-italic = MonoLisa
font-family-bold-italic = MonoLisa
font-size = 13
# Nerd Font icons (FiraCode fallback for glyphs)
font-codepoint-map = U+E000-U+F8FF=FiraCode Nerd Font
font-codepoint-map = U+F0000-U+FFFFF=FiraCode Nerd Font
# Font rendering
font-thicken = true
The two font-codepoint-map lines cover the Private Use Area ranges where Nerd Font icons live. Ghostty renders those glyphs with FiraCode Nerd Font while using MonoLisa for everything else — cleaner than iTerm2's dual-font approach and with no performance overhead.
Part 6: Shell History with Atuin
Atuin replaces the default shell history with a SQLite database that:
- Syncs across machines (encrypted, end-to-end)
- Provides fuzzy search (Ctrl+R)
- Records context (directory, exit code, duration)
Configuration (config/atuin/config.toml)
auto_sync = true
sync_frequency = "5m"
sync_address = "https://api.atuin.sh"
search_mode = "fuzzy"
filter_mode = "global"
style = "compact"
# Privacy
secrets_filter = true
cwd_filter = ["/tmp"]
Part 7: Git with Delta
Delta provides syntax-highlighted git diffs with side-by-side view.
Git Configuration
[core]
pager = delta
[interactive]
diffFilter = delta --color-only
[delta]
navigate = true
side-by-side = true
line-numbers = true
syntax-theme = Catppuccin-mocha
[merge]
conflictstyle = diff3
Part 8: Terminal — cmux with Ghostty
We switched from iTerm2 to cmux, a native macOS terminal built on Ghostty and designed for AI-native workflows. It's fast (GPU-accelerated rendering), and its sidebar, notification system, and CLI API make it a natural fit for working alongside AI agents like Claude Code.
Ghostty Configuration
cmux uses Ghostty under the hood, so terminal behavior is configured via a standard Ghostty config file at config/ghostty/config:
# Ghostty Configuration (used by cmux)
# Fonts
font-family = MonoLisa
font-family-bold = MonoLisa
font-family-italic = MonoLisa
font-family-bold-italic = MonoLisa
font-size = 13
# Nerd Font icons (FiraCode fallback for glyphs)
font-codepoint-map = U+E000-U+F8FF=FiraCode Nerd Font
font-codepoint-map = U+F0000-U+FFFFF=FiraCode Nerd Font
# Font rendering
font-thicken = true
# Terminal Behavior
scrollback-limit = 100000
mouse-hide-while-typing = true
copy-on-select = clipboard
confirm-close-surface = false
# Option key as Alt (for terminal keybindings like Alt+C)
macos-option-as-alt = left
# Theme
theme = catppuccin-mocha
# Split Panes
unfocused-split-opacity = 0.85
split-divider-color = #313244
# Window
window-padding-x = 4
window-padding-y = 4
A few things worth noting:
macos-option-as-alt = leftreplaces iTerm2's "Option key as Esc+" setting — needed for keybindings likeAlt+C(FZF directory jump)theme = catppuccin-mochamatches the Starship prompt palette, so everything is visually consistentcopy-on-select = clipboardandconfirm-close-surface = falseremove small friction points that add up over a day of terminal usescrollback-limit = 100000keeps plenty of history for scrolling back through long build outputs
What cmux Adds on Top of Ghostty
cmux wraps Ghostty with features designed for multi-project, AI-assisted development:
- Sidebar — shows git branch, PR status, working directory, and open ports per workspace at a glance
- Notification system — pane glow, sidebar badges, and desktop notifications when background tasks finish (or when Claude Code needs your attention)
- Built-in browser —
cmux browser openlaunches a scriptable browser for previewing local dev servers without leaving the terminal - CLI/socket API —
cmux notify,cmux set-status, and workspace management commands that scripts and hooks can call
Part 8.5: Claude Code Integration
With cmux handling the terminal, we wired Claude Code into the workspace so it reports status and sends notifications through cmux's sidebar.
Status Line (config/claude/statusline.sh)
The status line script parses Claude Code's session data and builds a formatted display showing:
- Model — which Claude model is active
- Context usage — color-coded percentage (green <50%, yellow 50-80%, red >80%)
- Working directory — current project folder
- Git info — branch, ahead/behind counts, conflicts, and changed file stats
The output updates in cmux's sidebar, giving you a persistent view of Claude's state without switching to its pane.
cmux Notifications (config/claude/cmux-notify.sh)
A lightweight hook that bridges Claude Code's notification events to cmux's notification system:
#!/bin/bash
# Claude Code notification hook for cmux
# Sends notifications to cmux sidebar when Claude needs attention
# Only run inside cmux
[[ -z "$CMUX_SOCKET_PATH" ]] && exit 0
# Read hook input
input=$(cat)
hook_name=$(echo "$input" | jq -r '.hook_name // ""')
case "$hook_name" in
Notification)
title=$(echo "$input" | jq -r '.notification.title // "Claude Code"')
body=$(echo "$input" | jq -r '.notification.body // ""')
cmux notify --title "$title" --body "$body" 2>/dev/null || true
;;
esac
When Claude Code finishes a task or hits a question, this hook sends a notification to cmux — the pane glows and a badge appears in the sidebar. No need to keep watching the terminal; Claude tells you when it needs you.
How install.sh Configures It
The install script merges the status line and notification hook into ~/.claude/settings.json using jq, so there's no manual configuration. It also creates a sessions directory and sets up the correct permissions for workspace tracking.
Part 9: The Install Script
The install.sh script automates the entire setup:
#!/bin/bash
set -e
DOTFILES_DIR="$HOME/dotfiles"
CONFIG_DIR="$HOME/.config"
# Install Homebrew if missing
if ! command -v brew &> /dev/null; then
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
fi
# Install all packages
brew bundle --file="$DOTFILES_DIR/Brewfile"
# Create symlinks
ln -sf "$DOTFILES_DIR/.zshrc" "$HOME/.zshrc"
ln -sf "$DOTFILES_DIR/config/zsh" "$CONFIG_DIR/zsh"
ln -sf "$DOTFILES_DIR/config/starship.toml" "$CONFIG_DIR/starship.toml"
ln -sf "$DOTFILES_DIR/config/atuin" "$CONFIG_DIR/atuin"
# Ghostty config (used by cmux)
mkdir -p "$CONFIG_DIR/ghostty"
ln -sf "$DOTFILES_DIR/config/ghostty/config" "$CONFIG_DIR/ghostty/config"
# Claude Code: status line + cmux notification hook
# (merges settings into ~/.claude/settings.json via jq)
# Import shell history into Atuin
atuin import auto 2>/dev/null || true
Results
| Metric | Result |
|---|---|
| Shell startup | ~98ms |
| History search | Fuzzy search with sync |
| New machine setup | Minutes |
| AI notifications | cmux sidebar + glow |
Final Setup
The complete environment includes:
- Prompt: Starship with Catppuccin Mocha theme, two-line layout
- Terminal: cmux (Ghostty-based) with Catppuccin Mocha theme
- History: Atuin with cross-machine sync
- Navigation: Zoxide (smart cd) + FZF (fuzzy finder)
- File listing: Eza with icons and git integration
- File viewing: Bat with syntax highlighting
- Git: Delta for diffs, Lazygit for TUI
- Search: Ripgrep + fd
- AI: Claude Code with status line and cmux notifications
Getting Started
New Machine Setup
# 1. Clone dotfiles
git clone https://github.com/schuettc/dotfiles.git ~/dotfiles
# 2. Run install script
cd ~/dotfiles && ./install.sh
# 3. Open cmux and start working
# (Ghostty config is symlinked by install.sh — no manual terminal setup needed)
# 4. Set up Atuin sync
atuin login # or: atuin register
# 5. Restart terminal
Repository
The complete dotfiles are available at: github.com/schuettc/dotfiles
Keyboard Shortcuts & Commands
Quick reference for the installed tools:
Keyboard Shortcuts
| Shortcut | Tool | Description |
|---|---|---|
Ctrl+R |
Atuin | Fuzzy search command history |
Ctrl+T |
FZF | Fuzzy find files, insert path at cursor |
Alt+C |
FZF | Fuzzy cd into directory |
Tab |
zsh | Menu completion with arrow key navigation |
Note:
Alt+Crequiresmacos-option-as-alt = leftin the Ghostty config (included in the dotfiles).
FZF Triggers
Type ** then press Tab to trigger fuzzy completion:
cd **<Tab> # Fuzzy find directories
vim **<Tab> # Fuzzy find files to edit
kill **<Tab> # Fuzzy find processes
ssh **<Tab> # Fuzzy find SSH hosts
Commands
| Command | Tool | Description |
|---|---|---|
z <partial> |
Zoxide | Smart jump to frequently used directory |
zi |
Zoxide | Interactive directory picker with FZF |
lg |
Lazygit | Terminal UI for git operations |
ll |
eza | Long list with icons and git status |
lt |
eza | Tree view (2 levels deep) |
cat |
bat | Syntax-highlighted file viewer |
catp |
bat | Plain output without line numbers (for copying) |
Examples
# Jump to a project (learns from your cd history)
z blog # Jumps to ~/GitHub/schuettc/blog
# Interactive directory selection
zi # Opens FZF to pick any directory
# Find and edit a file
vim **<Tab> # Type partial name, select with arrows
# Search history for a command
Ctrl+R # Type partial command, select from matches
Conclusion
Building a proper dotfiles setup takes time upfront but saves time in the long run. A fast, clean terminal makes development more efficient, and a portable configuration means you're productive immediately on any new machine.
The key principles:
- Modular configuration - separate concerns into individual files
- Lazy loading - defer expensive operations until needed
- Modern tools - embrace Rust-based CLI tools that are faster and more ergonomic
- Automation - script everything for reproducibility
Fork the dotfiles repo and customize it for your own workflow.
Tools mentioned: cmux, Claude Code, Starship, Atuin, Eza, Bat, Zoxide, FZF, Ripgrep, fd, Delta, Lazygit, MonoLisa, Nerd Fonts, Catppuccin