Synchronized Output (DEC 2026)
DEC 2026 is a two-sequence protocol that asks the terminal to buffer
every byte between ESC [ ? 2026 h (begin) and ESC [ ? 2026 l
(end), then paint the contents atomically. Users see either the
full new frame or the full previous frame — never a partial frame in
flight. Every intermediate state is hidden.
FrankenTUI emits these brackets around each Presenter::present
call when TerminalCapabilities::use_sync_output() is true. On
terminals where the brackets are not safe — every multiplexer, every
terminal that doesn’t implement 2026 — the presenter falls back to
hiding the cursor for the duration of the emission, which hides the
jump-around tell-tale but not the cell churn.
This page documents the wire format, the capability gate, the fallback strategy, and two short proofs that tie the invariants to the flicker-free property the user perceives.
Motivation
A 200 × 60 terminal frame at 60 Hz is ~72 KB/s of output even in the typical case (diff-only, most cells unchanged). Without synchronization, the terminal receives bytes in chunks — a scrolled region, then a border redraw, then some text updates — and paints each chunk as it arrives. On a fast terminal this is invisible; on a slower path (tmux forwarding over SSH, an IME interpolating), intermediate paints are long enough that the human eye sees them as a tear or flash.
DEC 2026 moves the paint boundary to the end of the bracketed block: no matter how many bytes the terminal received, it shows them all at once. The guarantee is visual atomicity, not protocol atomicity — the bytes still flow through the TCP stack or the tmux socket one at a time.
Wire format
ESC [ ? 2026 h ← begin sync mode
... frame bytes (SGR, cursor, cells, OSC 8 links, ...) ...
ESC [ ? 2026 l ← end sync mode, paintIn hex: 1B 5B 3F 32 30 32 36 68 → begin, 1B 5B 3F 32 30 32 36 6C → end. Eight bytes of overhead per frame.
Terminals that do not recognize ?2026 treat the private-mode set as
a no-op (by convention — private modes >1000 are not guaranteed to
be ignored, but all major terminals follow this convention). The
fallback is “paint as it arrives”, i.e. the pre-2026 behavior.
Capability gate
TerminalCapabilities::use_sync_output() is the policy predicate
the presenter consults
(crates/ftui-core/src/terminal_capabilities.rs and the logic in
crates/ftui-core/src/inline_mode.rs):
use_sync_output() = sync_output && !in_any_mux()
= sync_output
&& !in_tmux && !in_screen && !in_zellij && !in_wezterm_muxThe environment detector (capabilities.rs:L975) sets
sync_output = true when the terminal is known to support it
(iTerm, Kitty, WezTerm-non-mux, Ghostty, Alacritty ≥ 0.13, modern
xterm, …) and unconditionally forces it false in any multiplexer.
Why muxes are disqualified
tmux, screen, zellij, and wezterm-mux forward escape sequences from the inner terminal to the outer one. On paper this should work — DEC 2026 is a pass-through sequence. In practice:
- Sequence splitting. Muxes may fragment sequences across
multiple forwards; the outer terminal can see a
beginwithout a matchingend, leaving it in sync mode indefinitely. - Pane boundaries. tmux’s redraw across pane boundaries emits its own escapes; when those land inside a sync bracket the outer terminal gets confused state.
- Version drift. Older tmux (< 3.3) does not preserve
?2026h/lat all, collapsing to 2026-off even when the outer terminal supports it.
The policy is conservative: if the user is inside any mux, the
presenter never emits sync brackets, and falls back to cursor-hide.
See the comment block at
crates/ftui-core/src/terminal_capabilities.rs:L68-L100.
Presenter integration
if bracket_supported {
ansi::sync_begin(&mut self.writer)?; // ESC [ ? 2026 h
} else {
ansi::cursor_hide(&mut self.writer)?; // ESC [ ? 25 l
}
self.emit_diff_runs(buffer, pool, links)?;
if bracket_supported {
ansi::sync_end(&mut self.writer)?; // ESC [ ? 2026 l
} else {
ansi::cursor_show(&mut self.writer)?; // ESC [ ? 25 h
}
self.writer.flush()?;On error mid-emission, the presenter always attempts the closing bracket (or cursor-show) even if the diff emission failed. Leaving the terminal in sync-mode with no close would freeze subsequent output from any process writing to the same TTY; the safety path prioritizes closure.
Fallback: cursor-hide
When brackets are unsafe, the presenter wraps the frame in:
ESC [ ? 25 l ← hide cursor
... frame bytes ...
ESC [ ? 25 h ← show cursorThis does not achieve visual atomicity — cells still appear as the terminal receives them — but it hides the cursor jumping between change runs, which is the most obvious flicker signature. Many users in tmux describe the result as “smooth enough” in practice; it is the honest best effort without DEC 2026.
Flicker-free proof sketches
Theorem 1 — atomicity under DEC 2026
Let be the set of bytes emitted by Presenter::present between
and . Let be the
terminal state at time . Then for any intermediate time , the user observes ,
not any for .
Sketch. DEC 2026 specifies that the terminal buffers all bytes
during sync mode without committing to the display. The rasterizer
consumes the buffered bytes and produces the post-end state only
once sync_end arrives. No prefix of causes a paint. Therefore
the user observes only (the pre-frame state) or
(the complete post-frame state).
Corollary: any two consecutive frames under DEC 2026 produce the user-visible sequence with no intermediate states between and .
Theorem 2 — single-writer preserves atomicity
DEC 2026 brackets apply to the output stream as a whole, not to individual writers. If two writers interleave bytes inside a bracket, the terminal still paints atomically — but the paint may include garbage from the second writer’s interleaving.
Sketch. The terminal buffers bytes from all sources between
begin and end. There is no per-writer isolation. The rasterizer
interprets the concatenated byte stream; if Writer A emitted
ESC[31m (red) and Writer B interleaved hello, the terminal
paints red-hello. The atomicity holds, but the content is
corrupted.
Conclusion. DEC 2026’s atomicity guarantee composes correctly with — and requires — the one-writer rule. Under single-writer, the frame is coherent; without it, atomic does not imply correct.
Worked example
use ftui_core::terminal_capabilities::TerminalCapabilities;
use ftui_render::buffer::Buffer;
use ftui_render::cell::Cell;
use ftui_render::diff::BufferDiff;
use ftui_render::presenter::Presenter;
let mut old = Buffer::new(80, 24);
let mut new = Buffer::new(80, 24);
// Simulate a frame with lots of changes (stress-test sync).
for y in 0..24 {
for x in 0..80 {
new.set(x, y, Cell::from_char(if (x + y) % 2 == 0 { 'X' } else { '.' }));
}
}
// Force a modern-terminal profile so sync_output is on.
let caps = TerminalCapabilities::kitty();
assert!(caps.use_sync_output());
let mut out = Vec::new();
let mut p = Presenter::new(&mut out, caps);
let diff = BufferDiff::compute(&old, &new);
p.present(&new, &diff)?;
// The output begins with DEC 2026 begin and ends with DEC 2026 end.
assert!(out.starts_with(b"\x1b[?2026h"));
assert!(out.windows(8).any(|w| w == b"\x1b[?2026l"));Diagnostics
The presenter records PresentStats { bytes_emitted, runs, cells_changed, sync_used } per frame. When sync is active but
frames are still visibly tearing, check:
- Is the session actually in a mux?
caps.in_any_mux()overridessync_outputtofalse. Some CI environments set$TMUXwithout a real mux — honor the override or clear the env var. - Is a second writer sharing stdout? A parallel log line inside the bracket corrupts the frame. See one-writer-rule.
- Is the frame too big for the terminal’s buffer? Some terminals (older VTE ≤ 0.60) drop bytes on very large sync blocks. Splitting into multiple presents is a last resort; the usual fix is to reduce diff churn via dirty tracking.
Never emit DEC 2026 manually from a widget. Concurrent
sync_begin calls corrupt the terminal parser state; a widget
that emits its own bracket inside the presenter’s frame creates
nested-sync undefined behavior. If a widget needs atomic update
semantics, render into a staging buffer and let the presenter wrap
the frame as a whole.
Cross-references
- Presenter — where the brackets are emitted.
- Capabilities — the flag that gates sync.
- Screen modes — inline mode passes sync
support down to
InlineRenderer. - Cell & Buffer — the data being synchronized.
- Bayesian diff strategy — the strategy selector that decides frame-by-frame whether to rely on sync + full diff or to bypass.
- One-writer rule — the ownership contract sync brackets depend on.