repl: Add inlay repl output display, skip empty lines on execution, and gutter execution display (#44523)

Matt Lui created

Adds various useful things to the repl inspired by ipynb and the julia
vscode extension which can be best seen with this video:


https://github.com/user-attachments/assets/6589715e-3783-456c-8f4b-e2d5a1c4090d

To summarize:

## Inline outputs
Added small, single-line outputs displayed inline at the end of the code
line instead of in a separate block. This provides a cleaner, more
compact view for simple results like numbers or short strings. This
occurs for execution views who only output a single mimetype/plain OR
output nothing, otherwise the default behavior of creating a block will
occur.

It looks like this: 
<img width="258" height="35" alt="image"
src="https://github.com/user-attachments/assets/ccdeca3f-c3b7-4387-a4de-53d8b9a25132"
/>
or with a Output
<img width="346" height="55" alt="image"
src="https://github.com/user-attachments/assets/0b4effc9-1bd7-4e8c-802f-8733cdcc77d1"
/>
This was inspired by julia vscode extension, but now it can be used with
any replanguage! Hooray!


<img width="524" height="450" alt="image"
src="https://github.com/user-attachments/assets/a3551e51-f5f7-4d3e-994a-213c9d2f948c"
/>

It saves lots of space compared to the ugly and distracting:
<img width="531" height="546" alt="image"
src="https://github.com/user-attachments/assets/7cf65bae-8ec1-4279-ab19-f0d4ec4052a2"
/>


## Gutters and execution numbers
Added gutters + execution number to display exactly what was executed.
The gutter highlighting is useful for when selecting multiple cells
manually to run, but you dont remember which ones

Ran at different times:
<img width="257" height="58" alt="image"
src="https://github.com/user-attachments/assets/6002ab16-156a-4598-9964-5a6b188e989c"
/>
Ran together:
<img width="306" height="64" alt="image"
src="https://github.com/user-attachments/assets/2690ea35-2bd3-4207-b039-6c0f98dad6e4"
/>

The execution number is useful in the same way that a normal jupyter
notebook execution number is useful.

If a gutter-region does not have a block assigned to it, when you edit
the text in the gutter region, the gutter will disappear, which is
useful for telling when you have modified your code, but does not delete
useful experiment results in blocks:

<img width="280" height="38" alt="image"
src="https://github.com/user-attachments/assets/d7f29224-87e4-4c14-8d9f-41cb10ab5009"
/>
<img width="254" height="31" alt="image"
src="https://github.com/user-attachments/assets/586c9e1d-f53c-4973-affb-c8ca05a7563b"
/>
<img width="264" height="29" alt="image"
src="https://github.com/user-attachments/assets/f306c364-1c92-44bd-9050-ecce1b7822a0"
/>
 
## Skip empty line
This is a minor fix which is intended to make lab workflow less tedious.
Currently when you execute on an empty line (which might be there for
formatting purposes) nothing will occur. This PR adds the ability to,
when executing from an empty line, skip ahead the range of inclusion
until you reach actual code, and then execute.

Before:
```
code //run execute
//empty space, so you have to move your cursor down or use arrow key
code //run execute
code //run execute
```
After:
```
code //run execute
//empty space, you can now run execute on it and it will include the next line of code
//empty space
code //automatically executed
code //run execute
```

Currently the only piece of tested code is related to this, i still have
to write tests for the gutter annotation api i added and all of the
gutter + inline related code. Also still have to add more config for
this stuff.

@rgbkrk would appreciate a review :D

Closes #22678

Release Notes:

- repl: Added an inline display of execution results (as opposed to the
large execution view) for simple REPL cells
- repl: Improved how execution of empty lines are handled
- repl: Added gutter execution display

Change summary

crates/editor/src/display_map/inlay_map.rs |  27 +++
crates/editor/src/inlays.rs                |   8 +
crates/project/src/project.rs              |   2 
crates/repl/src/outputs.rs                 |  51 ++++++
crates/repl/src/repl_editor.rs             | 102 +++++++++++++
crates/repl/src/repl_settings.rs           |  11 +
crates/repl/src/session.rs                 | 183 ++++++++++++++++++++++-
crates/settings/src/settings_content.rs    |   9 +
8 files changed, 380 insertions(+), 13 deletions(-)

Detailed changes

crates/editor/src/display_map/inlay_map.rs 🔗

@@ -334,6 +334,33 @@ impl<'a> Iterator for InlayChunks<'a> {
                     }),
                     InlayId::Hint(_) => self.highlight_styles.inlay_hint,
                     InlayId::DebuggerValue(_) => self.highlight_styles.inlay_hint,
+                    InlayId::ReplResult(_) => {
+                        let text = inlay.text().to_string();
+                        renderer = Some(ChunkRenderer {
+                            id: ChunkRendererId::Inlay(inlay.id),
+                            render: Arc::new(move |cx| {
+                                let colors = cx.theme().colors();
+                                div()
+                                    .flex()
+                                    .flex_row()
+                                    .items_center()
+                                    .child(div().w_4())
+                                    .child(
+                                        div()
+                                            .px_1()
+                                            .rounded_sm()
+                                            .bg(colors.surface_background)
+                                            .text_color(colors.text_muted)
+                                            .text_xs()
+                                            .child(text.trim().to_string()),
+                                    )
+                                    .into_any_element()
+                            }),
+                            constrain_width: false,
+                            measured_width: None,
+                        });
+                        self.highlight_styles.inlay_hint
+                    }
                     InlayId::Color(_) => {
                         if let InlayContent::Color(color) = inlay.content {
                             renderer = Some(ChunkRenderer {

crates/editor/src/inlays.rs 🔗

@@ -104,6 +104,14 @@ impl Inlay {
         }
     }
 
+    pub fn repl_result<T: Into<Rope>>(id: usize, position: Anchor, text: T) -> Self {
+        Self {
+            id: InlayId::ReplResult(id),
+            position,
+            content: InlayContent::Text(text.into()),
+        }
+    }
+
     pub fn text(&self) -> &Rope {
         static COLOR_TEXT: OnceLock<Rope> = OnceLock::new();
         match &self.content {

crates/project/src/project.rs 🔗

@@ -418,6 +418,7 @@ pub enum InlayId {
     // LSP
     Hint(usize),
     Color(usize),
+    ReplResult(usize),
 }
 
 impl InlayId {
@@ -427,6 +428,7 @@ impl InlayId {
             Self::DebuggerValue(id) => *id,
             Self::Hint(id) => *id,
             Self::Color(id) => *id,
+            Self::ReplResult(id) => *id,
         }
     }
 }

crates/repl/src/outputs.rs 🔗

@@ -34,7 +34,7 @@
 //! interpreting and displaying various types of Jupyter output.
 
 use editor::{Editor, MultiBuffer};
-use gpui::{AnyElement, ClipboardItem, Entity, Render, WeakEntity};
+use gpui::{AnyElement, ClipboardItem, Entity, EventEmitter, Render, WeakEntity};
 use language::Buffer;
 use runtimelib::{ExecutionState, JupyterMessageContent, MimeBundle, MimeType};
 use ui::{CommonAnimationExt, CopyButton, IconButton, Tooltip, prelude::*};
@@ -55,6 +55,9 @@ pub(crate) mod user_error;
 use user_error::ErrorView;
 use workspace::Workspace;
 
+use crate::repl_settings::ReplSettings;
+use settings::Settings;
+
 /// When deciding what to render from a collection of mediatypes, we need to rank them in order of importance
 fn rank_mime_type(mimetype: &MimeType) -> usize {
     match mimetype {
@@ -359,6 +362,9 @@ pub enum ExecutionStatus {
     Restarting,
 }
 
+pub struct ExecutionViewFinishedEmpty;
+pub struct ExecutionViewFinishedSmall(pub String);
+
 /// An ExecutionView shows the outputs of an execution.
 /// It can hold zero or more outputs, which the user
 /// sees as "the output" for a single execution.
@@ -369,6 +375,9 @@ pub struct ExecutionView {
     pub status: ExecutionStatus,
 }
 
+impl EventEmitter<ExecutionViewFinishedEmpty> for ExecutionView {}
+impl EventEmitter<ExecutionViewFinishedSmall> for ExecutionView {}
+
 impl ExecutionView {
     pub fn new(
         status: ExecutionStatus,
@@ -445,7 +454,16 @@ impl ExecutionView {
                     ExecutionState::Busy => {
                         self.status = ExecutionStatus::Executing;
                     }
-                    ExecutionState::Idle => self.status = ExecutionStatus::Finished,
+                    ExecutionState::Idle => {
+                        self.status = ExecutionStatus::Finished;
+                        if self.outputs.is_empty() {
+                            cx.emit(ExecutionViewFinishedEmpty);
+                        } else if ReplSettings::get_global(cx).inline_output {
+                            if let Some(small_text) = self.get_small_inline_output(cx) {
+                                cx.emit(ExecutionViewFinishedSmall(small_text));
+                            }
+                        }
+                    }
                     ExecutionState::Unknown => self.status = ExecutionStatus::Unknown,
                     ExecutionState::Starting => self.status = ExecutionStatus::ConnectingToKernel,
                     ExecutionState::Restarting => self.status = ExecutionStatus::Restarting,
@@ -497,6 +515,35 @@ impl ExecutionView {
         }
     }
 
+    /// Check if the output is a single small plain text that can be shown inline.
+    /// Returns the text if it's suitable for inline display (single line, short enough).
+    fn get_small_inline_output(&self, cx: &App) -> Option<String> {
+        // Only consider single outputs
+        if self.outputs.len() != 1 {
+            return None;
+        }
+
+        let output = self.outputs.first()?;
+
+        // Only Plain outputs can be inlined
+        let content = match output {
+            Output::Plain { content, .. } => content,
+            _ => return None,
+        };
+
+        let text = content.read(cx).full_text();
+        let trimmed = text.trim();
+
+        let max_length = ReplSettings::get_global(cx).inline_output_max_length;
+
+        // Must be a single line and within the configured max length
+        if trimmed.contains('\n') || trimmed.len() > max_length {
+            return None;
+        }
+
+        Some(trimmed.to_string())
+    }
+
     fn apply_terminal_text(
         &mut self,
         text: &str,

crates/repl/src/repl_editor.rs 🔗

@@ -433,6 +433,36 @@ fn runnable_ranges(
     }
 
     let snippet_range = cell_range(buffer, range.start.row, range.end.row);
+
+    // Check if the snippet range is entirely blank, if so, skip forward to find code
+    let is_blank =
+        (snippet_range.start.row..=snippet_range.end.row).all(|row| buffer.is_line_blank(row));
+
+    if is_blank {
+        // Search forward for the next non-blank line
+        let max_row = buffer.max_point().row;
+        let mut next_row = snippet_range.end.row + 1;
+        while next_row <= max_row && buffer.is_line_blank(next_row) {
+            next_row += 1;
+        }
+
+        if next_row <= max_row {
+            // Found a non-blank line, find the extent of this cell
+            let next_snippet_range = cell_range(buffer, next_row, next_row);
+            let start_language = buffer.language_at(next_snippet_range.start);
+            let end_language = buffer.language_at(next_snippet_range.end);
+
+            if start_language
+                .zip(end_language)
+                .is_some_and(|(start, end)| start == end)
+            {
+                return (vec![next_snippet_range], None);
+            }
+        }
+
+        return (Vec::new(), None);
+    }
+
     let start_language = buffer.language_at(snippet_range.start);
     let end_language = buffer.language_at(snippet_range.end);
 
@@ -821,4 +851,76 @@ mod tests {
             },]
         );
     }
+
+    #[gpui::test]
+    fn test_skip_blank_lines_to_next_cell(cx: &mut App) {
+        let test_language = Arc::new(Language::new(
+            LanguageConfig {
+                name: "TestLang".into(),
+                line_comments: vec!["# ".into()],
+                ..Default::default()
+            },
+            None,
+        ));
+
+        let buffer = cx.new(|cx| {
+            Buffer::local(
+                indoc! { r#"
+                    print(1 + 1)
+
+                    print(2 + 2)
+                "# },
+                cx,
+            )
+            .with_language(test_language.clone(), cx)
+        });
+        let snapshot = buffer.read(cx).snapshot();
+
+        // Selection on blank line should skip to next non-blank cell
+        let (snippets, _) = runnable_ranges(&snapshot, Point::new(1, 0)..Point::new(1, 0), cx);
+        let snippets = snippets
+            .into_iter()
+            .map(|range| snapshot.text_for_range(range).collect::<String>())
+            .collect::<Vec<_>>();
+        assert_eq!(snippets, vec!["print(2 + 2)"]);
+
+        // Multiple blank lines should also skip forward
+        let buffer = cx.new(|cx| {
+            Buffer::local(
+                indoc! { r#"
+                    print(1 + 1)
+
+
+
+                    print(2 + 2)
+                "# },
+                cx,
+            )
+            .with_language(test_language.clone(), cx)
+        });
+        let snapshot = buffer.read(cx).snapshot();
+
+        let (snippets, _) = runnable_ranges(&snapshot, Point::new(2, 0)..Point::new(2, 0), cx);
+        let snippets = snippets
+            .into_iter()
+            .map(|range| snapshot.text_for_range(range).collect::<String>())
+            .collect::<Vec<_>>();
+        assert_eq!(snippets, vec!["print(2 + 2)"]);
+
+        // Blank lines at end of file should return nothing
+        let buffer = cx.new(|cx| {
+            Buffer::local(
+                indoc! { r#"
+                    print(1 + 1)
+
+                "# },
+                cx,
+            )
+            .with_language(test_language, cx)
+        });
+        let snapshot = buffer.read(cx).snapshot();
+
+        let (snippets, _) = runnable_ranges(&snapshot, Point::new(1, 0)..Point::new(1, 0), cx);
+        assert!(snippets.is_empty());
+    }
 }

crates/repl/src/repl_settings.rs 🔗

@@ -13,6 +13,15 @@ pub struct ReplSettings {
     ///
     /// Default: 128
     pub max_columns: usize,
+    /// Whether to show small single-line outputs inline instead of in a block.
+    ///
+    /// Default: true
+    pub inline_output: bool,
+    /// Maximum number of characters for an output to be shown inline.
+    /// Only applies when `inline_output` is true.
+    ///
+    /// Default: 50
+    pub inline_output_max_length: usize,
 }
 
 impl Settings for ReplSettings {
@@ -22,6 +31,8 @@ impl Settings for ReplSettings {
         Self {
             max_lines: repl.max_lines.unwrap(),
             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),
         }
     }
 }

crates/repl/src/session.rs 🔗

@@ -4,19 +4,26 @@ use crate::setup_editor_session_actions;
 use crate::{
     KernelStatus,
     kernels::{Kernel, KernelSpecification, NativeRunningKernel},
-    outputs::{ExecutionStatus, ExecutionView},
+    outputs::{
+        ExecutionStatus, ExecutionView, ExecutionViewFinishedEmpty, ExecutionViewFinishedSmall,
+    },
 };
 use anyhow::Context as _;
 use collections::{HashMap, HashSet};
 use editor::SelectionEffects;
 use editor::{
-    Anchor, AnchorRangeExt as _, Editor, MultiBuffer, ToPoint,
+    Anchor, AnchorRangeExt as _, Editor, Inlay, MultiBuffer, ToOffset, ToPoint,
     display_map::{
         BlockContext, BlockId, BlockPlacement, BlockProperties, BlockStyle, CustomBlockId,
         RenderBlock,
     },
     scroll::Autoscroll,
 };
+use project::InlayId;
+
+/// Marker types
+enum ReplExecutedRange {}
+
 use futures::FutureExt as _;
 use gpui::{
     Context, Entity, EventEmitter, Render, Subscription, Task, WeakEntity, Window, div, prelude::*,
@@ -36,9 +43,13 @@ pub struct Session {
     fs: Arc<dyn Fs>,
     editor: WeakEntity<Editor>,
     pub kernel: Kernel,
-    blocks: HashMap<String, EditorBlock>,
     pub kernel_specification: KernelSpecification,
-    _buffer_subscription: Subscription,
+
+    blocks: HashMap<String, EditorBlock>,
+    result_inlays: HashMap<String, (InlayId, Range<Anchor>, usize)>,
+    next_inlay_id: usize,
+
+    _subscriptions: Vec<Subscription>,
 }
 
 struct EditorBlock {
@@ -220,8 +231,10 @@ impl Session {
             editor,
             kernel: Kernel::StartingKernel(Task::ready(()).shared()),
             blocks: HashMap::default(),
+            result_inlays: HashMap::default(),
+            next_inlay_id: 0,
             kernel_specification,
-            _buffer_subscription: subscription,
+            _subscriptions: vec![subscription],
         };
 
         session.start_kernel(window, cx);
@@ -321,20 +334,56 @@ impl Session {
             let snapshot = buffer.read(cx).snapshot(cx);
 
             let mut blocks_to_remove: HashSet<CustomBlockId> = HashSet::default();
+            let mut gutter_ranges_to_remove: Vec<Range<Anchor>> = Vec::new();
+            let mut keys_to_remove: Vec<String> = Vec::new();
 
-            self.blocks.retain(|_id, block| {
+            self.blocks.retain(|id, block| {
                 if block.invalidation_anchor.is_valid(&snapshot) {
                     true
                 } else {
                     blocks_to_remove.insert(block.block_id);
+                    gutter_ranges_to_remove.push(block.code_range.clone());
+                    keys_to_remove.push(id.clone());
                     false
                 }
             });
 
-            if !blocks_to_remove.is_empty() {
+            let mut inlays_to_remove: Vec<InlayId> = Vec::new();
+
+            self.result_inlays
+                .retain(|id, (inlay_id, code_range, original_len)| {
+                    let start_offset = code_range.start.to_offset(&snapshot);
+                    let end_offset = code_range.end.to_offset(&snapshot);
+                    let current_len = end_offset.saturating_sub(start_offset);
+
+                    if current_len != *original_len {
+                        inlays_to_remove.push(*inlay_id);
+                        gutter_ranges_to_remove.push(code_range.clone());
+                        keys_to_remove.push(id.clone());
+                        false
+                    } else {
+                        true
+                    }
+                });
+
+            if !blocks_to_remove.is_empty()
+                || !inlays_to_remove.is_empty()
+                || !gutter_ranges_to_remove.is_empty()
+            {
                 self.editor
                     .update(cx, |editor, cx| {
-                        editor.remove_blocks(blocks_to_remove, None, cx);
+                        if !blocks_to_remove.is_empty() {
+                            editor.remove_blocks(blocks_to_remove, None, cx);
+                        }
+                        if !inlays_to_remove.is_empty() {
+                            editor.splice_inlays(&inlays_to_remove, vec![], cx);
+                        }
+                        if !gutter_ranges_to_remove.is_empty() {
+                            editor.remove_gutter_highlights::<ReplExecutedRange>(
+                                gutter_ranges_to_remove,
+                                cx,
+                            );
+                        }
                     })
                     .ok();
                 cx.notify();
@@ -350,17 +399,72 @@ impl Session {
         anyhow::Ok(())
     }
 
+    fn replace_block_with_inlay(&mut self, message_id: &str, text: &str, cx: &mut Context<Self>) {
+        let Some(block) = self.blocks.remove(message_id) else {
+            return;
+        };
+
+        let Some(editor) = self.editor.upgrade() else {
+            return;
+        };
+
+        let code_range = block.code_range.clone();
+
+        editor.update(cx, |editor, cx| {
+            let mut block_ids = HashSet::default();
+            block_ids.insert(block.block_id);
+            editor.remove_blocks(block_ids, None, cx);
+
+            let buffer = editor.buffer().read(cx).snapshot(cx);
+            let start_offset = code_range.start.to_offset(&buffer);
+            let end_offset = code_range.end.to_offset(&buffer);
+            let original_len = end_offset.saturating_sub(start_offset);
+
+            let end_point = code_range.end.to_point(&buffer);
+            let inlay_position = buffer.anchor_after(end_point);
+
+            let inlay_id = self.next_inlay_id;
+            self.next_inlay_id += 1;
+
+            let inlay = Inlay::repl_result(inlay_id, inlay_position, format!("    {}", text));
+
+            editor.splice_inlays(&[], vec![inlay], cx);
+            self.result_inlays.insert(
+                message_id.to_string(),
+                (
+                    InlayId::ReplResult(inlay_id),
+                    code_range.clone(),
+                    original_len,
+                ),
+            );
+
+            editor.insert_gutter_highlight::<ReplExecutedRange>(
+                code_range,
+                |cx| cx.theme().status().success,
+                cx,
+            );
+        });
+
+        cx.notify();
+    }
+
     pub fn clear_outputs(&mut self, cx: &mut Context<Self>) {
         let blocks_to_remove: HashSet<CustomBlockId> =
             self.blocks.values().map(|block| block.block_id).collect();
 
+        let inlays_to_remove: Vec<InlayId> =
+            self.result_inlays.values().map(|(id, _, _)| *id).collect();
+
         self.editor
             .update(cx, |editor, cx| {
                 editor.remove_blocks(blocks_to_remove, None, cx);
+                editor.splice_inlays(&inlays_to_remove, vec![], cx);
+                editor.clear_gutter_highlights::<ReplExecutedRange>(cx);
             })
             .ok();
 
         self.blocks.clear();
+        self.result_inlays.clear();
     }
 
     pub fn execute(
@@ -388,6 +492,8 @@ impl Session {
         let message: JupyterMessage = execute_request.into();
 
         let mut blocks_to_remove: HashSet<CustomBlockId> = HashSet::default();
+        let mut inlays_to_remove: Vec<InlayId> = Vec::new();
+        let mut gutter_ranges_to_remove: Vec<Range<Anchor>> = Vec::new();
 
         let buffer = editor.read(cx).buffer().read(cx).snapshot(cx);
 
@@ -400,9 +506,27 @@ impl Session {
             }
         });
 
+        self.result_inlays
+            .retain(|_key, (inlay_id, inlay_range, _)| {
+                if anchor_range.overlaps(inlay_range, &buffer) {
+                    inlays_to_remove.push(*inlay_id);
+                    gutter_ranges_to_remove.push(inlay_range.clone());
+                    false
+                } else {
+                    true
+                }
+            });
+
         self.editor
             .update(cx, |editor, cx| {
                 editor.remove_blocks(blocks_to_remove, None, cx);
+                if !inlays_to_remove.is_empty() {
+                    editor.splice_inlays(&inlays_to_remove, vec![], cx);
+                }
+                if !gutter_ranges_to_remove.is_empty() {
+                    editor
+                        .remove_gutter_highlights::<ReplExecutedRange>(gutter_ranges_to_remove, cx);
+                }
             })
             .ok();
 
@@ -418,6 +542,7 @@ impl Session {
         let parent_message_id = message.header.msg_id.clone();
         let session_view = cx.entity().downgrade();
         let weak_editor = self.editor.clone();
+        let code_range_for_close = anchor_range.clone();
 
         let on_close: CloseBlockFn = Arc::new(
             move |block_id: CustomBlockId, _: &mut Window, cx: &mut App| {
@@ -433,23 +558,59 @@ impl Session {
                         let mut block_ids = HashSet::default();
                         block_ids.insert(block_id);
                         editor.remove_blocks(block_ids, None, cx);
+                        editor.remove_gutter_highlights::<ReplExecutedRange>(
+                            vec![code_range_for_close.clone()],
+                            cx,
+                        );
                     });
                 }
             },
         );
 
-        let Ok(editor_block) =
-            EditorBlock::new(self.editor.clone(), anchor_range, status, on_close, cx)
-        else {
+        let Ok(editor_block) = EditorBlock::new(
+            self.editor.clone(),
+            anchor_range.clone(),
+            status,
+            on_close,
+            cx,
+        ) else {
             return;
         };
 
+        self.editor
+            .update(cx, |editor, cx| {
+                editor.insert_gutter_highlight::<ReplExecutedRange>(
+                    anchor_range.clone(),
+                    |cx| cx.theme().status().success,
+                    cx,
+                );
+            })
+            .ok();
+
         let new_cursor_pos = if let Some(next_cursor) = next_cell {
             next_cursor
         } else {
             editor_block.invalidation_anchor
         };
 
+        let msg_id = message.header.msg_id.clone();
+        let subscription = cx.subscribe(
+            &editor_block.execution_view,
+            move |session, _execution_view, _event: &ExecutionViewFinishedEmpty, cx| {
+                session.replace_block_with_inlay(&msg_id, "✓", cx);
+            },
+        );
+        self._subscriptions.push(subscription);
+
+        let msg_id = message.header.msg_id.clone();
+        let subscription = cx.subscribe(
+            &editor_block.execution_view,
+            move |session, _execution_view, event: &ExecutionViewFinishedSmall, cx| {
+                session.replace_block_with_inlay(&msg_id, &event.0, cx);
+            },
+        );
+        self._subscriptions.push(subscription);
+
         self.blocks
             .insert(message.header.msg_id.clone(), editor_block);
 

crates/settings/src/settings_content.rs 🔗

@@ -977,6 +977,15 @@ pub struct ReplSettingsContent {
     ///
     /// Default: 128
     pub max_columns: Option<usize>,
+    /// Whether to show small single-line outputs inline instead of in a block.
+    ///
+    /// Default: true
+    pub inline_output: Option<bool>,
+    /// Maximum number of characters for an output to be shown inline.
+    /// Only applies when `inline_output` is true.
+    ///
+    /// Default: 50
+    pub inline_output_max_length: Option<usize>,
 }
 
 /// Settings for configuring the which-key popup behaviour.