fix(tui): ignore leaked mouse reports in composer

PR #1421 from @reidliu41. Filters SGR mouse-report bursts that some terminal chains leak into stdin while mouse capture is enabled, while preserving ordinary coordinate-like text.
This commit is contained in:
reidliu41
2026-05-10 23:27:14 -05:00
committed by Hunter Bown
parent f5784909cf
commit 4afa88ebfc
+127
View File
@@ -369,6 +369,66 @@ fn sanitize_api_key_text(text: &str) -> String {
text.chars().filter(|c| !c.is_control()).collect()
}
fn strip_raw_mouse_report_runs(input: &str, cursor: usize) -> Option<(String, usize)> {
let chars: Vec<char> = input.chars().collect();
let mut output = String::with_capacity(input.len());
let mut new_cursor = 0usize;
let mut changed = false;
let mut index = 0usize;
while index < chars.len() {
if is_raw_mouse_report_run_char(chars[index]) {
let start = index;
while index < chars.len() && is_raw_mouse_report_run_char(chars[index]) {
index += 1;
}
let run = &chars[start..index];
if looks_like_raw_mouse_report_run(run) {
changed = true;
continue;
}
for (offset, ch) in run.iter().copied().enumerate() {
if start + offset < cursor {
new_cursor += 1;
}
output.push(ch);
}
continue;
}
if index < cursor {
new_cursor += 1;
}
output.push(chars[index]);
index += 1;
}
changed.then(|| {
let cursor = new_cursor.min(char_count(&output));
(output, cursor)
})
}
fn is_raw_mouse_report_run_char(ch: char) -> bool {
matches!(ch, '\x1b' | '[' | '<' | ';' | ':' | 'M' | 'm') || ch.is_ascii_digit()
}
fn looks_like_raw_mouse_report_run(run: &[char]) -> bool {
if run.len() < 5 {
return false;
}
let has_separator = run.iter().any(|ch| matches!(ch, ';' | ':'));
let terminators = run.iter().filter(|ch| matches!(ch, 'M' | 'm')).count();
if !has_separator || terminators == 0 {
return false;
}
has_sgr_mouse_marker(run) || terminators >= 2
}
fn has_sgr_mouse_marker(run: &[char]) -> bool {
run.windows(2).any(|window| window == ['[', '<'])
}
const MAX_SUBMITTED_INPUT_CHARS: usize = 16_000;
const MAX_DRAFT_HISTORY: usize = 50;
@@ -2537,6 +2597,7 @@ impl App {
let byte_index = byte_index_at_char(&self.input, cursor);
self.input.insert_str(byte_index, text);
self.cursor_position = cursor + char_count(text);
self.strip_raw_mouse_reports_from_input();
self.slash_menu_hidden = false;
self.mention_menu_hidden = false;
self.mention_menu_selected = 0;
@@ -2798,12 +2859,25 @@ impl App {
let byte_index = byte_index_at_char(&self.input, cursor);
self.input.insert(byte_index, c);
self.cursor_position = cursor + 1;
self.strip_raw_mouse_reports_from_input();
self.slash_menu_hidden = false;
self.mention_menu_hidden = false;
self.mention_menu_selected = 0;
self.needs_redraw = true;
}
fn strip_raw_mouse_reports_from_input(&mut self) {
if !self.use_mouse_capture {
return;
}
if let Some((input, cursor_position)) =
strip_raw_mouse_report_runs(&self.input, self.cursor_position)
{
self.input = input;
self.cursor_position = cursor_position;
}
}
pub fn delete_char(&mut self) {
self.clear_input_history_navigation();
self.selected_attachment_index = None;
@@ -4041,6 +4115,59 @@ mod tests {
assert!(app.trust_mode);
}
#[test]
fn composer_strips_raw_sgr_mouse_report_when_mouse_capture_is_enabled() {
let mut app = App::new(test_options(false), &Config::default());
app.use_mouse_capture = true;
app.insert_str("[<35;44;18M");
assert_eq!(app.input, "");
assert_eq!(app.cursor_position, 0);
}
#[test]
fn composer_strips_corrupted_mouse_report_burst() {
let mut app = App::new(test_options(false), &Config::default());
app.use_mouse_capture = true;
app.insert_str("draft ");
let leaked = "43;19M[<35;44;18M[<35;45;18M5;46;18M;48;18M";
app.insert_str(leaked);
assert_eq!(app.input, "draft ");
assert_eq!(app.cursor_position, "draft ".chars().count());
}
#[test]
fn composer_keeps_mouse_like_text_when_mouse_capture_is_disabled() {
let mut app = App::new(test_options(false), &Config::default());
app.insert_str("[<35;44;18M");
assert_eq!(app.input, "[<35;44;18M");
}
#[test]
fn composer_keeps_normal_bracket_text_with_mouse_capture_enabled() {
let mut app = App::new(test_options(false), &Config::default());
app.use_mouse_capture = true;
app.insert_str("Use [<tag>] normally");
assert_eq!(app.input, "Use [<tag>] normally");
}
#[test]
fn composer_keeps_coordinate_like_text_with_mouse_capture_enabled() {
let mut app = App::new(test_options(false), &Config::default());
app.use_mouse_capture = true;
app.insert_str("Size 12;34M");
assert_eq!(app.input, "Size 12;34M");
}
#[test]
fn onboarded_user_still_gets_workspace_trust_prompt_when_needed() {
assert_eq!(