refactor(tui): unify terminal-mode recovery in recover_terminal_modes()

Extract the common "re-arm keyboard enhancement, mouse capture, bracketed
paste, and focus events" sequence used by app startup, FocusGained
recovery, and resume_terminal into a single best-effort helper.

The FocusGained handler previously only re-pushed keyboard enhancement
flags + (after v0.8.24) mouse capture, missing bracketed paste and focus
events. Each new mode added at startup was a fresh gap waiting to be
discovered when a user Cmd+Tabbed away. Co-locating the four flags in
one canonical helper means future mode additions are one edit, not three.

Adds a unit test that pins the gating (mouse + bracketed paste honor
their booleans; keyboard + focus are unconditional) by writing into an
in-memory buffer and asserting on the CSI sequences crossterm emits.

Test: cargo test -p deepseek-tui --bin deepseek-tui --locked
This commit is contained in:
Hunter Bown
2026-05-09 09:02:22 -05:00
parent 42cccee02d
commit a6ec43ece4
2 changed files with 95 additions and 44 deletions
+58 -44
View File
@@ -208,30 +208,24 @@ pub async fn run_tui(config: &Config, options: TuiOptions) -> Result<()> {
if use_alt_screen {
execute!(stdout, EnterAlternateScreen)?;
}
if use_mouse_capture {
execute!(stdout, EnableMouseCapture)?;
}
if use_bracketed_paste {
execute!(stdout, EnableBracketedPaste)?;
}
// Enable focus events so the terminal reports FocusGained/FocusLost.
// Necessary for IME compositor re-activation on macOS when the user
// switches away (Cmd+Tab) and returns.
execute!(stdout, EnableFocusChange)?;
// #442: opt into the Kitty keyboard protocol's escape-code
// disambiguation so terminals that support it (Kitty, Ghostty,
// Mouse capture, bracketed paste, focus events, and the Kitty
// keyboard-protocol escape-disambiguation flag (#442). Single source
// of truth shared with the FocusGained recovery path and
// resume_terminal — see recover_terminal_modes.
//
// Focus events are necessary for IME compositor re-activation on
// macOS when the user switches away (Cmd+Tab) and returns. The Kitty
// keyboard protocol opt-in is best-effort: terminals that don't
// support it (iTerm2, Terminal.app, Windows 10 conhost) silently
// discard the escape, while supporting terminals (Kitty, Ghostty,
// Alacritty 0.13+, WezTerm, recent Konsole, recent xterm) report
// unambiguous events for Option/Alt-modified keys, plain Esc, and
// multi-byte sequences. Terminals that don't recognise the escape
// silently discard it; behaviour is identical to today on legacy
// terminals (iTerm2, Terminal.app, Windows 10 conhost).
// unambiguous events for Option/Alt-modified keys and plain Esc.
//
// Only `DISAMBIGUATE_ESCAPE_CODES` is pushed — the higher tiers
// (`REPORT_EVENT_TYPES`, `REPORT_ALL_KEYS_AS_ESCAPE_CODES`) emit
// release events that the existing key handlers would mis-route
// as duplicate presses. Best-effort: failure to push is logged
// and ignored so a quirky terminal can't block startup.
push_keyboard_enhancement_flags(&mut stdout);
// as duplicate presses.
recover_terminal_modes(&mut stdout, use_mouse_capture, use_bracketed_paste);
let color_depth = palette::ColorDepth::detect();
let palette_mode = palette::PaletteMode::detect();
tracing::debug!(
@@ -1586,24 +1580,18 @@ async fn run_event_loop(
continue;
}
// Re-push keyboard enhancement flags on focus-gain and force a
// full viewport reset before repainting. App-switching and
// interactive handoffs can leave the host terminal scrolled away
// from row 0; treating focus as a recapture point prevents the
// native scrollback gutter / blank-top-row failure mode from
// persisting after the user returns.
// On macOS, switching away (Cmd+Tab) and back can reset the
// terminal's keyboard mode, which breaks IME compositor state.
// Acknowledging FocusGained and re-pushing the flags restores
// the IME so CJK input methods work after a focus toggle.
// The same reset can drop the terminal's mouse-tracking mode,
// leaving wheel scroll dead until restart — re-arm mouse
// capture on focus-gain so wheel events keep flowing.
// Re-establish terminal mode flags on focus-gain and force a full
// viewport reset before repainting. App-switching and interactive
// handoffs can leave the host terminal scrolled away from row 0
// and (on macOS) can drop the keyboard, mouse-tracking, or
// bracketed-paste modes — recover_terminal_modes() is the
// canonical place those flags live.
if terminal_event_needs_viewport_recapture(&evt) {
push_keyboard_enhancement_flags(terminal.backend_mut());
if app.use_mouse_capture {
let _ = execute!(terminal.backend_mut(), EnableMouseCapture);
}
recover_terminal_modes(
terminal.backend_mut(),
app.use_mouse_capture,
app.use_bracketed_paste,
);
force_terminal_repaint = true;
app.needs_redraw = true;
}
@@ -6408,14 +6396,11 @@ fn resume_terminal(
if use_alt_screen {
execute!(terminal.backend_mut(), EnterAlternateScreen)?;
}
if use_mouse_capture {
execute!(terminal.backend_mut(), EnableMouseCapture)?;
}
if use_bracketed_paste {
execute!(terminal.backend_mut(), EnableBracketedPaste)?;
}
execute!(terminal.backend_mut(), EnableFocusChange)?;
push_keyboard_enhancement_flags(terminal.backend_mut());
recover_terminal_modes(
terminal.backend_mut(),
use_mouse_capture,
use_bracketed_paste,
);
reset_terminal_viewport(terminal)?;
Ok(())
}
@@ -6446,6 +6431,35 @@ fn push_keyboard_enhancement_flags<W: Write>(writer: &mut W) {
}
}
/// Re-establish terminal mode flags. Idempotent and best-effort: each
/// underlying flag is silently discarded by terminals that don't support
/// it, and a single flag's failure doesn't prevent later flags from being
/// attempted.
///
/// **Canonical location for terminal-mode setup.** If you add a new mode
/// flag at startup or in `resume_terminal`, add it here too — `FocusGained`
/// recovery calls this and will silently fall behind otherwise.
///
/// Excluded by design: raw mode and the alternate screen — those persist
/// across focus events and are only re-established by `resume_terminal`
/// after a suspension, which always runs a separate path.
fn recover_terminal_modes<W: Write>(
writer: &mut W,
use_mouse_capture: bool,
use_bracketed_paste: bool,
) {
push_keyboard_enhancement_flags(writer);
if use_mouse_capture && let Err(err) = execute!(writer, EnableMouseCapture) {
tracing::debug!(?err, "EnableMouseCapture ignored");
}
if use_bracketed_paste && let Err(err) = execute!(writer, EnableBracketedPaste) {
tracing::debug!(?err, "EnableBracketedPaste ignored");
}
if let Err(err) = execute!(writer, EnableFocusChange) {
tracing::debug!(?err, "EnableFocusChange ignored");
}
}
fn terminal_event_needs_viewport_recapture(evt: &Event) -> bool {
matches!(evt, Event::FocusGained)
}
+37
View File
@@ -36,6 +36,43 @@ fn focus_gained_forces_terminal_viewport_recapture() {
assert!(!terminal_event_needs_viewport_recapture(&Event::FocusLost));
}
#[test]
fn recover_terminal_modes_emits_expected_csi_sequences_with_gating() {
let mut all_on: Vec<u8> = Vec::new();
let mut all_off: Vec<u8> = Vec::new();
recover_terminal_modes(&mut all_on, true, true);
recover_terminal_modes(&mut all_off, false, false);
let on = String::from_utf8_lossy(&all_on);
let off = String::from_utf8_lossy(&all_off);
assert!(
on.contains("\x1b[?1004h") && off.contains("\x1b[?1004h"),
"EnableFocusChange must be re-armed regardless of gating"
);
assert!(
on.contains("\x1b[>1u") && off.contains("\x1b[>1u"),
"Kitty keyboard disambiguation flag must be re-pushed regardless of gating"
);
assert!(
on.contains("\x1b[?1000h"),
"EnableMouseCapture missing when use_mouse_capture=true"
);
assert!(
!off.contains("\x1b[?1000h"),
"EnableMouseCapture must be gated by use_mouse_capture"
);
assert!(
on.contains("\x1b[?2004h"),
"EnableBracketedPaste missing when use_bracketed_paste=true"
);
assert!(
!off.contains("\x1b[?2004h"),
"EnableBracketedPaste must be gated by use_bracketed_paste"
);
}
#[test]
fn terminal_origin_reset_resets_scroll_region_origin_and_clears() {
assert!(