fix(tui): drop destructive 2J/3J from viewport reset to fix flicker

The forced-repaint sequence written before each TurnComplete /
focus-gain / resize used to be:

    \x1b[r\x1b[?6l\x1b[H\x1b[2J\x1b[3J

which combined with the immediately-following ratatui
`terminal.clear()` produced a double-clear. Terminals that don't
optimize successive clears against the alt-screen buffer (Ghostty,
VSCode integrated terminal, Win10 conhost) rendered the second
clear as a visible blank-then-repaint flicker on every redraw
trigger.

The lighter sequence `\x1b[r\x1b[?6l\x1b[H` resets DECSTBM and DECOM
and homes the cursor (still solving the original viewport-drift fix
that 0.8.22 added) but leaves the pixel-clear to ratatui's diff
renderer. The alt-screen buffer's double-buffering absorbs that
single clear without flicker on every terminal we tested. Terminals
that were already flicker-free (macOS Terminal.app, iTerm2,
alacritty) remain so.

Closes #1119, #1260, #1295, #1352, #1356, #1363, #1366.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hunter Bown
2026-05-10 09:26:16 -05:00
parent fe0673d683
commit abf3fa66f6
3 changed files with 41 additions and 9 deletions
+10
View File
@@ -67,6 +67,16 @@ internal fix. Big thanks to every contributor below.
### Fixed
- **Cross-terminal flicker on TurnComplete / focus / resize** (#1119,
#1260, #1295, #1352, #1356, #1363, #1366) — the viewport-reset
sequence emitted before each forced repaint no longer includes
`\x1b[2J\x1b[3J`. Combined with the immediately-following ratatui
`terminal.clear()`, the destructive pair produced a double-clear that
Ghostty, the VSCode integrated terminal, and Win10 conhost rendered
as a visible blank-then-repaint flicker. The lighter sequence
(`\x1b[r\x1b[?6l\x1b[H`) plus the alt-screen buffer's double-buffering
handles viewport correctness without flicker. macOS Terminal.app /
iTerm2 / alacritty users were already unaffected and remain so.
- **HTTP 400 quota errors retried** (#1203) — some OpenAI-compatible
gateways return quota/rate-limit errors as HTTP 400 instead of 429.
These are now classified as retryable `RateLimited` errors.
+14 -4
View File
@@ -139,7 +139,15 @@ const SIDEBAR_VISIBLE_MIN_WIDTH: u16 = 100;
const DEFAULT_TERMINAL_PROBE_TIMEOUT_MS: u64 = 500;
type AppTerminal = Terminal<ColorCompatBackend<Stdout>>;
const TERMINAL_ORIGIN_RESET: &[u8] = b"\x1b[r\x1b[?6l\x1b[H\x1b[2J\x1b[3J";
// Reset scroll region (`\x1b[r`), origin mode (`\x1b[?6l`), and home the cursor
// (`\x1b[H`) before letting ratatui's diff renderer repaint. The destructive
// `\x1b[2J\x1b[3J` pair was previously appended here to also wipe the visible
// screen and saved scrollback, but combined with the immediately-following
// `terminal.clear()` it produced a double-clear that several terminals
// (Ghostty, VSCode terminal, Win10 conhost) render as visible flicker on every
// 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";
/// Run the interactive TUI event loop.
///
@@ -6564,9 +6572,11 @@ fn reset_terminal_viewport(terminal: &mut AppTerminal) -> Result<()> {
// Reset scroll margins and origin mode before clearing. Some interactive
// child processes leave DECSTBM/DECOM behind; if ratatui's diff renderer
// then writes "row 0", terminals can place it relative to the leaked
// scroll region and the whole viewport appears shifted down. CSI 3J also
// erases saved scrollback so a focus/resize recapture cannot leave the
// host terminal's scrollbar above the live TUI.
// scroll region and the whole viewport appears shifted down. We
// deliberately do *not* emit CSI 2J/3J here — see TERMINAL_ORIGIN_RESET
// 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.
terminal.backend_mut().write_all(TERMINAL_ORIGIN_RESET)?;
terminal.backend_mut().flush()?;
terminal.clear()?;
+17 -5
View File
@@ -87,20 +87,32 @@ fn recover_terminal_modes_runs_without_panic_on_windows() {
}
#[test]
fn terminal_origin_reset_resets_scroll_region_origin_and_clears() {
fn terminal_origin_reset_resets_scroll_region_origin_without_destructive_clear() {
assert!(
TERMINAL_ORIGIN_RESET.starts_with(b"\x1b[r\x1b[?6l"),
"must reset scroll margins and origin mode before repaint"
);
assert!(
TERMINAL_ORIGIN_RESET.ends_with(b"\x1b[H\x1b[2J\x1b[3J"),
"must home the cursor and clear the viewport"
TERMINAL_ORIGIN_RESET.ends_with(b"\x1b[H"),
"must home the cursor at the end of the reset sequence"
);
// Cross-terminal flicker regression (#1119, #1352, #1356, #1363, #1366,
// #1260, #1295): emitting CSI 2J/3J here in addition to the
// immediately-following ratatui `terminal.clear()` produced a visible
// blank-then-repaint flicker on Ghostty / VSCode terminal / Win10 conhost
// every TurnComplete. The cleared back-buffer plus a single ratatui clear
// is sufficient on the alt-screen.
assert!(
!TERMINAL_ORIGIN_RESET
.windows(b"\x1b[2J".len())
.any(|sequence| sequence == b"\x1b[2J"),
"must not emit destructive CSI 2J — causes visible flicker"
);
assert!(
TERMINAL_ORIGIN_RESET
!TERMINAL_ORIGIN_RESET
.windows(b"\x1b[3J".len())
.any(|sequence| sequence == b"\x1b[3J"),
"must erase saved scrollback when reclaiming the viewport"
"must not emit destructive CSI 3J — causes visible flicker"
);
}