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, npx without 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 = left replaces iTerm2's "Option key as Esc+" setting — needed for keybindings like Alt+C (FZF directory jump)
  • theme = catppuccin-mocha matches the Starship prompt palette, so everything is visually consistent
  • copy-on-select = clipboard and confirm-close-surface = false remove small friction points that add up over a day of terminal use
  • scrollback-limit = 100000 keeps 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 browsercmux browser open launches a scriptable browser for previewing local dev servers without leaving the terminal
  • CLI/socket APIcmux 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+C requires macos-option-as-alt = left in 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:

  1. Modular configuration - separate concerns into individual files
  2. Lazy loading - defer expensive operations until needed
  3. Modern tools - embrace Rust-based CLI tools that are faster and more ergonomic
  4. 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