cat posts/2026-10-16-the-rest-of-my-workflow-moved-into-the-terminal.md

The rest of my workflow moved into the terminal

· 8 min read · tools terminal dotfiles neovim shell

My shell used to take about half a second to give me a prompt. I’d measured it once, years ago, shrugged, and forgot about it. Then in July I was opening a new tab every few minutes, because that’s how I start an agent session, and half a second every few minutes is the kind of thing you stop being able to ignore. After a morning with an agent it was down to about 80 milliseconds.

That was the start of three months of small changes, nearly all of them made by an agent in my dotfiles repo while I read the diffs. The agents live in the terminal, so I live in the terminal, and every time something made me leave it, I fixed that thing.

The shell

Three things were making startup slow: the oh-my-zsh theme I’d used for years (agnoster, the one with the powerline arrows), nvm being sourced eagerly, and fzf --zsh spawning the fzf binary on every new shell just to print its own key bindings. The theme went, and Starship does the prompt now. It was already the prompt on the Linux box, a small VPS I’ve been using as a dev box since July, so the Macs ended up matching it instead of the other way round. Two days later the Windows laptop got an oh-my-posh theme made to look like Starship, so PowerShell matches too, roughly.

If you’d asked me in August, I’d have said I replaced oh-my-zsh with Starship, and that’s wrong. oh-my-zsh still loads underneath, doing the history and completion setup I didn’t want to recreate by hand. What changed is that it no longer does the prompt and the plugin list got short: fzf-tab (only on machines that have fzf), zsh-autosuggestions and fast-syntax-highlighting, which replaced zsh-syntax-highlighting, because the fork is faster and more actively developed. The z plugin went too, replaced by zoxide, which imported my old z database. The git plugin went in October, unused. And as of 8 October, oh-my-zsh and its plugins are chezmoi externals, so chezmoi clones them on a fresh Mac and pulls them at most once a week when I apply, and oh-my-zsh’s own updater is switched off. Before that they were hand-cloned on each Mac, and nothing kept the two Macs in step.

The fzf changes are the ones my hands notice. fzf had been falling back to find for Ctrl-T and Alt-C, walking .git and node_modules, even though fd and ripgrep were installed. It uses fd now, or rg where there’s no fd. fzf-tab turns Tab completion into an fzf list with previews, so completing cd shows a directory listing and completing git checkout shows the branch’s log. And there’s Alt+A, which fuzzy-finds a single argument from my shell history and inserts it at the cursor. PSFzf has given me that on Windows for a while, and I kept pressing it on the Mac and getting nothing.

Node, three times in eight days

The nvm part is the one I’d do differently. Making nvm lazy is a well-known trick. You define little stub functions called node, npm and so on, and the first time you run one it loads nvm, deletes the stubs and calls the real thing. The agent wrote it, startup got fast, and for a week I was happy.

Then I reinstalled Node one night and the terminal filled with hundreds of lines of command not found: _load_nvm, ending in maximum nested function level reached. The node stub ended by calling node, and if loading nvm failed, that was the stub itself, so it recursed until zsh gave up. The fix was for each stub to pass its own name to one shared helper, so a stub can’t call itself however loading fails. The same night we found a quieter problem. Only the five stubbed commands triggered a load, so everything else npm installs globally, the TypeScript language server and eslint among them, wasn’t on PATH until I happened to run node. Neovim’s language servers and Claude Code, started from a fresh shell, couldn’t see them.

I looked at the pile of workarounds the next morning and asked why I was still on nvm, and by breakfast it was fnm. fnm is a single Rust binary, so loading it eagerly costs almost nothing, and the stubs went away. The next day there was one more surprise. fnm env puts a per-shell directory on PATH that disappears when the shell does, so a process that outlives its shell loses Node, which nvm never did. We prepended fnm’s stable default-version link, in front of Homebrew’s, which at the time had its own Node 26 installed as a dependency of something else. The Linux box moved to fnm the same day. So in eight days I went from eager nvm to lazy nvm to fnm, and the short version is that lazy nvm was a mistake I should have skipped.

One shell setup on the Macs and the Linux box

The Linux box runs bash and the Macs run zsh, and by July my aliases had drifted into a zsh-only file again, despite what I wrote in 2023. The VPS showed the cost. Its Debian .bashrc defines alias l='ls -CF', and once ls became an alias for lsd, l expanded to lsd -F -CF, and lsd rejects -C. Now there are two shared files. .shell_env holds environment variables and is sourced early, because fzf decides whether to install its key bindings by checking those variables when its scripts load. .shell_aliases holds aliases and functions and is sourced last, so it wins over whatever the distro or oh-my-zsh defined. Aliases check for the tool rather than the OS, which also covers Debian renaming fd to fdfind. Along the way the agent found that zsh was keeping 50,000 history lines in memory and saving only 10,000 to disk, which I’d apparently lived with for years.

The other half is .chezmoiignore, which decides which files land on which OS. The 2023 rules hadn’t kept up with the files I’d added since, and I wanted a bare chezmoi apply to be safe on any machine. This is where it ended up:

Macs Linux box Windows .zshrc, .zprofile oh-my-zsh + plugins (externals) Ghostty config .bashrc .shell_env, .shell_aliases .tmux.conf .ideavimrc oh-my-posh theme Neovim config lazygit config Filled: chezmoi writes it on that OS. lazygit gets one config body at a different path on each OS.
What a bare chezmoi apply writes on each kind of machine, as of mid-October.

lazygit was the fiddly one, since its config lives somewhere different on each OS, so the body is one chezmoi template with three thin wrappers.

Ghostty and git

Ghostty hasn’t changed much since I switched to it. The one real addition is Option+h/j/k/l sending arrow keys, which means I can move through fzf lists and shell history without leaving the home row. Those four lines had only ever been in one Mac’s local config, and they went into the repo in July along with the font-size line, which is 14. The other change was for tabs, which all said “nvim” when several were running Neovim. Neovim now sets the title to the name of the directory it was opened in, and tmux, which I run on the Mac too now, passes it through to Ghostty once you tell it to.

Git was the last thing I regularly left the terminal for. For a proper side-by-side diff, or to walk back through a file’s history, I’d end up in VS Code. Now it’s lazygit, with delta rendering the diffs. That took a flag I wouldn’t have found myself. My git config tells delta to go side-by-side for plain git diff, delta reads it even when lazygit calls it, and lazygit’s diff pane ended up as two unreadably narrow columns. The fix is --no-gitconfig and passing the flags I do want by hand. Inside Neovim, fzf-lua’s git pickers for file history, status and branches were already installed and just weren’t bound to any keys. The agent bound those and left several others unbound on purpose, because their defaults are destructive (Ctrl-X in the status picker resets your working tree). By default Enter in the repo-log picker checks out the commit, leaving you on a detached HEAD from a window you opened to read history, so Enter shows the diff now. diffview handles the side-by-side walk through history, and it follows renames.

One set of keys, checked

In the June post I said the README’s keymap table was the one place Neovim, the VS Code extension and IdeaVim were reconciled. On 25 September I spent a day checking the table against the configs, and it was in worse shape than I’d assumed. Four mappings called commands that don’t exist. VS Code has no command to toggle inlay hints (it’s a setting, so the mapping now flips the setting), and three IdeaVim action IDs were plausible-sounding names that IntelliJ doesn’t have. Several shared keys did different things in different editors. Q formatted the file in IdeaVim and replays a macro in Neovim. And the table itself had left out about a third of the shared keys.

Once that was fixed I didn’t want to do it again, so that evening the agent wrote a script to check the table. It builds the config from the repo, loads the real Neovim and VS Code keymaps in a headless Neovim, parses the IdeaVim file, and fails if any cell of the table is wrong or a mapped key is missing from it. It exits with a different code when it couldn’t load one of the editors at all, because otherwise a broken load shows up as a page of false mismatches. It can’t tell whether a command exists, so it wouldn’t have caught the original four, but it would have stopped the table rotting to the point where I’d stopped reading it closely enough to notice them.

Neovim since June

Briefly, since the June post covered the rebuild. TypeScript 7, the Go rewrite, dropped the tsserver.js that ts_ls depends on, so a TS 7 project just never got a language server, and nothing said why. tsgo and ts_ls are both enabled now, and each declines projects it can’t serve, which matters because a TS 5 repo and a TS 7 repo can be open in the same session. Markdown renders in the buffer, with the line under the cursor left raw, and Mermaid diagrams render as images underneath their code blocks through Ghostty’s support for the kitty graphics protocol. Windows Terminal doesn’t speak that protocol, so on the laptop they stay as text. Over SSH, yanks go through OSC 52, so copying something in Neovim on the Linux box puts it on the Mac’s clipboard.

Most of this I could have done years ago and didn’t, because none of it was worth an evening on its own. Three months of evenings I didn’t spend turned out to be worth quite a lot. The shell still starts in about 80 milliseconds, for what it’s worth. I checked this morning.