What @terminal actually sends: five runs, the tail, and a hunt for file:line
twinny does not read your scrollback. It listens to VS Code's shell integration, keeps the last five commands per terminal, sends the model the end of the output, and, when a failure names a file in the workspace, can turn it into an inline edit at that line. The constants, the regexes and the decisions in src/extension/terminal.
The terminal features arrived in 4.0.10 to 4.0.13, on 10 and 11 September: @terminal in chat, Fix the last terminal error, and Write a terminal command from a description. The docs page says how to use them. This post is about what is captured, what is cut, and how a line of compiler output becomes an edit in the editor.
The code is src/extension/terminal/: history.ts listens to VS Code, output.ts is pure functions with no VS Code in them (and so the one with a test file, src/test/suite/terminal-output.test.ts), command.ts writes commands and fix.ts fixes errors.
What is remembered
twinny never reads the terminal buffer. It subscribes to onDidStartTerminalShellExecution and onDidEndTerminalShellExecution, the shell integration events; VS Code 1.93 is the extension’s minimum for that reason. A shell without integration fires neither event, and twinny then says it has not seen a command finish rather than guessing from the screen.
When a command starts, its output stream is read chunk by chunk into a buffer. The buffer is capped at 200,000 characters and trimmed from the front, because the failure is at the end. When the command ends, the run is stored: the command line as the shell reported it, the output with ANSI stripped, the exit code if the shell gave one, the working directory, the terminal’s name and a timestamp. Five runs are kept per terminal; the sixth pushes out the first. Closing a terminal forgets its runs. Nothing is written to disk.
The ANSI stripper is one regex: CSI sequences (colours, cursor moves), OSC sequences (the ones shell integration itself uses), charset selects, and bare carriage returns, which is what a progress bar is made of.
Which run is “the last one” depends on who asks. @terminal takes the most recent command in the active terminal, or across all terminals ordered by finish time if the active one has none. It is the last command, not the last failed one, so @terminal after a passing test run attaches the passing output. Fix the last terminal error asks for the last failed run first, falling back to the last run. Failure is the exit code when the shell reported one; when it did not, it is a word test over the output for error, exception, traceback, failed, fatal or panic.
What the model gets is formatTerminalRun: a line saying the command failed with exit code N (or exit code 0, or nothing when unknown) and where it ran, the command in a fence, and the output in a fence. The output is tailOutput: the last 200 lines, and if those are still over 12,000 characters, the oldest quarter of what is kept is dropped repeatedly until they fit, with a [N earlier lines not shown] line on top. Cutting at line boundaries matters because the next step is reading those lines for file names.
Finding the file
extractFileLocations runs three patterns over the stripped output. The first is path:line or path:line:col, optionally inside parentheses so stack-trace frames like at fn (src/a.ts:12:5) match; the path must have an extension of one to ten characters, which keeps 12:30:00 timestamps out. The second is path(line,col), the form tsc prints. The third is Python’s File "path", line N.
Then the filter. Anything starting http:, file:, node: or internal/ is dropped. So is any path through node_modules, site-packages, .venv, venv, dist, out or build, on the reasoning in the source comment that “the fix is never there”. Angle-bracket pseudo-paths like <anonymous> go too. What is left is deduplicated on path:line, in order of appearance, so the first location is the first thing the tool complained about.
Those are strings, not files yet. resolveLocations in fix.ts tries each relative path against the run’s working directory and then every workspace folder, and keeps it only if a file exists there and sits inside a workspace folder. A frame outside the workspace, say a standard-library file by absolute path, exists and is thrown out at the last check. If nothing survives, the output goes to the chat with no files attached.
If something does survive you get a two-item picker, with the error’s summary as the placeholder:
Fix in editor opens the first location and asks the language server for the symbols of that file. symbolAtLine keeps every symbol whose range contains the line and takes the smallest, so inside a method inside a class you get the method; the docs say function, the code says innermost symbol of any kind. If it spans under 120 lines it becomes the selection, otherwise the single line does. Then the inline edit command runs with the instruction Fix this error reported by \
. The summary is summariseError: the last three lines that mention an error-ish word (error, exception, failed, fatal, panic, cannot, undefined, not found`), or the last three lines if none do, cut at 400 characters. The rest of the output never reaches this prompt. From here on it is an ordinary inline edit.
Ask in chat attaches up to three of the resolved files, each as its enclosing symbol if under 200 lines, otherwise 20 lines either side. The transcript shows the command and 3,000 characters or 40 lines of output; the model gets the full 12,000-character tail and is asked to explain what went wrong and show the fix, as code or as a command.
Writing a command
Write a terminal command from a description is one user message with no system message, sent through generateSimpleCompletion, the one-shot path also used for conversation titles and commit messages. The prompt names the shell (from $SHELL, or PowerShell on Windows), the platform and release, the workspace folder, and the rules: one line, no explanation, no fences; pipes or && rather than a script; do not invent flags; never delete or overwrite data unless asked for exactly that. If a previous command is remembered, it is appended with up to 30 lines or 1,500 characters of its output, which is what makes “again but sorted by size” work.
The reply is not trusted to be one line. extractShellCommand takes the first fenced block if there is one, otherwise the whole text, drops blank lines, comments and lines that look like a label (Here is the command:), and keeps the first survivor with any leading $ and wrapping backticks removed. Nothing runs yet: the command appears in an input box where you read and edit it; Escape runs nothing, Enter sends it to the Twinny terminal, found by name or created, with a newline.
The terminal button on a shell code block in chat is the quieter cousin: it pastes the block into the same terminal with prompt markers stripped and without the newline, so it sits there until you press Enter. Commands run by the model in agent mode use neither path; that is the 4.3 work and has its own post.
One limit worth knowing: the model sees exactly what the terminal printed, so a token echoed by a script is in the prompt. The secret shield replaces the formats it recognises before a prompt leaves the machine, but not for a local server by default.
The user-facing version, with the setting to check when capture reports nothing, is at docs.twinny.dev/features/terminal.