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, } } }