ZIm/crates/repl/src/outputs/plain.rs
Agus Zubiaga b103d7621b
Improve handling of large output in embedded terminals (#32416)
#31922 made embedded terminals automatically grow to fit the content. We
since found some issues with large output which this PR addresses by:

- Only shaping / laying out lines that are visible in the viewport
(based on `window.content_mask`)
- Falling back to embedded scrolling after 1K lines. The perf fix above
actually makes it possible to handle a lot of lines, but:
- Alacrity uses a `u16` for rows internally, so we needed a limit to
prevent overflow.
- Scrolling through thousands of lines to get to the other side of a
terminal tool call isn't great UX, so we might as well set the limit
low.
- We can consider raising the limit when we make card headers sticky.

Release Notes:

- Agent: Improve handling of large terminal output
2025-06-09 18:11:31 -03:00

341 lines
12 KiB
Rust

//! # Plain Text Output
//!
//! This module provides functionality for rendering plain text output in a terminal-like format.
//! It uses the Alacritty terminal emulator backend to process and display text, supporting
//! ANSI escape sequences for formatting, colors, and other terminal features.
//!
//! The main component of this module is the `TerminalOutput` struct, which handles the parsing
//! and rendering of text input, simulating a basic terminal environment within REPL output.
//!
//! This module is used for displaying:
//!
//! - Standard output (stdout)
//! - Standard error (stderr)
//! - Plain text content
//! - Error tracebacks
//!
use alacritty_terminal::{
event::VoidListener,
grid::Dimensions as _,
index::{Column, Line, Point},
term::Config,
vte::ansi::Processor,
};
use gpui::{Bounds, ClipboardItem, Entity, FontStyle, TextStyle, WhiteSpace, canvas, size};
use language::Buffer;
use settings::Settings as _;
use terminal_view::terminal_element::TerminalElement;
use theme::ThemeSettings;
use ui::{IntoElement, prelude::*};
use crate::outputs::OutputContent;
/// The `TerminalOutput` struct handles the parsing and rendering of text input,
/// simulating a basic terminal environment within REPL output.
///
/// `TerminalOutput` is designed to handle various types of text-based output, including:
///
/// * stdout (standard output)
/// * stderr (standard error)
/// * text/plain content
/// * error tracebacks
///
/// It uses the Alacritty terminal emulator backend to process and render text,
/// supporting ANSI escape sequences for text formatting and colors.
///
pub struct TerminalOutput {
full_buffer: Option<Entity<Buffer>>,
/// ANSI escape sequence processor for parsing input text.
parser: Processor,
/// Alacritty terminal instance that manages the terminal state and content.
handler: alacritty_terminal::Term<VoidListener>,
}
const DEFAULT_NUM_LINES: usize = 32;
const DEFAULT_NUM_COLUMNS: usize = 128;
/// Returns the default text style for the terminal output.
pub fn text_style(window: &mut Window, cx: &mut App) -> TextStyle {
let settings = ThemeSettings::get_global(cx).clone();
let font_size = settings.buffer_font_size(cx).into();
let font_family = settings.buffer_font.family;
let font_features = settings.buffer_font.features;
let font_weight = settings.buffer_font.weight;
let font_fallbacks = settings.buffer_font.fallbacks;
let theme = cx.theme();
let text_style = TextStyle {
font_family,
font_features,
font_weight,
font_fallbacks,
font_size,
font_style: FontStyle::Normal,
line_height: window.line_height().into(),
background_color: Some(theme.colors().terminal_ansi_background),
white_space: WhiteSpace::Normal,
// These are going to be overridden per-cell
color: theme.colors().terminal_foreground,
..Default::default()
};
text_style
}
/// Returns the default terminal size for the terminal output.
pub fn terminal_size(window: &mut Window, cx: &mut App) -> terminal::TerminalBounds {
let text_style = text_style(window, cx);
let text_system = window.text_system();
let line_height = window.line_height();
let font_pixels = text_style.font_size.to_pixels(window.rem_size());
let font_id = text_system.resolve_font(&text_style.font());
let cell_width = text_system
.advance(font_id, font_pixels, 'w')
.unwrap()
.width;
let num_lines = DEFAULT_NUM_LINES;
let columns = DEFAULT_NUM_COLUMNS;
// Reversed math from terminal::TerminalSize to get pixel width according to terminal width
let width = columns as f32 * cell_width;
let height = num_lines as f32 * window.line_height();
terminal::TerminalBounds {
cell_width,
line_height,
bounds: Bounds {
origin: gpui::Point::default(),
size: size(width, height),
},
}
}
impl TerminalOutput {
/// Creates a new `TerminalOutput` instance.
///
/// This method initializes a new terminal emulator with default configuration
/// and sets up the necessary components for handling terminal events and rendering.
///
pub fn new(window: &mut Window, cx: &mut App) -> Self {
let term = alacritty_terminal::Term::new(
Config::default(),
&terminal_size(window, cx),
VoidListener,
);
Self {
parser: Processor::new(),
handler: term,
full_buffer: None,
}
}
/// Creates a new `TerminalOutput` instance with initial content.
///
/// Initializes a new terminal output and populates it with the provided text.
///
/// # Arguments
///
/// * `text` - A string slice containing the initial text for the terminal output.
/// * `cx` - A mutable reference to the `WindowContext` for initialization.
///
/// # Returns
///
/// A new instance of `TerminalOutput` containing the provided text.
pub fn from(text: &str, window: &mut Window, cx: &mut App) -> Self {
let mut output = Self::new(window, cx);
output.append_text(text, cx);
output
}
/// Appends text to the terminal output.
///
/// Processes each byte of the input text, handling newline characters specially
/// to ensure proper cursor movement. Uses the ANSI parser to process the input
/// and update the terminal state.
///
/// As an example, if the user runs the following Python code in this REPL:
///
/// ```python
/// import time
/// print("Hello,", end="")
/// time.sleep(1)
/// print(" world!")
/// ```
///
/// Then append_text will be called twice, with the following arguments:
///
/// ```rust
/// terminal_output.append_text("Hello,")
/// terminal_output.append_text(" world!")
/// ```
/// Resulting in a single output of "Hello, world!".
///
/// # Arguments
///
/// * `text` - A string slice containing the text to be appended.
pub fn append_text(&mut self, text: &str, cx: &mut App) {
for byte in text.as_bytes() {
if *byte == b'\n' {
// Dirty (?) hack to move the cursor down
self.parser.advance(&mut self.handler, &[b'\r']);
self.parser.advance(&mut self.handler, &[b'\n']);
} else {
self.parser.advance(&mut self.handler, &[*byte]);
}
}
// This will keep the buffer up to date, though with some terminal codes it won't be perfect
if let Some(buffer) = self.full_buffer.as_ref() {
buffer.update(cx, |buffer, cx| {
buffer.edit([(buffer.len()..buffer.len(), text)], None, cx);
});
}
}
fn full_text(&self) -> String {
let mut full_text = String::new();
// Get the total number of lines, including history
let total_lines = self.handler.grid().total_lines();
let visible_lines = self.handler.screen_lines();
let history_lines = total_lines - visible_lines;
// Capture history lines in correct order (oldest to newest)
for line in (0..history_lines).rev() {
let line_index = Line(-(line as i32) - 1);
let start = Point::new(line_index, Column(0));
let end = Point::new(line_index, Column(self.handler.columns() - 1));
let line_content = self.handler.bounds_to_string(start, end);
if !line_content.trim().is_empty() {
full_text.push_str(&line_content);
full_text.push('\n');
}
}
// Capture visible lines
for line in 0..visible_lines {
let line_index = Line(line as i32);
let start = Point::new(line_index, Column(0));
let end = Point::new(line_index, Column(self.handler.columns() - 1));
let line_content = self.handler.bounds_to_string(start, end);
if !line_content.trim().is_empty() {
full_text.push_str(&line_content);
full_text.push('\n');
}
}
// Trim any trailing newlines
full_text.trim_end().to_string()
}
}
impl Render for TerminalOutput {
/// Renders the terminal output as a GPUI element.
///
/// Converts the current terminal state into a renderable GPUI element. It handles
/// the layout of the terminal grid, calculates the dimensions of the output, and
/// creates a canvas element that paints the terminal cells and background rectangles.
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let text_style = text_style(window, cx);
let text_system = window.text_system();
let grid = self
.handler
.renderable_content()
.display_iter
.map(|ic| terminal::IndexedCell {
point: ic.point,
cell: ic.cell.clone(),
});
let (cells, rects) =
TerminalElement::layout_grid(grid, 0, &text_style, text_system, None, window, cx);
// lines are 0-indexed, so we must add 1 to get the number of lines
let text_line_height = text_style.line_height_in_pixels(window.rem_size());
let num_lines = cells.iter().map(|c| c.point.line).max().unwrap_or(0) + 1;
let height = num_lines as f32 * text_line_height;
let font_pixels = text_style.font_size.to_pixels(window.rem_size());
let font_id = text_system.resolve_font(&text_style.font());
let cell_width = text_system
.advance(font_id, font_pixels, 'w')
.map(|advance| advance.width)
.unwrap_or(Pixels(0.0));
canvas(
// prepaint
move |_bounds, _, _| {},
// paint
move |bounds, _, window, cx| {
for rect in rects {
rect.paint(
bounds.origin,
&terminal::TerminalBounds {
cell_width,
line_height: text_line_height,
bounds,
},
window,
);
}
for cell in cells {
cell.paint(
bounds.origin,
&terminal::TerminalBounds {
cell_width,
line_height: text_line_height,
bounds,
},
bounds,
window,
cx,
);
}
},
)
// We must set the height explicitly for the editor block to size itself correctly
.h(height)
}
}
impl OutputContent for TerminalOutput {
fn clipboard_content(&self, _window: &Window, _cx: &App) -> Option<ClipboardItem> {
Some(ClipboardItem::new_string(self.full_text()))
}
fn has_clipboard_content(&self, _window: &Window, _cx: &App) -> bool {
true
}
fn has_buffer_content(&self, _window: &Window, _cx: &App) -> bool {
true
}
fn buffer_content(&mut self, _: &mut Window, cx: &mut App) -> Option<Entity<Buffer>> {
if self.full_buffer.as_ref().is_some() {
return self.full_buffer.clone();
}
let buffer = cx.new(|cx| {
let mut buffer =
Buffer::local(self.full_text(), cx).with_language(language::PLAIN_TEXT.clone(), cx);
buffer.set_capability(language::Capability::ReadOnly, cx);
buffer
});
self.full_buffer = Some(buffer.clone());
Some(buffer)
}
}