diff --git a/crates/terminal/src/terminal.rs b/crates/terminal/src/terminal.rs index 91c972ed8d3f01633ba9c628739a202bd625a494..27ab93ccbae017d8bb389375079a7fa1d63c0373 100644 --- a/crates/terminal/src/terminal.rs +++ b/crates/terminal/src/terminal.rs @@ -1217,12 +1217,30 @@ impl Terminal { pub fn write_output(&mut self, bytes: &[u8], cx: &mut Context) { // Inject bytes directly into the terminal emulator and refresh the UI. // This bypasses the PTY/event loop for display-only terminals. + // + // We first convert LF to CRLF, to get the expected line wrapping in Alacritty. + // When output comes from piped commands (not a PTY) such as codex-acp, and that + // output only contains LF (\n) without a CR (\r) after it, such as the output + // of the `ls` command when running outside a PTY, Alacritty moves the cursor + // cursor down a line but does not move it back to the initial column. This makes + // the rendered output look ridiculous. To prevent this, we insert a CR (\r) before + // each LF that didn't already have one. (Alacritty doesn't have a setting for this.) + let mut converted = Vec::with_capacity(bytes.len()); + let mut prev_byte = 0u8; + for &byte in bytes { + if byte == b'\n' && prev_byte != b'\r' { + converted.push(b'\r'); + } + converted.push(byte); + prev_byte = byte; + } + let mut processor = alacritty_terminal::vte::ansi::Processor::< alacritty_terminal::vte::ansi::StdSyncHandler, >::new(); { let mut term = self.term.lock(); - processor.advance(&mut *term, bytes); + processor.advance(&mut *term, &converted); } cx.emit(Event::Wakeup); } @@ -2681,4 +2699,116 @@ mod tests { ..Default::default() } } + + #[gpui::test] + async fn test_write_output_converts_lf_to_crlf(cx: &mut TestAppContext) { + let terminal = cx.new(|cx| { + TerminalBuilder::new_display_only(CursorShape::default(), AlternateScroll::On, None, 0) + .unwrap() + .subscribe(cx) + }); + + // Test simple LF conversion + terminal.update(cx, |terminal, cx| { + terminal.write_output(b"line1\nline2\n", cx); + }); + + // Get the content by directly accessing the term + let content = terminal.update(cx, |terminal, _cx| { + let term = terminal.term.lock_unfair(); + Terminal::make_content(&term, &terminal.last_content) + }); + + // If LF is properly converted to CRLF, each line should start at column 0 + // The diagonal staircase bug would cause increasing column positions + + // Get the cells and check that lines start at column 0 + let cells = &content.cells; + let mut line1_col0 = false; + let mut line2_col0 = false; + + for cell in cells { + if cell.c == 'l' && cell.point.column.0 == 0 { + if cell.point.line.0 == 0 && !line1_col0 { + line1_col0 = true; + } else if cell.point.line.0 == 1 && !line2_col0 { + line2_col0 = true; + } + } + } + + assert!(line1_col0, "First line should start at column 0"); + assert!(line2_col0, "Second line should start at column 0"); + } + + #[gpui::test] + async fn test_write_output_preserves_existing_crlf(cx: &mut TestAppContext) { + let terminal = cx.new(|cx| { + TerminalBuilder::new_display_only(CursorShape::default(), AlternateScroll::On, None, 0) + .unwrap() + .subscribe(cx) + }); + + // Test that existing CRLF doesn't get doubled + terminal.update(cx, |terminal, cx| { + terminal.write_output(b"line1\r\nline2\r\n", cx); + }); + + // Get the content by directly accessing the term + let content = terminal.update(cx, |terminal, _cx| { + let term = terminal.term.lock_unfair(); + Terminal::make_content(&term, &terminal.last_content) + }); + + let cells = &content.cells; + + // Check that both lines start at column 0 + let mut found_lines_at_column_0 = 0; + for cell in cells { + if cell.c == 'l' && cell.point.column.0 == 0 { + found_lines_at_column_0 += 1; + } + } + + assert!( + found_lines_at_column_0 >= 2, + "Both lines should start at column 0" + ); + } + + #[gpui::test] + async fn test_write_output_preserves_bare_cr(cx: &mut TestAppContext) { + let terminal = cx.new(|cx| { + TerminalBuilder::new_display_only(CursorShape::default(), AlternateScroll::On, None, 0) + .unwrap() + .subscribe(cx) + }); + + // Test that bare CR (without LF) is preserved + terminal.update(cx, |terminal, cx| { + terminal.write_output(b"hello\rworld", cx); + }); + + // Get the content by directly accessing the term + let content = terminal.update(cx, |terminal, _cx| { + let term = terminal.term.lock_unfair(); + Terminal::make_content(&term, &terminal.last_content) + }); + + let cells = &content.cells; + + // Check that we have "world" at the beginning of the line + let mut text = String::new(); + for cell in cells.iter().take(5) { + if cell.point.line.0 == 0 { + text.push(cell.c); + } + } + + assert!( + text.starts_with("world"), + "Bare CR should allow overwriting: got '{}'", + text + ); + } }