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
This commit is contained in:
Agus Zubiaga 2025-06-09 14:11:31 -07:00 committed by GitHub
parent ab70e524c8
commit b103d7621b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 199 additions and 87 deletions

View file

@ -2,7 +2,7 @@ use editor::{CursorLayout, HighlightedRange, HighlightedRangeLine};
use gpui::{
AnyElement, App, AvailableSpace, Bounds, ContentMask, Context, DispatchPhase, Element,
ElementId, Entity, FocusHandle, Font, FontStyle, FontWeight, GlobalElementId, HighlightStyle,
Hitbox, Hsla, InputHandler, InteractiveElement, Interactivity, IntoElement, LayoutId,
Hitbox, Hsla, InputHandler, InteractiveElement, Interactivity, IntoElement, LayoutId, Length,
ModifiersChangedEvent, MouseButton, MouseMoveEvent, Pixels, Point, ShapedLine,
StatefulInteractiveElement, StrikethroughStyle, Styled, TextRun, TextStyle, UTF16Selection,
UnderlineStyle, WeakEntity, WhiteSpace, Window, WindowTextSystem, div, fill, point, px,
@ -32,7 +32,7 @@ use workspace::Workspace;
use std::mem;
use std::{fmt::Debug, ops::RangeInclusive, rc::Rc};
use crate::{BlockContext, BlockProperties, TerminalMode, TerminalView};
use crate::{BlockContext, BlockProperties, ContentMode, TerminalMode, TerminalView};
/// The information generated during layout that is necessary for painting.
pub struct LayoutState {
@ -49,6 +49,7 @@ pub struct LayoutState {
gutter: Pixels,
block_below_cursor_element: Option<AnyElement>,
base_text_style: TextStyle,
content_mode: ContentMode,
}
/// Helper struct for converting data between Alacritty's cursor points, and displayed cursor points.
@ -202,6 +203,7 @@ impl TerminalElement {
pub fn layout_grid(
grid: impl Iterator<Item = IndexedCell>,
start_line_offset: i32,
text_style: &TextStyle,
// terminal_theme: &TerminalStyle,
text_system: &WindowTextSystem,
@ -218,6 +220,8 @@ impl TerminalElement {
let linegroups = grid.into_iter().chunk_by(|i| i.point.line);
for (line_index, (_, line)) in linegroups.into_iter().enumerate() {
let alac_line = start_line_offset + line_index as i32;
for cell in line {
let mut fg = cell.fg;
let mut bg = cell.bg;
@ -245,7 +249,7 @@ impl TerminalElement {
|| {
Some(LayoutRect::new(
AlacPoint::new(
line_index as i32,
alac_line,
cell.point.column.0 as i32,
),
1,
@ -260,10 +264,7 @@ impl TerminalElement {
rects.push(cur_rect.take().unwrap());
}
cur_rect = Some(LayoutRect::new(
AlacPoint::new(
line_index as i32,
cell.point.column.0 as i32,
),
AlacPoint::new(alac_line, cell.point.column.0 as i32),
1,
convert_color(&bg, theme),
));
@ -272,7 +273,7 @@ impl TerminalElement {
None => {
cur_alac_color = Some(bg);
cur_rect = Some(LayoutRect::new(
AlacPoint::new(line_index as i32, cell.point.column.0 as i32),
AlacPoint::new(alac_line, cell.point.column.0 as i32),
1,
convert_color(&bg, theme),
));
@ -295,7 +296,7 @@ impl TerminalElement {
);
cells.push(LayoutCell::new(
AlacPoint::new(line_index as i32, cell.point.column.0 as i32),
AlacPoint::new(alac_line, cell.point.column.0 as i32),
layout_cell,
))
};
@ -430,7 +431,13 @@ impl TerminalElement {
}
}
fn register_mouse_listeners(&mut self, mode: TermMode, hitbox: &Hitbox, window: &mut Window) {
fn register_mouse_listeners(
&mut self,
mode: TermMode,
hitbox: &Hitbox,
content_mode: &ContentMode,
window: &mut Window,
) {
let focus = self.focus.clone();
let terminal = self.terminal.clone();
let terminal_view = self.terminal_view.clone();
@ -512,14 +519,18 @@ impl TerminalElement {
),
);
if !matches!(self.mode, TerminalMode::Embedded { .. }) {
if content_mode.is_scrollable() {
self.interactivity.on_scroll_wheel({
let terminal_view = self.terminal_view.downgrade();
move |e, _window, cx| {
move |e, window, cx| {
terminal_view
.update(cx, |terminal_view, cx| {
terminal_view.scroll_wheel(e, cx);
cx.notify();
if matches!(terminal_view.mode, TerminalMode::Standalone)
|| terminal_view.focus_handle.is_focused(window)
{
terminal_view.scroll_wheel(e, cx);
cx.notify();
}
})
.ok();
}
@ -605,6 +616,32 @@ impl Element for TerminalElement {
window: &mut Window,
cx: &mut App,
) -> (LayoutId, Self::RequestLayoutState) {
let height: Length = match self.terminal_view.read(cx).content_mode(window, cx) {
ContentMode::Inline {
displayed_lines,
total_lines: _,
} => {
let rem_size = window.rem_size();
let line_height = window.text_style().font_size.to_pixels(rem_size)
* TerminalSettings::get_global(cx)
.line_height
.value()
.to_pixels(rem_size)
.0;
(displayed_lines * line_height).into()
}
ContentMode::Scrollable => {
if let TerminalMode::Embedded { .. } = &self.mode {
let term = self.terminal.read(cx);
if !term.scrolled_to_top() && !term.scrolled_to_bottom() && self.focused {
self.interactivity.occlude_mouse();
}
}
relative(1.).into()
}
};
let layout_id = self.interactivity.request_layout(
global_id,
inspector_id,
@ -612,29 +649,7 @@ impl Element for TerminalElement {
cx,
|mut style, window, cx| {
style.size.width = relative(1.).into();
match &self.mode {
TerminalMode::Scrollable => {
style.size.height = relative(1.).into();
}
TerminalMode::Embedded { max_lines } => {
let rem_size = window.rem_size();
let line_height = window.text_style().font_size.to_pixels(rem_size)
* TerminalSettings::get_global(cx)
.line_height
.value()
.to_pixels(rem_size)
.0;
let mut line_count = self.terminal.read(cx).total_lines();
if !self.focused {
if let Some(max_lines) = max_lines {
line_count = line_count.min(*max_lines);
}
}
style.size.height = (line_count * line_height).into();
}
}
style.size.height = height;
window.request_layout(style, None, cx)
},
@ -693,7 +708,7 @@ impl Element for TerminalElement {
TerminalMode::Embedded { .. } => {
window.text_style().font_size.to_pixels(window.rem_size())
}
TerminalMode::Scrollable => terminal_settings
TerminalMode::Standalone => terminal_settings
.font_size
.map_or(buffer_font_size, |size| theme::adjusted_font_size(size, cx)),
};
@ -733,7 +748,7 @@ impl Element for TerminalElement {
let player_color = theme.players().local();
let match_color = theme.colors().search_match_background;
let gutter;
let dimensions = {
let (dimensions, line_height_px) = {
let rem_size = window.rem_size();
let font_pixels = text_style.font_size.to_pixels(rem_size);
// TODO: line_height should be an f32 not an AbsoluteLength.
@ -759,7 +774,10 @@ impl Element for TerminalElement {
let mut origin = bounds.origin;
origin.x += gutter;
TerminalBounds::new(line_height, cell_width, Bounds { origin, size })
(
TerminalBounds::new(line_height, cell_width, Bounds { origin, size }),
line_height,
)
};
let search_matches = self.terminal.read(cx).matches.clone();
@ -827,16 +845,42 @@ impl Element for TerminalElement {
// then have that representation be converted to the appropriate highlight data structure
let (cells, rects) = TerminalElement::layout_grid(
cells.iter().cloned(),
&text_style,
window.text_system(),
last_hovered_word
.as_ref()
.map(|last_hovered_word| (link_style, &last_hovered_word.word_match)),
window,
cx,
);
let content_mode = self.terminal_view.read(cx).content_mode(window, cx);
let (cells, rects) = match content_mode {
ContentMode::Scrollable => TerminalElement::layout_grid(
cells.iter().cloned(),
0,
&text_style,
window.text_system(),
last_hovered_word
.as_ref()
.map(|last_hovered_word| (link_style, &last_hovered_word.word_match)),
window,
cx,
),
ContentMode::Inline { .. } => {
let intersection = window.content_mask().bounds.intersect(&bounds);
let start_row = (intersection.top() - bounds.top()) / line_height_px;
let end_row = start_row + intersection.size.height / line_height_px;
let line_range = (start_row as i32)..=(end_row as i32);
TerminalElement::layout_grid(
cells
.iter()
.skip_while(|i| &i.point.line < line_range.start())
.take_while(|i| &i.point.line <= line_range.end())
.cloned(),
*line_range.start(),
&text_style,
window.text_system(),
last_hovered_word.as_ref().map(|last_hovered_word| {
(link_style, &last_hovered_word.word_match)
}),
window,
cx,
)
}
};
// Layout cursor. Rectangle is used for IME, so we should lay it out even
// if we don't end up showing it.
@ -932,6 +976,7 @@ impl Element for TerminalElement {
gutter,
block_below_cursor_element,
base_text_style: text_style,
content_mode,
}
},
)
@ -969,7 +1014,12 @@ impl Element for TerminalElement {
workspace: self.workspace.clone(),
};
self.register_mouse_listeners(layout.mode, &layout.hitbox, window);
self.register_mouse_listeners(
layout.mode,
&layout.hitbox,
&layout.content_mode,
window,
);
if window.modifiers().secondary()
&& bounds.contains(&window.mouse_position())
&& self.terminal_view.read(cx).hover.is_some()