Pairing with the agent, still
Keeping Neovim’s undo history through Pi’s edits
For the past year, I’ve been letting my coding agent do most of the groundwork while I focus on the research and thinking around how the code should be. When I review its results, I might notice things that aren’t exactly how I want them. When it’s small, nitpicky things, I don’t even bother prompting the agent again; I just make the changes myself.
I’ve been doing more of this lately while working on an existing codebase for a client. I don’t know the codebase very well yet, and I need to go through the files myself to get a feel for it before prompting and steering the agent.Sean Goedecke’s In defense of not understanding your codebase got me thinking about this. I still need to be in the code to get a feel for it, even if an agent wrote all of it. I do that in Neovim, and sometimes I make changes while I’m there.
The issue is that we end up iterating on the same file. The agent makes changes (that I asked it to), then I make some more, and at some point I want to undo one. For the past fifteen years, I’ve been relying on Neovim’s persistent undo for this. Having the agent write to the file breaks that muscle memory.
Sometimes it’s faster to make the change myself and then continue with the agent. I’ll still give it the context it needs, but I don’t need to set up a whole system for a few nitpicky changes.
I have persistent undo enabled in my configuration, along with backups:
vim.opt.backup = true
vim.opt.undofile = true
vim.opt.backupdir = vim.fn.stdpath('state') .. '/backup//'
I also use an undo tree display to move between changes.Since Neovim 0.12 it’s been a native plugin that you can enable with packadd nvim.undotree but I used other plugins before. But when the agent edits the file, Neovim can no longer load its saved history, so I lose the changes I wanted to go back to.
What’s an undofile?
Neovim keeps the undo history in memory. With undofile enabled, it also saves it to disk when it writes the file, then reads it back when I open the file again. That’s how u still works after I restart.
Each change gets a sequence number. If I undo a change and make another one from there, the change I undid stays in the history, on another branch. I can go back to either version. You can try it below: make a couple of edits, undo one, then edit again.
Those files live in undodir. In my setup that’s ~/.local/state/nvim/undo//. The filename is the full path of the file it belongs to, with every separator replaced by %, so /Users/aliou/notes.md becomes %Users%aliou%notes.md.
Inside, there’s a hash of the file contents and a line count, then the records for the changes. Each record has its sequence number, links to the other changes, cursor positions, a timestamp, and the text needed to undo the change.
When Neovim reads the undofile, it checks the hash against the file it’s opening. If the agent has written to the file, they no longer match, and Neovim refuses to load the history.This is specifically about loading the saved undofile. Having a buffer already open while something edits the file is another part of Neovim’s external-change handling. See Neovim’s persistent undo documentation. The agent changed the file without updating the undofile, so that’s what I need to fix.
Making it work with Pi
Before, I probably would’ve just lived with this annoyance. Now it’s pretty cheap to give a small task like this to an agent, especially when I already know how Pi’s tool events and Neovim’s undo history work. I can explain what needs to happen: keep the file’s contents before the tool runs, read it again after, and append the change to the history.
Native tools
Pi emits tool_call before a tool runs. For edit and write, the path is in the arguments, so I can read the file and keep its contents around, keyed by the tool call ID. When the matching tool_result comes back, I check that the tool succeeded, read the file again, and append the change to its saved history.
Appending means taking the next sequence number and writing a record with the old contents, since that’s what undo has to put back. Then I update the hash and line count to match the new file, and point the newest head at the record I just added.
Before doing any of that, I check that the undofile still matches the contents captured before the tool call, and that parsing and serializing it gives back the same bytes. If something doesn’t match, or the tree has a shape I don’t handle, I leave it alone. The updated history goes into a temporary file first, then gets renamed into place.
The agent calls edit or write like it always does. There’s nothing to add to the prompt or context, and I don’t have to remind it about my undo history.
I’ve implemented this in nvim-pi, my Neovim extension for Pi. It’s off by default; Persistent undo tools in /neovim:settings turns it on. Below, try Pi edit/write with the hook off, then reset and try again with it on.
Other editing tools
For other editing tools, the extension needs to know where to find the paths. If the tool takes a path in input.path, you can register its name:
pi.events.emit("neovim:undo:register-tool", "my_path_based_tool");
Otherwise, you pass an object with the tool name and a resolvePaths function. It gets the tool’s arguments and returns the paths to capture. For something like apply_patch, that can be several files, with the paths pulled out of the patch body.
Sometimes the agent ignores the editing tools entirely and writes the file with cat > FILE <<'EOF' in bash, or uses Python. This hook doesn’t catch those writes. It needs the paths before the tool runs, and I haven’t made it inspect shell commands or scripts to figure out what they’ll change.There’s a whole discussion around whether bash is all you need. For this hook, I still need to know which paths to capture before the tool runs.
Is it even worth it?
For now, it fixes something that was annoying me. I don’t think I’ll spend much more time trying to catch all the ways an agent can write a file, especially as I’m doing less and less of this work on my local machine.Sandboxes and async agents and whatever. This exact setup probably won’t last very long.
I’m moving that work to sandboxes on another machine, with custom orbs, a coordinator and an executor. I’ll write more about that. I’ll probably still need to go through the code myself when I start on an unfamiliar project; what changes is where I do it, and whether it’s still in Neovim.