Dev Setup

A detailed walkthrough of my development environment — hardware, dotfiles, terminal configuration, coding agents. Everything annotated.

Tom Harada February 2026 ~20 min read
Contents
  1. Hardware
  2. macOS Configuration
  3. Terminal & Shell
  4. Ghostty
  5. Dotfiles Deep Dive
  6. tmux
  7. Coding Agents & Pi
  8. Combined Dotfiles

Hardware

My hardware philosophy is simple: invest in things you touch all day, every day. A great keyboard, a sharp monitor, and a machine with enough headroom that you never wait for it.

Wired split keyboard + Magic Trackpad mod
5K external monitor
MacBook Pro M4 Max
128GB / 8TB — max the specs

Keyboard Mod

I use the Nulea split ergonomic keyboard, but with a twist: I rip out the numpad keys and velcro-attach an Apple Magic Trackpad in their place. This keeps the trackpad centered between the split halves, right where your thumbs naturally rest. No more reaching for a mouse — and the ergonomic split keeps your wrists happy during long sessions.

Computer

If you use your laptop as your primary machine — especially for mobile development, client builds, or running multiple dev environments — max out the specs. The M4 Max with 128GB RAM and 8TB storage handles everything I throw at it without breaking a sweat. I also keep a cloud desktop (EC2) for heavier workloads and remote pairing.

Headphones

I use Apple wired USB-C EarPods. I lost too many AirPods Pro to the laundry. These work fine — good microphone, easy to switch between phone and laptop, and they just work. Sometimes the simplest tool is the best tool.

macOS Configuration

A few small macOS tweaks that make a huge difference for keyboard-heavy workflows.

System Preferences

defaults write

These commands disable the press-and-hold character picker in favor of key repeat — essential for Vim-style editing in VS Code and across macOS.

Terminal
defaults write com.microsoft.VSCode ApplePressAndHoldEnabled -bool false
defaults write -g ApplePressAndHoldEnabled -bool false
defaults write NSGlobalDomain ApplePressAndHoldEnabled -bool false

Why three commands? The first targets VS Code specifically, the second sets the global default for the current user, and the third writes to NSGlobalDomain as a catch-all. Belt and suspenders — some apps read one, some read another.

Chrome Extensions

Terminal & Shell

The terminal is Ghostty locally and tmux everywhere. I've moved most of my work to cloud instances that run 24/7 — SSH in, reattach tmux, pick up where I left off. Ghostty is the local terminal emulator (fast, native, great color support), and tmux handles splits, sessions, and persistence whether I'm local or remote. The shell is zsh with vi-mode keybindings.

Ghostty

The Ghostty config is intentionally minimal. The main thing worth documenting is the color scheme: Solarized Light.

~/Library/Application Support/com.mitchellh.ghostty/config
# Solarized Light Color Scheme
background = #fdf6e3
foreground = #073642

# Normal colors
palette = 0=#073642
palette = 1=#dc322f
palette = 2=#859900
palette = 3=#b58900
palette = 4=#268bd2
palette = 5=#d33682
palette = 6=#2aa198
palette = 7=#eee8d5

# Bright colors
palette = 8=#002b36
palette = 9=#cb4b16
palette = 10=#586e75
palette = 11=#657b83
palette = 12=#839496
palette = 13=#6c71c4
palette = 14=#93a1a1
palette = 15=#fdf6e3

# Cursor & selection
cursor-color = #586e75
cursor-text = #fdf6e3
selection-background = #eee8d5
selection-foreground = #586e75

Why tmux full-time? I used to run cmux for its workspace sidebar and built-in browser, but as more of my work moved to always-on cloud instances, tmux's persistence became the killer feature. SSH in from anywhere, tmux attach, and everything is exactly where you left it — long-running agents, build watchers, test suites. No state lost to a laptop lid close or a flaky connection. The ergonomics of a local GUI terminal are nice, but they don't survive a disconnect.

Why Solarized Light? Light themes are easier to read on high-brightness screens and in daylit environments. Solarized's warm off-white background (#fdf6e3) is softer than pure white, and the palette has enough contrast for syntax highlighting, git status colors, and shell prompts without being harsh.

Dotfiles Deep Dive

These are the configuration files I carry across machines. Each one is annotated below. Full backups live in a dedicated directory and are periodically snapshotted.

.zshrc

The shell configuration — aliases, history, vi-mode, path setup, and tool integrations. This is the longest file and the one that evolves most.

~/.zshrc — Aliases & Basics
alias l='CLICOLOR_FORCE=1 ls -altrh'
alias gloa='git log --all --oneline --graph --decorate'
alias gloar='git log --all --oneline --graph --decorate --remotes'
alias gs='git status'
alias gfa='git fetch --all --prune'

Short aliases, big wins. l gives a colorized, reverse-time-sorted listing — the most recent files at the bottom where your cursor is. gloa is a compact graph view of all branches. gfa fetches everything and prunes stale remotes in one shot. These save hundreds of keystrokes per day.

~/.zshrc — History
HISTFILE=$HOME/.zsh_history
HISTSIZE=100000
SAVEHIST=100000

setopt appendhistory      # Append history to the history file (no overwriting)
setopt sharehistory       # Share history across terminals
setopt incappendhistory   # Write immediately, don't wait for shell exit

100K lines of history, shared everywhere. With sharehistory and incappendhistory, every terminal session can see commands from every other session in real time. Combined with Ctrl-R reverse search, this becomes a personal command database.

~/.zshrc — Vi Mode
set -o vi
bindkey -v                 # Vi-mode keybindings
bindkey fd vi-cmd-mode     # Escape to normal mode with 'fd'

PROMPT='%n@%m %* %~ %# '

alias h1='history 1'
alias vi='nvim'
export VISUAL="vim"
export EDITOR="$VISUAL"

Vi everywhere. The shell runs in vi-mode, and fd is mapped to Escape — the same mapping used in the .vimrc and nvim configs. This muscle memory carries across every tool. vi is aliased to nvim so the modern editor is always the default.

The prompt includes username, hostname, time, and full path — useful when you're SSH'd into remote machines and need context at a glance.

~/.zshrc — Performance Optimizations
# Cache brew shellenv (static output, no need to fork every time)
export HOMEBREW_PREFIX="/opt/homebrew"
export HOMEBREW_CELLAR="/opt/homebrew/Cellar"
export HOMEBREW_REPOSITORY="/opt/homebrew"
export PATH="/opt/homebrew/bin:/opt/homebrew/sbin${PATH+:$PATH}"

# Hardcoded path (was: code --locate-shell-integration-path zsh — took 5s!)
[[ "$TERM_PROGRAM" == "vscode" ]] && . "/Applications/Visual Studio Code.app/..."

# Ruby / rbenv (lazy-loaded — only runs rbenv init when you first use ruby)
_rbenv_lazy_init() {
  unfunction ruby gem irb bundle rake 2>/dev/null
  eval "$(rbenv init - --no-rehash)"
}
for cmd in ruby gem irb bundle rake; do
  eval "$cmd() { _rbenv_lazy_init; $cmd \"\$@\" }"
done

# fnm - Fast Node Manager (instead of nvm)
eval "$(fnm env --use-on-cd --shell zsh)"

# Starship prompt
eval "$(starship init zsh)"

Shell startup speed matters. Several optimizations here: Homebrew's shellenv output is hardcoded instead of forked every launch. VS Code's shell integration path is hardcoded (the code --locate-... command took 5 seconds!). Ruby's rbenv is lazy-loaded — it only initializes when you first run ruby, gem, etc. And fnm replaces nvm as the Node version manager because it's dramatically faster.

.vimrc / nvim init.lua

The Vim config is kept minimal — no plugin manager, no 200-line statusline. Just keybindings and sensible defaults. The nvim init.lua is a Lua translation of the same config.

~/.vimrc — Core Keybindings
inoremap fd <Esc>
vnoremap fd <Esc>

" Swap ; and : — enter command mode without Shift
nnoremap ; :
nnoremap : ;
vnoremap ; :
vnoremap : ;

" Split navigation with Ctrl-HJKL
set splitbelow
set splitright
noremap <C-h> <C-w>h
noremap <C-j> <C-w>j
noremap <C-k> <C-w>k
noremap <C-l> <C-w>l

" Same in terminal mode
tnoremap <C-h> <C-\><C-n><C-w>h
tnoremap <C-j> <C-\><C-n><C-w>j
tnoremap <C-k> <C-\><C-n><C-w>k
tnoremap <C-l> <C-\><C-n><C-w>l

The same movement everywhere. fd for Escape, Ctrl-HJKL for pane navigation — identical to the shell config. The ;/: swap is a classic Vim optimization: you enter command mode with ; (no Shift key), and the rarely-used repeat-find moves to :. Over a day of editing, this saves thousands of Shift keypresses.

~/.vimrc — Sensible Defaults
syntax on
filetype plugin indent on
set encoding=utf-8
set tabstop=4
set softtabstop=4
set shiftwidth=4
set expandtab               " Spaces, not tabs
set autoindent
set fileformat=unix

set mouse=                  " Disable Vim mouse — let the terminal handle drag/copy

set incsearch               " Search as you type
set hlsearch                " Highlight results
set ignorecase              " Case-insensitive search...
set smartcase               " ...unless uppercase used

set backspace=indent,eol,start
set clipboard=unnamed       " System clipboard integration

" Remove trailing whitespace on save (Python files)
autocmd BufWritePre *.py :%s/\s\+$//e

" Persistent undo, swap, and backup
set undofile
set undodir=~/.vim/undo//
set swapfile
set directory=~/.vim/swap//
set backup
set backupdir=~/.vim/backup//
silent !mkdir -p ~/.vim/swap ~/.vim/backup ~/.vim/undo

Persistent undo is the unsung hero here. Close a file, reopen it days later, and you can still undo. The // suffix on directory paths tells Vim to use the full file path in the swap/backup name, avoiding collisions. Trailing whitespace is auto-stripped on Python files because PEP 8.

set mouse= (empty) disables Vim's built-in mouse handling entirely. The default mouse=a intercepts drag events, causing Vim to enter visual mode instead of letting the terminal select text. With mouse disabled, drag-to-select and Cmd+C / Cmd+V work exactly like they do outside of Vim.

nvim init.lua

The Neovim config is a Lua translation of the same vimrc settings. The key mouse setting maps directly: vim.opt.mouse = ''.

~/.config/nvim/init.lua — Mouse & Key Settings
-- Key mappings
vim.keymap.set('i', 'fd', '<Esc>')
vim.keymap.set('v', 'fd', '<Esc>')
vim.keymap.set('n', ';', ':')
vim.keymap.set('n', ':', ';')
vim.keymap.set('v', ';', ':')
vim.keymap.set('v', ':', ';')

-- Basic settings
vim.cmd('syntax on')
vim.cmd('filetype plugin indent on')
vim.opt.encoding = 'utf-8'
vim.opt.mouse = ''  -- Disable Neovim mouse handling; let the terminal handle drag/select/copy-paste

-- System clipboard integration
vim.opt.clipboard = 'unnamed'

Why mouse = '' instead of 'a'? Neovim ships with mouse = 'a' as its default — mouse enabled in all modes. This means dragging enters visual selection mode inside Neovim, and you lose native terminal copy/paste. Setting it to empty string hands all mouse events back to the terminal so drag-to-select, Cmd+C, and middle-click paste all work as expected.

.tmux.conf

tmux is the backbone of the whole setup — it handles splits, sessions, and persistence on both local and remote machines. The config below covers the most impactful settings: mouse support for scrolling without stealing terminal copy/paste, generous scroll history, sensible pane navigation, and a transparent status bar that doesn't fight your terminal's color scheme.

~/.tmux.conf — Mouse & Scroll
# Mouse support (scroll, click panes, resize)
set -g mouse on

# Scroll naturally — enter copy mode on scroll up, pass through on scroll down
bind -n WheelUpPane if-shell -F -t = "#{mouse_any_flag}" "send-keys -M" \
  "if -Ft= '#{pane_in_mode}' 'send-keys -M' 'select-pane -t=; copy-mode -e; send-keys -M'"
bind -n WheelDownPane select-pane -t= \; send-keys -M

# Generous scroll history
set -g history-limit 500000

Mouse on, copy/paste intact. set -g mouse on lets you scroll, click panes, and resize splits. The WheelUpPane binding enters copy mode on scroll-up (so you can scroll back through output) while the WheelDown binding exits it — no getting stuck in copy mode. This is the tmux complement to the Vim mouse='' change: tmux handles scroll, terminal handles drag-select.

500K lines of history means you can scroll back through long build logs or test output without losing anything.

~/.tmux.conf — Pane Navigation
# Active pane border highlight
set -g pane-border-style 'fg=colour250'
set -g pane-active-border-style 'fg=colour136'
set -g pane-border-status top
set -g pane-border-format ' #{pane_index}: #{pane_current_command} '

# Ctrl-hjkl: move to adjacent pane, or auto-split a new one at the edge
bind -n C-h if -F '#{pane_at_left}'   'split-window -hb -c "#{pane_current_path}"; select-layout even-horizontal' 'select-pane -L'
bind -n C-j if -F '#{pane_at_bottom}' 'split-window -v  -c "#{pane_current_path}"; select-layout even-vertical'  'select-pane -D'
bind -n C-k if -F '#{pane_at_top}'    'split-window -vb -c "#{pane_current_path}"; select-layout even-vertical'  'select-pane -U'
bind -n C-l if -F '#{pane_at_right}'  'split-window -h  -c "#{pane_current_path}"; select-layout even-horizontal' 'select-pane -R'

bind -n C-x confirm-before -p "Kill pane? (y/n)" kill-pane
bind -n C-z resize-pane -Z         # Zoom/unzoom current pane
bind -n C-= select-layout even-horizontal

# Reload config
bind r source-file ~/.tmux.conf \; display "Config reloaded"

The same Ctrl-HJKL movement everywhere. These bindings mirror the Vim split navigation — pressing Ctrl-L moves to the right pane in both Vim and tmux. At the edge of the window, it auto-creates a new split instead of doing nothing. This means you never have to think about whether you're in Vim or a shell pane: the motion keys just work.

The pane border status line shows the current command in each pane — useful when you have 4+ panes open and need to find your running server or test watcher.

~/.tmux.conf — Colors & Terminal
# Use terminal's native background (no override)
set -g window-style 'fg=default,bg=default'
set -g window-active-style 'fg=default,bg=default'

# Transparent status bar — don't fight your terminal theme
set -g status-style 'fg=default,bg=default'
set -g status-left-style 'fg=default,bg=default'
set -g status-right-style 'fg=default,bg=default'
set -g window-status-style 'fg=default,bg=default'
set -g window-status-current-style 'fg=colour136,bg=default,bold'
set -g message-style 'fg=default,bg=default'

# True color + hyperlink support
set -g default-terminal "tmux-256color"
set -ga terminal-overrides ',xterm-ghostty:Tc'
set -as terminal-features ",xterm-256color:hyperlinks"
set -as terminal-features ",xterm-ghostty:hyperlinks"
set -g allow-passthrough on

Transparent everything. Setting all style values to default means tmux inherits your terminal's background color. No jarring green tmux status bar clashing with a dark terminal theme. The active window tab gets a subtle amber highlight (colour136) — visible but not loud.

allow-passthrough on lets escape sequences pass through tmux to the terminal — needed for things like inline images, OSC 8 hyperlinks, and some TUI apps.

Coding Agents & Pi

I use Pi as my primary coding agent. I run a fork of pi-mono that adds a wrapper script for loading and using Pi with AWS Bedrock as the model provider.

The pc Script

I keep a pc (pi commit) script in ~/bin that runs Pi in non-interactive mode for quick tasks — especially auto-generating commit messages from staged changes.

~/bin/pc
#!/usr/bin/env bash
# pc: run pi in non-interactive mode
# Uses pi's -p (--print): process prompt and exit. No TUI.
# Usage: pc [--haiku|--micro|--lite] [prompt...]
# With no args (besides flags), generates a commit message from staged changes.

P_FLAGS=()
PROMPT_ARGS=()
while [[ $# -gt 0 ]]; do
  case "$1" in
    --haiku|--micro|--lite)
      P_FLAGS+=("$1"); shift ;;
    *)
      PROMPT_ARGS+=("$1"); shift ;;
  esac
done

if [[ ${#PROMPT_ARGS[@]} -ge 1 ]]; then
  pi "${P_FLAGS[@]}" -p "${PROMPT_ARGS[*]}"
else
  DIFF=$(git diff --cached)
  if [[ -z "$DIFF" ]]; then
    echo "No staged changes." >&2; exit 1
  fi
  PROMPT="Given the following git diff of staged changes, write a good
commit message using conventional commit format. Only output the
commit message, nothing else.

$DIFF"
  MSG=$(echo "$PROMPT" | pi "${P_FLAGS[@]}" -p \
    | perl -0pe 's/<thinking>.*?<\/thinking>\n?//gs')
  if [[ -z "$MSG" ]]; then
    echo "Failed to generate commit message." >&2; exit 1
  fi
  git commit -m "$MSG"
fi

One command to commit. pc with no arguments reads git diff --cached, sends it to Pi (backed by Bedrock), strips any thinking tags, and commits with the generated message. Pass --haiku or --lite for cheaper/faster models on trivial commits. Pass a prompt directly (pc "explain this codebase") and it works as a general-purpose CLI.

The pi alias (defined in .zshrc) wires up the AWS profile and points at the local pi-mono build. This keeps the provider config in one place — pc inherits it automatically.

Pi Setup

The fork at p10q/pi-mono wraps the upstream Pi with a Bedrock provider integration, along with several custom agents and skills.

Pi is invoked via a shell alias that wires up the AWS profile and points at the local build:

~/.zshrc
alias pi='AWS_PROFILE=<profile> node /path/to/pi-mono/packages/coding-agent/dist/cli.js -'
alias pi-sonnet='pi --model us.anthropic.claude-sonnet-4-6'
alias pi-opus='pi --model us.anthropic.claude-opus-4-6-v1'

export PI_SPAWN_CMD="AWS_PROFILE=<profile> node /path/to/pi-mono/packages/coding-agent/dist/cli.js -"

The alias keeps provider config in one place. Prefixing with AWS_PROFILE means every Pi invocation — interactive or spawned — automatically uses the right Bedrock credentials without touching the global AWS config. Running pi in the terminal drops straight into an interactive session.

Model variants. pi-sonnet and pi-opus pin to specific model versions via --model. Reach for pi-sonnet when speed matters — fast iteration, quick edits, running through multiple ideas in sequence. Use pi-opus when a task genuinely benefits from deeper thinking: complex architecture decisions, tricky debugging, or anything where you'd rather it reason carefully once than go back and forth. Both inherit the same provider config from the base pi alias.

Agent spawning. PI_SPAWN_CMD mirrors the alias so that subagents spawned by Pi inherit the same provider config. Both point at the same local build of pi-mono, so changes to agents and skills are picked up immediately without a reinstall.

Slash Commands

The ones I reach for most in any session:

/tree and /compact are complementary mid-session tools: /tree gives the agent a structural anchor when it's drifted, and /compact reclaims context budget so you can keep going without starting over.

/export is the save button. Instant, lossless. Use it before a risky change, before handing off to someone else, or just at a good stopping point. The session file is plain text — easy to search, share, or resume from.

The spawn commands are the real force multiplier. Being able to say "handle this subtask" and immediately have it running in a visible pane — not buried in a subprocess — changes how you think about agent workflows. You keep your main thread moving while parallel agents handle research, tests, or a separate feature branch. /spawns is the control surface for all of it.

Custom Agents

I use a few specialized agents that extend Pi's capabilities:

Composable agent architecture. The beauty of Pi's agent system is that each agent is just a configuration file — a system prompt, a set of tools, and optional skills. They compose together: tmux-spawn can spin up a chrome-devtools agent in a separate pane, which can reference the figma-mcp agent for design specs. It's agents all the way down.

tmux-spawn specifically closes the loop between Pi's parallel agent model and visible terminal sessions. Before it, spawned agents were essentially invisible — you'd kick off a subtask and have no easy way to monitor or interact with it. Now each spawn is a first-class tmux pane. The combination of /sw / /sr / /sd for spawning and /spawns for oversight makes parallel workflows practical rather than theoretical.

Combined Dotfiles

Complete, copy-ready versions of every config file discussed above. Copy to clipboard or tap to view the full file.

ghostty config
~/Library/Application Support/com.mitchellh.ghostty/config
.zshrc
~/.zshrc · 56 lines
.vimrc
~/.vimrc · 52 lines
init.lua
~/.config/nvim/init.lua
.tmux.conf
~/.tmux.conf
pc
~/bin/pc · 36 lines