fix(tui): debounce FocusGained recovery to prevent flicker loop on Tabby

Some terminal emulators (observed on Tabby / xterm.js) re-trigger a
FocusGained event when the application sends EnableFocusChange
(\e[?1004h) inside recover_terminal_modes(). This creates a tight
event→redraw→event loop that manifests as continuous screen flicker
whenever the terminal window has focus.

Root cause chain:
  FocusGained → recover_terminal_modes() → EnableFocusChange
      ↑                                              ↓
      └── Tabby retriggers FocusGained ←─────────────┘

Fix: add a 200 ms debounce window on FocusGained-triggered mode
recovery.  If a FocusGained arrives within 200 ms of the last
recovery call, skip recover_terminal_modes() (avoiding the
EnableFocusChange that feeds the loop) but still mark a repaint.

The debounce constant FOCUS_RECOVERY_DEBOUNCE uses a 200 ms window,
which is short enough that legitimate app-switch focus events (>200 ms
apart) still get full mode recovery, while spurious back-to-back
events from terminal quirks are suppressed.

Fixes flickering reported when running deepseek-tui inside Tabby on
macOS (deepseek-tui 0.8.32).

--- Fixed with DeepSeek V4 Pro ---

Signed-off-by: DeepSeek V4 Pro <via deepseek-tui>
This commit is contained in:
v_wanghuanping01
2026-05-13 13:03:54 +08:00
parent 81e4b93cc9
commit 9038e70bcb
+17 -5
View File
@@ -745,6 +745,14 @@ async fn run_event_loop(
let mut terminal_paused_at: Option<Instant> = None;
let mut force_terminal_repaint = false;
let mut draws_since_last_full_repaint: u64 = 0;
// FocusGained debounce: some terminal emulators (e.g. Tabby) re-trigger
// FocusGained when we re-arm focus-change reporting inside
// recover_terminal_modes, creating a tight repaint loop. Skip
// mode recovery (but still mark a repaint) within the debounce window.
const FOCUS_RECOVERY_DEBOUNCE: Duration = Duration::from_millis(200);
let mut last_focus_recovery = Instant::now()
.checked_sub(Duration::from_secs(60))
.unwrap_or_else(Instant::now);
loop {
if !drain_web_config_events(&mut web_config_session, app, config, &engine_handle).await {
@@ -1954,11 +1962,15 @@ async fn run_event_loop(
// bracketed-paste modes — recover_terminal_modes() is the
// canonical place those flags live.
if terminal_event_needs_viewport_recapture(&evt) {
recover_terminal_modes(
terminal.backend_mut(),
app.use_mouse_capture,
app.use_bracketed_paste,
);
let now = Instant::now();
if now.duration_since(last_focus_recovery) >= FOCUS_RECOVERY_DEBOUNCE {
recover_terminal_modes(
terminal.backend_mut(),
app.use_mouse_capture,
app.use_bracketed_paste,
);
last_focus_recovery = now;
}
force_terminal_repaint = true;
app.needs_redraw = true;
}