Add configurable REPL output size limits (#47114)

Casper van Elteren created

Closes #47113

Adds configurable REPL output size limits with two new settings,
`repl.output_max_height_lines` and `repl.output_max_width_columns`, so
large outputs scroll instead of expanding and images scale down to fit
the available space. The output containers in both inline REPL blocks
and notebook cells now respect these bounds, and image sizing uses the
same text metrics as the terminal output for consistent column-based
width calculations.

Release Notes:

- REPL output now supports configurable max height and width limits,
with large outputs scrolling and images scaling to stay within the
viewport.

Change summary

assets/settings/default.json                    |   6 +
crates/repl/src/notebook/cell.rs                |  78 +++++++++++++
crates/repl/src/outputs.rs                      |   8 +
crates/repl/src/outputs/image.rs                | 108 +++++++++++++++++-
crates/repl/src/outputs/plain.rs                |  75 ++++++++++++
crates/repl/src/repl_settings.rs                |  12 ++
crates/repl/src/session.rs                      |  12 ++
crates/settings_content/src/settings_content.rs |  10 +
8 files changed, 297 insertions(+), 12 deletions(-)

Detailed changes

assets/settings/default.json 🔗

@@ -2279,6 +2279,12 @@
     // Maximum number of lines to keep in REPL's scrollback buffer.
     // Clamped with [4, 256] range.
     "max_lines": 32,
+    // Maximum number of lines of output to display before scrolling.
+    // Set to 0 to disable output height limits.
+    "output_max_height_lines": 0,
+    // Maximum number of columns of output to display before scaling images.
+    // Set to 0 to disable output width limits.
+    "output_max_width_columns": 0,
   },
   // Vim settings
   "vim": {

crates/repl/src/notebook/cell.rs 🔗

@@ -19,7 +19,8 @@ use util::ResultExt;
 
 use crate::{
     notebook::{CODE_BLOCK_INSET, GUTTER_WIDTH},
-    outputs::{Output, plain::TerminalOutput, user_error::ErrorView},
+    outputs::{Output, plain, plain::TerminalOutput, user_error::ErrorView},
+    repl_settings::ReplSettings,
 };
 
 #[derive(Copy, Clone, PartialEq, PartialOrd)]
@@ -1036,6 +1037,17 @@ impl RunnableCell for CodeCell {
 
 impl Render for CodeCell {
     fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
+        let output_max_height = ReplSettings::get_global(cx).output_max_height_lines;
+        let output_max_height = if output_max_height > 0 {
+            Some(window.line_height() * output_max_height as f32)
+        } else {
+            None
+        };
+        let output_max_width = plain::max_width_for_columns(
+            ReplSettings::get_global(cx).output_max_width_columns,
+            window,
+            cx,
+        );
         // get the language from the editor's buffer
         let language_name = self
             .editor
@@ -1094,6 +1106,70 @@ impl Render for CodeCell {
                     ),
             )
             // Output portion
+            .child(
+                h_flex()
+                    .w_full()
+                    .pr_6()
+                    .rounded_xs()
+                    .items_start()
+                    .gap(DynamicSpacing::Base08.rems(cx))
+                    .bg(self.selected_bg_color(window, cx))
+                    .child(self.gutter_output(window, cx))
+                    .child(
+                        div().py_1p5().w_full().child(
+                            div()
+                                .flex()
+                                .size_full()
+                                .flex_1()
+                                .py_3()
+                                .px_5()
+                                .rounded_lg()
+                                .border_1()
+                                .child(
+                                    div()
+                                        .id((ElementId::from(self.id.to_string()), "output-scroll"))
+                                        .w_full()
+                                        .when_some(output_max_width, |div, max_w| {
+                                            div.max_w(max_w).overflow_x_scroll()
+                                        })
+                                        .when_some(output_max_height, |div, max_h| {
+                                            div.max_h(max_h).overflow_y_scroll()
+                                        })
+                                        .children(self.outputs.iter().map(|output| {
+                                            let content = match output {
+                                                Output::Plain { content, .. } => {
+                                                    Some(content.clone().into_any_element())
+                                                }
+                                                Output::Markdown { content, .. } => {
+                                                    Some(content.clone().into_any_element())
+                                                }
+                                                Output::Stream { content, .. } => {
+                                                    Some(content.clone().into_any_element())
+                                                }
+                                                Output::Image { content, .. } => {
+                                                    Some(content.clone().into_any_element())
+                                                }
+                                                Output::Message(message) => Some(
+                                                    div().child(message.clone()).into_any_element(),
+                                                ),
+                                                Output::Table { content, .. } => {
+                                                    Some(content.clone().into_any_element())
+                                                }
+                                                Output::Json { content, .. } => {
+                                                    Some(content.clone().into_any_element())
+                                                }
+                                                Output::ErrorOutput(error_view) => {
+                                                    error_view.render(window, cx)
+                                                }
+                                                Output::ClearOutputWaitMarker => None,
+                                            };
+
+                                            div().children(content)
+                                        })),
+                                ),
+                        ),
+                    ),
+            )
             .when(
                 self.has_outputs() || self.execution_duration.is_some() || self.is_executing,
                 |this| {

crates/repl/src/outputs.rs 🔗

@@ -255,6 +255,11 @@ impl Output {
         window: &mut Window,
         cx: &mut Context<ExecutionView>,
     ) -> impl IntoElement + use<> {
+        let max_width = plain::max_width_for_columns(
+            ReplSettings::get_global(cx).output_max_width_columns,
+            window,
+            cx,
+        );
         let content = match self {
             Self::Plain { content, .. } => Some(content.clone().into_any_element()),
             Self::Markdown { content, .. } => Some(content.clone().into_any_element()),
@@ -272,7 +277,8 @@ impl Output {
         h_flex()
             .id("output-content")
             .w_full()
-            .when(needs_horizontal_scroll, |el| el.overflow_x_scroll())
+            .when_some(max_width, |this, max_w| this.max_w(max_w))
+            .overflow_x_scroll()
             .items_start()
             .child(
                 div()

crates/repl/src/outputs/image.rs 🔗

@@ -4,10 +4,12 @@ use base64::{
     engine::{DecodePaddingMode, GeneralPurpose, GeneralPurposeConfig},
 };
 use gpui::{App, ClipboardItem, Image, ImageFormat, RenderImage, Window, img};
+use settings::Settings as _;
 use std::sync::Arc;
 use ui::{IntoElement, Styled, div, prelude::*};
 
-use crate::outputs::OutputContent;
+use crate::outputs::{OutputContent, plain};
+use crate::repl_settings::ReplSettings;
 
 /// ImageView renders an image inline in an editor, adapting to the line height to fit the image.
 pub struct ImageView {
@@ -67,20 +69,60 @@ impl ImageView {
             image: Arc::new(gpui_image_data),
         })
     }
+
+    fn scaled_size(
+        &self,
+        line_height: Pixels,
+        max_width: Option<Pixels>,
+        max_height: Option<Pixels>,
+    ) -> (Pixels, Pixels) {
+        let (mut height, mut width) = if self.height as f32 / f32::from(line_height)
+            == u8::MAX as f32
+        {
+            let height = u8::MAX as f32 * line_height;
+            let width = Pixels::from(self.width as f32 * f32::from(height) / self.height as f32);
+            (height, width)
+        } else {
+            (self.height.into(), self.width.into())
+        };
+
+        let mut scale: f32 = 1.0;
+        if let Some(max_width) = max_width {
+            if width > max_width {
+                scale = scale.min(max_width / width);
+            }
+        }
+
+        if let Some(max_height) = max_height {
+            if height > max_height {
+                scale = scale.min(max_height / height);
+            }
+        }
+
+        if scale < 1.0 {
+            width *= scale;
+            height *= scale;
+        }
+
+        (height, width)
+    }
 }
 
 impl Render for ImageView {
-    fn render(&mut self, window: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
+    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
+        let settings = ReplSettings::get_global(cx);
         let line_height = window.line_height();
 
-        let (height, width) = if self.height as f32 / f32::from(line_height) == u8::MAX as f32 {
-            let height = u8::MAX as f32 * line_height;
-            let width = self.width as f32 * height / self.height as f32;
-            (height, width)
+        let max_width = plain::max_width_for_columns(settings.output_max_width_columns, window, cx);
+
+        let max_height = if settings.output_max_height_lines > 0 {
+            Some(line_height * settings.output_max_height_lines as f32)
         } else {
-            (self.height.into(), self.width.into())
+            None
         };
 
+        let (height, width) = self.scaled_size(line_height, max_width, max_height);
+
         let image = self.image.clone();
 
         div().h(height).w(width).child(img(image))
@@ -96,3 +138,55 @@ impl OutputContent for ImageView {
         true
     }
 }
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    fn encode_test_image(width: u32, height: u32) -> String {
+        let image_buffer =
+            image::ImageBuffer::from_pixel(width, height, image::Rgba([0, 0, 0, 255]));
+        let image = image::DynamicImage::ImageRgba8(image_buffer);
+
+        let mut bytes = Vec::new();
+        let mut cursor = std::io::Cursor::new(&mut bytes);
+        if let Err(error) = image.write_to(&mut cursor, image::ImageFormat::Png) {
+            panic!("failed to encode test image: {error}");
+        }
+
+        base64::engine::general_purpose::STANDARD.encode(bytes)
+    }
+
+    #[test]
+    fn test_image_view_scaled_size_respects_limits() {
+        let encoded = encode_test_image(200, 120);
+        let image_view = match ImageView::from(&encoded) {
+            Ok(view) => view,
+            Err(error) => panic!("failed to decode image view: {error}"),
+        };
+
+        let line_height = Pixels::from(10.0);
+        let max_width = Pixels::from(50.0);
+        let max_height = Pixels::from(40.0);
+        let (height, width) =
+            image_view.scaled_size(line_height, Some(max_width), Some(max_height));
+
+        assert_eq!(f32::from(width), 50.0);
+        assert_eq!(f32::from(height), 30.0);
+    }
+
+    #[test]
+    fn test_image_view_scaled_size_unbounded() {
+        let encoded = encode_test_image(200, 120);
+        let image_view = match ImageView::from(&encoded) {
+            Ok(view) => view,
+            Err(error) => panic!("failed to decode image view: {error}"),
+        };
+
+        let line_height = Pixels::from(10.0);
+        let (height, width) = image_view.scaled_size(line_height, None, None);
+
+        assert_eq!(f32::from(width), 200.0);
+        assert_eq!(f32::from(height), 120.0);
+    }
+}

crates/repl/src/outputs/plain.rs 🔗

@@ -55,7 +55,7 @@ pub struct TerminalOutput {
 }
 
 /// Returns the default text style for the terminal output.
-pub fn text_style(window: &mut Window, cx: &mut App) -> TextStyle {
+pub fn text_style(window: &mut Window, cx: &App) -> TextStyle {
     let settings = ThemeSettings::get_global(cx).clone();
 
     let font_size = settings.buffer_font_size(cx).into();
@@ -94,8 +94,8 @@ pub fn terminal_size(window: &mut Window, cx: &mut App) -> terminal::TerminalBou
 
     let cell_width = text_system
         .advance(font_id, font_pixels, 'w')
-        .unwrap()
-        .width;
+        .map(|advance| advance.width)
+        .unwrap_or(Pixels::ZERO);
 
     let num_lines = ReplSettings::get_global(cx).max_lines;
     let columns = ReplSettings::get_global(cx).max_columns;
@@ -114,6 +114,27 @@ pub fn terminal_size(window: &mut Window, cx: &mut App) -> terminal::TerminalBou
     }
 }
 
+pub fn max_width_for_columns(
+    columns: usize,
+    window: &mut Window,
+    cx: &App,
+) -> Option<gpui::Pixels> {
+    if columns == 0 {
+        return None;
+    }
+
+    let text_style = text_style(window, cx);
+    let text_system = window.text_system();
+    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::ZERO);
+
+    Some(cell_width * columns as f32)
+}
+
 impl TerminalOutput {
     /// Creates a new `TerminalOutput` instance.
     ///
@@ -244,6 +265,54 @@ impl TerminalOutput {
     }
 }
 
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use gpui::{TestAppContext, VisualTestContext};
+    use settings::SettingsStore;
+
+    fn init_test(cx: &mut TestAppContext) -> &mut VisualTestContext {
+        cx.update(|cx| {
+            let settings_store = SettingsStore::test(cx);
+            cx.set_global(settings_store);
+            theme::init(theme::LoadThemes::JustBase, cx);
+        });
+        cx.add_empty_window()
+    }
+
+    #[gpui::test]
+    fn test_max_width_for_columns_zero(cx: &mut TestAppContext) {
+        let cx = init_test(cx);
+        let result = cx.update(|window, cx| max_width_for_columns(0, window, cx));
+        assert!(result.is_none());
+    }
+
+    #[gpui::test]
+    fn test_max_width_for_columns_matches_cell_width(cx: &mut TestAppContext) {
+        let cx = init_test(cx);
+        let columns = 5;
+        let (result, expected) = cx.update(|window, cx| {
+            let text_style = text_style(window, cx);
+            let text_system = window.text_system();
+            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(gpui::Pixels::ZERO);
+            let result = max_width_for_columns(columns, window, cx);
+            (result, cell_width * columns as f32)
+        });
+
+        let Some(result) = result else {
+            panic!("expected max width for columns {columns}");
+        };
+        let result_f32: f32 = result.into();
+        let expected_f32: f32 = expected.into();
+        assert!((result_f32 - expected_f32).abs() < 0.01);
+    }
+}
+
 impl Render for TerminalOutput {
     /// Renders the terminal output as a GPUI element.
     ///

crates/repl/src/repl_settings.rs 🔗

@@ -22,6 +22,16 @@ pub struct ReplSettings {
     ///
     /// Default: 50
     pub inline_output_max_length: usize,
+    /// Maximum number of lines of output to display before scrolling.
+    /// Set to 0 to disable output height limits.
+    ///
+    /// Default: 0
+    pub output_max_height_lines: usize,
+    /// Maximum number of columns of output to display before scaling images.
+    /// Set to 0 to disable output width limits.
+    ///
+    /// Default: 0
+    pub output_max_width_columns: usize,
 }
 
 impl Settings for ReplSettings {
@@ -33,6 +43,8 @@ impl Settings for ReplSettings {
             max_columns: repl.max_columns.unwrap(),
             inline_output: repl.inline_output.unwrap_or(true),
             inline_output_max_length: repl.inline_output_max_length.unwrap_or(50),
+            output_max_height_lines: repl.output_max_height_lines.unwrap_or(0),
+            output_max_width_columns: repl.output_max_width_columns.unwrap_or(0),
         }
     }
 }

crates/repl/src/session.rs 🔗

@@ -7,6 +7,7 @@ use crate::{
     outputs::{
         ExecutionStatus, ExecutionView, ExecutionViewFinishedEmpty, ExecutionViewFinishedSmall,
     },
+    repl_settings::ReplSettings,
 };
 use anyhow::Context as _;
 use collections::{HashMap, HashSet};
@@ -34,6 +35,7 @@ use runtimelib::{
     ExecuteRequest, ExecutionState, InterruptRequest, JupyterMessage, JupyterMessageContent,
     ShutdownRequest,
 };
+use settings::Settings as _;
 use std::{env::temp_dir, ops::Range, sync::Arc, time::Duration};
 use theme::ActiveTheme;
 use ui::{IconButtonShape, Tooltip, prelude::*};
@@ -143,6 +145,12 @@ impl EditorBlock {
             let rem_size = cx.window.rem_size();
 
             let text_line_height = text_style.line_height_in_pixels(rem_size);
+            let output_settings = ReplSettings::get_global(cx.app);
+            let output_max_height = if output_settings.output_max_height_lines > 0 {
+                Some(text_line_height * output_settings.output_max_height_lines as f32)
+            } else {
+                None
+            };
 
             let close_button = h_flex()
                 .flex_none()
@@ -190,11 +198,15 @@ impl EditorBlock {
                 )
                 .child(
                     div()
+                        .id((ElementId::from(cx.block_id), "output-scroll"))
                         .flex_1()
                         .overflow_x_hidden()
                         .py(text_line_height / 2.)
                         .mr(editor_margins.right)
                         .pr_2()
+                        .when_some(output_max_height, |div, max_h| {
+                            div.max_h(max_h).overflow_y_scroll()
+                        })
                         .child(execution_view),
                 )
                 .into_any_element()

crates/settings_content/src/settings_content.rs 🔗

@@ -1065,6 +1065,16 @@ pub struct ReplSettingsContent {
     ///
     /// Default: 50
     pub inline_output_max_length: Option<usize>,
+    /// Maximum number of lines of output to display before scrolling.
+    /// Set to 0 to disable output height limits.
+    ///
+    /// Default: 0
+    pub output_max_height_lines: Option<usize>,
+    /// Maximum number of columns of output to display before scaling images.
+    /// Set to 0 to disable output width limits.
+    ///
+    /// Default: 0
+    pub output_max_width_columns: Option<usize>,
 }
 
 /// Settings for configuring the which-key popup behaviour.