fix: add DEC 2026 synchronized updates to prevent flickering on GPU terminals

Wrap terminal.draw() and reset_terminal_viewport() with ESC[?2026h/l
so GPU-accelerated terminals (Ghostty, VS Code, Kitty) defer rendering
until the full frame is written, eliminating intermediate-frame flicker.

Merge viewport-reset + draw into a single sync batch to avoid a
visible blank frame between the two operations.

Best-effort — unsupported terminals silently ignore the sequences.

Fixes #1352
This commit is contained in:
xuezhaoyu
2026-05-10 17:20:01 +08:00
committed by Hunter Bown
parent 9b2e747eeb
commit 0bd2832e56
+47 -6
View File
@@ -148,6 +148,16 @@ type AppTerminal = Terminal<ColorCompatBackend<Stdout>>;
// TurnComplete / focus-gain / resize. The alt-screen buffer's double-buffering
// plus ratatui's `terminal.clear()` are sufficient to repaint cleanly.
const TERMINAL_ORIGIN_RESET: &[u8] = b"\x1b[r\x1b[?6l\x1b[H";
/// Begin synchronized update (DEC 2026): tell the terminal to defer
/// rendering until END_SYNC_UPDATE is received. Best-effort —
/// terminals that don't support this silently ignore the sequence.
/// Reduces flicker on GPU-accelerated terminals (Ghostty, VSCode
/// Terminal, Kitty, WezTerm) by batching ratatui's incremental
/// diff writes into a single frame.
const BEGIN_SYNC_UPDATE: &[u8] = b"\x1b[?2026h";
/// End synchronized update (DEC 2026): tell the terminal to render
/// the complete frame now.
const END_SYNC_UPDATE: &[u8] = b"\x1b[?2026l";
/// Run the interactive TUI event loop.
///
@@ -1566,11 +1576,8 @@ async fn run_event_loop(
None
};
if app.needs_redraw && draw_wait.is_none() {
if force_terminal_repaint {
reset_terminal_viewport(terminal)?;
force_terminal_repaint = false;
}
draw_app_frame(terminal, app)?;
draw_app_frame_inner(terminal, app, force_terminal_repaint)?;
force_terminal_repaint = false;
frame_rate_limiter.mark_emitted(Instant::now());
app.needs_redraw = false;
}
@@ -5728,12 +5735,38 @@ fn render(f: &mut Frame, app: &mut App) {
}
}
fn draw_app_frame(terminal: &mut AppTerminal, app: &mut App) -> Result<()> {
/// Draw a complete application frame, optionally with a full viewport reset.
///
/// When `full_repaint` is true, the terminal scroll margins and origin mode
/// are reset, the screen is cleared, ratatui's buffer is emptied, and then
/// the full UI is drawn — all within a single DEC 2026 synchronized-update
/// batch so GPU-accelerated terminals (Ghostty, VS Code, Kitty) render one
/// complete frame instead of a blank intermediate frame followed by the UI.
///
/// When `full_repaint` is false, only the diff from the previous draw is
/// written (normal incremental update path).
fn draw_app_frame_inner(
terminal: &mut AppTerminal,
app: &mut App,
full_repaint: bool,
) -> Result<()> {
terminal.backend_mut().set_palette_mode(app.ui_theme.mode);
let _ = terminal.backend_mut().write_all(BEGIN_SYNC_UPDATE);
if full_repaint {
terminal.backend_mut().write_all(TERMINAL_ORIGIN_RESET)?;
terminal.backend_mut().flush()?;
terminal.clear()?;
}
terminal.draw(|f| render(f, app))?;
let _ = terminal.backend_mut().write_all(END_SYNC_UPDATE);
let _ = terminal.backend_mut().flush();
Ok(())
}
fn draw_app_frame(terminal: &mut AppTerminal, app: &mut App) -> Result<()> {
draw_app_frame_inner(terminal, app, false)
}
/// Pull the latest snapshot of cells / revisions / render options into the
/// live transcript overlay sitting on top of the view stack. No-op if the
/// top view isn't a `LiveTranscriptOverlay`.
@@ -6630,9 +6663,17 @@ fn reset_terminal_viewport(terminal: &mut AppTerminal) -> Result<()> {
// for why; the immediately-following ratatui `terminal.clear()` flushes a
// single clear via the diff renderer, which the alt-screen buffer absorbs
// without visible flicker on the affected terminals.
//
// Wrap the reset+clear sequence in DEC 2026 synchronized-output mode
// (`\x1b[?2026h` … `\x1b[?2026l`) so GPU-accelerated terminals
// (Ghostty, VSCode, Kitty, WezTerm) defer rendering until the whole
// frame is staged. Terminals that don't support it silently ignore.
let _ = terminal.backend_mut().write_all(BEGIN_SYNC_UPDATE);
terminal.backend_mut().write_all(TERMINAL_ORIGIN_RESET)?;
terminal.backend_mut().flush()?;
terminal.clear()?;
let _ = terminal.backend_mut().write_all(END_SYNC_UPDATE);
terminal.backend_mut().flush()?;
Ok(())
}