Skip to Content
ftui-renderSynchronized output

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, paint

In hex: 1B 5B 3F 32 30 32 36 68begin, 1B 5B 3F 32 30 32 36 6Cend. 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_mux

The 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 begin without a matching end, 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/l at 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

crates/ftui-render/src/presenter.rs
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 cursor

This 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 FF be the set of bytes emitted by Presenter::present between sync_begin\text{sync\_begin} and sync_end\text{sync\_end}. Let sts_t be the terminal state at time tt. Then for any intermediate time t(begin,end)t \in (\text{begin}, \text{end}), the user observes sbegins_{\text{begin}}, not any sts_{t'} for t(begin,t]t' \in (\text{begin}, t].

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 FF causes a paint. Therefore the user observes only sbegins_{\text{begin}} (the pre-frame state) or sends_{\text{end}} (the complete post-frame state). \square

Corollary: any two consecutive frames under DEC 2026 produce the user-visible sequence s0,s1,s2,s_0, s_1, s_2, \ldots with no intermediate states between sis_i and si+1s_{i+1}.

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

examples/sync.rs
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:

  1. Is the session actually in a mux? caps.in_any_mux() overrides sync_output to false. Some CI environments set $TMUX without a real mux — honor the override or clear the env var.
  2. Is a second writer sharing stdout? A parallel log line inside the bracket corrupts the frame. See one-writer-rule.
  3. 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

Where next