All posts
9 min read

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.

dotfilesneovimtmuxzshtooling

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
done

The 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 Vimpadnotes 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.

SR

Suvrajit Ray

Founder & Engineer — KAI Terminal

Open to opportunities

I build low-latency trading systems end to end — a .NET real-time risk engine and a React/Next.js cockpit for Indian index-options sellers.