The environment KAI Terminal gets built in
One git repo, one bootstrap script, and a single idea carried through every layer: vim motions everywhere. A tour of the terminal, shell, multiplexer, and editor setup behind the product — now open source.
Most of the writing on this blog is about what KAI Terminal is — the tick bus, the risk engine, the broker abstraction. This one is about where it gets built: the terminal, the shell, the multiplexer, and the editors I spend the day inside.
I've just made the whole thing public at github.com/suvrajitray/dotfiles. It's not a framework and it's not trying to be anyone else's setup. But there are a handful of decisions in it that took me a while to arrive at, and those are worth writing down.
There's one idea running through every layer: vim motions everywhere. Not as a purity contest — because the alternative is paying a context-switch tax every time I move between the shell, a pane, a file, and my notes. Once the motions are the same in all four, they stop being something I think about.
Everything is one directory
The single structural decision that makes the rest work: the repo is
~/.config. Not a folder of files that get symlinked into place by a script —
the config directory itself is the git working tree.
~/.config/ ← this is the repo
├── ghostty/config
├── zsh/.zshrc
├── tmux/tmux.conf
├── nvim/
├── git/config
├── starship/starship.toml
└── setup/install.sh
Most dotfile setups need a symlink manager (GNU Stow, or a bespoke link.sh)
because the tools expect their config in scattered, historical locations —
~/.zshrc, ~/.gitconfig, ~/.tmux.conf. But the XDG Base Directory spec has
quietly won. Ghostty, Starship, Neovim, btop, atuin and git all read from
~/.config/<tool>/ natively, with no coaxing.
That leaves git and zsh as the two interesting cases.
Git needs nothing. It has read ~/.config/git/config for years, and
~/.config/git/ignore as the global gitignore alongside it. Both are picked up
automatically.
Zsh is the one genuine holdout — it insists on ~/.zshrc. The escape hatch
is ZDOTDIR. macOS reads ~/.zshenv before anything else, so that file becomes
a one-line redirect and the actual config stays in the repo:
export ZDOTDIR="$HOME/.config/zsh"That's the entire symlink layer: one line, in one file, written once by the bootstrap script. Everything else is just there by virtue of being cloned to the right path. New machine, one clone, no linking step, and nothing to drift out of sync.
The bootstrap script, and why it doesn't set -e
setup/install.sh takes a fresh Mac to a working environment: Xcode Command Line
Tools, Homebrew, every CLI tool, every GUI app, the ZDOTDIR wiring, and the
offline tldr cache.
Two decisions in it matter more than the tool list.
Formulae install in one brew call; casks install in a loop. That split isn't
stylistic. Passing forty formulae to a single brew install lets Homebrew
resolve the whole dependency graph once, which is dramatically faster than forty
sequential calls. Casks are different — they're independent app downloads with no
shared graph, and any one of them can fail for reasons that have nothing to do
with your setup (a dead vendor URL, a cask that got renamed upstream). So each
cask gets its own iteration and its own failure:
for app in "${casks[@]}"; do
if brew list --cask "$app" >/dev/null 2>&1; then
ok "$app (already installed)"
else
brew install --cask --no-quarantine "$app" \
&& ok "$app" \
|| warn "$app failed to install — skipping."
fi
doneThe script deliberately does not set -e. This is the part that surprises
people, because "always set -euo pipefail" is close to received wisdom. It uses
set -u — catching an unset variable is a real typo guard — but not -e.
The reasoning: on a bootstrap script, aborting on first error is the worst
available behaviour. If the eleventh of twenty casks 404s, set -e kills the run
and you've installed eleven things. Re-running gets you to twelve. What I want
instead is for the run to complete, install the other nineteen, and print a
yellow warning about the one that failed — which I then handle by hand, once.
That only works because every step is idempotent. Every install checks before acting, so re-running on a configured machine is a no-op that quietly fills in whatever's missing. "Safe to re-run" and "don't abort on failure" are the same design decision viewed from two directions: the script is a convergence tool, not a transaction. It moves the machine toward the desired state as far as it can get this run, and you run it again.
Vim motions, layer by layer
Here's the through-line, from the outside in.
The shell
bindkey -v turns on vi mode in zsh. Two things make it actually usable, and
without them I'd have turned it back off years ago.
The mode-switch delay. By default zsh waits 400ms after Esc to see whether
you're mid-escape-sequence. That delay is perceptible and it makes vi mode feel
broken. KEYTIMEOUT=5 cuts it to 50ms.
Knowing which mode you're in. Vi mode without a visual indicator means
constantly mashing Esc to re-establish where you are. So the cursor shape
carries the mode — block in normal, beam in insert:
function zle-keymap-select zle-line-init {
if [[ $KEYMAP == vicmd ]]; then
echo -ne '\e[1 q' # block cursor
else
echo -ne '\e[5 q' # beam cursor
fi
}
zle -N zle-keymap-select
zle -N zle-line-init
# Restore beam cursor after each command runs
preexec() { echo -ne '\e[5 q'; }That preexec line is the kind of detail you only add after being annoyed
fifty times: without it, the cursor keeps whatever shape it had when you hit
Enter, and you come back from a long-running command to a lying cursor.
The other concession is that vi mode clobbers the emacs-style Ctrl bindings that
are genuinely better than their vi equivalents for line editing. I bind those
back explicitly — Ctrl+A, Ctrl+E, Ctrl+K, Ctrl+U, Ctrl+W. Vi mode for
motion, emacs keys for the five things emacs keys are better at. Purity would be
worse.
Tool initialisation order is load-bearing
The shell loads fzf, zoxide, atuin and direnv, and the order matters for one
specific reason: fzf and atuin both want Ctrl+R.
source <(fzf --zsh) # sets up Ctrl+T, Ctrl+R, Alt+C
eval "$(zoxide init zsh --cmd cd)" # replaces `cd` outright
eval "$(atuin init zsh --disable-up-arrow)" # loaded after fzf, so it owns Ctrl+R
eval "$(direnv hook zsh)"atuin loads after fzf and therefore wins the binding — which is what I want,
because atuin's history is a SQLite database that records the working directory,
exit code and duration of every command, and searching that beats fuzzy-matching
a flat text file. I keep fzf's Ctrl+T and Alt+C. The --disable-up-arrow
flag leaves plain Up-arrow as ordinary history, which matters in vi mode where
k is already doing that job.
zoxide init --cmd cd is worth calling out too: rather than aliasing to z, it
replaces cd entirely. cd still works exactly as before for real paths, but it
learns. The point of a tool like this is that it disappears — if it needs its own
verb, I'll forget to use it.
The multiplexer
tmux uses Ctrl+a as prefix rather than the default Ctrl+b (closer to the home
row), splits inherit the current pane's directory, and escape-time drops to
10ms so Esc in Neovim isn't laggy — the same class of fix as KEYTIMEOUT.
But the piece I'd actually recommend stealing is vim-aware pane navigation.
The problem: Neovim has splits, tmux has panes, and they nest. Naively you need one set of keys to move between Neovim splits and a different, prefixed set to move between tmux panes — and you have to track which context you're in before every movement. That's exactly the context-switch tax the whole setup exists to avoid.
The fix is to make tmux ask what's running in the pane before deciding what the key means:
vim_pattern='(\S+/)?g?\.?(view|l?n?vim?x?|fzf)(diff)?(-wrapped)?'
is_vim="ps -o state= -o comm= -t '#{pane_tty}' \
| grep -iqE '^[^TXZ ]+ +${vim_pattern}$'"
bind-key -n 'C-h' if-shell "$is_vim" 'send-keys C-h' 'select-pane -L'
bind-key -n 'C-j' if-shell "$is_vim" 'send-keys C-j' 'select-pane -D'
bind-key -n 'C-k' if-shell "$is_vim" 'send-keys C-k' 'select-pane -U'
bind-key -n 'C-l' if-shell "$is_vim" 'send-keys C-l' 'select-pane -R'is_vim inspects the process attached to the pane's TTY. If it's Neovim (or fzf),
the keystroke is forwarded and Neovim moves its own split. If it's anything else,
tmux moves its pane. With the matching vim-tmux-navigator plugin on the Neovim
side, Ctrl+h/j/k/l becomes one uniform movement across a boundary I no longer
have to think about. Whether the thing to my left is a Neovim split or a tmux
pane running a build is not a question I want to answer thirty times an hour.
The state filter in that regex — ^[^TXZ ]+ — excludes stopped, dead and zombie
processes, so a suspended Neovim doesn't keep swallowing your navigation keys.
Copy mode is vi-keyed too (mode-keys vi), with v to select, Ctrl+v for
block selection, and y yanking straight to the macOS clipboard via pbcopy.
Same motions as everywhere else.
Two editors, on purpose
I use Neovim and VS Code, and I've stopped feeling like I need to pick.
Neovim is where code gets written. It's LazyVim rather than a from-scratch config — I'd rather inherit a maintained set of sensible defaults than hand-roll LSP wiring, and my actual customisation on top is deliberately small: a Catppuccin Mocha theme with transparency on, the language extras I need, and a short list of keymaps. The ones I use constantly:
-- better escape
vim.keymap.set("i", "jk", "<Esc>", { desc = "Exit insert mode with jk" })
vim.keymap.set("i", "kj", "<Esc>", { desc = "Exit insert mode with kj" })
-- paste over a selection without clobbering the register
vim.keymap.set("v", "p", '"_dP')
-- delete a character without affecting the register
vim.keymap.set("n", "x", '"_x')Those last two solve the same small, constant irritation: vim's default register
behaviour means pasting over a selection replaces what you were about to paste
again, and x silently overwrites your yank. Routing both to the black-hole
register ("_) fixes it.
VS Code is where I go to debug. Stepping through a live .NET process, inspecting a call stack, watching variables mutate across a breakpoint — the graphical debugger is genuinely better at that, and pretending otherwise to stay in the terminal would be exactly the kind of purity that costs real time. When KAI's risk engine does something I didn't predict, I want the debugger, not a principle.
The split is clean because the two activities are genuinely different. Writing code is a text-manipulation problem, and vim motions are the best interface to text manipulation I've found. Understanding a running program is a state inspection problem, and a GUI is better at rendering state.
The gap this left, and what I did about it
Once shell, multiplexer and editor all speak vim, the remaining friction shows up somewhere unexpected: notes. I'd move fluidly through code all day, open a notes app in the browser, and lose the motions entirely — back to arrow keys and a mouse.
So I built Vimpad — notes that move at the speed of
vim. A vim-native Markdown editor that runs in the browser, with real vim
motions (CodeMirror-powered, so dd, ciw, :%s/old/new/g and macros all
behave), live preview, and local-first storage in IndexedDB. Notes optionally
sync to your own Google Drive as plain .md files — no notes backend, no
account, no tracking. Free forever.
It exists because of the gap described above. The last surface where I couldn't use the motions was the one I reached for most often between coding sessions, and closing that gap turned out to be worth building a whole app for.
Take what's useful
The repo is at github.com/suvrajitray/dotfiles. The bootstrap script is macOS + Homebrew specific, but the configs mostly aren't — tmux, Neovim, zsh and starship all travel fine.
If you only take one thing, take the vim-aware pane navigation. It's twenty lines of tmux config and it removes a decision you were making hundreds of times a day without noticing.
And if you're reading this on a machine where your notes app doesn't speak vim — that's what Vimpad is for.