From 95d3fe723f11758c4de969da7ebd5e67dd5d64a7 Mon Sep 17 00:00:00 2001 From: Blankeos Date: Mon, 27 Jul 2026 20:47:23 +0800 Subject: [PATCH] fix(gui,os): harden terminal input handling and isolate background commands - add async `InputReader` with per-keystroke sequence reassembly and bounded batch delivery - move fragmented escape/Cursor/report parsing logic and tests into new `src/gui/input.rs` - refactor main loop to consume event batches and centralize resize reflow handling - make spawned commands non-interactive (`GIT_TERMINAL_PROMPT=0`, `SSH_ASKPASS_REQUIRE=never`, optional `GIT_SSH_COMMAND`, `setsid`, stdin null) - add `libc` dependency for session-detaching background processes --- Cargo.lock | 1 + Cargo.toml | 1 + src/gui/input.rs | 553 +++++++++++++++++++++++++++++++++++++++++++++++ src/gui/mod.rs | 370 +++++++++---------------------- src/os/cmd.rs | 33 +++ 5 files changed, 691 insertions(+), 267 deletions(-) create mode 100644 src/gui/input.rs diff --git a/Cargo.lock b/Cargo.lock index c38d67f..e280a57 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -435,6 +435,7 @@ dependencies = [ "crossterm", "dirs", "include_dir", + "libc", "once_cell", "ratatui", "serde", diff --git a/Cargo.toml b/Cargo.toml index 4f41050..427c024 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -39,6 +39,7 @@ tree-sitter-md = "0.3" unicode-width = "0.2" tui-textarea = { version = "0.7", features = ["crossterm"] } textwrap = "0.16.2" +libc = "0.2" [profile.release] lto = true diff --git a/src/gui/input.rs b/src/gui/input.rs new file mode 100644 index 0000000..e894eda --- /dev/null +++ b/src/gui/input.rs @@ -0,0 +1,553 @@ +//! Terminal input. +//! +//! Two jobs, both of which the render loop used to do badly by hand: +//! +//! 1. **Read continuously.** Reading only once per rendered frame means a +//! terminal control sequence that a PTY read split after the initial `ESC` +//! (crossterm-rs/crossterm#993) has its tail sitting in the buffer for a +//! whole frame. By the time anyone looks, the pieces no longer look like one +//! sequence, and a focus-in report (`ESC [ I`) turns into `Esc`, `[`, `I` — +//! three bogus shortcuts. A dedicated thread that blocks on `event::read()` +//! picks the tail up microseconds later, so reassembly is reliable and its +//! timing window can stay short enough to never delay a real `Esc`. +//! +//! 2. **Hand over whole batches.** Auto-repeat delivers keys far faster than a +//! full repaint. Processing one key per frame lets the queue grow without +//! bound, so held keys keep scrolling long after release. [`InputReader`] +//! exposes every event queued so far and lets the caller draw once. +//! +//! Reassembly never leaks an escape payload into shortcut handling: a partial +//! sequence is dropped rather than replayed byte by byte. It is also lossless +//! for real keys — anything that cannot appear inside the sequence being parsed +//! is handed back as the keypress it is. + +use std::io; +use std::sync::mpsc::{self, Receiver, RecvTimeoutError}; +use std::thread; +use std::time::{Duration, Instant}; + +use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers}; + +/// How long to look for something immediately behind a bare `Esc`. +/// +/// A sequence is split by a read boundary, not by the terminal: the tail was +/// written in the same breath as the `ESC` and is already in the buffer, so the +/// reader thread sees it in microseconds. The budget only has to cover scheduler +/// jitter, so one frame is ample — and it is short enough that a real `Esc` +/// press feels immediate, which the old 25ms-per-byte-after-a-full-frame scheme +/// could not manage. No human types `Esc` then `[` inside this window. +const INTRODUCER_PROBE: Duration = Duration::from_millis(15); + +/// How long to wait per byte once an introducer has confirmed a sequence is in +/// flight. At that point no real keypress is being held up, so this can be +/// generous enough to absorb a slow or descheduled terminal. +const CONTINUATION_WINDOW: Duration = Duration::from_millis(100); + +/// Upper bound on how long one reassembly may take, so a terminal dribbling +/// bytes can never stall input. +const SEQUENCE_DEADLINE: Duration = Duration::from_millis(400); + +/// Guard against a pathological run of parameter bytes. +const MAX_SEQUENCE_BYTES: usize = 64; + +/// Most events handed to the UI in one batch. Keeps a paste storm or a very long +/// auto-repeat burst from starving the renderer. +const MAX_BATCH: usize = 512; + +/// Reads terminal events on a dedicated thread and serves them in batches. +pub struct InputReader { + rx: Receiver, +} + +impl InputReader { + pub fn spawn() -> Self { + let (tx, rx) = mpsc::channel(); + thread::Builder::new() + .name("input-reader".into()) + .spawn(move || { + // Exits when the receiver goes away at shutdown. + loop { + match next_events(&mut CrosstermSource) { + Ok(events) => { + for event in events { + if tx.send(event).is_err() { + return; + } + } + } + Err(_) => return, + } + } + }) + .expect("spawn input reader thread"); + Self { rx } + } + + /// Wait up to `timeout` for input, then return it together with everything + /// else already queued. Empty means the timeout expired with nothing to do. + pub fn wait_batch(&self, timeout: Duration) -> Vec { + let mut batch = Vec::new(); + match self.rx.recv_timeout(timeout) { + Ok(event) => batch.push(event), + Err(RecvTimeoutError::Timeout) => return batch, + Err(RecvTimeoutError::Disconnected) => return batch, + } + while batch.len() < MAX_BATCH { + match self.rx.try_recv() { + Ok(event) => batch.push(event), + Err(_) => break, + } + } + batch + } +} + +/// Source of already-parsed crossterm events, abstracted so the reassembly state +/// machine can be tested without a terminal. +trait EventSource { + /// Next event, or `None` if `timeout` expired first. + fn next(&mut self, timeout: Duration) -> io::Result>; +} + +struct CrosstermSource; + +impl EventSource for CrosstermSource { + fn next(&mut self, timeout: Duration) -> io::Result> { + if timeout.is_zero() { + return Ok(None); + } + if event::poll(timeout)? { + return event::read().map(Some); + } + Ok(None) + } +} + +/// Blocking read of the next logical event(s). +fn next_events(source: &mut impl EventSource) -> io::Result> { + // A bare `Esc` is the only event that can be the head of a sequence that + // crossterm failed to keep together, so everything else passes straight + // through. + let first = loop { + if let Some(event) = source.next(Duration::from_secs(3600))? { + break event; + } + }; + let Event::Key(key) = first else { + return Ok(vec![first]); + }; + if key.code != KeyCode::Esc || key.kind != KeyEventKind::Press { + return Ok(vec![Event::Key(key)]); + } + resolve_escape(source) +} + +/// Decide what a bare `Esc` actually was: a keypress, an `Alt`-modified key, or +/// the start of a control sequence that arrived in pieces. +fn resolve_escape(source: &mut impl EventSource) -> io::Result> { + let esc = Event::Key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + + let Some(second) = source.next(INTRODUCER_PROBE)? else { + return Ok(vec![esc]); + }; + // Anything that is not a plain character cannot continue a sequence, so + // deliver both rather than dropping either. + let Event::Key(mut key) = second else { + return Ok(vec![esc, second]); + }; + let KeyCode::Char(introducer) = key.code else { + return Ok(vec![esc, Event::Key(key)]); + }; + + let deadline = Instant::now() + SEQUENCE_DEADLINE; + match introducer { + '[' => read_csi(source, deadline), + 'O' => read_ss3(source, deadline), + // OSC/DCS/PM/APC carry a string payload that is never a shortcut. Read + // to its terminator and emit nothing. + ']' | 'P' | '^' | '_' => { + consume_control_string(source, deadline)?; + Ok(Vec::new()) + } + // A real `Alt`+key press, which is how terminals without the enhanced + // keyboard protocol encode it. + _ => { + key.modifiers |= KeyModifiers::ALT; + Ok(vec![Event::Key(key)]) + } + } +} + +/// Read the body of a `CSI` sequence and turn it into the event it encodes. +fn read_csi(source: &mut impl EventSource, deadline: Instant) -> io::Result> { + let mut body = String::new(); + loop { + let Some(event) = next_before(source, deadline)? else { + // Truncated sequence: drop it rather than replay its bytes as keys. + return Ok(Vec::new()); + }; + // A non-character event ends the sequence. Keep the event, drop the + // partial sequence. + let Event::Key(key) = event else { + return Ok(vec![event]); + }; + let KeyCode::Char(ch) = key.code else { + return Ok(vec![Event::Key(key)]); + }; + + if is_csi_final(ch) { + if let Some(event) = parse_csi(&body, ch) { + return Ok(vec![event]); + } + // Unrecognised. Most letters are legal `CSI` terminators, so `ESC [` + // followed by a plain letter is far more likely a stray `ESC` plus a + // real keypress than a control sequence — losing `q` there would + // read as the app ignoring quit. Only drop the character when it + // could actually terminate a report a terminal sends us. + if body.is_empty() && !is_report_final(ch) { + return Ok(vec![Event::Key(key)]); + } + return Ok(Vec::new()); + } + if !is_csi_body(ch) || body.len() >= MAX_SEQUENCE_BYTES { + // Cannot belong to this sequence, so it is a genuine keypress. + return Ok(vec![Event::Key(key)]); + } + body.push(ch); + } +} + +/// `SS3` (`ESC O x`) encodes one key in the single byte that follows. +fn read_ss3(source: &mut impl EventSource, deadline: Instant) -> io::Result> { + let Some(event) = next_before(source, deadline)? else { + return Ok(Vec::new()); + }; + let Event::Key(key) = event else { + return Ok(vec![event]); + }; + let KeyCode::Char(ch) = key.code else { + return Ok(vec![Event::Key(key)]); + }; + let code = match ch { + 'A' => KeyCode::Up, + 'B' => KeyCode::Down, + 'C' => KeyCode::Right, + 'D' => KeyCode::Left, + 'H' => KeyCode::Home, + 'F' => KeyCode::End, + 'P' => KeyCode::F(1), + 'Q' => KeyCode::F(2), + 'R' => KeyCode::F(3), + 'S' => KeyCode::F(4), + _ => return Ok(vec![Event::Key(key)]), + }; + Ok(vec![Event::Key(KeyEvent::new(code, KeyModifiers::NONE))]) +} + +/// Swallow a control string up to `ST` (`ESC \`) or `BEL`. +fn consume_control_string(source: &mut impl EventSource, deadline: Instant) -> io::Result<()> { + let mut saw_esc = false; + loop { + let Some(event) = next_before(source, deadline)? else { + return Ok(()); + }; + let Event::Key(key) = event else { + return Ok(()); + }; + match key.code { + KeyCode::Esc => saw_esc = true, + KeyCode::Char('\\') if saw_esc => return Ok(()), + KeyCode::Char('g') if key.modifiers.contains(KeyModifiers::CONTROL) => return Ok(()), + _ => saw_esc = false, + } + } +} + +fn next_before(source: &mut impl EventSource, deadline: Instant) -> io::Result> { + let budget = deadline + .saturating_duration_since(Instant::now()) + .min(CONTINUATION_WINDOW); + source.next(budget) +} + +/// Parameter and intermediate bytes, i.e. everything legal inside a `CSI` body. +fn is_csi_body(ch: char) -> bool { + matches!(ch, '\u{20}'..='\u{3f}') +} + +fn is_csi_final(ch: char) -> bool { + matches!(ch, '\u{40}'..='\u{7e}') +} + +/// Final bytes of the parameterless reports a terminal actually sends us: focus +/// in/out, `CSI Z` for back-tab, the cursor keys, and legacy X10 mouse. Anything +/// else with an empty parameter list is treated as a real keypress instead. +fn is_report_final(ch: char) -> bool { + matches!( + ch, + 'I' | 'O' | 'Z' | 'M' | 'A' | 'B' | 'C' | 'D' | 'H' | 'F' + ) +} + +/// Modifier mask from the enhanced-keyboard/xterm encoding, which is 1-based and +/// may carry an event type after a `:`. +fn parse_modifiers(value: &str) -> KeyModifiers { + let mask = value + .split(':') + .next() + .and_then(|value| value.parse::().ok()) + .unwrap_or(1) + .saturating_sub(1); + let mut modifiers = KeyModifiers::NONE; + modifiers.set(KeyModifiers::SHIFT, mask & 1 != 0); + modifiers.set(KeyModifiers::ALT, mask & 2 != 0); + modifiers.set(KeyModifiers::CONTROL, mask & 4 != 0); + modifiers.set(KeyModifiers::SUPER, mask & 8 != 0); + modifiers.set(KeyModifiers::HYPER, mask & 16 != 0); + modifiers.set(KeyModifiers::META, mask & 32 != 0); + modifiers +} + +/// Rebuild the event a `CSI ` sequence stands for. +fn parse_csi(body: &str, final_byte: char) -> Option { + if body.is_empty() { + match final_byte { + 'I' => return Some(Event::FocusGained), + 'O' => return Some(Event::FocusLost), + 'Z' => { + return Some(Event::Key(KeyEvent::new( + KeyCode::BackTab, + KeyModifiers::SHIFT, + ))); + } + _ => {} + } + } + + let modifiers = body + .rsplit(';') + .next() + .filter(|_| body.contains(';')) + .map(parse_modifiers) + .unwrap_or(KeyModifiers::NONE); + + let code = match final_byte { + 'A' => KeyCode::Up, + 'B' => KeyCode::Down, + 'C' => KeyCode::Right, + 'D' => KeyCode::Left, + 'H' => KeyCode::Home, + 'F' => KeyCode::End, + 'u' => { + let codepoint = body.split(';').next()?.split(':').next()?.parse().ok()?; + match codepoint { + 9 => KeyCode::Tab, + 13 => KeyCode::Enter, + 27 => KeyCode::Esc, + 127 => KeyCode::Backspace, + value => KeyCode::Char(char::from_u32(value)?), + } + } + '~' => match body.split(';').next()? { + "1" | "7" => KeyCode::Home, + "2" => KeyCode::Insert, + "3" => KeyCode::Delete, + "4" | "8" => KeyCode::End, + "5" => KeyCode::PageUp, + "6" => KeyCode::PageDown, + _ => return None, + }, + _ => return None, + }; + + Some(Event::Key(KeyEvent { + code, + modifiers, + kind: KeyEventKind::Press, + state: KeyEventState::NONE, + })) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Feeds a scripted list of events; `None` entries stand for a timeout. + struct Scripted(std::collections::VecDeque>); + + impl Scripted { + fn new(items: Vec>) -> Self { + Self(items.into_iter().collect()) + } + + /// Everything the state machine yields for the scripted input, assuming + /// the leading `Esc` has already been taken. + fn resolve(items: Vec>) -> Vec { + let mut source = Scripted::new(items); + resolve_escape(&mut source).expect("resolve") + } + } + + impl EventSource for Scripted { + fn next(&mut self, _timeout: Duration) -> io::Result> { + Ok(self.0.pop_front().flatten()) + } + } + + fn ch(c: char) -> Option { + Some(Event::Key(KeyEvent::new( + KeyCode::Char(c), + KeyModifiers::NONE, + ))) + } + + fn key(code: KeyCode) -> Event { + Event::Key(KeyEvent::new(code, KeyModifiers::NONE)) + } + + #[test] + fn bare_escape_stays_an_escape() { + assert_eq!(Scripted::resolve(vec![None]), vec![key(KeyCode::Esc)]); + } + + #[test] + fn split_focus_report_never_reaches_shortcut_handling() { + // `ESC [ I` arriving as three key events must become a focus event, not + // the `I` that opens the interactive rebase picker. + assert_eq!( + Scripted::resolve(vec![ch('['), ch('I')]), + vec![Event::FocusGained] + ); + } + + #[test] + fn split_arrow_key_is_reassembled() { + assert_eq!( + Scripted::resolve(vec![ch('['), ch('A')]), + vec![key(KeyCode::Up)] + ); + } + + #[test] + fn split_modified_key_keeps_its_modifier() { + assert_eq!( + Scripted::resolve(vec![ch('['), ch('4'), ch('9'), ch(';'), ch('9'), ch('u')]), + vec![Event::Key(KeyEvent { + code: KeyCode::Char('1'), + modifiers: KeyModifiers::SUPER, + kind: KeyEventKind::Press, + state: KeyEventState::NONE, + })] + ); + } + + #[test] + fn escape_then_letter_is_alt_modified() { + assert_eq!( + Scripted::resolve(vec![ch('x')]), + vec![Event::Key(KeyEvent::new( + KeyCode::Char('x'), + KeyModifiers::ALT + ))] + ); + } + + #[test] + fn truncated_sequence_is_dropped_rather_than_leaked() { + // Payload bytes must never be replayed as keypresses. + assert_eq!(Scripted::resolve(vec![ch('['), ch('3'), None]), vec![]); + } + + #[test] + fn key_that_cannot_continue_a_sequence_is_still_delivered() { + // `q` happens to be a legal `CSI` terminator, but `ESC [ q` is not a + // report any terminal sends, so quit must still get through. + assert_eq!( + Scripted::resolve(vec![ch('['), ch('q')]), + vec![key(KeyCode::Char('q'))] + ); + } + + #[test] + fn unmodelled_terminal_report_is_dropped_not_typed() { + // A device-attributes reply (`ESC [ ? 6 2 ; 2 2 c`) has to vanish rather + // than land in a text field. + assert_eq!( + Scripted::resolve(vec![ + ch('['), + ch('?'), + ch('6'), + ch('2'), + ch(';'), + ch('2'), + ch('2'), + ch('c'), + ]), + vec![] + ); + } + + #[test] + fn interior_key_press_is_not_swallowed_by_a_stalled_sequence() { + // Enter cannot appear inside a `CSI` body, so it is a real keypress. + assert_eq!( + Scripted::resolve(vec![ch('['), ch('1'), Some(key(KeyCode::Enter))]), + vec![key(KeyCode::Enter)] + ); + } + + #[test] + fn typed_text_after_a_stray_escape_survives() { + // Non-ASCII input is neither a parameter nor a terminator byte. + assert_eq!( + Scripted::resolve(vec![ch('['), ch('é')]), + vec![key(KeyCode::Char('é'))] + ); + } + + #[test] + fn non_key_event_after_introducer_is_preserved() { + let resize = Event::Resize(80, 24); + assert_eq!( + Scripted::resolve(vec![ch('['), Some(resize.clone())]), + vec![resize] + ); + } + + #[test] + fn control_string_payload_is_swallowed_entirely() { + // An OSC reply must not spray its text into the UI. + assert_eq!( + Scripted::resolve(vec![ + ch(']'), + ch('1'), + ch('1'), + ch(';'), + ch('r'), + ch('g'), + ch('b'), + Some(key(KeyCode::Esc)), + ch('\\'), + ]), + vec![] + ); + } + + #[test] + fn ss3_function_key_is_reassembled() { + assert_eq!( + Scripted::resolve(vec![ch('O'), ch('B')]), + vec![key(KeyCode::Down)] + ); + } + + #[test] + fn csi_grammar_classifies_parameter_and_final_bytes() { + assert!(is_csi_body('3')); + assert!(is_csi_body(';')); + assert!(!is_csi_body('q')); + assert!(is_csi_final('I')); + assert!(is_csi_final('u')); + assert!(!is_csi_final('3')); + } +} diff --git a/src/gui/mod.rs b/src/gui/mod.rs index 8ac2753..b442414 100644 --- a/src/gui/mod.rs +++ b/src/gui/mod.rs @@ -1,5 +1,6 @@ pub mod context; pub mod controller; +pub mod input; pub mod layout; pub mod modes; pub mod popup; @@ -14,7 +15,7 @@ use std::sync::{Arc, Mutex, mpsc}; use std::time::{Duration, Instant}; use anyhow::{Context, Result}; -use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyModifiers, MouseEvent}; +use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers, MouseEvent}; use crossterm::terminal::{self, EnterAlternateScreen, LeaveAlternateScreen}; use crossterm::{Command, cursor, execute}; use ratatui::Terminal; @@ -31,6 +32,7 @@ use crate::pager::side_by_side::{ }; use self::context::{ContextId, ContextManager, SideWindow}; +use self::input::InputReader; use self::layout::LayoutState; use self::modes::diff_mode::DiffModeState; use self::modes::patch_building::PatchBuildingState; @@ -66,9 +68,6 @@ fn list_picker_visible_height(terminal_height: usize) -> usize { } pub type Term = Terminal>; -const EVENT_DRAIN_LIMIT: usize = 256; -const ESCAPE_CONTINUATION_TIMEOUT: Duration = Duration::from_millis(25); -const MAX_ESCAPE_SEQUENCE_EVENTS: usize = 256; const COMMIT_DETAILS_DEBOUNCE: Duration = Duration::from_millis(120); const MAX_CONCURRENT_DIFF_JOBS: usize = 2; const DIFF_PREVIEW_CACHE_ENTRIES: usize = 8; @@ -84,157 +83,6 @@ fn plain_char_key(key: KeyEvent, expected: char) -> bool { key.code == KeyCode::Char(expected) && key.modifiers == modifiers } -fn parse_kitty_modifiers(value: &str) -> KeyModifiers { - let mask = value - .split(':') - .next() - .and_then(|value| value.parse::().ok()) - .unwrap_or(1) - .saturating_sub(1); - let mut modifiers = KeyModifiers::NONE; - modifiers.set(KeyModifiers::SHIFT, mask & 1 != 0); - modifiers.set(KeyModifiers::ALT, mask & 2 != 0); - modifiers.set(KeyModifiers::CONTROL, mask & 4 != 0); - modifiers.set(KeyModifiers::SUPER, mask & 8 != 0); - modifiers.set(KeyModifiers::HYPER, mask & 16 != 0); - modifiers.set(KeyModifiers::META, mask & 32 != 0); - modifiers -} - -fn parse_fragmented_escape_sequence(sequence: &str) -> Option { - if let Some(csi) = sequence.strip_prefix('[') { - let final_byte = csi.chars().last()?; - let body = &csi[..csi.len().saturating_sub(final_byte.len_utf8())]; - - if body.is_empty() { - match final_byte { - 'I' => return Some(Event::FocusGained), - 'O' => return Some(Event::FocusLost), - 'Z' => { - return Some(Event::Key(KeyEvent::new( - KeyCode::BackTab, - KeyModifiers::SHIFT, - ))); - } - _ => {} - } - } - - let modifiers = body - .rsplit(';') - .next() - .filter(|_| body.contains(';')) - .map(parse_kitty_modifiers) - .unwrap_or(KeyModifiers::NONE); - let code = match final_byte { - 'A' => KeyCode::Up, - 'B' => KeyCode::Down, - 'C' => KeyCode::Right, - 'D' => KeyCode::Left, - 'H' => KeyCode::Home, - 'F' => KeyCode::End, - 'u' => { - let codepoint = body.split(';').next()?.split(':').next()?.parse().ok()?; - match codepoint { - 9 => KeyCode::Tab, - 13 => KeyCode::Enter, - 27 => KeyCode::Esc, - 127 => KeyCode::Backspace, - value => KeyCode::Char(char::from_u32(value)?), - } - } - '~' => match body.split(';').next()? { - "1" | "7" => KeyCode::Home, - "2" => KeyCode::Insert, - "3" => KeyCode::Delete, - "4" | "8" => KeyCode::End, - "5" => KeyCode::PageUp, - "6" => KeyCode::PageDown, - _ => return None, - }, - _ => return None, - }; - return Some(Event::Key(KeyEvent::new(code, modifiers))); - } - - let code = match sequence { - "OA" => KeyCode::Up, - "OB" => KeyCode::Down, - "OC" => KeyCode::Right, - "OD" => KeyCode::Left, - "OH" => KeyCode::Home, - "OF" => KeyCode::End, - "OP" => KeyCode::F(1), - "OQ" => KeyCode::F(2), - "OR" => KeyCode::F(3), - "OS" => KeyCode::F(4), - _ => return None, - }; - Some(Event::Key(KeyEvent::new(code, KeyModifiers::NONE))) -} - -/// Work around crossterm-rs/crossterm#993, where a PTY read ending after the -/// initial ESC can split one terminal control sequence into ordinary key events. -fn read_terminal_events() -> io::Result> { - let first = event::read()?; - let Event::Key(first_key) = first else { - return Ok(vec![first]); - }; - if first_key.code != KeyCode::Esc || first_key.kind != crossterm::event::KeyEventKind::Press { - return Ok(vec![Event::Key(first_key)]); - } - - if !event::poll(ESCAPE_CONTINUATION_TIMEOUT)? { - return Ok(vec![Event::Key(first_key)]); - } - let second = event::read()?; - let Event::Key(mut second_key) = second else { - return Ok(vec![Event::Key(first_key), second]); - }; - let KeyCode::Char(introducer @ ('[' | 'O' | ']' | 'P' | '^' | '_')) = second_key.code else { - second_key.modifiers |= KeyModifiers::ALT; - return Ok(vec![Event::Key(second_key)]); - }; - - let deadline = Instant::now() + ESCAPE_CONTINUATION_TIMEOUT; - let mut sequence = String::from(introducer); - let control_string = matches!(introducer, ']' | 'P' | '^' | '_'); - let mut control_string_esc = false; - for _ in 0..MAX_ESCAPE_SEQUENCE_EVENTS { - let remaining = deadline.saturating_duration_since(Instant::now()); - if remaining.is_zero() || !event::poll(remaining)? { - break; - } - let next = event::read()?; - let Event::Key(key) = next else { - return Ok(vec![next]); - }; - if control_string { - if key.code == KeyCode::Char('g') && key.modifiers.contains(KeyModifiers::CONTROL) { - return Ok(Vec::new()); - } - if control_string_esc && key.code == KeyCode::Char('\\') { - return Ok(Vec::new()); - } - control_string_esc = key.code == KeyCode::Esc; - continue; - } - let KeyCode::Char(ch) = key.code else { - return Ok(vec![Event::Key(key)]); - }; - sequence.push(ch); - if ch.is_ascii() && ('@'..='~').contains(&ch) { - return Ok(parse_fragmented_escape_sequence(&sequence) - .into_iter() - .collect()); - } - } - - // A confirmed but malformed/incomplete terminal sequence must never leak - // its payload into shortcut handling or text inputs. - Ok(Vec::new()) -} - fn has_command_modifier(modifiers: KeyModifiers) -> bool { modifiers.intersects(KeyModifiers::SUPER | KeyModifiers::META) } @@ -262,19 +110,6 @@ pub(crate) fn textarea_input( true } -fn drain_pending_terminal_events(idle_timeout: Duration) { - for _ in 0..EVENT_DRAIN_LIMIT { - match event::poll(idle_timeout) { - Ok(true) => { - if event::read().is_err() { - break; - } - } - Ok(false) | Err(_) => break, - } - } -} - /// A completed diff result from the background thread. pub(crate) struct DiffResult { /// Generation counter to discard stale results. @@ -880,19 +715,22 @@ impl Gui { pub fn run(&mut self) -> Result<()> { let (mut terminal, keyboard_enhanced) = setup_terminal()?; + // Continuous reader thread: reassembly needs reads between frames + // (see `input` module). One event-per-frame is what leaked ↑ as 'A'. + let input = InputReader::spawn(); // Sync layout dimensions with actual terminal size so mouse handling // uses the correct geometry from the very first frame. let size = terminal.size()?; self.layout.update_size(size.width, size.height); - let result = self.main_loop(&mut terminal); + let result = self.main_loop(&mut terminal, &input); restore_terminal(&mut terminal, keyboard_enhanced)?; result } - fn main_loop(&mut self, terminal: &mut Term) -> Result<()> { + fn main_loop(&mut self, terminal: &mut Term, input: &InputReader) -> Result<()> { loop { // Drain any model parts that have arrived from the background load. if let Some(rx) = &self.initial_load_rx { @@ -1168,73 +1006,25 @@ impl Gui { } })?; - // Handle events - if event::poll(std::time::Duration::from_millis(16))? { - for terminal_event in read_terminal_events()? { - match terminal_event { - Event::Key(key) if key.kind == crossterm::event::KeyEventKind::Press => { - if let Err(err) = self.handle_key(key) { - self.show_error("Command failed", err); - } - } - Event::Mouse(mouse) => self.handle_mouse(mouse), - Event::Resize(w, h) => { - self.layout.update_size(w, h); - // Re-flow any active commit-message textarea to the new width so - // wrapping stays consistent with what the user sees. - let popup_width = (w * 60 / 100).min(60).max(30).min(w); - let popup_inner = popup_width.saturating_sub(4) as usize; - let config_width = self.config.user_config.git.commit.auto_wrap_width; - let effective_width = if config_width > 0 { - popup_inner.min(config_width) - } else { - popup_inner - }; - match &mut self.popup { - PopupState::Input { - textarea, - is_commit: true, - .. - } => { - if effective_width > 0 { - auto_wrap_textarea(textarea, effective_width); - } - } - PopupState::Input { - textarea, - is_commit: false, - .. - } => { - // Single-line input: re-flow the soft wrap to the new width. - let raw: String = textarea.lines().join(""); - if popup_inner > 0 && !raw.is_empty() { - let mut new_ta = popup::make_textarea(""); - new_ta.insert_str(&raw); - soft_wrap_textarea(&mut new_ta, popup_inner); - *textarea = new_ta; - } - } - PopupState::CommitInput { - body_textarea, - body_state, - .. - } => { - if effective_width > 0 { - body_state.render_into(body_textarea, effective_width); - } - } - _ => {} - } - } - Event::FocusGained if self.config.user_config.git.auto_refresh => { - self.needs_refresh = true; - } - Event::Paste(data) => { - self.handle_paste(data); - } - _ => {} - } - } + // One batch per frame. Reassembly lives on the reader thread so a + // split ESC [ A cannot leak as Char('A') → amend between frames. + // Keep the frame budget tight while anything animated/async is up. + let timeout = if self.ai_commit_generation_active() + || self.diff_loading + || self.initial_load_rx.is_some() + || self.refresh_in_progress + { + Duration::from_millis(16) + } else if self.config.user_config.git.auto_refresh { + Duration::from_millis(50) + } else { + Duration::from_millis(200) + }; + let events = input.wait_batch(timeout); + self.handle_event_batch(events); + + if self.should_quit { + break; } // Background auto-refresh on refresher.refreshInterval (0 = disabled). @@ -1271,6 +1061,77 @@ impl Gui { Ok(()) } + /// Apply one batch of terminal events before the next paint. + fn handle_event_batch(&mut self, events: Vec) { + for event in events { + match event { + Event::Key(key) if key.kind == crossterm::event::KeyEventKind::Press => { + if let Err(err) = self.handle_key(key) { + self.show_error("Command failed", err); + } + } + Event::Mouse(mouse) => self.handle_mouse(mouse), + Event::Resize(w, h) => self.handle_resize(w, h), + Event::FocusGained if self.config.user_config.git.auto_refresh => { + self.needs_refresh = true; + } + Event::Paste(data) => self.handle_paste(data), + _ => {} + } + if self.should_quit { + break; + } + } + } + + fn handle_resize(&mut self, w: u16, h: u16) { + self.layout.update_size(w, h); + // Re-flow any active commit-message textarea to the new width so + // wrapping stays consistent with what the user sees. + let popup_width = (w * 60 / 100).min(60).max(30).min(w); + let popup_inner = popup_width.saturating_sub(4) as usize; + let config_width = self.config.user_config.git.commit.auto_wrap_width; + let effective_width = if config_width > 0 { + popup_inner.min(config_width) + } else { + popup_inner + }; + match &mut self.popup { + PopupState::Input { + textarea, + is_commit: true, + .. + } => { + if effective_width > 0 { + auto_wrap_textarea(textarea, effective_width); + } + } + PopupState::Input { + textarea, + is_commit: false, + .. + } => { + let raw: String = textarea.lines().join(""); + if popup_inner > 0 && !raw.is_empty() { + let mut new_ta = popup::make_textarea(""); + new_ta.insert_str(&raw); + soft_wrap_textarea(&mut new_ta, popup_inner); + *textarea = new_ta; + } + } + PopupState::CommitInput { + body_textarea, + body_state, + .. + } => { + if effective_width > 0 { + body_state.render_into(body_textarea, effective_width); + } + } + _ => {} + } + } + /// Receive completed diff results from the background thread (non-blocking). fn receive_diff_results(&mut self) { // Drain all available results, keeping only the latest valid one @@ -7777,33 +7638,6 @@ mod terminal_mouse_tests { assert!(!flags.contains(crossterm::event::KeyboardEnhancementFlags::REPORT_EVENT_TYPES)); } - #[test] - fn reconstructs_fragmented_arrow_sequence_without_leaking_amend_shortcut() { - assert_eq!( - parse_fragmented_escape_sequence("[A"), - Some(Event::Key(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE))) - ); - } - - #[test] - fn reconstructs_fragmented_focus_sequence_without_leaking_rebase_shortcut() { - assert_eq!( - parse_fragmented_escape_sequence("[I"), - Some(Event::FocusGained) - ); - } - - #[test] - fn reconstructs_fragmented_kitty_command_key_with_its_modifier() { - assert_eq!( - parse_fragmented_escape_sequence("[49;9u"), - Some(Event::Key(KeyEvent::new( - KeyCode::Char('1'), - KeyModifiers::SUPER - ))) - ); - } - #[test] fn plain_character_shortcuts_reject_extra_modifiers() { assert!(plain_char_key( @@ -7952,9 +7786,12 @@ fn setup_terminal() -> Result<(Term, bool)> { Ok((terminal, keyboard_enhanced)) } +/// Put the terminal back the way we found it. +/// +/// Nothing drains leftover input here: crossterm guards its reader with a +/// process-wide mutex that the input thread holds for the duration of its +/// blocking read, so any drain from this thread would silently no-op. fn restore_terminal(terminal: &mut Term, keyboard_enhanced: bool) -> Result<()> { - drain_pending_terminal_events(Duration::from_millis(0)); - if keyboard_enhanced { execute!( terminal.backend_mut(), @@ -7977,7 +7814,6 @@ fn restore_terminal(terminal: &mut Term, keyboard_enhanced: bool) -> Result<()> } terminal.backend_mut().flush()?; - drain_pending_terminal_events(Duration::from_millis(25)); terminal::disable_raw_mode()?; Ok(()) diff --git a/src/os/cmd.rs b/src/os/cmd.rs index e57b7fd..525adf1 100644 --- a/src/os/cmd.rs +++ b/src/os/cmd.rs @@ -4,6 +4,33 @@ use std::sync::{Arc, Mutex}; use anyhow::{Context, Result}; +/// Detach a child from the controlling terminal and keep it non-interactive. +/// +/// Background `git fetch`/`ls-remote` can spawn `ssh`, which opens `/dev/tty` +/// and races the TUI for keystrokes — swallowing `q` and navigation. `setsid` +/// gives the child no controlling terminal so `/dev/tty` fails; null stdin +/// stops anything that reads the pipe we inherit. +fn make_non_interactive(cmd: &mut Command) { + cmd.env("GIT_TERMINAL_PROMPT", "0"); + cmd.env("SSH_ASKPASS_REQUIRE", "never"); + if std::env::var_os("GIT_SSH_COMMAND").is_none() { + cmd.env("GIT_SSH_COMMAND", "ssh -oBatchMode=yes"); + } + + #[cfg(unix)] + { + use std::os::unix::process::CommandExt; + unsafe { + cmd.pre_exec(|| { + if libc::setsid() == -1 { + return Err(std::io::Error::last_os_error()); + } + Ok(()) + }); + } + } +} + /// Shared command log that CmdBuilder writes to when set. pub type CommandLog = Arc>>; @@ -135,8 +162,14 @@ impl CmdBuilder { cmd.env(key, value); } + make_non_interactive(&mut cmd); + if self.stdin_data.is_some() { cmd.stdin(Stdio::piped()); + } else { + // Don't inherit the TUI's stdin — a child that reads it races us + // for keystrokes (notably `q` while background fetch/ssh runs). + cmd.stdin(Stdio::null()); } cmd.stdout(Stdio::piped());