repl: Render markdown output from kernels (#15742)

Kyle Kelley created

<img width="1268" alt="image"
src="https://github.com/user-attachments/assets/73e03a28-f5e3-4395-a58c-cabd07f57889">

Release Notes:

- Added markdown rendering for Jupyter/REPL outputs. Push Markdown from
Deno/Typescript with `Deno.jupyter.md` and in IPython use
`IPython.display.Markdown`.

Change summary

Cargo.lock                 |  1 
crates/repl/Cargo.toml     |  1 
crates/repl/src/outputs.rs | 86 ++++++++++++++++++++++++++++++---------
3 files changed, 67 insertions(+), 21 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -8749,6 +8749,7 @@ dependencies = [
  "language",
  "languages",
  "log",
+ "markdown_preview",
  "multi_buffer",
  "project",
  "runtimelib",

crates/repl/Cargo.toml 🔗

@@ -26,6 +26,7 @@ gpui.workspace = true
 image.workspace = true
 language.workspace = true
 log.workspace = true
+markdown_preview.workspace = true
 multi_buffer.workspace = true
 project.workspace = true
 runtimelib.workspace = true

crates/repl/src/outputs.rs 🔗

@@ -5,8 +5,8 @@ use crate::stdio::TerminalOutput;
 use anyhow::Result;
 use base64::prelude::*;
 use gpui::{
-    img, percentage, Animation, AnimationExt, AnyElement, FontWeight, ImageData, Render, TextRun,
-    Transformation,
+    img, percentage, Animation, AnimationExt, AnyElement, FontWeight, ImageData, Render, Task,
+    TextRun, Transformation, View,
 };
 use runtimelib::datatable::TableSchema;
 use runtimelib::media::datatable::TabularDataResource;
@@ -16,6 +16,11 @@ use settings::Settings;
 use theme::ThemeSettings;
 use ui::{div, prelude::*, v_flex, IntoElement, Styled, ViewContext};
 
+use markdown_preview::{
+    markdown_elements::ParsedMarkdown, markdown_parser::parse_markdown,
+    markdown_renderer::render_markdown_block,
+};
+
 /// 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 {
@@ -268,6 +273,58 @@ impl ErrorView {
     }
 }
 
+pub struct MarkdownView {
+    contents: Option<ParsedMarkdown>,
+    parsing_markdown_task: Option<Task<Result<()>>>,
+}
+
+impl MarkdownView {
+    pub fn from(text: String, cx: &mut ViewContext<Self>) -> Self {
+        let task = cx.spawn(|markdown, mut cx| async move {
+            let text = text.clone();
+            let parsed = cx
+                .background_executor()
+                .spawn(async move { parse_markdown(&text, None, None).await });
+
+            let content = parsed.await;
+
+            markdown.update(&mut cx, |markdown, cx| {
+                markdown.parsing_markdown_task.take();
+                markdown.contents = Some(content);
+                cx.notify();
+            })
+        });
+
+        Self {
+            contents: None,
+            parsing_markdown_task: Some(task),
+        }
+    }
+}
+
+impl Render for MarkdownView {
+    fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
+        let Some(parsed) = self.contents.as_ref() else {
+            return div().into_any_element();
+        };
+
+        let mut markdown_render_context =
+            markdown_preview::markdown_renderer::RenderContext::new(None, cx);
+
+        v_flex()
+            .gap_3()
+            .py_4()
+            .children(parsed.children.iter().map(|child| {
+                div().relative().child(
+                    div()
+                        .relative()
+                        .child(render_markdown_block(child, &mut markdown_render_context)),
+                )
+            }))
+            .into_any_element()
+    }
+}
+
 pub struct Output {
     content: OutputContent,
     display_id: Option<String>,
@@ -296,33 +353,17 @@ pub enum OutputContent {
     ErrorOutput(ErrorView),
     Message(String),
     Table(TableView),
+    Markdown(View<MarkdownView>),
     ClearOutputWaitMarker,
 }
 
-impl std::fmt::Debug for OutputContent {
-    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
-        match self {
-            OutputContent::Plain(_) => f.debug_struct("OutputContent(Plain)"),
-            OutputContent::Stream(_) => f.debug_struct("OutputContent(Stream)"),
-            OutputContent::Image(_) => f.debug_struct("OutputContent(Image)"),
-            OutputContent::ErrorOutput(_) => f.debug_struct("OutputContent(ErrorOutput)"),
-            OutputContent::Message(_) => f.debug_struct("OutputContent(Message)"),
-            OutputContent::Table(_) => f.debug_struct("OutputContent(Table)"),
-            OutputContent::ClearOutputWaitMarker => {
-                f.debug_struct("OutputContent(ClearOutputWaitMarker)")
-            }
-        }
-        .finish()
-    }
-}
-
 impl OutputContent {
     fn render(&self, cx: &ViewContext<ExecutionView>) -> Option<AnyElement> {
         let el = match self {
             // Note: in typical frontends we would show the execute_result.execution_count
             // Here we can just handle either
             Self::Plain(stdio) => Some(stdio.render(cx)),
-            // Self::Markdown(markdown) => Some(markdown.render(theme)),
+            Self::Markdown(markdown) => Some(markdown.clone().into_any_element()),
             Self::Stream(stdio) => Some(stdio.render(cx)),
             Self::Image(image) => Some(image.render(cx)),
             Self::Message(message) => Some(div().child(message.clone()).into_any_element()),
@@ -337,7 +378,10 @@ impl OutputContent {
     pub fn new(data: &MimeBundle, cx: &mut WindowContext) -> Self {
         match data.richest(rank_mime_type) {
             Some(MimeType::Plain(text)) => OutputContent::Plain(TerminalOutput::from(text, cx)),
-            Some(MimeType::Markdown(text)) => OutputContent::Plain(TerminalOutput::from(text, cx)),
+            Some(MimeType::Markdown(text)) => {
+                let view = cx.new_view(|cx| MarkdownView::from(text.clone(), cx));
+                OutputContent::Markdown(view)
+            }
             Some(MimeType::Png(data)) | Some(MimeType::Jpeg(data)) => match ImageView::from(data) {
                 Ok(view) => OutputContent::Image(view),
                 Err(error) => OutputContent::Message(format!("Failed to load image: {}", error)),