From 0e8b982cfe143e0a057115b1ec73a9bd5d7bbc86 Mon Sep 17 00:00:00 2001 From: Scott Mohekey Date: Thu, 30 Jul 2026 05:46:45 +1200 Subject: [PATCH] feat: support synchronized output (DEC private mode 2026) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Applications that redraw a full frame (e.g. ratatui-based TUIs such as the Codex CLI) bracket each atomic update in `ESC[?2026h` … `ESC[?2026l` to tell the terminal "don't paint until I'm done". xterm.dart ignored the mode and painted every intermediate write, which shows as flicker during redraws. Honour mode 2026: suspend `notifyListeners()` between begin and end, then flush a single repaint on end so the completed frame appears atomically. A 150ms safety timeout force-ends the update if the matching `?2026l` is lost (e.g. across a reconnect) so the display can never stay frozen. --- lib/src/terminal.dart | 30 ++++++++++++++++-- test/src/synchronized_output_test.dart | 43 ++++++++++++++++++++++++++ 2 files changed, 71 insertions(+), 2 deletions(-) create mode 100644 test/src/synchronized_output_test.dart diff --git a/lib/src/terminal.dart b/lib/src/terminal.dart index 461e2084..73fd5fd7 100644 --- a/lib/src/terminal.dart +++ b/lib/src/terminal.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:math' show max; import 'package:xterm/src/base/observable.dart'; @@ -224,9 +225,19 @@ class Terminal with Observable implements TerminalState, EscapeHandler { /// Writes the data from the underlying program to the terminal. Calling this /// updates the states of the terminal and emits events such as [onBell] or /// [onTitleChange] when the escape sequences in [data] request it. + /// True while an application holds a synchronized update (DEC private mode 2026): repaints are + /// suspended so its partially-drawn frames never reach the screen, then flushed once on end. + bool _synchronizedUpdate = false; + + /// Safety valve for [_synchronizedUpdate]: if the matching `?2026l` never arrives (e.g. it was lost + /// across a reconnect), force-end the update so the display can't stay frozen. + Timer? _synchronizedUpdateTimeout; + void write(String data) { _parser.write(data); - notifyListeners(); + // Codex (and other ratatui apps) brackets every atomic redraw in `?2026h` … `?2026l`; painting the + // intermediate states between them is what's seen as flicker. Hold the repaint until the update ends. + if (!_synchronizedUpdate) notifyListeners(); } /// Sends a key event to the underlying program. @@ -752,7 +763,22 @@ class Terminal with Observable implements TerminalState, EscapeHandler { @override void setUnknownDecMode(int mode, bool enabled) { - // no-op + // DEC 2026 — synchronized output. Suspend repaints between begin (`?2026h`) and end (`?2026l`), then + // flush a single repaint on end so the completed frame appears atomically instead of mid-draw. + if (mode == 2026) { + _synchronizedUpdate = enabled; + _synchronizedUpdateTimeout?.cancel(); + if (enabled) { + _synchronizedUpdateTimeout = Timer(const Duration(milliseconds: 150), () { + if (_synchronizedUpdate) { + _synchronizedUpdate = false; + notifyListeners(); + } + }); + } else { + notifyListeners(); + } + } } /* Select Graphic Rendition (SGR) */ diff --git a/test/src/synchronized_output_test.dart b/test/src/synchronized_output_test.dart new file mode 100644 index 00000000..1ea70805 --- /dev/null +++ b/test/src/synchronized_output_test.dart @@ -0,0 +1,43 @@ +import 'package:test/test.dart'; +import 'package:xterm/core.dart'; + +void main() { + group('synchronized output (DEC private mode 2026)', () { + test('repaints are held between begin (?2026h) and end (?2026l)', () { + final terminal = Terminal(); + terminal.resize(20, 5); + + var notifications = 0; + terminal.addListener(() => notifications++); + + // Begin a synchronized update. + terminal.write('\x1b[?2026h'); + final afterBegin = notifications; + + // Content drawn while the update is held must not trigger a repaint. + terminal.write('hello'); + terminal.write('\x1b[10;5Hworld'); + expect(notifications, afterBegin, + reason: 'no repaint while a synchronized update is held'); + + // Ending the update flushes exactly one repaint of the completed frame. + terminal.write('\x1b[?2026l'); + expect(notifications, greaterThan(afterBegin), + reason: 'a repaint is emitted when the update ends'); + + // The content written during the hold is present once painting resumes. + expect(terminal.buffer.lines[0].toString(), startsWith('hello')); + }); + + test('normal writes still repaint when no update is held', () { + final terminal = Terminal(); + terminal.resize(20, 5); + + var notifications = 0; + terminal.addListener(() => notifications++); + + terminal.write('hi'); + expect(notifications, greaterThan(0)); + }); + }); +}