~
My 2026 terminal stack
all writing
·
  • #terminal
  • #productivity
  • #cli
  • #unix
  • #tools

My 2026 Terminal Stack

The terminal tools, configs, and workflows I reach for every day after ten years of living in the command line.

Frameworks come and go. Languages rise and fall. Deployment targets have shifted from bare metal to VMs to containers to serverless and, in some corners, back to bare metal again. Through all of it, the one constant in my workflow has been the terminal. Ten years of professional engineering, and the black rectangle with a blinking cursor is still where I spend most of my day.

This isn’t a “you should use these tools” post. It’s a snapshot of what actually works for me right now, in mid-2026, after years of trying things, keeping what stuck, and dropping what didn’t. Some of these choices are boring. That’s the point.

The Foundation: Shell, Prompt, and Multiplexer

zsh with a minimal config. I tried Fish for six months and appreciated the out-of-the-box autosuggestions, but the POSIX incompatibility bit me one too many times when pasting commands from documentation. I ran oh-my-zsh years ago and moved away from it when startup time crossed 300ms. These days my .zshrc is under 80 lines.

A few config gems I’d miss if they disappeared:

# .zshrc excerpts

# History: big, shared, deduplicated
HISTSIZE=50000
SAVEHIST=50000
setopt SHARE_HISTORY
setopt HIST_IGNORE_ALL_DUPS
setopt HIST_REDUCE_BLANKS

# Directory navigation without cd
setopt AUTO_CD

# Better glob patterns
setopt EXTENDED_GLOB

# Edit command in $EDITOR with ctrl-x ctrl-e
autoload -U edit-command-line
zle -N edit-command-line
bindkey '^x^e' edit-command-line

That last one is underrated. When a command gets long or complicated, ctrl-x ctrl-e opens it in your editor. Full syntax highlighting, multi-line editing, write and quit to execute. I use this several times a day for complex pipelines and multi-line commands that would be miserable to edit at a single-line prompt.

The other config choice worth explaining: 50,000 lines of history with deduplication. That sounds excessive until you realize how often you need to find “that kubectl command from last Thursday” or “the exact curl with the right headers I used three weeks ago.” Shared history across sessions means it doesn’t matter which terminal window you ran it in. It’s all searchable.

Starship for the prompt. Cross-shell, fast, and shows me the three things I actually care about: current directory, git branch, and the exit status of the last command. I’ve tried p10k and it’s excellent, but Starship’s TOML config is easier to share across machines and doesn’t require a font-installation step.

# ~/.config/starship.toml (excerpts)
[character]
success_symbol = "[❯](green)"
error_symbol = "[❯](red)"

[directory]
truncation_length = 3
truncate_to_repo = true

[git_branch]
format = "[$symbol$branch]($style) "

[git_status]
format = '([$all_status$ahead_behind]($style) )'

[golang]
format = "[$symbol($version)]($style) "

[python]
format = "[$symbol($version)]($style) "

[cmd_duration]
min_time = 2_000  # only show if command took > 2s
format = "took [$duration](bold yellow) "

The cmd_duration module is quietly useful. If a build or test run takes longer than two seconds, the time shows up in the prompt. No more wondering “did that go test take 4 seconds or 40 seconds?”

tmux as the multiplexer. I’m aware of Zellij and have nothing against it, but tmux is muscle memory at this point. My config is unremarkable except for a few bindings I can’t live without:

# ~/.tmux.conf excerpts

# Split panes with | and - (easier to remember than % and ")
bind | split-window -h -c "#{pane_current_path}"
bind - split-window -v -c "#{pane_current_path}"

# Switch panes with Alt+arrow (no prefix needed)
bind -n M-Left select-pane -L
bind -n M-Right select-pane -R
bind -n M-Up select-pane -U
bind -n M-Down select-pane -D

# Quick window switching
bind -n M-1 select-window -t 1
bind -n M-2 select-window -t 2
bind -n M-3 select-window -t 3

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

# Start windows and panes at 1, not 0
set -g base-index 1
setw -g pane-base-index 1

The pane_current_path bit means new splits open in the same directory as the current pane. Small detail, saves hundreds of cd commands per week. And starting window indexes at 1 means Alt+1 goes to the first window, which is where your fingers expect it.

Terminal emulator: Ghostty. I switched from kitty about six months ago. Both are excellent GPU-accelerated terminals with proper 24-bit color and ligature support. Ghostty won me over with its native macOS feel, faster startup, and a configuration language that’s refreshingly simple. But honestly, any modern terminal emulator works. The difference between kitty, Ghostty, and WezTerm is smaller than the difference between any of them and the macOS default Terminal.app.

The Search Trio: fzf, ripgrep, fd

I’ve written about fzf before, so I’ll keep this section focused on what’s changed since that post.

fzf for fuzzy finding anything: files, command history, git branches, processes. I use ctrl-r for history search and ctrl-t for file insertion dozens of times per day. The shell integration that fzf’s installer sets up is the single best productivity improvement per minute of setup time I’ve ever encountered.

ripgrep (rg) for content search. Faster than grep by a wide margin on large codebases, respects .gitignore by default, and produces clean output. When I need to find every file that references a function, rg plus fzf gets me there in seconds.

fd for finding files by name. Same philosophy as ripgrep: fast, respects .gitignore, sane defaults. fd 'test.*\.go$' replaces a find command I could never quite remember the flags for.

A few shell functions that tie these three together:

# Find and edit files (fzf + fd + bat preview)
fe() {
  local file
  file=$(fd --type f --hidden --exclude .git | \
    fzf --preview 'bat --color=always --line-range :200 {}')
  [ -n "$file" ] && ${EDITOR:-nvim} "$file"
}

# Search file contents and jump to the match (rg + fzf)
fif() {
  [ "$#" -eq 0 ] && echo "Usage: fif <pattern>" && return 1
  local match
  match=$(rg --line-number --no-heading "$1" | \
    fzf --preview 'bat --color=always --highlight-line {2} {1} --line-range {2}:+20' \
        --delimiter ':')
  [ -n "$match" ] && ${EDITOR:-nvim} "$(echo "$match" | cut -d: -f1)" \
    +"$(echo "$match" | cut -d: -f2)"
}

# Interactive git branch switching with log preview
fbr() {
  local branch
  branch=$(git branch --all | grep -v HEAD | \
    fzf --preview "git log --oneline --graph --color=always {1}" | \
    sed 's/.* //' | sed 's#remotes/[^/]*/##')
  [ -n "$branch" ] && git checkout "$branch"
}

These three functions (fe, fif, fbr) probably save me 20 minutes a day across a dozen uses. If you haven’t tried the fzf ecosystem yet, my earlier guide walks through the setup in detail.

Editor: Neovim

I’m not going to start a war about this. Neovim is my editor. It has been for years. I’m aware of VS Code, Helix, Zed, and the rest. They’re all fine.

The reason I stick with Neovim isn’t ideology. It’s that my config is dialed in to the point where the editor disappears. LSP completion, diagnostics, inline type hints, fuzzy file search, git integration. All running in a terminal, all starting in under 100ms, all working over SSH without forwarding a GUI.

One piece of self-aware humor: I’ve spent more time configuring Neovim than any other tool in my life. The old joke about spending 80% of your time configuring vim and 20% being productive is real, but the ratio is more like 95/5 the first year and then 1/99 every year after. The compound returns are genuine once you stop chasing the plugin of the week.

The plugins that actually matter for my daily work are surprisingly few: telescope.nvim for fuzzy finding (files, buffers, grep results, LSP symbols), nvim-lspconfig for language server integration, nvim-cmp for completion, gitsigns.nvim for inline git blame and hunk staging, and oil.nvim for file management. That’s the core. Everything else is nice-to-have.

My LSP setup alone has saved me more context switches than any other single investment. Jumping to definition, finding references, and renaming across a project without leaving the terminal. For Go, TypeScript, and Python, the language server experience in Neovim is now on par with what VS Code offers, sometimes better because there’s less chrome between you and the code. I don’t miss waiting three seconds for a GUI editor to load a workspace.

One config pattern worth mentioning: I keep language-specific settings in after/ftplugin/ files rather than cramming everything into init.lua. Go files get expandtab = false (tabs, as God and gofmt intended), Python gets shiftwidth = 4, TypeScript gets shiftwidth = 2. The settings load automatically when you open a file of that type. Cleaner than a wall of autocmds.

Dogfooding: Living with My Own Tools

There’s nothing like daily usage to reveal what your software actually needs. I use all three of my TUI tools (ports, envdiff, restless) as part of my regular workflow, and each one has gotten meaningfully better because of it.

ports gets opened multiple times a week, usually when a dev server won’t start because something is already listening on 3000. Before ports, the workflow was: lsof -i :3000, squint at the output to find the PID in the right column, then kill -9 <pid>. Now it’s ports, scroll to the row, press k to kill. Done. The TCP/UDP toggle (t) turned out to be more useful than I expected, too. When you’re debugging DNS issues or checking whether a UDP-based service is actually running, switching views is one keystroke.

The feature that surprised me most from dogfooding: I added the “open in browser” shortcut (o) because I kept doing ports, finding my dev server, then manually typing http://localhost:PORT in my browser. Three keystrokes shouldn’t require context-switching to another application.

envdiff runs before every deploy and before most PRs that touch configuration. The matrix view, which renders all your .env files side by side, has caught missing variables at least a dozen times that would have caused runtime failures in staging or production. The TOML schema validation catches type mismatches and missing required keys before they hit CI, which is faster than waiting for a pipeline to tell you.

The feature I almost didn’t build was the interactive sync TUI, where you can cherry-pick which variables to copy between environment files. I thought the CLI envdiff sync command was sufficient. Turns out, interactively choosing “yes, copy this value” or “no, skip that one” file by file is the killer workflow. Every teammate who’s tried it uses it now. Some things need to be interactive.

restless has replaced my GUI API client for all project work. The .http files live in the repo, the team reviews request changes in PRs, and the CI assertions (# @assert status == 200) catch API regressions after deploys. The pre/post-request scripting handles the authentication flows that used to require manual token-copying between requests. Environments switch with a keystroke.

I still keep a GUI client installed for the occasional one-off request where I just want to paste a URL and see what comes back. But for anything that matters enough to commit to version control, restless is where it lives.

The honest truth about dogfooding: it’s uncomfortable. You find bugs that feel embarrassing. You discover UX friction that’s invisible until you’re in the middle of real work and the tool gets in your way at the worst moment. But there’s no faster feedback loop for building better software. If you wouldn’t use your own tool voluntarily, something is wrong, and you should figure out what before anyone else has to.

The 2026 Shift: AI in the Terminal

I’d be dishonest if I didn’t mention the biggest change in my terminal workflow over the past year: LLM-powered CLI tools.

I’m cautious about hype, and I’m especially cautious about “AI will replace developers” takes. But LLM CLIs have become a genuine part of my workflow for a narrow set of tasks:

  • Generating boilerplate that I know I’ll review line by line anyway.
  • Explaining unfamiliar code in large codebases I’ve just joined.
  • Drafting commit messages from diffs (which I always edit before committing).
  • Quick lookups I’d otherwise spend five minutes searching for: “what’s the jq syntax for filtering by nested key?” or “write me a regex that matches ISO 8601 dates.”
  • Translating between data formats. “Convert this YAML to the equivalent TOML.” Five seconds versus five minutes of fiddly syntax differences.

The key insight: LLMs work best in the terminal when they’re pipe-able. Feed them stdin, get text on stdout, pipe it onward. They fit the Unix philosophy better than any GUI chat interface.

# Draft a commit message from the current diff
git diff --staged | llm "write a concise commit message for this diff"

# Explain a confusing regex in a codebase
rg 'PATTERN_REGEX' -l | head -1 | xargs cat | llm "explain what this regex does"

# Quick data transformation
cat data.json | llm "convert this to a CSV with headers" > data.csv

A tool that takes a diff on stdin and proposes a commit message on stdout is more useful to me than a chat window I have to copy-paste from. The overhead of switching to a browser, pasting context, copying the result back, is enough friction to make me not bother for small tasks. A pipe removes that friction entirely.

What I don’t use them for: writing business logic, making architectural decisions, or anything where being subtly wrong is worse than being slow. The terminal is about composable, trustworthy primitives. LLMs aren’t fully trustworthy yet, but they’re useful primitives for the tasks where you can verify the output in seconds. The moment verification takes longer than doing the task yourself, the economics flip.

Small Delights: Tools Worth Stealing

A few smaller tools that have earned permanent spots in my dotfiles:

bat over cat. Syntax highlighting, line numbers, git integration. I aliased cat to bat --plain for muscle memory compatibility and never looked back. When I need the full experience (line numbers, git markers, paging), I call bat directly.

eza over ls. Better defaults, git status in directory listings, tree view built in. My daily aliases:

alias ll='eza -la --git --group-directories-first'
alias lt='eza --tree --level=3 --git-ignore'

The tree alias is particularly useful for getting a quick overview of a project’s structure without the noise of node_modules and .git.

delta as my git diff pager. Side-by-side diffs with syntax highlighting. Set it up in your .gitconfig:

# ~/.gitconfig (excerpts)
[core]
    pager = delta

[delta]
    navigate = true
    side-by-side = true
    line-numbers = true

[interactive]
    diffFilter = delta --color-only

Every git diff, git log -p, and git show becomes drastically more readable. This is the tool people notice fastest when they watch me work. “Wait, your git diffs look like that?”

zoxide over cd. It learns which directories you visit frequently and lets you jump to them with partial matches. z proj takes me to ~/code/project-name without typing the full path. After a week of training, it’s almost psychic. z env takes me to the envdiff repo. z rest takes me to restless. It just knows.

jq for JSON wrangling. Not new, not exciting, absolutely indispensable. Pipe any JSON to jq . and it becomes readable. Pipe it to jq '.items[] | select(.status == "active")' and you’ve got a query language for API responses, Kubernetes output, and any other structured data your tools produce.

direnv for per-project environment variables. Drop a .envrc file in a project directory, and direnv loads its variables when you cd in and unloads them when you leave. No more “I forgot to source the env file” bugs. Combined with envdiff for validation, this covers most of my environment variable workflow.

The Terminal Renaissance Is Real

Ten years ago, telling someone you worked primarily in a terminal got you polite skepticism or outright pity. The tools were powerful but hostile. Configuration was arcane. The gap between what was possible and what was pleasant was enormous.

That gap has closed. Modern terminal emulators (Ghostty, kitty, WezTerm) support GPU rendering, ligatures, image protocols, and 24-bit color. The Rust-rewrite generation of Unix tools (ripgrep, fd, bat, eza, delta) fixed decades of ergonomic debt. Frameworks like Bubble Tea made building TUIs accessible to anyone who can write Go. The terminal is having its best era.

I don’t think terminals will replace GUIs for everything. Nor should they. Design work, video editing, complex data visualization: those deserve graphical interfaces. But for the work I do, writing code, managing infrastructure, wrangling APIs, debugging production systems, the terminal is faster, more composable, and more honest than any alternative. It shows you what’s happening, not what a designer decided you should see.

If you build tools, consider building for the terminal. There’s a growing audience of people who live here, and they notice the craft. They’ll file issues about edge cases in terminals you’ve never heard of (I speak from experience). But they’ll also use your tool every day and tell their friends about it.

The black rectangle with the blinking cursor isn’t going anywhere. Might as well make it comfortable.