Support synchronized output (DEC private mode 2026) - #35
Conversation
A full-screen application redraws in many writes, and a renderer that paints between them shows a frame half old and half new -- the tearing you see when a TUI repaints under load. Mode 2026 is how an application says it is mid-frame and the display should hold still. The emulator's job is to report the state and nothing more: SynchronizedOutput plus a SynchronizedOutputChanged event, raised only on real transitions. Applications wrap every frame, so the mode is set over and over, and an event per frame saying nothing changed is noise a renderer would have to filter itself. Holding the frame is the renderer's decision, and so is the timeout that must bound it -- without one, an application that sets the mode and then crashes would freeze the display for good. DECRQM comes with it, because without it the feature goes largely unused: a well-behaved application asks whether the terminal understands 2026 before relying on it, and silence means no. It answers for 2026 alone. The reply distinguishes set and reset from not-recognised, and this terminal keeps mode state as individual properties rather than a registry -- so answering for everything means a switch mapping every mode back to its property, where one wrong entry talks an application out of a feature that actually works. Staying silent for the rest is exactly the behaviour before this change, so nothing regresses while the one mode that needs an answer gets a correct one. 856 tests pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
🟡 Changes recommended
The new SynchronizedOutputChanged event is declared as EventHandler<bool>, which cannot compile because EventHandler<T> requires T : EventArgs.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
Adds support for DEC private mode 2026 (synchronized output) so host renderers can avoid mid-frame tearing by reacting to “atomic update begins/ends” signals, and implements the DECRQM query path so applications can detect support.
Changes:
- Introduces
Terminal.SynchronizedOutputplus a transition-only change notification event. - Implements DECRQM handling (CSI
? Ps $ p) specifically for mode 2026, returning DECRPM state replies. - Adds a dedicated test suite covering mode edges, repeated sets, normal content behavior, and DECRQM replies/silence.
File summaries
| File | Description |
|---|---|
| src/XTerm.NET/Terminal.cs | Adds synchronized output state + change event plumbing. |
| src/XTerm.NET/InputHandler.cs | Parses/handles DECRQM and toggles mode 2026 via DECSET/DECRST. |
| src/XTerm.NET/Common/TerminalMode.cs | Defines TerminalMode.SynchronizedOutput = 2026. |
| src/XTerm.NET/Common/CsiCommand.cs | Adds CsiCommand.RequestMode (DECRQM). |
| src/XTerm.NET/Common/CommandExtensions.cs | Maps "$p" CSI identifier to RequestMode. |
| src/XTerm.NET.Tests/SynchronizedOutputTests.cs | Adds tests for mode 2026 behavior and DECRQM replies. |
Review details
- Files reviewed: 6/6 changed files
- Comments generated: 2
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Both from review. Terminal.Reset() cleared every other mode in its block and not this one, so after RIS the terminal still reported an atomic update that nobody began. Three symptoms from that one cause, and the third is the one that would bite in the field: the flag goes stale; the flag is also the event's dedupe key, so once stuck true the next application's begin is swallowed as "no change" and its end raises a lone false -- the transitions-only contract inverted and staying inverted; DECRQM answers "set" to a client probing before it draws, telling it a frame is already open when none is. It also lands on precisely the path this feature's timeout exists for: an application sets the mode and dies, and the usual recovery is reset -- which was the one thing that could not clear it. Cleared through RaiseSynchronizedOutputChanged rather than by assignment, so a renderer holding a frame is told it can stop and there stays one writer of the flag. Four tests, each of which fails without the fix. The event now carries SynchronizedOutputEventArgs rather than a bare bool. The reason given is a good one and outlives this feature: adding a property to an EventArgs is a minor version, while changing a bool into one is a major -- so anything this needs to carry later can arrive without breaking every subscriber. Worth noting for the record that the compile error reported alongside it is not one: EventHandler<TEventArgs> dropped its EventArgs constraint in .NET 4.5, so EventHandler<bool> built and ran fine. The change is right for the versioning reason, not that one. 860 tests pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
🟡 Changes recommended
The XML documentation in TerminalEvents.cs currently contains duplicated/misattached <summary> tags that will generate incorrect/invalid API docs and should be corrected before merging.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
- Files reviewed: 7/7 changed files
- Comments generated: 1
- Review effort level: Lite
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
A full-screen application redraws in many writes, and painting between them shows a frame half old and half new -- the tearing you see when a TUI repaints under load. Mode 2026 is how an application says it is mid-frame. Every paint request in the view now goes through one gate, which declines while an update is in progress; the end of the update asks for exactly one frame. Gating in one place rather than at the call sites is what keeps a future paint path from quietly missing it -- there are twenty-six of them today. The 150 ms timeout is not optional. An application that begins an update and then crashes, or stops at a breakpoint, would otherwise freeze the display for as long as it stays that way. That is the single failure mode of this feature and it is worse than the tearing it prevents: a tear is a bad frame, while a frozen terminal looks like the application hung. Depends on the emulator reporting the state, which is tomlm/XTerm.NET#35. Until that ships in a package this does not compile -- the only two errors are the two references to SynchronizedOutputChanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A full-screen application redraws in many writes, and painting between them shows a frame half old and half new -- the tearing you see when a TUI repaints under load. Mode 2026 is how an application says it is mid-frame. Every paint request in the view now goes through one gate, which declines while an update is in progress; the end of the update asks for exactly one frame. Gating in one place rather than at the call sites is what keeps a future paint path from quietly missing it -- there are twenty-six of them today. The 150 ms timeout is not optional. An application that begins an update and then crashes, or stops at a breakpoint, would otherwise freeze the display for as long as it stays that way. That is the single failure mode of this feature and it is worse than the tearing it prevents: a tear is a bad frame, while a frozen terminal looks like the application hung. Depends on the emulator reporting the state, which is tomlm/XTerm.NET#35. Until that ships in a package this does not compile -- the only two errors are the two references to SynchronizedOutputChanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* Hold the frame while an application is mid-update (DEC 2026) A full-screen application redraws in many writes, and painting between them shows a frame half old and half new -- the tearing you see when a TUI repaints under load. Mode 2026 is how an application says it is mid-frame. Every paint request in the view now goes through one gate, which declines while an update is in progress; the end of the update asks for exactly one frame. Gating in one place rather than at the call sites is what keeps a future paint path from quietly missing it -- there are twenty-six of them today. The 150 ms timeout is not optional. An application that begins an update and then crashes, or stops at a breakpoint, would otherwise freeze the display for as long as it stays that way. That is the single failure mode of this feature and it is worse than the tearing it prevents: a tear is a bad frame, while a frozen terminal looks like the application hung. Depends on the emulator reporting the state, which is tomlm/XTerm.NET#35. Until that ships in a package this does not compile -- the only two errors are the two references to SynchronizedOutputChanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Add the tearing demo so this can be checked by hand Two runs of the same program one escape sequence apart. Without mode 2026 a frame assembles in visible bands; with it each frame appears whole. It also exercises the part with no unit test. Interrupt a synchronized run partway through a frame and the display catches up rather than staying frozen -- that is the 150 ms timeout, and whether a frame was ever shown half-drawn is a question about when paints happened rather than about what the buffer holds, which no assertion on the buffer can answer. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Give SynchronizedOutputChanged the same lifecycle as every other terminal event Tom's review, confirmed: the event was subscribed in OnInitialized AND in the attach block, and unsubscribed nowhere -- so the handler ran twice from the first attach, and _terminal held a reference to every detached view's handler forever. Harmless-looking, because duplicate delivery happens to be idempotent here, which is exactly why it would only ever have surfaced under a profiler. Rather than only adding the two removals, the OnInitialized subscription is gone: the event now lives in the same three places as every other event on _terminal -- the attach block's defensive removal, its subscription, and the detach removal. One event with a private lifecycle is how this happened. Detach also clears the update state. A view detached mid-update kept the gate closed and its timer running; the timeout self-heals in 150 ms, but the timer holds the view for that window, and a view re-attached inside it would start out refusing to paint for no reason. 322 tests pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Full-screen applications redraw in many writes. A renderer that paints between them shows a frame half old and half new — the tearing you see when a TUI repaints under load, or over a slow link. DEC private mode 2026 is how an application says it is mid-frame and the display should hold still.
This matters most for exactly the applications this library exists to host: anything that repaints a whole screen per frame gets to look atomic instead of assembling in bands.
What the emulator does
Reports the state, and nothing more:
Terminal.SynchronizedOutput— is an atomic update in progressTerminal.SynchronizedOutputChanged— raised on transitions onlyThe event fires only on real edges because applications wrap every frame, so the mode gets set over and over. An event per frame saying nothing changed is noise a renderer would have to filter itself.
Holding the frame is the renderer's decision, and so is the timeout that must bound it — without one, an application that begins an update and then crashes, or stops at a breakpoint, would freeze the display for as long as it stays that way. A tear is a bad frame; a permanently frozen terminal looks like the application hung. I have the consumer side working in Iciclecreek and can open it once this ships in a package.
DECRQM comes with it
Without it the feature goes largely unused. A well-behaved application asks whether the terminal understands 2026 before relying on it, and silence means no — so implementing the mode without answering the query leaves it switched off for the applications most likely to want it.
It answers for 2026 alone, deliberately. The reply distinguishes "set" and "reset" from "not recognised", and this terminal keeps mode state as individual properties rather than a registry — so answering for everything means a switch mapping every mode back to its property, where one wrong entry tells an application a feature is missing when it is not. Staying silent for every other mode is exactly the behaviour before this change, so nothing regresses while the one mode that needs an answer gets a correct one.
Happy to widen it if you'd rather, it's just more surface to get right in one go.
Tests
Seven, covering both halves: the mode's edges, that repeating it raises nothing, that content written inside an update lands normally, and the DECRQM replies for set, reset, and the modes it stays quiet about.
Full suite: 856 passing. Independent of #32 and #34 — different files entirely.