From 9b8e8e7f36cb9b2e5e27321e9fc93b31a539bd29 Mon Sep 17 00:00:00 2001 From: Nick Ebert Date: Sat, 13 Jun 2026 15:37:12 -0700 Subject: [PATCH] agent: Cap the height of large tool output and diff blocks Tool output (including MCP/context server results), file diffs, and other text content blocks in the agent thread were rendered at full height, so an accidentally huge buffer could lay out thousands of lines at once and cause serious UI/scrolling performance problems. Cap each such block and reveal the rest behind an expand control: - Markdown/text output is capped at agent.tool_output_max_lines (default 10). - File diffs are sized proportionally to their length (3 + lines / 10). - A centered, transparent chevron over a bottom vignette toggles each block between collapsed and expanded (2x the cap). The vignette band is reserved on top of the cap so the intended lines stay visible above it, the chrome is skipped entirely when the buffer already fits, and the fade only draws when content is actually clipped. - Expanded blocks are scrollable and capped; collapsed blocks are static previews. Scrolling chains out to the thread at the block's top/bottom edges. - agent.tool_output_max_lines = 0 disables capping entirely. Terminal output is already bounded, so it's left as-is. The gating logic is covered by unit tests. --- assets/settings/default.json | 8 + crates/agent/src/tool_permissions.rs | 1 + crates/agent_settings/src/agent_settings.rs | 2 + crates/agent_ui/src/agent_ui.rs | 1 + .../src/conversation_view/thread_view.rs | 390 ++++++++++++++++-- crates/agent_ui/src/entry_view_state.rs | 6 +- crates/settings_content/src/agent.rs | 12 + crates/terminal_view/src/terminal_element.rs | 19 + crates/terminal_view/src/terminal_view.rs | 78 ++-- 9 files changed, 464 insertions(+), 53 deletions(-) diff --git a/assets/settings/default.json b/assets/settings/default.json index e79e5ba13453b3..0c3c7a28eabf94 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -1231,6 +1231,14 @@ // // Default: true "show_merge_conflict_indicator": true, + // Maximum number of lines a tool output, file diff, or terminal block in + // the agent thread shows before it is collapsed behind an expand control. + // Both the collapsed and expanded states are height-capped, so a very + // large buffer never renders at full height. Set to 0 to disable the cap + // and render blocks at full height. + // + // Default: 10 + "tool_output_max_lines": 10, }, // Whether the screen sharing icon is shown in the os status bar. "show_call_status_icon": true, diff --git a/crates/agent/src/tool_permissions.rs b/crates/agent/src/tool_permissions.rs index 59d52f563d89a1..425bd44f20706b 100644 --- a/crates/agent/src/tool_permissions.rs +++ b/crates/agent/src/tool_permissions.rs @@ -602,6 +602,7 @@ mod tests { message_editor_min_lines: 1, tool_permissions, sandbox_permissions: Default::default(), + tool_output_max_lines: 24, show_turn_stats: false, show_merge_conflict_indicator: true, sidebar_side: Default::default(), diff --git a/crates/agent_settings/src/agent_settings.rs b/crates/agent_settings/src/agent_settings.rs index bcd5d85afa96b2..79730e77f6078a 100644 --- a/crates/agent_settings/src/agent_settings.rs +++ b/crates/agent_settings/src/agent_settings.rs @@ -239,6 +239,7 @@ pub struct AgentSettings { pub show_merge_conflict_indicator: bool, pub tool_permissions: ToolPermissions, pub sandbox_permissions: SandboxPermissions, + pub tool_output_max_lines: usize, } impl AgentSettings { @@ -770,6 +771,7 @@ impl Settings for AgentSettings { show_merge_conflict_indicator: agent.show_merge_conflict_indicator.unwrap(), tool_permissions: compile_tool_permissions(agent.tool_permissions), sandbox_permissions: compile_sandbox_permissions(agent.sandbox_permissions), + tool_output_max_lines: agent.tool_output_max_lines.unwrap_or(10), } } } diff --git a/crates/agent_ui/src/agent_ui.rs b/crates/agent_ui/src/agent_ui.rs index 71b52a4243207a..a19693154b2ef6 100644 --- a/crates/agent_ui/src/agent_ui.rs +++ b/crates/agent_ui/src/agent_ui.rs @@ -969,6 +969,7 @@ mod tests { message_editor_min_lines: 1, tool_permissions: Default::default(), sandbox_permissions: Default::default(), + tool_output_max_lines: 24, show_turn_stats: false, show_merge_conflict_indicator: true, sidebar_side: Default::default(), diff --git a/crates/agent_ui/src/conversation_view/thread_view.rs b/crates/agent_ui/src/conversation_view/thread_view.rs index 6c4dc654621ac8..27b0e7638051f8 100644 --- a/crates/agent_ui/src/conversation_view/thread_view.rs +++ b/crates/agent_ui/src/conversation_view/thread_view.rs @@ -27,6 +27,7 @@ use language_model::{ LanguageModelProviderId, LanguageModelRegistry, Speed, }; use settings::{update_settings_file, update_settings_file_with_completion}; +use terminal_view::ContentMode; use ui::{ ButtonLike, CalloutBorderPosition, SpinnerLabel, SpinnerVariant, SplitButton, SplitButtonStyle, Tab, @@ -260,6 +261,33 @@ fn parse_single_fenced_code_block(markdown: &str) -> Option<(&str, &str)> { Some((tag, code)) } +/// Decides how a tool output / diff content block of `content_lines` lines +/// should be height-capped, given the `agent.tool_output_max_lines` setting and +/// whether the user has expanded the block. +/// +/// Returns `None` when the block should render unconstrained (the cap is +/// disabled with `0`, or the content already fits within the cap), or +/// `Some(cap)` with the maximum number of lines to show. Expanding multiplies +/// the cap by [`OUTPUT_BLOCK_EXPANDED_FACTOR`] so an expanded block is taller +/// but still bounded rather than rendering the whole buffer. +fn constrained_block_cap_lines( + max_lines: usize, + content_lines: usize, + is_expanded: bool, +) -> Option { + if max_lines == 0 || content_lines <= max_lines { + return None; + } + Some(if is_expanded { + max_lines.saturating_mul(OUTPUT_BLOCK_EXPANDED_FACTOR) + } else { + max_lines + }) +} + +/// Factor by which expanding a constrained output block raises its line cap. +const OUTPUT_BLOCK_EXPANDED_FACTOR: usize = 2; + /// Walks `code` exactly once: for each line it validates and strips the /// `NNN\t` prefix, then pushes the line's content into the accumulating /// code buffer (with `\n` between lines, no trailing newline). Verifies that @@ -458,6 +486,27 @@ fn highlight_code_runs( mod numbered_code_block_tests { use super::*; + #[test] + fn constrained_block_cap_lines_disabled_and_fitting_content_is_unconstrained() { + // A cap of 0 disables constraining entirely. + assert_eq!(constrained_block_cap_lines(0, 10_000, false), None); + // Content within the cap renders unconstrained, collapsed or expanded. + assert_eq!(constrained_block_cap_lines(24, 24, false), None); + assert_eq!(constrained_block_cap_lines(24, 10, true), None); + } + + #[test] + fn constrained_block_cap_lines_caps_overflowing_content() { + // Collapsed: capped at exactly the configured number of lines. + assert_eq!(constrained_block_cap_lines(24, 25, false), Some(24)); + assert_eq!(constrained_block_cap_lines(24, 100_000, false), Some(24)); + // Expanded: a larger, still-bounded cap (never the whole buffer). + assert_eq!( + constrained_block_cap_lines(24, 100_000, true), + Some(24 * OUTPUT_BLOCK_EXPANDED_FACTOR) + ); + } + #[test] fn parses_cat_numbered_markdown_code_block() { let parsed = parse_cat_numbered_markdown_code_block( @@ -581,6 +630,15 @@ pub struct ThreadView { pub expanded_thinking_blocks: HashSet<(usize, usize)>, auto_expanded_thinking_block: Option<(usize, usize)>, user_toggled_thinking_blocks: HashSet<(usize, usize)>, + /// Tool output / diff content blocks (keyed by `(entry_ix, context_ix)`) + /// the user has expanded past the default height cap. Even when expanded, + /// the block stays height-capped and scrollable. + expanded_output_blocks: HashSet<(usize, usize)>, + /// Per-block vertical scroll handles (keyed by `(entry_ix, context_ix)`) + /// for constrained output/diff blocks, so each block keeps its own scroll + /// position and we can chain scrolling out to the thread at the block's + /// top/bottom edges. + output_block_scroll_handles: RefCell>, /// Tracks which context compaction entries (by entry index) have their /// summary expanded. expanded_compactions: HashSet, @@ -966,6 +1024,8 @@ impl ThreadView { expanded_thinking_blocks: HashSet::default(), auto_expanded_thinking_block: None, user_toggled_thinking_blocks: HashSet::default(), + expanded_output_blocks: HashSet::default(), + output_block_scroll_handles: RefCell::new(HashMap::default()), expanded_compactions: HashSet::default(), subagent_scroll_handles: RefCell::new(HashMap::default()), edits_expanded: false, @@ -6538,6 +6598,163 @@ impl ThreadView { cx.notify(); } + fn toggle_output_block_expansion(&mut self, key: (usize, usize), cx: &mut Context) { + if !self.expanded_output_blocks.remove(&key) { + self.expanded_output_blocks.insert(key); + } + cx.notify(); + } + + /// Bounds the rendered height of a tool output / diff content block so a + /// very large buffer never lays out at full height (which can severely + /// degrade scrolling/layout performance). When `content_lines` exceeds + /// `max_lines`, the block is capped at that many lines with a bottom + /// vignette and a centered chevron that expands it to a larger (still + /// bounded) height. A `max_lines` of 0, or content within the cap, renders + /// the element unchanged. + fn render_constrained_output_block( + &self, + content: AnyElement, + key: (usize, usize), + content_lines: usize, + max_lines: usize, + collapsed_top_offset_lines: usize, + window: &Window, + cx: &Context, + ) -> AnyElement { + let is_expanded = self.expanded_output_blocks.contains(&key); + let Some(cap_lines) = constrained_block_cap_lines(max_lines, content_lines, is_expanded) + else { + return content; + }; + // Reserve a band for the bottom vignette/chevron on *top* of the + // visible cap, so the intended `cap_lines` of content stay fully + // visible above it. Without this, a small cap (e.g. a short diff) is + // almost entirely covered by the fixed-height vignette and the content + // is hidden behind the fade/chevron. + const VIGNETTE_LINES: f32 = 2.5; + let line_height = window.line_height(); + let vignette_height = line_height * VIGNETTE_LINES; + // Nothing is actually hidden if the whole buffer fits within the cap + // plus the reserved band, so render it as-is. Keep the chrome when the + // user has explicitly expanded it, so they can still collapse. + let overflows = content_lines as f32 > cap_lines as f32 + VIGNETTE_LINES; + if !overflows && !is_expanded { + return content; + } + let max_height = line_height * (cap_lines as f32 + VIGNETTE_LINES); + // When collapsed, anchor the preview to a specific row (diffs use this + // to skip leading context so the change is visible) rather than always + // starting at row 0. Clamp so we never scroll past the end of the + // content into blank space. + let visible_lines = cap_lines as f32 + VIGNETTE_LINES; + let max_offset_lines = (content_lines as f32 - visible_lines).max(0.); + let collapsed_offset = + line_height * (collapsed_top_offset_lines as f32).min(max_offset_lines); + // Fade toward the editor background (forced fully opaque so the vignette + // stays visible even with translucent/transparent themes where the + // panel background has alpha). + let vignette_bg = cx.theme().colors().editor_background; + let content_id = SharedString::from(format!("tool-output-block-{}-{}", key.0, key.1)); + let toggle_id = SharedString::from(format!("tool-output-toggle-{}-{}", key.0, key.1)); + let toggle_icon = if is_expanded { + IconName::ChevronUp + } else { + IconName::ChevronDown + }; + let scroll_handle = self + .output_block_scroll_handles + .borrow_mut() + .entry(key) + .or_insert_with(ScrollHandle::new) + .clone(); + + div() + .relative() + .w_full() + .child( + div() + .id(content_id) + .max_h(max_height) + .overflow_hidden() + // Only the expanded state scrolls. While expanded we drive + // the scroll ourselves and occlude so the thread list + // behind the block doesn't also scroll (its wheel handler + // only checks hit-test membership, not propagation), and at + // the block's top/bottom edges we carry the scroll out to + // the thread. Collapsed blocks aren't scrollable, so the + // wheel just scrolls the thread. + .when(is_expanded, |this| { + this.track_scroll(&scroll_handle) + .occlude() + .on_scroll_wheel(cx.listener({ + let scroll_handle = scroll_handle.clone(); + move |this, event: &gpui::ScrollWheelEvent, window, cx| { + let delta = event.delta.pixel_delta(window.line_height()).y; + if delta == px(0.) { + return; + } + let offset = scroll_handle.offset(); + let max_y = scroll_handle.max_offset().y; + let new_y = (offset.y + delta).clamp(-max_y, px(0.)); + if new_y != offset.y { + // The block can still move: scroll it. + scroll_handle.set_offset(gpui::point(offset.x, new_y)); + } else { + // At the top/bottom edge: carry the + // scroll over to the surrounding thread. + this.list_state.scroll_by(-delta); + } + cx.notify(); + } + })) + }) + .child( + div() + .w_full() + .when(!is_expanded && collapsed_offset > px(0.), |this| { + this.mt(-collapsed_offset) + }) + .child(content), + ), + ) + .child( + // A vertical vignette across the bottom of the block fades the + // buffer out, with a transparent-background chevron centered on + // top of it that flips to reflect the collapsed/expanded state. + h_flex() + .absolute() + .bottom_0() + .left_0() + .right_0() + .h(vignette_height) + .pb_1() + .justify_center() + .items_end() + .when(overflows, |this| { + this.bg(linear_gradient( + 180., + linear_color_stop(vignette_bg.opacity(0.), 0.), + linear_color_stop(vignette_bg.opacity(1.), 1.), + )) + }) + .child( + IconButton::new(toggle_id, toggle_icon) + .icon_size(IconSize::Small) + .icon_color(Color::Muted) + .tooltip(Tooltip::text(if is_expanded { + "Collapse" + } else { + "Expand" + })) + .on_click(cx.listener(move |this, _, _window, cx| { + this.toggle_output_block_expansion(key, cx); + })), + ), + ) + .into_any_element() + } + fn render_thinking_block( &self, entry_ix: usize, @@ -6938,6 +7155,7 @@ impl ThreadView { &self, active_session_id: &acp::SessionId, entry_ix: usize, + context_ix: usize, terminal: &Entity, tool_call: &ToolCall, focus_handle: &FocusHandle, @@ -7187,14 +7405,77 @@ impl ThreadView { .text_ui_sm(cx) .h_full() .children(terminal_view.map(|terminal_view| { - let element = if terminal_view - .read(cx) - .content_mode(window, cx) - .is_scrollable() - { - div().h_72().child(terminal_view).into_any_element() - } else { - terminal_view.into_any_element() + let content_mode = terminal_view.read(cx).content_mode(window, cx); + let block_key = (entry_ix, context_ix); + let element = match content_mode { + ContentMode::Scrollable => { + div().h_72().child(terminal_view).into_any_element() + } + // The terminal natively renders the most recent + // `displayed_lines` (its embedded cap), which is + // gap-free and accurate even mid-stream. Show a + // chevron below to expand/collapse the cap when + // there's more output than is shown. + ContentMode::Inline { + displayed_lines, + total_lines, + } => { + let output_expanded = + self.expanded_output_blocks.contains(&block_key); + let show_toggle = + total_lines > displayed_lines || output_expanded; + let toggle = show_toggle.then(|| { + let terminal_view = terminal_view.clone(); + h_flex().w_full().justify_center().pt_1().child( + IconButton::new( + SharedString::from(format!( + "terminal-output-toggle-{}-{}", + entry_ix, context_ix + )), + if output_expanded { + IconName::ChevronUp + } else { + IconName::ChevronDown + }, + ) + .icon_size(IconSize::Small) + .icon_color(Color::Muted) + .tooltip(Tooltip::text(if output_expanded { + "Collapse" + } else { + "Expand" + })) + .on_click( + cx.listener(move |this, _, _window, cx| { + let now_expanded = !this + .expanded_output_blocks + .contains(&block_key); + this.toggle_output_block_expansion( + block_key, cx, + ); + let base = AgentSettings::get_global(cx) + .tool_output_max_lines; + let cap = if now_expanded { + base.saturating_mul(2) + } else { + base + }; + terminal_view.update(cx, |tv, cx| { + tv.set_embedded_mode( + (cap != 0).then_some(cap), + cx, + ); + }); + }), + ), + ) + }); + v_flex() + .w_full() + .child(terminal_view) + .children(toggle) + .into_any_element() + } }; div() @@ -7266,18 +7547,24 @@ impl ThreadView { ), ) } else if has_terminals { - this.children(tool_call.terminals().map(|terminal| { - self.render_terminal_tool_call( - active_session_id, - entry_ix, - terminal, - tool_call, - focus_handle, - layout, - window, - cx, - ) - })) + this.children( + tool_call + .terminals() + .enumerate() + .map(|(terminal_ix, terminal)| { + self.render_terminal_tool_call( + active_session_id, + entry_ix, + terminal_ix, + terminal, + tool_call, + focus_handle, + layout, + window, + cx, + ) + }), + ) } else { this.child(self.render_tool_call( active_session_id, @@ -8762,12 +9049,13 @@ impl ThreadView { Empty.into_any_element() } } - ToolCallContent::Diff(diff) => { - self.render_diff_editor(entry_ix, diff, tool_call, has_failed, cx) - } + ToolCallContent::Diff(diff) => self.render_diff_editor( + entry_ix, context_ix, diff, tool_call, has_failed, window, cx, + ), ToolCallContent::Terminal(terminal) => self.render_terminal_tool_call( session_id, entry_ix, + context_ix, terminal, tool_call, focus_handle, @@ -8844,9 +9132,11 @@ impl ThreadView { fn render_diff_editor( &self, entry_ix: usize, + context_ix: usize, diff: &Entity, tool_call: &ToolCall, has_failed: bool, + window: &Window, cx: &Context, ) -> AnyElement { let tool_progress = matches!( @@ -8874,7 +9164,49 @@ impl ThreadView { .border_color(self.tool_card_border_color(cx)) }) .child(if let Some(editor) = revealed_diff_editor { - editor.into_any_element() + let content_lines = diff + .read(cx) + .multibuffer() + .read(cx) + .snapshot(cx) + .max_row() + .0 as usize + + 1; + // Size the diff preview proportionally to its length rather + // than with the flat output cap: a few lines of headroom plus a + // tenth of the diff, so small diffs show (almost) fully and + // large diffs grow slowly. `tool_output_max_lines == 0` still + // disables capping entirely. + let max_lines = if AgentSettings::get_global(cx).tool_output_max_lines == 0 { + 0 + } else { + 3 + content_lines / 10 + }; + // Anchor the collapsed preview to the first change so the diff + // is actually visible: the multibuffer only contains hunks, but + // each one carries leading context (and the diff renders deleted + // rows), which with a small cap would otherwise fill the window + // with unchanged lines. Everything above the first hunk is plain + // context (1:1 display mapping), so its multibuffer row is a good + // anchor; keep one line of context above the change. + let collapsed_top_offset_lines = diff + .read(cx) + .multibuffer() + .read(cx) + .snapshot(cx) + .diff_hunks() + .next() + .map_or(0, |hunk| hunk.row_range.start.0 as usize) + .saturating_sub(1); + self.render_constrained_output_block( + editor.into_any_element(), + (entry_ix, context_ix), + content_lines, + max_lines, + collapsed_top_offset_lines, + window, + cx, + ) } else if tool_progress && self.as_native_connection(cx).is_some() { self.render_diff_loading(cx) } else { @@ -8894,6 +9226,7 @@ impl ThreadView { cx: &Context, ) -> AnyElement { let markdown_style = MarkdownStyle::themed(MarkdownFont::Agent, window, cx); + let content_lines = markdown.read(cx).source().lines().count(); let output = self .render_numbered_read_file_output( markdown.clone(), @@ -8907,6 +9240,15 @@ impl ThreadView { self.render_markdown(markdown, markdown_style, cx) .into_any() }); + let output = self.render_constrained_output_block( + output, + (entry_ix, context_ix), + content_lines, + AgentSettings::get_global(cx).tool_output_max_lines, + 0, + window, + cx, + ); v_flex() .gap_2() diff --git a/crates/agent_ui/src/entry_view_state.rs b/crates/agent_ui/src/entry_view_state.rs index 68627d1ec871e4..94a14e339a2693 100644 --- a/crates/agent_ui/src/entry_view_state.rs +++ b/crates/agent_ui/src/entry_view_state.rs @@ -432,7 +432,11 @@ fn create_terminal( window, cx, ); - view.set_embedded_mode(Some(1000), cx); + // Cap the embedded terminal to the configured number of lines (showing + // the most recent output). The agent thread toggles this cap via a + // chevron to expand/collapse. `0` disables the cap. + let max_lines = agent_settings::AgentSettings::get_global(cx).tool_output_max_lines; + view.set_embedded_mode((max_lines != 0).then_some(max_lines), cx); view }) } diff --git a/crates/settings_content/src/agent.rs b/crates/settings_content/src/agent.rs index e8c3faefe81ee2..2b249466886897 100644 --- a/crates/settings_content/src/agent.rs +++ b/crates/settings_content/src/agent.rs @@ -340,6 +340,18 @@ pub struct AgentSettingsContent { /// These are populated when choosing "Allow always" from a sandbox /// escalation prompt. pub sandbox_permissions: Option, + + /// Maximum number of lines a tool output, file diff, or terminal block in + /// the agent thread shows before it is collapsed behind an expand control. + /// Both the collapsed and expanded states are height-capped, so a very + /// large buffer never renders at full height (which can cause UI + /// performance problems). Expanding a block raises the cap; it does not + /// remove it. + /// + /// Set to 0 to disable the cap and render blocks at full height. + /// + /// Default: 10 + pub tool_output_max_lines: Option, } impl AgentSettingsContent { diff --git a/crates/terminal_view/src/terminal_element.rs b/crates/terminal_view/src/terminal_element.rs index cd0ed9241fa983..1d9892c1cd19bb 100644 --- a/crates/terminal_view/src/terminal_element.rs +++ b/crates/terminal_view/src/terminal_element.rs @@ -1020,6 +1020,25 @@ impl Element for TerminalElement { let mut size = bounds.size; size.width -= gutter; + // For capped embedded terminals, grow the grid one row taller + // than the cap so the trailing blank cursor line the grid keeps + // after each newline sits just below the visible (clipped) box. + // The element bounds stay at the cap height, so painting clips + // that extra row and the box hugs the real output with no + // padding above or below the text. Skipped on the alternate + // screen, where a full-screen TUI owns the whole grid. + if let TerminalMode::Embedded { + max_lines: Some(max_lines), + } = &self.mode + && !self + .terminal + .read(cx) + .last_content() + .mode + .contains(Modes::ALT_SCREEN) + { + size.height = px((*max_lines + 1) as f32 * line_height); + } let available_height = size.height; // https://github.com/zed-industries/zed/issues/2750 diff --git a/crates/terminal_view/src/terminal_view.rs b/crates/terminal_view/src/terminal_view.rs index 9c4321ca691a9e..40e78df7d8b567 100644 --- a/crates/terminal_view/src/terminal_view.rs +++ b/crates/terminal_view/src/terminal_view.rs @@ -161,7 +161,10 @@ pub enum TerminalMode { #[default] Standalone, Embedded { - max_lines_when_unfocused: Option, + /// Caps the number of lines rendered inline (the most recent lines are + /// shown). `None` means no cap. Applies whether or not the terminal is + /// focused, so the displayed height is deterministic. + max_lines: Option, }, } @@ -303,15 +306,10 @@ impl TerminalView { } } - /// Enable 'embedded' mode where the terminal displays the full content with an optional limit of lines. - pub fn set_embedded_mode( - &mut self, - max_lines_when_unfocused: Option, - cx: &mut Context, - ) { - self.mode = TerminalMode::Embedded { - max_lines_when_unfocused, - }; + /// Enable 'embedded' mode where the terminal displays its content inline, + /// optionally capped to the most recent `max_lines` lines. + pub fn set_embedded_mode(&mut self, max_lines: Option, cx: &mut Context) { + self.mode = TerminalMode::Embedded { max_lines }; cx.notify(); } @@ -320,29 +318,53 @@ impl TerminalView { /// Returns the current `ContentMode` depending on the set `TerminalMode` and the current number of lines /// /// Note: Even in embedded mode, the terminal will fallback to scrollable when its content exceeds `MAX_EMBEDDED_LINES` - pub fn content_mode(&self, window: &Window, cx: &App) -> ContentMode { + pub fn content_mode(&self, _window: &Window, cx: &App) -> ContentMode { match &self.mode { TerminalMode::Standalone => ContentMode::Scrollable, - TerminalMode::Embedded { - max_lines_when_unfocused, - } => { - let total_lines = self.terminal.read(cx).total_lines(); + TerminalMode::Embedded { max_lines } => { + let terminal = self.terminal.read(cx); + let raw_total_lines = terminal.total_lines(); - if total_lines > Self::MAX_EMBEDDED_LINES { - ContentMode::Scrollable - } else { - let mut displayed_lines = total_lines; + if raw_total_lines > Self::MAX_EMBEDDED_LINES { + return ContentMode::Scrollable; + } - if !self.focus_handle.is_focused(window) - && let Some(max_lines) = max_lines_when_unfocused - { - displayed_lines = displayed_lines.min(*max_lines) - } + let Some(max_lines) = max_lines else { + // No cap configured: render every line inline, matching the + // raw grid (including the trailing cursor line). + return ContentMode::Inline { + displayed_lines: raw_total_lines, + total_lines: raw_total_lines, + }; + }; - ContentMode::Inline { - displayed_lines, - total_lines, - } + // On the alternate screen a full-screen TUI owns the whole grid and + // positions the cursor arbitrarily, so it is not a reliable marker + // of where content ends. Fall back to sizing from the raw grid. + if terminal.last_content().mode.contains(Modes::ALT_SCREEN) { + return ContentMode::Inline { + displayed_lines: raw_total_lines.min(*max_lines), + total_lines: raw_total_lines, + }; + } + + // Count the real output lines rather than the grid height. The + // grid keeps a trailing blank line under the cursor after each + // newline, and pads unused rows; using those would make the inline + // box render at full height with empty padding. Deriving the + // content extent from the cursor lets the box hug the output and + // grow up to the cap as it streams in. + let cursor = terminal.last_content().cursor.point; + let viewport_lines = terminal.viewport_lines(); + let content_rows = ((cursor.line.max(0) as usize) + usize::from(cursor.column > 0)) + .min(viewport_lines); + let history_lines = raw_total_lines.saturating_sub(viewport_lines); + let total_lines = history_lines + content_rows; + let displayed_lines = total_lines.min(*max_lines); + + ContentMode::Inline { + displayed_lines, + total_lines, } } }