Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 28 additions & 2 deletions lib/src/terminal.dart
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:math' show max;

import 'package:xterm/src/base/observable.dart';
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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) */
Expand Down
43 changes: 43 additions & 0 deletions test/src/synchronized_output_test.dart
Original file line number Diff line number Diff line change
@@ -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));
});
});
}