twinny / blog

← all posts

The diff lives in the file: how an inline edit is shown, streamed and settled

Ctrl+I does not open a preview. The old lines and the new lines are both written into your document, tinted, and one of the two sets is deleted when you decide. What that costs, what it buys, and the diff and bookkeeping that make it hold still while the model is still typing.

Inline edit arrived in 4.0.7 to 4.0.9, on 7 and 8 September. You select some lines, press Ctrl+I (Cmd+I on macOS), type an instruction, and the rewrite streams in as red and green lines where the selection was. The docs page covers how to use it. This post is about the decision underneath, which is a little unusual: there is no preview pane and no virtual document. The removed lines and the added lines are both really in the file, and the diff is a set of decorations over them. Accepting deletes the red lines; rejecting deletes the green ones.

The code is in src/extension/edit/: prompt.ts and diff.ts are pure functions with no VS Code in them, region.ts is the diff as it lives in a document, and service.ts asks the model and settles the verdict.

What the model is asked, and what it is believed

The request is two messages. The system prompt tells the model it is editing code inside an editor and must reply with the complete replacement, no diff, no fragment, no fences, no commentary, and keeping everything the instruction did not mention. The user turn carries the file name, up to 40 whole lines above and below the selection (capped at 6,000 characters each side, EDIT_CONTEXT_LINES and EDIT_CONTEXT_CHARS in prompt.ts), the code to edit, and the instruction. The surrounding lines are marked “for context only, do not include it in your reply”. No temperature is sent; the provider’s default applies.

Models being models, the reply is not taken at its word. extractEditedCode drops <think>…</think> blocks, including one that has not closed yet because the stream is still inside it, so the reasoning never lands in your file. Anything before an opening fence is treated as chatter and dropped; an opening fence with no closing fence yet yields whatever came after it, so the unwrapping works on a half-arrived reply; a reply with no fence at all is taken verbatim.

Then indentation. A model given a nested block routinely returns it flush left, or adds a level. matchIndentation computes the leading whitespace shared by every non-blank line of the original and of the reply, and if they differ, swaps one for the other on every line. Finally the trailing newline of the original is put back, and a reply that is blank after all this is replaced with the original, which the service reports as no changes suggested.

A diff that holds still while it streams

diff.ts is a line diff. It trims the common prefix and suffix, then fills an LCS table over what is left, a Uint32Array of (a+1)·(b+1) cells, and walks it so that within a hunk removals come before additions. Past 4,000,000 cells the table is skipped and the hunk is shown as remove-everything, add-everything.

The interesting part is layoutDiff with streaming set. The last line of a partial reply is unfinished, or empty right after a newline, and diffing it would make it match some line of the original one chunk and a different line the next. So the layout pops it off, diffs only the complete lines, and appends the partial line as an added line at the bottom, below whatever original lines the stream has not reached yet. From one chunk to the next the picture grows downward instead of rearranging itself.

Within a hunk, each removed line is paired with the added line it most resembles, most alike first, and the changed words in the pair get a harder tint. The likeness test is a token diff of the two lines; if the tokens they share come to less than half the shorter line, there is no pairing and the whole line stays a flat colour. The comment in the source puts it as “a solid tint reads better than confetti”. A streaming partial line is never paired, because it is not yet where it will end up.

Living in the document

DiffRegion in region.ts owns the stretch of the document the diff occupies: its range, the absolute line numbers of the removed and added lines, and the word spans. Three details do most of the work.

Renders are queued. Every chunk from the model produces a new layout, and each one is applied by replacing the region’s text through editor.edit; a promise chain serialises them so a fast stream cannot interleave two replacements. If the layout’s text already equals what the region contains, nothing is written.

The whole thing is one undo step. The first render opens an undo stop (undoStopBefore: true) and nothing after it does; the final deletion at accept or reject is the only edit that closes one (undoStopAfter: true on the last hunk). So Ctrl+Z after accepting an edit takes you back to before you pressed Ctrl+I, not back one token.

Other people’s edits are followed. The service subscribes to onDidChangeTextDocument; a change the region made itself is ignored (a flag is set around its own edits), and any other change is passed to track. A change above the region shifts every tracked line by the number of lines it added or removed. A change that cuts through a tracked line, so that line no longer exists as a line, marks the region broken: the decorations are cleared and the diff is forgotten; Undo reverts it. Typing on a highlighted line keeps the proposal and only drops that line’s word highlights; joining or deleting a tracked line is what breaks it. So you can keep working elsewhere in the file while a proposal waits.

Hunks are not stored. They are computed from the line numbers each time, as runs of neighbouring diff lines, which is what the CodeLens uses to offer Accept and Reject per hunk once there is more than one, with Accept all and Reject all above.

Pressing Ctrl+I while a diff is pending sends the model the proposed side of the region, the document text minus the red lines, with your new instruction, and the result is diffed against the baseline, the text minus the green lines. The diff keeps showing against what was there before you started.

The cost of the design is the one the docs state plainly: while a change is pending, both versions are in the buffer, so saving writes both. Accept or reject first.

The same region, from the chat

The apply button on a chat code block goes through the same DiffRegion. With a selection, the block replaces it. Without one, locateSnippet runs the line diff in early mode, which drops the suffix trim and prefers the earliest match for a line that could match in several places, and takes the stretch between the first and last file lines the snippet repeats, ignoring lines like // ... that models write to stand for code they left out. If fewer than half the snippet’s lines are found in the file, it is inserted at the cursor instead. And in agent mode, twinny.chatToolsEdits set to review turns each edit_file into one of these regions rather than a saved change.

Everything above is in src/extension/edit/ and can be read in an afternoon; the usage side, keys included, is at docs.twinny.dev/features/inline-edit.

#inline-edit#internals