From 2db859916868f3a596482bb86c6120f6f88496fc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 8 Feb 2026 07:26:51 +0000 Subject: [PATCH 1/5] Initial plan From aec377e754b52d1fe3e780084c8c07522ec90cc9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 8 Feb 2026 07:33:10 +0000 Subject: [PATCH 2/5] Implement line wrapping in vi display - Made expand_and_wrap_line() public in screen.rs - Rewrote refresh_screen() to use wrapped lines instead of truncation - Added calculate_cursor_screen_row() to properly position cursor with wrapped lines - Fixed cursor column calculation to account for wrapping within a line Co-authored-by: jgarzik <494411+jgarzik@users.noreply.github.com> --- editors/vi/editor.rs | 165 +++++++++++++++++++++++++++++++++------- editors/vi/ui/screen.rs | 2 +- 2 files changed, 139 insertions(+), 28 deletions(-) diff --git a/editors/vi/editor.rs b/editors/vi/editor.rs index 9be14de2..890915fa 100644 --- a/editors/vi/editor.rs +++ b/editors/vi/editor.rs @@ -3010,6 +3010,71 @@ impl Editor { Ok(()) } + /// Calculate which screen row the cursor should appear on, accounting for line wrapping. + /// Returns the screen row (1-indexed) or 0 if the cursor is not visible. + fn calculate_cursor_screen_row( + &self, + cursor_line: usize, + cursor_col: usize, + top_line: usize, + top_offset: usize, + max_rows: usize, + cols: usize, + ) -> usize { + if cursor_line < top_line { + return 0; // Cursor is above visible area + } + + let mut screen_row = 1; // 1-indexed + let mut buffer_line = top_line; + let mut skip_wrapped_rows = top_offset; + + // Walk through buffer lines until we reach the cursor line + while buffer_line < cursor_line && screen_row <= max_rows { + if let Some(line) = self.buffer.line(buffer_line) { + let wrapped = self.screen.expand_and_wrap_line(line.content(), cols); + + // Skip wrapped rows from top_offset for the first line + if buffer_line == top_line && skip_wrapped_rows > 0 { + let visible_rows = wrapped.len().saturating_sub(skip_wrapped_rows); + screen_row += visible_rows; + skip_wrapped_rows = 0; + } else { + screen_row += wrapped.len(); + } + } else { + screen_row += 1; + } + buffer_line += 1; + } + + if buffer_line != cursor_line || screen_row > max_rows { + return 0; // Cursor line not reached or off-screen + } + + // Now we're at the cursor line - find which wrapped row the cursor is on + if let Some(line) = self.buffer.line(cursor_line) { + let display_col = self.screen.buffer_col_to_display_col(line.content(), cursor_col); + let wrapped_row_index = display_col / cols; + + // If this is the top line, account for offset + if cursor_line == top_line { + if wrapped_row_index < skip_wrapped_rows { + return 0; // Cursor is in a wrapped row that's scrolled off + } + screen_row += wrapped_row_index - skip_wrapped_rows; + } else { + screen_row += wrapped_row_index; + } + } + + if screen_row > max_rows { + 0 // Off-screen + } else { + screen_row + } + } + /// Refresh the screen. fn refresh_screen(&mut self) -> Result<()> { const LINE_NUMBER_WIDTH: usize = 8; // "%6d " format @@ -3022,32 +3087,64 @@ impl Editor { self.terminal.hide_cursor()?; self.terminal.move_cursor_home()?; - // Render buffer lines + // Render buffer lines with wrapping let top = self.screen.top_line(); + let top_offset = self.screen.top_line_offset(); let height = (size.rows as usize).saturating_sub(1); // Leave room for status + let avail_cols = if self.options.number { + (size.cols as usize).saturating_sub(LINE_NUMBER_WIDTH) + } else { + size.cols as usize + }; + + let mut screen_row = 0; + let mut buffer_line = top; + let mut skip_wrapped_rows = top_offset; - for screen_row in 0..height { - let line_num = top + screen_row; + while screen_row < height { self.terminal.move_cursor((screen_row + 1) as u16, 1)?; self.terminal.clear_line_to_end()?; - if line_num <= self.buffer.line_count() { - if let Some(line) = self.buffer.line(line_num) { - // Line number prefix when option set - if self.options.number { - self.terminal.write_str(&format!("{:6} ", line_num))?; + if buffer_line <= self.buffer.line_count() { + if let Some(line) = self.buffer.line(buffer_line) { + // Wrap the line + let wrapped = self.screen.expand_and_wrap_line(line.content(), avail_cols); + + // Skip wrapped rows if we're starting mid-line + for (wrap_idx, wrapped_row) in wrapped.iter().enumerate() { + if skip_wrapped_rows > 0 { + skip_wrapped_rows -= 1; + continue; + } + if screen_row >= height { + break; + } + + // Move to the correct position + self.terminal.move_cursor((screen_row + 1) as u16, 1)?; + self.terminal.clear_line_to_end()?; + + // Line number prefix when option set (only on first wrapped row) + if self.options.number && wrap_idx == 0 { + self.terminal.write_str(&format!("{:6} ", buffer_line))?; + } else if self.options.number { + // Indent continuation lines + self.terminal.write_str(" ")?; + } + + self.terminal.write_str(wrapped_row)?; + screen_row += 1; } - let avail_cols = if self.options.number { - (size.cols as usize).saturating_sub(LINE_NUMBER_WIDTH) - } else { - size.cols as usize - }; - // Expand tabs and truncate (expand_line already caps at max_cols) - let content = self.screen.expand_line(line.content(), avail_cols); - self.terminal.write_str(&content)?; + buffer_line += 1; + } else { + self.terminal.write_str("~")?; + screen_row += 1; + buffer_line += 1; } } else { self.terminal.write_str("~")?; + screen_row += 1; + buffer_line += 1; } } @@ -3059,20 +3156,34 @@ impl Editor { // Position cursor let cursor = self.buffer.cursor(); - let display_line = (cursor.line - top + 1) as u16; - let display_col = self.screen.buffer_col_to_display_col( - self.buffer - .line(cursor.line) - .map(|l| l.content()) - .unwrap_or(""), + + // Calculate the actual screen row considering line wrapping + let display_line = self.calculate_cursor_screen_row( + cursor.line, + cursor.column, + top, + top_offset, + height, + avail_cols, + ) as u16; + + let cursor_line_content = self.buffer + .line(cursor.line) + .map(|l| l.content()) + .unwrap_or(""); + let display_col_in_line = self.screen.buffer_col_to_display_col( + cursor_line_content, cursor.column, - ) as u16 - + 1; - // Add line number offset + ); + + // Which wrapped row of the cursor's line are we on? + let display_col_in_wrapped_row = display_col_in_line % avail_cols; + + // Add 1 for 1-indexed and add line number width if needed let display_col = if self.options.number { - display_col + LINE_NUMBER_WIDTH as u16 + (display_col_in_wrapped_row + 1 + LINE_NUMBER_WIDTH) as u16 } else { - display_col + (display_col_in_wrapped_row + 1) as u16 }; if self.mode == Mode::Ex { diff --git a/editors/vi/ui/screen.rs b/editors/vi/ui/screen.rs index 18e0f584..b24dea56 100644 --- a/editors/vi/ui/screen.rs +++ b/editors/vi/ui/screen.rs @@ -295,7 +295,7 @@ impl Screen { /// Expand and wrap a line into multiple display rows. /// Returns a vector of strings, each representing one wrapped display row. - fn expand_and_wrap_line(&self, line: &str, max_cols: usize) -> Vec { + pub fn expand_and_wrap_line(&self, line: &str, max_cols: usize) -> Vec { if max_cols == 0 { return vec![String::new()]; } From ac6a0ec580d8e2190cc4063a2c85978bc2066284 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 8 Feb 2026 07:34:23 +0000 Subject: [PATCH 3/5] Add integration test for vi long line wrapping Added test_pty_vi_long_line_wrapping to verify that vi correctly wraps long lines across multiple display rows instead of truncating them. Co-authored-by: jgarzik <494411+jgarzik@users.noreply.github.com> --- editors/tests/pty/mod.rs | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/editors/tests/pty/mod.rs b/editors/tests/pty/mod.rs index 6ea9c48d..d00281fd 100644 --- a/editors/tests/pty/mod.rs +++ b/editors/tests/pty/mod.rs @@ -212,3 +212,38 @@ fn test_pty_vi_set_number() { let contents = std::fs::read_to_string(&file_path).unwrap(); assert_eq!(contents, "line1\nline2\nline3\n"); } + +/// Test: Long lines should wrap to multiple display rows. +/// This test verifies that vi wraps long lines for display (POSIX requirement) +/// rather than truncating them. +#[test] +fn test_pty_vi_long_line_wrapping() { + let td = tempdir().unwrap(); + let file_path = td.path().join("test_wrap.txt"); + + // Create a single very long line (200+ characters) + let long_line = (1..=100).map(|n| n.to_string()).collect::>().join(""); + std::fs::write(&file_path, format!("{}\n", long_line)).unwrap(); + + // Use narrow terminal (40 cols) to force wrapping + let mut vi = ViPtySession::new(&file_path, 25, 40); + vi.sleep_ms(500); + + // Move cursor to the end of the line + vi.keys("$"); + vi.sleep_ms(100); + + // If wrapping works, we should be able to navigate without panic + vi.keys("0"); // Go to start + vi.sleep_ms(100); + vi.keys("$"); // Go to end + vi.sleep_ms(100); + + // Quit without saving + vi.keys(":q!\r"); + vi.wait(); + + // File should be unchanged + let contents = std::fs::read_to_string(&file_path).unwrap(); + assert_eq!(contents, format!("{}\n", long_line)); +} From e1b6f6279dab6180650b240d6e6104ed0683d101 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 8 Feb 2026 07:35:55 +0000 Subject: [PATCH 4/5] Address code review feedback - Add guard against zero/very small terminal width to prevent division by zero - Replace magic string ' ' with LINE_NUMBER_WIDTH constant for consistency - Add named constant for test line length - Add early return when terminal is too narrow to display content Co-authored-by: jgarzik <494411+jgarzik@users.noreply.github.com> --- editors/tests/pty/mod.rs | 4 +++- editors/vi/editor.rs | 12 ++++++++++-- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/editors/tests/pty/mod.rs b/editors/tests/pty/mod.rs index d00281fd..0c492757 100644 --- a/editors/tests/pty/mod.rs +++ b/editors/tests/pty/mod.rs @@ -218,11 +218,13 @@ fn test_pty_vi_set_number() { /// rather than truncating them. #[test] fn test_pty_vi_long_line_wrapping() { + const TEST_LINE_LENGTH: usize = 100; // Create line with numbers 1-100 concatenated + let td = tempdir().unwrap(); let file_path = td.path().join("test_wrap.txt"); // Create a single very long line (200+ characters) - let long_line = (1..=100).map(|n| n.to_string()).collect::>().join(""); + let long_line = (1..=TEST_LINE_LENGTH).map(|n| n.to_string()).collect::>().join(""); std::fs::write(&file_path, format!("{}\n", long_line)).unwrap(); // Use narrow terminal (40 cols) to force wrapping diff --git a/editors/vi/editor.rs b/editors/vi/editor.rs index 890915fa..69f5682a 100644 --- a/editors/vi/editor.rs +++ b/editors/vi/editor.rs @@ -3096,6 +3096,14 @@ impl Editor { } else { size.cols as usize }; + + // Guard against zero or very small terminal width + if avail_cols == 0 { + // Terminal too narrow to display content + self.terminal.show_cursor()?; + self.terminal.flush()?; + return Ok(()); + } let mut screen_row = 0; let mut buffer_line = top; @@ -3128,8 +3136,8 @@ impl Editor { if self.options.number && wrap_idx == 0 { self.terminal.write_str(&format!("{:6} ", buffer_line))?; } else if self.options.number { - // Indent continuation lines - self.terminal.write_str(" ")?; + // Indent continuation lines with same width as line numbers + self.terminal.write_str(&" ".repeat(LINE_NUMBER_WIDTH))?; } self.terminal.write_str(wrapped_row)?; From 8f34320977cbdc73a055344c2bdf9b9541ba0b43 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 8 Feb 2026 13:40:58 +0000 Subject: [PATCH 5/5] Run cargo fmt to fix formatting issues Co-authored-by: jgarzik <494411+jgarzik@users.noreply.github.com> --- editors/tests/pty/mod.rs | 15 +++++++++------ editors/vi/editor.rs | 28 +++++++++++++++------------- 2 files changed, 24 insertions(+), 19 deletions(-) diff --git a/editors/tests/pty/mod.rs b/editors/tests/pty/mod.rs index 0c492757..a6bd5063 100644 --- a/editors/tests/pty/mod.rs +++ b/editors/tests/pty/mod.rs @@ -219,28 +219,31 @@ fn test_pty_vi_set_number() { #[test] fn test_pty_vi_long_line_wrapping() { const TEST_LINE_LENGTH: usize = 100; // Create line with numbers 1-100 concatenated - + let td = tempdir().unwrap(); let file_path = td.path().join("test_wrap.txt"); - + // Create a single very long line (200+ characters) - let long_line = (1..=TEST_LINE_LENGTH).map(|n| n.to_string()).collect::>().join(""); + let long_line = (1..=TEST_LINE_LENGTH) + .map(|n| n.to_string()) + .collect::>() + .join(""); std::fs::write(&file_path, format!("{}\n", long_line)).unwrap(); // Use narrow terminal (40 cols) to force wrapping let mut vi = ViPtySession::new(&file_path, 25, 40); vi.sleep_ms(500); - + // Move cursor to the end of the line vi.keys("$"); vi.sleep_ms(100); - + // If wrapping works, we should be able to navigate without panic vi.keys("0"); // Go to start vi.sleep_ms(100); vi.keys("$"); // Go to end vi.sleep_ms(100); - + // Quit without saving vi.keys(":q!\r"); vi.wait(); diff --git a/editors/vi/editor.rs b/editors/vi/editor.rs index 69f5682a..7ca058b1 100644 --- a/editors/vi/editor.rs +++ b/editors/vi/editor.rs @@ -3033,7 +3033,7 @@ impl Editor { while buffer_line < cursor_line && screen_row <= max_rows { if let Some(line) = self.buffer.line(buffer_line) { let wrapped = self.screen.expand_and_wrap_line(line.content(), cols); - + // Skip wrapped rows from top_offset for the first line if buffer_line == top_line && skip_wrapped_rows > 0 { let visible_rows = wrapped.len().saturating_sub(skip_wrapped_rows); @@ -3054,9 +3054,11 @@ impl Editor { // Now we're at the cursor line - find which wrapped row the cursor is on if let Some(line) = self.buffer.line(cursor_line) { - let display_col = self.screen.buffer_col_to_display_col(line.content(), cursor_col); + let display_col = self + .screen + .buffer_col_to_display_col(line.content(), cursor_col); let wrapped_row_index = display_col / cols; - + // If this is the top line, account for offset if cursor_line == top_line { if wrapped_row_index < skip_wrapped_rows { @@ -3096,7 +3098,7 @@ impl Editor { } else { size.cols as usize }; - + // Guard against zero or very small terminal width if avail_cols == 0 { // Terminal too narrow to display content @@ -3164,7 +3166,7 @@ impl Editor { // Position cursor let cursor = self.buffer.cursor(); - + // Calculate the actual screen row considering line wrapping let display_line = self.calculate_cursor_screen_row( cursor.line, @@ -3174,19 +3176,19 @@ impl Editor { height, avail_cols, ) as u16; - - let cursor_line_content = self.buffer + + let cursor_line_content = self + .buffer .line(cursor.line) .map(|l| l.content()) .unwrap_or(""); - let display_col_in_line = self.screen.buffer_col_to_display_col( - cursor_line_content, - cursor.column, - ); - + let display_col_in_line = self + .screen + .buffer_col_to_display_col(cursor_line_content, cursor.column); + // Which wrapped row of the cursor's line are we on? let display_col_in_wrapped_row = display_col_in_line % avail_cols; - + // Add 1 for 1-indexed and add line number width if needed let display_col = if self.options.number { (display_col_in_wrapped_row + 1 + LINE_NUMBER_WIDTH) as u16