From 7c8ed2c31049d45c56475d31667220e79cadda94 Mon Sep 17 00:00:00 2001 From: "shingo.imota" Date: Sat, 5 Sep 2026 09:00:28 +0900 Subject: [PATCH 1/2] fix(input): strip reassembled bracketed-paste markers --- crates/noa-app/src/input/paste.rs | 16 +++++++-------- crates/noa-app/src/input/tests.rs | 33 +++++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 9 deletions(-) diff --git a/crates/noa-app/src/input/paste.rs b/crates/noa-app/src/input/paste.rs index 10d09a9a..1700d4ab 100644 --- a/crates/noa-app/src/input/paste.rs +++ b/crates/noa-app/src/input/paste.rs @@ -76,15 +76,13 @@ pub(crate) fn paste_is_unsafe(text: &str, bracketed_paste: bool) -> bool { fn sanitize_paste_payload(bytes: &[u8]) -> Vec { let mut sanitized = Vec::with_capacity(bytes.len()); - let mut i = 0; - while i < bytes.len() { - if bytes[i..].starts_with(b"\x1b[200~") { - i += b"\x1b[200~".len(); - } else if bytes[i..].starts_with(b"\x1b[201~") { - i += b"\x1b[201~".len(); - } else { - sanitized.push(bytes[i]); - i += 1; + for &byte in bytes { + sanitized.push(byte); + // Match the output suffix: removing a nested marker can join its + // surrounding bytes into another marker. The remaining prefix has + // already been sanitized, so one suffix check per byte suffices. + if sanitized.ends_with(b"\x1b[200~") || sanitized.ends_with(b"\x1b[201~") { + sanitized.truncate(sanitized.len() - b"\x1b[201~".len()); } } sanitized diff --git a/crates/noa-app/src/input/tests.rs b/crates/noa-app/src/input/tests.rs index 13d2d8ab..c554b8a5 100644 --- a/crates/noa-app/src/input/tests.rs +++ b/crates/noa-app/src/input/tests.rs @@ -777,6 +777,39 @@ fn paste_strips_nested_bracket_markers_from_payload() { ); } +#[test] +fn paste_strips_bracket_markers_reassembled_by_removal() { + for marker in ["\x1b[200~", "\x1b[201~"] { + for nested in ["\x1b[200~", "\x1b[201~"] { + for split in 1..marker.len() { + let text = format!( + "日本{}{}{}echo harmless\n", + &marker[..split], + nested, + &marker[split..] + ); + assert_eq!( + encode_paste(&text, false), + Some("日本echo harmless\n".as_bytes().to_vec()), + "{text:?}" + ); + assert_eq!( + encode_paste(&text, true), + Some("\x1b[200~日本echo harmless\n\x1b[201~".as_bytes().to_vec()), + "{text:?}" + ); + } + } + } +} + +#[test] +fn paste_strips_deeply_nested_bracket_markers() { + let text = format!("{}{}", "\x1b[20".repeat(4096), "1~".repeat(4096)); + assert!(encode_paste(&text, true).is_none()); + assert!(encode_paste(&text, false).is_none()); +} + #[test] fn paste_with_only_bracket_markers_emits_no_bytes() { assert_eq!(encode_paste("\x1b[200~\x1b[201~", true), None); From 8b180908135e84a299455f7c7e36e70e2d3e7bd8 Mon Sep 17 00:00:00 2001 From: "shingo.imota" Date: Sat, 5 Sep 2026 09:00:42 +0900 Subject: [PATCH 2/2] fix(grid): preserve content below the cursor during reflow --- crates/noa-grid/src/screen/reflow.rs | 15 +++++-- crates/noa-grid/src/tests/text_resize.rs | 51 ++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 3 deletions(-) diff --git a/crates/noa-grid/src/screen/reflow.rs b/crates/noa-grid/src/screen/reflow.rs index 57893bbd..ab9922f8 100644 --- a/crates/noa-grid/src/screen/reflow.rs +++ b/crates/noa-grid/src/screen/reflow.rs @@ -5,8 +5,8 @@ use super::*; impl Screen { /// Resize the grid to `cols`×`rows`, reflowing soft-wrapped logical lines, /// clamping the cursor, and resetting the scroll region to full-screen. On - /// row-shrink, rows below the cursor are dropped first; if the cursor would - /// fall off the bottom, rows are moved to scrollback and the cursor follows. + /// row-shrink, blank trailing rows below the cursor are dropped first; + /// otherwise top rows move to scrollback and the cursor follows. pub fn resize(&mut self, cols: u16, rows: u16) { let cols = cols.max(1); let rows = rows.max(1); @@ -110,19 +110,28 @@ impl Screen { // Records where each old logical line landed, so placements can be // re-anchored onto the new row numbering after reflow re-packs history. let mut line_remaps: Vec = Vec::new(); + let mut content_end = 0; let (reflowed_len, cursor_position, saved_position) = self.stream_reflow_lines( cols, &blank, cursor_point, saved_point, - |_, _| {}, + |r, row| { + if !row.is_blank() || row.wrapped { + content_end = r + 1; + } + }, |remap| line_remaps.push(remap), ); let cursor_row = cursor_position.row.min(reflowed_len.saturating_sub(1)); let max_grid_start = reflowed_len.saturating_sub(target_rows); + // A cursor above populated rows must not make the reflow discard + // those rows. Keep the last content row in the window and send any + // displaced top rows to scrollback, just like a row-only shrink. let grid_start = cursor_row .saturating_sub(target_rows.saturating_sub(1)) + .max(content_end.saturating_sub(target_rows)) .min(max_grid_start); let grid_end = (grid_start + target_rows).min(reflowed_len); diff --git a/crates/noa-grid/src/tests/text_resize.rs b/crates/noa-grid/src/tests/text_resize.rs index e67b632e..5aeec1b1 100644 --- a/crates/noa-grid/src/tests/text_resize.rs +++ b/crates/noa-grid/src/tests/text_resize.rs @@ -308,6 +308,57 @@ fn resize_shrink_rows_preserves_nonempty_rows_below_cursor() { assert_eq!(t.primary.cursor.y, 0); } +#[test] +fn resize_reflow_preserves_nonempty_rows_below_cursor() { + for cols in [9, 11] { + let mut t = run_size(10, 4, b"AAAA\r\nBBBB\r\nCCCC\r\nDDDD\x1b[H"); + + t.resize(GridSize::new(cols, 3)); + + assert_eq!(t.scrollback_len(), 1); + assert_eq!(t.primary.absolute_row(0).unwrap().cells[0].ch, 'A'); + assert_eq!(row_text(&t, 0, 4), "BBBB"); + assert_eq!(row_text(&t, 1, 4), "CCCC"); + assert_eq!(row_text(&t, 2, 4), "DDDD"); + assert_eq!(t.primary.cursor.y, 0); + } +} + +#[test] +fn resize_reflow_preserves_wrapped_content_below_cursor() { + let mut t = run_size(8, 3, b"AAAA\r\nBBBBBBBB\r\nCCCCCCCC\x1b[H"); + + t.resize(GridSize::new(4, 3)); + + assert_eq!(t.scrollback_len(), 2); + assert_eq!(t.primary.absolute_row(0).unwrap().cells[0].ch, 'A'); + let wrapped = t.primary.absolute_row(1).unwrap(); + assert_eq!(wrapped.cells[0].ch, 'B'); + assert!(wrapped.wrapped); + assert_eq!(row_text(&t, 0, 4), "BBBB"); + assert_eq!(row_text(&t, 1, 4), "CCCC"); + assert!(t.primary.grid[1].wrapped); + assert_eq!(row_text(&t, 2, 4), "CCCC"); +} + +#[test] +fn resize_reflow_drops_erased_trailing_rows_before_scrolling() { + let mut t = run_size( + 10, + 5, + b"AAAA\r\nBBBB\r\nCCCC\r\nDDDD\r\nEEEE\x1b[4;1H\x1b[J\x1b[3;1H\x1b7", + ); + + t.resize(GridSize::new(11, 3)); + + assert_eq!(t.scrollback_len(), 0); + assert_eq!(row_text(&t, 0, 4), "AAAA"); + assert_eq!(row_text(&t, 1, 4), "BBBB"); + assert_eq!(row_text(&t, 2, 4), "CCCC"); + assert_eq!(t.primary.cursor.y, 2); + assert_eq!(t.primary.saved_cursor.unwrap().y, 2); +} + #[test] fn resize_shrink_rows_treats_erased_rows_below_cursor_as_disposable() { // Rows 4-5 held text and were then cleared with EL 2; they are visually