Taking back a reply: undo for an agent that has already saved
Agent mode writes its edits straight to disk, and the commands it runs change files too. How twinny keeps each file as it was before a reply touched it, reads what a command changed through a copy of git's index, and puts it all back through the editor so Undo can bring it back again. From src/extension/chat.
By default agent mode applies its edits as it goes: the file is changed and saved, and the model carries on to the next step. That is what makes it useful, and it is also what makes a bad reply expensive. Ctrl+Z in one file takes back one edit, but a reply that touched six files, deleted one and ran a formatter is not six Ctrl+Zs, and a command’s changes were never in the editor’s undo stack at all.
Since 4.3.8 a reply that changed anything ends with the list: 3 files changed +24 −7, each file with its own counts and whether it is new or deleted, an undo beside each and undo all at the bottom. This post is how that list is kept honest. The code is three files in src/extension/chat/: reply-changes.ts, command-snapshot.ts and reply-changes-editor.ts.
The first copy is the one that counts
Each reply gets a ReplyChanges. Before an edit, a new file, a delete, a move or a rename lands, the tool calls touch(file), and the first time a reply touches a file its current state is kept. Later touches of the same file are ignored: undo needs the file as the reply found it, not as it was three edits in.
“Current state” means what you see. If the file is open, the editor’s text is taken, unsaved changes included; otherwise it is read from disk, with a byte-order mark dropped since the editor keeps that apart. Three states come out of readState: the text, null when the file does not exist yet, and undefined when it cannot be kept, a binary file or one over 1 MB (MAX_KEPT_FILE_BYTES). A file in that last state is still listed when it changes, marked as one that cannot be undone from the chat, rather than left out.
Commands, through a copy of the index
The edit tools know which file they are about to change. A command does not say: npx prettier --write . or sed -i can touch anything. So in a git repository each command is bracketed by two snapshots of the working tree (snapshotTree in command-snapshot.ts).
A snapshot copies .git/index to a temporary file, points GIT_INDEX_FILE at the copy, and runs git add -A and git write-tree. The result is a tree id for the working tree as git would commit it: tracked and untracked files alike, .gitignore honoured. Copying the real index first matters for speed, since its file stats let git skip rehashing everything that did not change, and doing it in a copy means your own staging is never touched. The copy is deleted when the snapshot is written.
After the command, git diff-tree -r --no-renames --name-status between the two trees names what changed, and each file’s old text is read back from the first tree with git cat-file. Those go into the reply with touchWith, which, like touch, keeps a file only if the reply had not kept it already. Renames are off on purpose: a rename is a delete and a create, and each undoes on its own.
The costs are written down in the file’s header. Each snapshot is about one git status of the whole repository, so two per command. Writing a tree adds objects to the repository’s object store, as git stash create does, so a large untracked file dropped into the workspace is copied in once; git’s own housekeeping clears them in time. A command that changed more than 200 files (MAX_COMMAND_CHANGES) is logged and not listed, because undo all over a thousand files would be a guess. Outside a repository, or if git fails, there is no snapshot and the command runs exactly as before. That last rule is the important one: the snapshot never stands in a command’s way.
Writing it down
When the reply ends, record() reads every kept file again and drops the ones that came back to where they started. It first waits for changes still landing, up to five seconds, because a stop does not wait for a tool to finish but the record should. Each remaining file gets a status (created when it did not exist before, deleted when it does not exist now, modified otherwise), added and removed line counts from the same line diff the inline edit uses, and a SHA-256 of what the reply left.
The record goes to one JSON file per reply under the extension’s workspace storage. Only the newest 50 replies are kept; older ones can no longer be undone from the chat.
Looking before undoing
Clicking a file opens VS Code’s diff with the left side on a twinny-before: URI, a read-only document served from the record, and the title before the agent ↔ now. A deleted file opens as it was.
undo first plans: for each file it reads what is there now and compares its hash with the one the reply left. A mismatch means the file changed after the reply, by you or by a later reply. Those files are named in a modal, “src/app.ts changed after this reply. Undo anyway? The later changes to it are lost too.”, and nothing happens unless you choose Undo anyway.
Then everything goes into one WorkspaceEdit: a full replace for a file that still exists, a create for one the reply deleted, a delete for one the reply created. Going through the editor rather than writing to disk is the point. It is one step in VS Code’s own undo stack, so if you undo the agent and change your mind, Ctrl+Z brings the agent’s change back. Files that end up dirty are saved, since the agent’s edits were saved too, and the record marks them as undone instead of deleting itself, so the chat still shows what happened.
What it does not do
Commands are only covered inside a git repository. Files .gitignore excludes never appear, since the snapshot honours it, and the agent may not touch them anyway. Binary files and files over 1 MB are listed but cannot be put back from the chat. And nothing an MCP server does can be undone from the reply, which is why MCP tool calls ask before they run, whatever the auto-run switch says.
The settings and the rest of agent mode are on the agent mode page at docs.twinny.dev.