From 83b3c91d4fea0b209b1dabaf9e44eca72a24dc99 Mon Sep 17 00:00:00 2001 From: MagMueller Date: Fri, 29 May 2026 11:39:27 -0700 Subject: [PATCH 1/2] Composer: clip click past end-of-line to end of clicked visual row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug: clicking past the visible end of a line in the composer placed the cursor on a LATER visual row of the same wrapped logical line, at an offset proportional to how far past the end the user clicked. Fix Composer::set_cursor_from_wrapped_position: compute the visible range of the clicked visual row and clamp the click column to it. For non-last visual rows of a wrapped logical line, clamp one position earlier — position == row_end is *visually* the start of the next wrapped row (no character separates soft-wrapped rows), so without this the cursor still rendered one row down. Last-visual-row clamps to line_len so end-of-line clicks land before the trailing \n, which renders correctly. Adds click_past_end_of_line_clips_to_that_line_end regression test covering both plain multi-line and wrapped cases. --- crates/browser-use-tui/src/composer.rs | 55 ++++++++++++++++++++++++-- 1 file changed, 51 insertions(+), 4 deletions(-) diff --git a/crates/browser-use-tui/src/composer.rs b/crates/browser-use-tui/src/composer.rs index cc59e32c..3496c541 100644 --- a/crates/browser-use-tui/src/composer.rs +++ b/crates/browser-use-tui/src/composer.rs @@ -174,10 +174,22 @@ impl Composer { } else { target_col }; - let line_col = row_in_line - .saturating_mul(wrap_width) - .saturating_add(col_in_line) - .min(line_len); + // Clip the click to the visible content of THIS visual row. + // For wrapped logical lines, position == row_end is *visually* + // the start of the next wrapped row (no character separates + // soft-wrapped rows), so clicking past end-of-row would render + // the cursor one row below where the user clicked. Clamp one + // position earlier when there is a next visual row, so cursor + // stays on the clicked row. + let row_start = row_in_line.saturating_mul(wrap_width); + let row_end = row_start.saturating_add(wrap_width).min(line_len); + let is_last_visual_row_of_line = row_in_line.saturating_add(1) >= line_visual_rows; + let click_max = if is_last_visual_row_of_line { + row_end + } else { + row_end.saturating_sub(1).max(row_start) + }; + let line_col = row_start.saturating_add(col_in_line).min(click_max); self.cursor = line_start.saturating_add(line_col).min(self.input_len()); self.preferred_column = None; return true; @@ -938,6 +950,41 @@ mod tests { ); } + #[test] + fn click_past_end_of_line_clips_to_that_line_end() { + // Plain (non-wrapped) lines: clicking far past the end of a short + // logical line must stop at the end of that line, not bleed into + // the next logical line. + let mut composer = Composer::default(); + composer.set_input("first line\nsecond word\nthird".to_string()); + + // Click on row 0 ("first line") way past the visible end. + assert!(composer.set_cursor_from_wrapped_position(4, 80, 0, 60)); + assert_eq!(composer.cursor(), "first line".chars().count()); + + // Click on row 1 ("second word") way past the visible end. + assert!(composer.set_cursor_from_wrapped_position(4, 80, 1, 60)); + assert_eq!( + composer.cursor(), + "first line\nsecond word".chars().count() + ); + + // Wrapped logical line: clicking past the end of visual row 0 + // must land at the end of that visual row, NOT at the end of the + // whole logical line (which would be on a later visual row). + let mut composer = Composer::default(); + // width=8, prompt "> " takes 2 cols => content/wrap width = 6. + // "abcdefghij" wraps into "abcdef" and "ghij". + composer.set_input("abcdefghij".to_string()); + // Click on row 0 at the rightmost in-rect column (col 7, past 'f'). + assert!(composer.set_cursor_from_wrapped_position(4, 8, 0, 7)); + assert_eq!(composer.cursor(), "abcdef".chars().count()); + // Click on row 1 past the end of "ghij" still pins to end of row 1 + // (which is also the end of the logical line here). + assert!(composer.set_cursor_from_wrapped_position(4, 8, 1, 7)); + assert_eq!(composer.cursor(), "abcdefghij".chars().count()); + } + #[test] fn ctrl_w_keeps_whitespace_delimited_shell_behavior() { let mut composer = Composer::default(); From 307916f1f568130817daa052735210cc5e3b22e6 Mon Sep 17 00:00:00 2001 From: MagMueller Date: Fri, 29 May 2026 13:22:04 -0700 Subject: [PATCH 2/2] Composer: tighten click-past-EOL test to assert visual row, add multi-line plain repro --- crates/browser-use-tui/src/composer.rs | 53 ++++++++++++++++++++++---- 1 file changed, 45 insertions(+), 8 deletions(-) diff --git a/crates/browser-use-tui/src/composer.rs b/crates/browser-use-tui/src/composer.rs index 3496c541..ae5fcff2 100644 --- a/crates/browser-use-tui/src/composer.rs +++ b/crates/browser-use-tui/src/composer.rs @@ -964,27 +964,64 @@ mod tests { // Click on row 1 ("second word") way past the visible end. assert!(composer.set_cursor_from_wrapped_position(4, 80, 1, 60)); - assert_eq!( - composer.cursor(), - "first line\nsecond word".chars().count() - ); + assert_eq!(composer.cursor(), "first line\nsecond word".chars().count()); // Wrapped logical line: clicking past the end of visual row 0 - // must land at the end of that visual row, NOT at the end of the - // whole logical line (which would be on a later visual row). + // must land in a position that *renders* on visual row 0, not on + // the next wrapped row. Position == row_end is the start of the + // next wrapped row visually (no character separates wrapped rows), + // so we clamp one position earlier on non-last visual rows so the + // cursor stays on the clicked row. let mut composer = Composer::default(); // width=8, prompt "> " takes 2 cols => content/wrap width = 6. // "abcdefghij" wraps into "abcdef" and "ghij". composer.set_input("abcdefghij".to_string()); // Click on row 0 at the rightmost in-rect column (col 7, past 'f'). assert!(composer.set_cursor_from_wrapped_position(4, 8, 0, 7)); - assert_eq!(composer.cursor(), "abcdef".chars().count()); + // cursor=5 (between 'e' and 'f') renders at (row=0, col=5) — last + // cell of visual row 0. cursor=6 would render at (row=1, col=0) + // because in soft-wrapped text, position == wrap_width has no + // visual home on row 0. + assert_eq!(composer.cursor(), 5); + let (vrow, _vcol) = composer.cursor_visual_row_col_wrapped(6); + assert_eq!(vrow, 0, "cursor must render on the clicked visual row"); // Click on row 1 past the end of "ghij" still pins to end of row 1 - // (which is also the end of the logical line here). + // (which is the LAST visual row of the logical line, so clamping + // to line_len is fine — there's no next wrapped row to spill into). assert!(composer.set_cursor_from_wrapped_position(4, 8, 1, 7)); assert_eq!(composer.cursor(), "abcdefghij".chars().count()); } + #[test] + fn click_past_end_of_plain_line_in_multiline_composer_stays_on_clicked_row() { + // Magnus's repro: composer has multi-line text via Shift+Enter + // (so logical lines separated by \n, each non-wrapped). Clicking + // far past the visible end of a line must place the cursor at the + // end of that line AND render it on the clicked row, not on the + // next logical line. + let mut composer = Composer::default(); + composer.set_input("first line\nsecond line\nthird line".to_string()); + + // Click row 0 at col 100 (way past "first line"'s 10 chars). + assert!(composer.set_cursor_from_wrapped_position(4, 80, 0, 100)); + assert_eq!( + composer.cursor(), + "first line".chars().count(), + "cursor byte position should land at end of first line" + ); + let (vrow, _) = composer.cursor_visual_row_col_wrapped(78); + assert_eq!(vrow, 0, "cursor must render on row 0, not the next line"); + + // Click row 1 at col 100. + assert!(composer.set_cursor_from_wrapped_position(4, 80, 1, 100)); + assert_eq!( + composer.cursor(), + "first line\nsecond line".chars().count(), + ); + let (vrow, _) = composer.cursor_visual_row_col_wrapped(78); + assert_eq!(vrow, 1, "cursor must render on row 1, not the next line"); + } + #[test] fn ctrl_w_keeps_whitespace_delimited_shell_behavior() { let mut composer = Composer::default();