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:
@@ -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.
|
||||
|
||||
@@ -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()?;
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user