Bring Jupyter to Zed Editing (#12062)

Kyle Kelley created

Run any Jupyter kernel in Zed on any buffer (editor):

<img width="1074" alt="image"
src="https://github.com/zed-industries/zed/assets/836375/eac8ed69-d02b-4d46-b379-6186d8f59470">

## TODO

### Lifecycle

* [x] Launch kernels on demand
* [x] Wait for kernel to be started
* [x] Request Kernel info on start
* [x] Show in progress indicator
* [ ] Allow picking kernel (it defaults to first matching language name)
* [ ] Menu for interrupting and shutting down the kernel
* [ ] Drop running kernels once editor is dropped

### Media Outputs

* [x] Render text and tracebacks with ANSI color handling
* [x] Render markdown as text
* [x] Render PNG and JPEG images using an explicit height based on
line-height
* ~~Render SVG~~ -- not happening for this PR due to lack of text in SVG
support
* [ ] Process `update_display_data` message and related `display_id`
* [x] Process `page` data from payloads as outputs
* [ ] Render markdown as, well, rendered markdown -- Note: unsure if we
can get line heights here

### Document

* [x] Select code and run
* [x] Run current line
* [x] Clear previous overlapping runs
* [ ] Support running markdown code blocks
* [ ] Action to export session as notebook or output files
* [ ] Action to clear all outputs
* [ ] Delete outputs when lines are deleted

## Other missing features

The following is a list of missing functionality or expectations that
are out of scope for this PR.

### Python Environments

Detecting python environments should probably be done in a separate PR
in tandem with how they're used with LSP. Users likely want to pick an
environment for their project, whether a virtualenv, conda env, pyenv,
poetry backed virtualenv, or the system. Related issues:

* https://github.com/zed-industries/zed/issues/7646
* https://github.com/zed-industries/zed/issues/7808
* https://github.com/zed-industries/zed/issues/7296

### LSP Integration

* Submit `complete_request` messages for completions to interleave
interactive variables with LSP
* LSP for IPython semantics (`%%timeit`, `!ls`, `get_ipython`, etc.)

## Future release notes

- Run code in any editor, whether it's a script or a markdown document

Release Notes:

- N/A

Change summary

Cargo.lock                                   | 222 +++++++
Cargo.toml                                   |   6 
crates/collab/src/db/queries/extensions.rs   |   1 
crates/gpui/src/executor.rs                  |   6 
crates/multi_buffer/src/anchor.rs            |   9 
crates/repl/Cargo.toml                       |  49 +
crates/repl/src/outputs.rs                   | 479 ++++++++++++++++++
crates/repl/src/repl.rs                      | 571 ++++++++++++++++++++++
crates/repl/src/runtime_settings.rs          |  66 ++
crates/repl/src/runtimes.rs                  | 329 ++++++++++++
crates/repl/src/stdio.rs                     | 394 +++++++++++++++
crates/terminal_view/src/terminal_element.rs |   2 
crates/ui/src/utils/format_distance.rs       |   4 
crates/zed/Cargo.toml                        |   1 
crates/zed/src/main.rs                       |   2 
15 files changed, 2,117 insertions(+), 24 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -86,6 +86,30 @@ dependencies = [
  "memchr",
 ]
 
+[[package]]
+name = "alacritty_terminal"
+version = "0.23.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f6d1ea4484c8676f295307a4892d478c70ac8da1dbd8c7c10830a504b7f1022f"
+dependencies = [
+ "base64 0.22.0",
+ "bitflags 2.4.2",
+ "home",
+ "libc",
+ "log",
+ "miow",
+ "parking_lot",
+ "piper",
+ "polling 3.3.2",
+ "regex-automata 0.4.5",
+ "rustix-openpty",
+ "serde",
+ "signal-hook",
+ "unicode-width",
+ "vte",
+ "windows-sys 0.48.0",
+]
+
 [[package]]
 name = "alacritty_terminal"
 version = "0.24.1-dev"
@@ -424,6 +448,16 @@ dependencies = [
  "util",
 ]
 
+[[package]]
+name = "async-attributes"
+version = "1.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a3203e79f4dd9bdda415ed03cf14dae5a2bf775c683a00f94e9cd1faf0f596e5"
+dependencies = [
+ "quote",
+ "syn 1.0.109",
+]
+
 [[package]]
 name = "async-broadcast"
 version = "0.7.0"
@@ -487,6 +521,16 @@ dependencies = [
  "pin-project-lite",
 ]
 
+[[package]]
+name = "async-dispatcher"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5c8bff43baa5b0ca8f8bcd7f9338f5d30fbd75236a2aa89130a7c5121a06d6ca"
+dependencies = [
+ "async-task",
+ "futures-lite 1.13.0",
+]
+
 [[package]]
 name = "async-executor"
 version = "1.5.1"
@@ -736,6 +780,7 @@ version = "1.12.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "62565bb4402e926b29953c785397c6dc0391b7b446e45008b0049eb43cec6f5d"
 dependencies = [
+ "async-attributes",
  "async-channel 1.9.0",
  "async-global-executor",
  "async-io 1.13.0",
@@ -838,6 +883,19 @@ dependencies = [
  "thiserror",
 ]
 
+[[package]]
+name = "asynchronous-codec"
+version = "0.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a860072022177f903e59730004fb5dc13db9275b79bb2aef7ba8ce831956c233"
+dependencies = [
+ "bytes 1.5.0",
+ "futures-sink",
+ "futures-util",
+ "memchr",
+ "pin-project-lite",
+]
+
 [[package]]
 name = "atoi"
 version = "2.0.0"
@@ -1999,9 +2057,9 @@ dependencies = [
 
 [[package]]
 name = "chrono"
-version = "0.4.31"
+version = "0.4.38"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7f2c685bad3eb3d45a01354cedb7d5faa66194d1d58ba6e267a8de788f79db38"
+checksum = "a21f936df1771bf62b77f047b726c4625ff2e8aa607c01ec06e5a05bd8463401"
 dependencies = [
  "android-tzdata",
  "iana-time-zone",
@@ -2009,7 +2067,7 @@ dependencies = [
  "num-traits",
  "serde",
  "wasm-bindgen",
- "windows-targets 0.48.5",
+ "windows-targets 0.52.5",
 ]
 
 [[package]]
@@ -3340,7 +3398,7 @@ version = "3.0.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "30baa043103c9d0c2a57cf537cc2f35623889dc0d405e6c3cccfadbc81c71309"
 dependencies = [
- "dirs-sys",
+ "dirs-sys 0.3.7",
 ]
 
 [[package]]
@@ -3349,7 +3407,16 @@ version = "4.0.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "ca3aa72a6f96ea37bbc5aa912f6788242832f75369bdfdadcb0e38423f100059"
 dependencies = [
- "dirs-sys",
+ "dirs-sys 0.3.7",
+]
+
+[[package]]
+name = "dirs"
+version = "5.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225"
+dependencies = [
+ "dirs-sys 0.4.1",
 ]
 
 [[package]]
@@ -3373,6 +3440,18 @@ dependencies = [
  "winapi",
 ]
 
+[[package]]
+name = "dirs-sys"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c"
+dependencies = [
+ "libc",
+ "option-ext",
+ "redox_users",
+ "windows-sys 0.48.0",
+]
+
 [[package]]
 name = "dirs-sys-next"
 version = "0.1.2"
@@ -4922,7 +5001,7 @@ dependencies = [
  "project",
  "rpc",
  "settings",
- "shellexpand",
+ "shellexpand 2.1.2",
  "signal-hook",
  "util",
 ]
@@ -5620,7 +5699,7 @@ dependencies = [
  "schemars",
  "serde",
  "settings",
- "shellexpand",
+ "shellexpand 2.1.2",
  "workspace",
 ]
 
@@ -7071,6 +7150,12 @@ dependencies = [
  "vcpkg",
 ]
 
+[[package]]
+name = "option-ext"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
+
 [[package]]
 name = "ordered-float"
 version = "2.10.0"
@@ -8392,6 +8477,38 @@ dependencies = [
  "thiserror",
 ]
 
+[[package]]
+name = "repl"
+version = "0.1.0"
+dependencies = [
+ "alacritty_terminal 0.23.0",
+ "anyhow",
+ "async-dispatcher",
+ "base64 0.13.1",
+ "collections",
+ "editor",
+ "env_logger",
+ "futures 0.3.28",
+ "gpui",
+ "http 0.1.0",
+ "image",
+ "language",
+ "log",
+ "project",
+ "runtimelib",
+ "schemars",
+ "serde",
+ "serde_json",
+ "settings",
+ "smol",
+ "terminal_view",
+ "theme",
+ "ui",
+ "util",
+ "uuid",
+ "workspace",
+]
+
 [[package]]
 name = "reqwest"
 version = "0.11.20"
@@ -8637,6 +8754,32 @@ dependencies = [
  "zeroize",
 ]
 
+[[package]]
+name = "runtimelib"
+version = "0.12.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "10a4a788465cf51b7ac8f36e4e4ca3dd26013dcddd5ba8376f98752278244294"
+dependencies = [
+ "anyhow",
+ "async-dispatcher",
+ "async-std",
+ "base64 0.22.0",
+ "bytes 1.5.0",
+ "chrono",
+ "data-encoding",
+ "dirs 5.0.1",
+ "futures 0.3.28",
+ "glob",
+ "rand 0.8.5",
+ "ring",
+ "serde",
+ "serde_json",
+ "shellexpand 3.1.0",
+ "smol",
+ "uuid",
+ "zeromq",
+]
+
 [[package]]
 name = "rust-embed"
 version = "8.4.0"
@@ -9380,6 +9523,15 @@ dependencies = [
  "dirs 4.0.0",
 ]
 
+[[package]]
+name = "shellexpand"
+version = "3.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "da03fa3b94cc19e3ebfc88c4229c49d8f08cdbd1228870a45f0ffdf84988e14b"
+dependencies = [
+ "dirs 5.0.1",
+]
+
 [[package]]
 name = "shlex"
 version = "1.3.0"
@@ -9611,12 +9763,12 @@ dependencies = [
 
 [[package]]
 name = "socket2"
-version = "0.5.4"
+version = "0.5.7"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4031e820eb552adee9295814c0ced9e5cf38ddf1e8b7d566d6de8e2538ea989e"
+checksum = "ce305eb0b4296696835b71df73eb912e0f1ffd2556a501fcede6e0c50349191c"
 dependencies = [
  "libc",
- "windows-sys 0.48.0",
+ "windows-sys 0.52.0",
 ]
 
 [[package]]
@@ -10359,7 +10511,7 @@ dependencies = [
  "serde",
  "serde_json_lenient",
  "sha2 0.10.7",
- "shellexpand",
+ "shellexpand 2.1.2",
  "util",
 ]
 
@@ -10433,7 +10585,7 @@ dependencies = [
 name = "terminal"
 version = "0.1.0"
 dependencies = [
- "alacritty_terminal",
+ "alacritty_terminal 0.24.1-dev",
  "anyhow",
  "collections",
  "dirs 4.0.0",
@@ -10475,7 +10627,7 @@ dependencies = [
  "serde",
  "serde_json",
  "settings",
- "shellexpand",
+ "shellexpand 2.1.2",
  "smol",
  "task",
  "tasks_ui",
@@ -10754,9 +10906,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
 
 [[package]]
 name = "tokio"
-version = "1.32.0"
+version = "1.37.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "17ed6077ed6cd6c74735e21f37eb16dc3935f96878b1fe961074089cc80893f9"
+checksum = "1adbebffeca75fcfd058afa480fb6c0b81e165a0323f9c9d39c9697e37c46787"
 dependencies = [
  "backtrace",
  "bytes 1.5.0",
@@ -10766,7 +10918,7 @@ dependencies = [
  "parking_lot",
  "pin-project-lite",
  "signal-hook-registry",
- "socket2 0.5.4",
+ "socket2 0.5.7",
  "tokio-macros",
  "windows-sys 0.48.0",
 ]
@@ -10784,9 +10936,9 @@ dependencies = [
 
 [[package]]
 name = "tokio-macros"
-version = "2.1.0"
+version = "2.2.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "630bdcf245f78637c13ec01ffae6187cca34625e8c63150d424b59e55af2675e"
+checksum = "5b8a1e28f2deaa14e508979454cb3a223b10b938b45af148bc0986de36f1923b"
 dependencies = [
  "proc-macro2",
  "quote",
@@ -11576,9 +11728,9 @@ dependencies = [
 
 [[package]]
 name = "uuid"
-version = "1.4.1"
+version = "1.8.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "79daa5ed5740825c40b389c5e50312b9c86df53fccd33f281df655642b43869d"
+checksum = "a183cf7feeba97b4dd1c0d46788634f6221d87fa961b305bed08c851829efcc0"
 dependencies = [
  "getrandom 0.2.10",
  "serde",
@@ -12431,7 +12583,7 @@ dependencies = [
  "heck 0.4.1",
  "proc-macro2",
  "quote",
- "shellexpand",
+ "shellexpand 2.1.2",
  "syn 2.0.59",
  "witx",
 ]
@@ -13334,6 +13486,7 @@ dependencies = [
  "quick_action_bar",
  "recent_projects",
  "release_channel",
+ "repl",
  "rope",
  "search",
  "serde",
@@ -13620,6 +13773,33 @@ dependencies = [
  "syn 2.0.59",
 ]
 
+[[package]]
+name = "zeromq"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fb0560d00172817b7f7c2265060783519c475702ae290b154115ca75e976d4d0"
+dependencies = [
+ "async-dispatcher",
+ "async-std",
+ "async-trait",
+ "asynchronous-codec",
+ "bytes 1.5.0",
+ "crossbeam-queue",
+ "dashmap",
+ "futures-channel",
+ "futures-io",
+ "futures-task",
+ "futures-util",
+ "log",
+ "num-traits",
+ "once_cell",
+ "parking_lot",
+ "rand 0.8.5",
+ "regex",
+ "thiserror",
+ "uuid",
+]
+
 [[package]]
 name = "zstd"
 version = "0.11.2+zstd.1.5.2"

Cargo.toml 🔗

@@ -77,6 +77,7 @@ members = [
     "crates/refineable/derive_refineable",
     "crates/release_channel",
     "crates/dev_server_projects",
+    "crates/repl",
     "crates/rich_text",
     "crates/rope",
     "crates/rpc",
@@ -227,6 +228,7 @@ quick_action_bar = { path = "crates/quick_action_bar" }
 recent_projects = { path = "crates/recent_projects" }
 release_channel = { path = "crates/release_channel" }
 dev_server_projects = { path = "crates/dev_server_projects" }
+repl = { path = "crates/repl" }
 rich_text = { path = "crates/rich_text" }
 rope = { path = "crates/rope" }
 rpc = { path = "crates/rpc" }
@@ -264,10 +266,12 @@ workspace = { path = "crates/workspace" }
 zed = { path = "crates/zed" }
 zed_actions = { path = "crates/zed_actions" }
 
+alacritty_terminal = "0.23"
 anyhow = "1.0.57"
 any_vec = "0.13"
 ashpd = "0.8.0"
 async-compression = { version = "0.4", features = ["gzip", "futures-io"] }
+async-dispatcher = { version = "0.1"}
 async-fs = "1.6"
 async-recursion = "1.0.0"
 async-tar = "0.4.2"
@@ -301,6 +305,7 @@ heed = { version = "0.20.1", features = ["read-txn-no-tls"] }
 hex = "0.4.3"
 html5ever = "0.27.0"
 ignore = "0.4.22"
+image = "0.23"
 indexmap = { version = "1.6.2", features = ["serde"] }
 indoc = "1"
 # We explicitly disable http2 support in isahc.
@@ -333,6 +338,7 @@ rand = "0.8.5"
 refineable = { path = "./crates/refineable" }
 regex = "1.5"
 repair_json = "0.1.0"
+runtimelib = { version="0.12", default-features = false, features = ["async-dispatcher-runtime"] }
 rusqlite = { version = "0.29.0", features = ["blob", "array", "modern_sqlite"] }
 rust-embed = { version = "8.4", features = ["include-exclude"] }
 schemars = "0.8"

crates/collab/src/db/queries/extensions.rs 🔗

@@ -379,6 +379,7 @@ fn metadata_from_extension_and_version(
 
 pub fn convert_time_to_chrono(time: time::PrimitiveDateTime) -> chrono::DateTime<Utc> {
     chrono::DateTime::from_naive_utc_and_offset(
+        #[allow(deprecated)]
         chrono::NaiveDateTime::from_timestamp_opt(time.assume_utc().unix_timestamp(), 0).unwrap(),
         Utc,
     )

crates/gpui/src/executor.rs 🔗

@@ -25,14 +25,16 @@ use rand::rngs::StdRng;
 /// for spawning background tasks.
 #[derive(Clone)]
 pub struct BackgroundExecutor {
-    dispatcher: Arc<dyn PlatformDispatcher>,
+    #[doc(hidden)]
+    pub dispatcher: Arc<dyn PlatformDispatcher>,
 }
 
 /// A pointer to the executor that is currently running,
 /// for spawning tasks on the main thread.
 #[derive(Clone)]
 pub struct ForegroundExecutor {
-    dispatcher: Arc<dyn PlatformDispatcher>,
+    #[doc(hidden)]
+    pub dispatcher: Arc<dyn PlatformDispatcher>,
     not_send: PhantomData<Rc<()>>,
 }
 

crates/multi_buffer/src/anchor.rs 🔗

@@ -117,6 +117,7 @@ impl ToPoint for Anchor {
 
 pub trait AnchorRangeExt {
     fn cmp(&self, b: &Range<Anchor>, buffer: &MultiBufferSnapshot) -> Ordering;
+    fn overlaps(&self, b: &Range<Anchor>, buffer: &MultiBufferSnapshot) -> bool;
     fn to_offset(&self, content: &MultiBufferSnapshot) -> Range<usize>;
     fn to_point(&self, content: &MultiBufferSnapshot) -> Range<Point>;
 }
@@ -129,6 +130,14 @@ impl AnchorRangeExt for Range<Anchor> {
         }
     }
 
+    fn overlaps(&self, other: &Range<Anchor>, buffer: &MultiBufferSnapshot) -> bool {
+        let start_cmp = self.start.cmp(&other.start, buffer);
+        let end_cmp = self.end.cmp(&other.end, buffer);
+
+        (start_cmp == Ordering::Less || start_cmp == Ordering::Equal)
+            && (end_cmp == Ordering::Greater || end_cmp == Ordering::Equal)
+    }
+
     fn to_offset(&self, content: &MultiBufferSnapshot) -> Range<usize> {
         self.start.to_offset(content)..self.end.to_offset(content)
     }

crates/repl/Cargo.toml 🔗

@@ -0,0 +1,49 @@
+[package]
+name = "repl"
+version = "0.1.0"
+edition = "2021"
+publish = false
+license = "GPL-3.0-or-later"
+
+[lints]
+workspace = true
+
+[lib]
+path = "src/repl.rs"
+doctest = false
+
+[dependencies]
+anyhow.workspace = true
+alacritty_terminal.workspace = true
+async-dispatcher.workspace = true
+base64.workspace = true
+collections.workspace = true
+editor.workspace = true
+gpui.workspace = true
+futures.workspace = true
+image.workspace = true
+language.workspace = true
+log.workspace = true
+project.workspace = true
+runtimelib.workspace = true
+schemars.workspace = true
+serde.workspace = true
+serde_json.workspace = true
+settings.workspace = true
+smol.workspace = true
+theme.workspace = true
+terminal_view.workspace = true
+ui.workspace = true
+uuid.workspace = true
+workspace.workspace = true
+
+[dev-dependencies]
+editor = { workspace = true, features = ["test-support"] }
+env_logger.workspace = true
+gpui = { workspace = true, features = ["test-support"] }
+language = { workspace = true, features = ["test-support"] }
+project = { workspace = true, features = ["test-support"] }
+settings = { workspace = true, features = ["test-support"] }
+theme = { workspace = true, features = ["test-support"] }
+util = { workspace = true, features = ["test-support"] }
+http = { workspace = true, features = ["test-support"] }

crates/repl/src/outputs.rs 🔗

@@ -0,0 +1,479 @@
+use std::sync::Arc;
+
+use crate::stdio::TerminalOutput;
+use anyhow::Result;
+use gpui::{img, AnyElement, FontWeight, ImageData, Render, View};
+use runtimelib::datatable::TableSchema;
+use runtimelib::media::datatable::TabularDataResource;
+use runtimelib::{ExecutionState, JupyterMessageContent, MimeBundle, MimeType};
+use serde_json::Value;
+use ui::{div, prelude::*, v_flex, IntoElement, Styled, ViewContext};
+
+// Given these outputs are destined for the editor with the block decorations API, all of them must report
+// how many lines they will take up in the editor.
+pub trait LineHeight: Sized {
+    fn num_lines(&self, cx: &mut WindowContext) -> u8;
+}
+
+// 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 {
+        MimeType::DataTable(_) => 6,
+        MimeType::Png(_) => 4,
+        MimeType::Jpeg(_) => 3,
+        MimeType::Markdown(_) => 2,
+        MimeType::Plain(_) => 1,
+        // All other media types are not supported in Zed at this time
+        _ => 0,
+    }
+}
+
+/// ImageView renders an image inline in an editor, adapting to the line height to fit the image.
+pub struct ImageView {
+    height: u32,
+    width: u32,
+    image: Arc<ImageData>,
+}
+
+impl ImageView {
+    fn render(&self, cx: &ViewContext<ExecutionView>) -> AnyElement {
+        let line_height = cx.line_height();
+
+        let (height, width) = if self.height as f32 / line_height.0 == u8::MAX as f32 {
+            let height = u8::MAX as f32 * line_height.0;
+            let width = self.width as f32 * height / self.height as f32;
+            (height, width)
+        } else {
+            (self.height as f32, self.width as f32)
+        };
+
+        let image = self.image.clone();
+
+        div()
+            .h(Pixels(height))
+            .w(Pixels(width))
+            .child(img(image))
+            .into_any_element()
+    }
+
+    fn from(base64_encoded_data: &str) -> Result<Self> {
+        let bytes = base64::decode(base64_encoded_data)?;
+
+        let format = image::guess_format(&bytes)?;
+        let data = image::load_from_memory_with_format(&bytes, format)?.into_bgra8();
+
+        let height = data.height();
+        let width = data.width();
+
+        let gpui_image_data = ImageData::new(data);
+
+        return Ok(ImageView {
+            height,
+            width,
+            image: Arc::new(gpui_image_data),
+        });
+    }
+}
+
+impl LineHeight for ImageView {
+    fn num_lines(&self, cx: &mut WindowContext) -> u8 {
+        let line_height = cx.line_height();
+
+        let lines = self.height as f32 / line_height.0;
+
+        if lines > u8::MAX as f32 {
+            return u8::MAX;
+        }
+        lines as u8
+    }
+}
+
+/// TableView renders a static table inline in a buffer.
+/// It uses the https://specs.frictionlessdata.io/tabular-data-resource/ specification for data interchange.
+pub struct TableView {
+    pub table: TabularDataResource,
+}
+
+impl TableView {
+    pub fn render(&self, cx: &ViewContext<ExecutionView>) -> AnyElement {
+        let data = match &self.table.data {
+            Some(data) => data,
+            None => return div().into_any_element(),
+        };
+
+        // todo!(): compute the width of each column by finding the widest cell in each column
+
+        let mut headings = serde_json::Map::new();
+        for field in &self.table.schema.fields {
+            headings.insert(field.name.clone(), Value::String(field.name.clone()));
+        }
+        let header = self.render_row(&self.table.schema, true, &Value::Object(headings), cx);
+
+        let body = data
+            .iter()
+            .map(|row| self.render_row(&self.table.schema, false, &row, cx));
+
+        v_flex()
+            .w_full()
+            .child(header)
+            .children(body)
+            .into_any_element()
+    }
+
+    pub fn render_row(
+        &self,
+        schema: &TableSchema,
+        is_header: bool,
+        row: &Value,
+        cx: &ViewContext<ExecutionView>,
+    ) -> AnyElement {
+        let theme = cx.theme();
+
+        let row_cells = schema
+            .fields
+            .iter()
+            .map(|field| {
+                let container = match field.field_type {
+                    runtimelib::datatable::FieldType::String => div(),
+
+                    runtimelib::datatable::FieldType::Number
+                    | runtimelib::datatable::FieldType::Integer
+                    | runtimelib::datatable::FieldType::Date
+                    | runtimelib::datatable::FieldType::Time
+                    | runtimelib::datatable::FieldType::Datetime
+                    | runtimelib::datatable::FieldType::Year
+                    | runtimelib::datatable::FieldType::Duration
+                    | runtimelib::datatable::FieldType::Yearmonth => v_flex().items_end(),
+
+                    _ => div(),
+                };
+
+                let value = match row.get(&field.name) {
+                    Some(Value::String(s)) => s.clone(),
+                    Some(Value::Number(n)) => n.to_string(),
+                    Some(Value::Bool(b)) => b.to_string(),
+                    Some(Value::Array(arr)) => format!("{:?}", arr),
+                    Some(Value::Object(obj)) => format!("{:?}", obj),
+                    Some(Value::Null) | None => String::new(),
+                };
+
+                let mut cell = container
+                    .w_full()
+                    .child(value)
+                    .px_2()
+                    .py_1()
+                    .border_color(theme.colors().border);
+
+                if is_header {
+                    cell = cell.border_2().bg(theme.colors().border_focused)
+                } else {
+                    cell = cell.border_1()
+                }
+                cell
+            })
+            .collect::<Vec<_>>();
+
+        h_flex().children(row_cells).into_any_element()
+    }
+}
+
+impl LineHeight for TableView {
+    fn num_lines(&self, _cx: &mut WindowContext) -> u8 {
+        let num_rows = match &self.table.data {
+            Some(data) => data.len(),
+            // We don't support Path based data sources
+            None => 0,
+        };
+
+        // Given that each cell has both `py_1` and a border, we have to estimate
+        // a reasonable size to add on, then round up.
+        let row_heights = (num_rows as f32 * 1.2) + 1.0;
+
+        (row_heights as u8).saturating_add(2) // Header + spacing
+    }
+}
+
+// Userspace error from the kernel
+pub struct ErrorView {
+    pub ename: String,
+    pub evalue: String,
+    pub traceback: TerminalOutput,
+}
+
+impl ErrorView {
+    fn render(&self, cx: &ViewContext<ExecutionView>) -> Option<AnyElement> {
+        let theme = cx.theme();
+
+        let colors = cx.theme().colors();
+
+        Some(
+            v_flex()
+                .w_full()
+                .bg(colors.background)
+                .p_4()
+                .border_l_1()
+                .border_color(theme.status().error_border)
+                .child(
+                    h_flex()
+                        .font_weight(FontWeight::BOLD)
+                        .child(format!("{}: {}", self.ename, self.evalue)),
+                )
+                .child(self.traceback.render(cx))
+                .into_any_element(),
+        )
+    }
+}
+
+impl LineHeight for ErrorView {
+    fn num_lines(&self, cx: &mut WindowContext) -> u8 {
+        let mut height: u8 = 0;
+        height = height.saturating_add(self.ename.lines().count() as u8);
+        height = height.saturating_add(self.evalue.lines().count() as u8);
+        height = height.saturating_add(self.traceback.num_lines(cx));
+        height
+    }
+}
+
+pub enum OutputType {
+    Plain(TerminalOutput),
+    Stream(TerminalOutput),
+    Image(ImageView),
+    ErrorOutput(ErrorView),
+    Message(String),
+    Table(TableView),
+    ClearOutputWaitMarker,
+}
+
+impl OutputType {
+    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::Stream(stdio) => Some(stdio.render(cx)),
+            Self::Image(image) => Some(image.render(cx)),
+            Self::Message(message) => Some(div().child(message.clone()).into_any_element()),
+            Self::Table(table) => Some(table.render(cx)),
+            Self::ErrorOutput(error_view) => error_view.render(cx),
+            Self::ClearOutputWaitMarker => None,
+        };
+
+        el
+    }
+}
+
+impl LineHeight for OutputType {
+    /// Calculates the expected number of lines
+    fn num_lines(&self, cx: &mut WindowContext) -> u8 {
+        match self {
+            Self::Plain(stdio) => stdio.num_lines(cx),
+            Self::Stream(stdio) => stdio.num_lines(cx),
+            Self::Image(image) => image.num_lines(cx),
+            Self::Message(message) => message.lines().count() as u8,
+            Self::Table(table) => table.num_lines(cx),
+            Self::ErrorOutput(error_view) => error_view.num_lines(cx),
+            Self::ClearOutputWaitMarker => 0,
+        }
+    }
+}
+
+impl From<&MimeBundle> for OutputType {
+    fn from(data: &MimeBundle) -> Self {
+        match data.richest(rank_mime_type) {
+            Some(MimeType::Plain(text)) => OutputType::Plain(TerminalOutput::from(text)),
+            Some(MimeType::Markdown(text)) => OutputType::Plain(TerminalOutput::from(text)),
+            Some(MimeType::Png(data)) | Some(MimeType::Jpeg(data)) => match ImageView::from(data) {
+                Ok(view) => OutputType::Image(view),
+                Err(error) => OutputType::Message(format!("Failed to load image: {}", error)),
+            },
+            Some(MimeType::DataTable(data)) => OutputType::Table(TableView {
+                table: data.clone(),
+            }),
+            // Any other media types are not supported
+            _ => OutputType::Message("Unsupported media type".to_string()),
+        }
+    }
+}
+
+#[derive(Default)]
+pub enum ExecutionStatus {
+    #[default]
+    Unknown,
+    #[allow(unused)]
+    ConnectingToKernel,
+    Executing,
+    Finished,
+}
+
+pub struct ExecutionView {
+    pub outputs: Vec<OutputType>,
+    pub status: ExecutionStatus,
+}
+
+impl ExecutionView {
+    pub fn new(_cx: &mut ViewContext<Self>) -> Self {
+        Self {
+            outputs: Default::default(),
+            status: ExecutionStatus::Unknown,
+        }
+    }
+
+    /// Accept a Jupyter message belonging to this execution
+    pub fn push_message(&mut self, message: &JupyterMessageContent, cx: &mut ViewContext<Self>) {
+        let output: OutputType = match message {
+            JupyterMessageContent::ExecuteResult(result) => (&result.data).into(),
+            JupyterMessageContent::DisplayData(result) => (&result.data).into(),
+            JupyterMessageContent::StreamContent(result) => {
+                // Previous stream data will combine together, handling colors, carriage returns, etc
+                if let Some(new_terminal) = self.apply_terminal_text(&result.text) {
+                    new_terminal
+                } else {
+                    cx.notify();
+                    return;
+                }
+            }
+            JupyterMessageContent::ErrorOutput(result) => {
+                let mut terminal = TerminalOutput::new();
+                terminal.append_text(&result.traceback.join("\n"));
+
+                OutputType::ErrorOutput(ErrorView {
+                    ename: result.ename.clone(),
+                    evalue: result.evalue.clone(),
+                    traceback: terminal,
+                })
+            }
+            JupyterMessageContent::ExecuteReply(reply) => {
+                for payload in reply.payload.iter() {
+                    match payload {
+                        // Pager data comes in via `?` at the end of a statement in Python, used for showing documentation.
+                        // Some UI will show this as a popup. For ease of implementation, it's included as an output here.
+                        runtimelib::Payload::Page { data, .. } => {
+                            let output: OutputType = (data).into();
+                            self.outputs.push(output);
+                        }
+
+                        // Set next input adds text to the next cell. Not required to support.
+                        // However, this could be implemented by
+                        // runtimelib::Payload::SetNextInput { text, replace } => todo!(),
+
+                        // Not likely to be used in the context of Zed, where someone could just open the buffer themselves
+                        // runtimelib::Payload::EditMagic { filename, line_number } => todo!(),
+
+                        //
+                        // runtimelib::Payload::AskExit { keepkernel } => todo!(),
+                        _ => {}
+                    }
+                }
+                cx.notify();
+                return;
+            }
+            JupyterMessageContent::ClearOutput(options) => {
+                if !options.wait {
+                    self.outputs.clear();
+                    cx.notify();
+                    return;
+                }
+
+                // Create a marker to clear the output after we get in a new output
+                OutputType::ClearOutputWaitMarker
+            }
+            JupyterMessageContent::Status(status) => {
+                match status.execution_state {
+                    ExecutionState::Busy => {
+                        self.status = ExecutionStatus::Executing;
+                    }
+                    ExecutionState::Idle => self.status = ExecutionStatus::Finished,
+                }
+                cx.notify();
+                return;
+            }
+            _msg => {
+                return;
+            }
+        };
+
+        // Check for a clear output marker as the previous output, so we can clear it out
+        if let Some(OutputType::ClearOutputWaitMarker) = self.outputs.last() {
+            self.outputs.clear();
+        }
+
+        self.outputs.push(output);
+
+        cx.notify();
+    }
+
+    fn apply_terminal_text(&mut self, text: &str) -> Option<OutputType> {
+        if let Some(last_output) = self.outputs.last_mut() {
+            match last_output {
+                OutputType::Stream(last_stream) => {
+                    last_stream.append_text(text);
+                    // Don't need to add a new output, we already have a terminal output
+                    return None;
+                }
+                // Edge case note: a clear output marker
+                OutputType::ClearOutputWaitMarker => {
+                    // Edge case note: a clear output marker is handled by the caller
+                    // since we will return a new output at the end here as a new terminal output
+                }
+                // A different output type is "in the way", so we need to create a new output,
+                // which is the same as having no prior output
+                _ => {}
+            }
+        }
+
+        let mut new_terminal = TerminalOutput::new();
+        new_terminal.append_text(text);
+        Some(OutputType::Stream(new_terminal))
+    }
+
+    pub fn set_status(&mut self, status: ExecutionStatus, cx: &mut ViewContext<Self>) {
+        self.status = status;
+        cx.notify();
+    }
+}
+
+impl Render for ExecutionView {
+    fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
+        if self.outputs.len() == 0 {
+            match self.status {
+                ExecutionStatus::ConnectingToKernel => {
+                    return div().child("Connecting to kernel...").into_any_element()
+                }
+                ExecutionStatus::Executing => {
+                    return div().child("Executing...").into_any_element()
+                }
+                ExecutionStatus::Finished => {
+                    return div().child(Icon::new(IconName::Check)).into_any_element()
+                }
+                ExecutionStatus::Unknown => return div().child("...").into_any_element(),
+            }
+        }
+
+        div()
+            .w_full()
+            .children(self.outputs.iter().filter_map(|output| output.render(cx)))
+            .into_any_element()
+    }
+}
+
+impl LineHeight for ExecutionView {
+    fn num_lines(&self, cx: &mut WindowContext) -> u8 {
+        if self.outputs.is_empty() {
+            return 1; // For the status message if outputs are not there
+        }
+
+        self.outputs
+            .iter()
+            .map(|output| output.num_lines(cx))
+            .fold(0, |acc, additional_height| {
+                acc.saturating_add(additional_height)
+            })
+    }
+}
+
+impl LineHeight for View<ExecutionView> {
+    fn num_lines(&self, cx: &mut WindowContext) -> u8 {
+        self.update(cx, |execution_view, cx| execution_view.num_lines(cx))
+    }
+}

crates/repl/src/repl.rs 🔗

@@ -0,0 +1,571 @@
+use anyhow::{anyhow, Context as _, Result};
+use async_dispatcher::{set_dispatcher, timeout, Dispatcher, Runnable};
+use collections::{HashMap, HashSet};
+use editor::{
+    display_map::{
+        BlockContext, BlockDisposition, BlockId, BlockProperties, BlockStyle, RenderBlock,
+    },
+    Anchor, AnchorRangeExt, Editor,
+};
+use futures::{
+    channel::mpsc::{self, UnboundedSender},
+    future::Shared,
+    Future, FutureExt, SinkExt as _, StreamExt,
+};
+use gpui::prelude::*;
+use gpui::{
+    actions, AppContext, Context, EntityId, Global, Model, ModelContext, PlatformDispatcher, Task,
+    WeakView,
+};
+use gpui::{Entity, View};
+use language::Point;
+use outputs::{ExecutionStatus, ExecutionView, LineHeight as _};
+use project::Fs;
+use runtime_settings::JupyterSettings;
+use runtimelib::JupyterMessageContent;
+use settings::{Settings as _, SettingsStore};
+use std::{ops::Range, time::Instant};
+use std::{sync::Arc, time::Duration};
+use theme::{ActiveTheme, ThemeSettings};
+use ui::prelude::*;
+use workspace::Workspace;
+
+mod outputs;
+// mod runtime_panel;
+mod runtime_settings;
+mod runtimes;
+mod stdio;
+
+use runtimes::{get_runtime_specifications, Request, RunningKernel, RuntimeSpecification};
+
+actions!(repl, [Run]);
+
+#[derive(Clone)]
+pub struct RuntimeManagerGlobal(Model<RuntimeManager>);
+
+impl Global for RuntimeManagerGlobal {}
+
+pub fn zed_dispatcher(cx: &mut AppContext) -> impl Dispatcher {
+    struct ZedDispatcher {
+        dispatcher: Arc<dyn PlatformDispatcher>,
+    }
+
+    // PlatformDispatcher is _super_ close to the same interface we put in
+    // async-dispatcher, except for the task label in dispatch. Later we should
+    // just make that consistent so we have this dispatcher ready to go for
+    // other crates in Zed.
+    impl Dispatcher for ZedDispatcher {
+        fn dispatch(&self, runnable: Runnable) {
+            self.dispatcher.dispatch(runnable, None)
+        }
+
+        fn dispatch_after(&self, duration: Duration, runnable: Runnable) {
+            self.dispatcher.dispatch_after(duration, runnable);
+        }
+    }
+
+    ZedDispatcher {
+        dispatcher: cx.background_executor().dispatcher.clone(),
+    }
+}
+
+pub fn init(fs: Arc<dyn Fs>, cx: &mut AppContext) {
+    set_dispatcher(zed_dispatcher(cx));
+    JupyterSettings::register(cx);
+
+    observe_jupyter_settings_changes(fs.clone(), cx);
+
+    cx.observe_new_views(
+        |workspace: &mut Workspace, _: &mut ViewContext<Workspace>| {
+            workspace.register_action(run);
+        },
+    )
+    .detach();
+
+    let settings = JupyterSettings::get_global(cx);
+
+    if !settings.enabled {
+        return;
+    }
+
+    initialize_runtime_manager(fs, cx);
+}
+
+fn initialize_runtime_manager(fs: Arc<dyn Fs>, cx: &mut AppContext) {
+    let runtime_manager = cx.new_model(|cx| RuntimeManager::new(fs.clone(), cx));
+    RuntimeManager::set_global(runtime_manager.clone(), cx);
+
+    cx.spawn(|mut cx| async move {
+        let fs = fs.clone();
+
+        let runtime_specifications = get_runtime_specifications(fs).await?;
+
+        runtime_manager.update(&mut cx, |this, _cx| {
+            this.runtime_specifications = runtime_specifications;
+        })?;
+
+        anyhow::Ok(())
+    })
+    .detach_and_log_err(cx);
+}
+
+fn observe_jupyter_settings_changes(fs: Arc<dyn Fs>, cx: &mut AppContext) {
+    cx.observe_global::<SettingsStore>(move |cx| {
+        let settings = JupyterSettings::get_global(cx);
+        if settings.enabled && RuntimeManager::global(cx).is_none() {
+            initialize_runtime_manager(fs.clone(), cx);
+        } else {
+            RuntimeManager::remove_global(cx);
+            // todo!(): Remove action from workspace(s)
+        }
+    })
+    .detach();
+}
+
+#[derive(Debug)]
+pub enum Kernel {
+    RunningKernel(RunningKernel),
+    StartingKernel(Shared<Task<()>>),
+    FailedLaunch,
+}
+
+// Per workspace
+pub struct RuntimeManager {
+    fs: Arc<dyn Fs>,
+    runtime_specifications: Vec<RuntimeSpecification>,
+
+    instances: HashMap<EntityId, Kernel>,
+    editors: HashMap<WeakView<Editor>, EditorRuntimeState>,
+    // todo!(): Next
+    // To reduce the number of open tasks and channels we have, let's feed the response
+    // messages by ID over to the paired ExecutionView
+    _execution_views_by_id: HashMap<String, View<ExecutionView>>,
+}
+
+#[derive(Debug, Clone)]
+struct EditorRuntimeState {
+    blocks: Vec<EditorRuntimeBlock>,
+    // todo!(): Store a subscription to the editor so we can drop them when the editor is dropped
+    // subscription: gpui::Subscription,
+}
+
+#[derive(Debug, Clone)]
+struct EditorRuntimeBlock {
+    code_range: Range<Anchor>,
+    _execution_id: String,
+    block_id: BlockId,
+    _execution_view: View<ExecutionView>,
+}
+
+impl RuntimeManager {
+    pub fn new(fs: Arc<dyn Fs>, _cx: &mut AppContext) -> Self {
+        Self {
+            fs,
+            runtime_specifications: Default::default(),
+            instances: Default::default(),
+            editors: Default::default(),
+            _execution_views_by_id: Default::default(),
+        }
+    }
+
+    fn get_or_launch_kernel(
+        &mut self,
+        entity_id: EntityId,
+        language_name: Arc<str>,
+        cx: &mut ModelContext<Self>,
+    ) -> Task<Result<UnboundedSender<Request>>> {
+        let kernel = self.instances.get(&entity_id);
+        let pending_kernel_start = match kernel {
+            Some(Kernel::RunningKernel(running_kernel)) => {
+                return Task::ready(anyhow::Ok(running_kernel.request_tx.clone()));
+            }
+            Some(Kernel::StartingKernel(task)) => task.clone(),
+            Some(Kernel::FailedLaunch) | None => {
+                self.instances.remove(&entity_id);
+
+                let kernel = self.launch_kernel(entity_id, language_name, cx);
+                let pending_kernel = cx
+                    .spawn(|this, mut cx| async move {
+                        let running_kernel = kernel.await;
+
+                        match running_kernel {
+                            Ok(running_kernel) => {
+                                let _ = this.update(&mut cx, |this, _cx| {
+                                    this.instances
+                                        .insert(entity_id, Kernel::RunningKernel(running_kernel));
+                                });
+                            }
+                            Err(_err) => {
+                                let _ = this.update(&mut cx, |this, _cx| {
+                                    this.instances.insert(entity_id, Kernel::FailedLaunch);
+                                });
+                            }
+                        }
+                    })
+                    .shared();
+
+                self.instances
+                    .insert(entity_id, Kernel::StartingKernel(pending_kernel.clone()));
+
+                pending_kernel
+            }
+        };
+
+        cx.spawn(|this, mut cx| async move {
+            pending_kernel_start.await;
+
+            this.update(&mut cx, |this, _cx| {
+                let kernel = this
+                    .instances
+                    .get(&entity_id)
+                    .ok_or(anyhow!("unable to get a running kernel"))?;
+
+                match kernel {
+                    Kernel::RunningKernel(running_kernel) => Ok(running_kernel.request_tx.clone()),
+                    _ => Err(anyhow!("unable to get a running kernel")),
+                }
+            })?
+        })
+    }
+
+    fn launch_kernel(
+        &mut self,
+        entity_id: EntityId,
+        language_name: Arc<str>,
+        cx: &mut ModelContext<Self>,
+    ) -> Task<Result<RunningKernel>> {
+        // Get first runtime that matches the language name (for now)
+        let runtime_specification =
+            self.runtime_specifications
+                .iter()
+                .find(|runtime_specification| {
+                    runtime_specification.kernelspec.language == language_name.to_string()
+                });
+
+        let runtime_specification = match runtime_specification {
+            Some(runtime_specification) => runtime_specification,
+            None => {
+                return Task::ready(Err(anyhow::anyhow!(
+                    "No runtime found for language {}",
+                    language_name
+                )));
+            }
+        };
+
+        let runtime_specification = runtime_specification.clone();
+
+        let fs = self.fs.clone();
+
+        cx.spawn(|_, cx| async move {
+            let running_kernel =
+                RunningKernel::new(runtime_specification, entity_id, fs.clone(), cx);
+
+            let running_kernel = running_kernel.await?;
+
+            let mut request_tx = running_kernel.request_tx.clone();
+
+            let overall_timeout_duration = Duration::from_secs(10);
+
+            let start_time = Instant::now();
+
+            loop {
+                if start_time.elapsed() > overall_timeout_duration {
+                    // todo!(): Kill the kernel
+                    return Err(anyhow::anyhow!("Kernel did not respond in time"));
+                }
+
+                let (tx, rx) = mpsc::unbounded();
+                match request_tx
+                    .send(Request {
+                        request: runtimelib::KernelInfoRequest {}.into(),
+                        responses_rx: tx,
+                    })
+                    .await
+                {
+                    Ok(_) => {}
+                    Err(_err) => {
+                        break;
+                    }
+                };
+
+                let mut rx = rx.fuse();
+
+                let kernel_info_timeout = Duration::from_secs(1);
+
+                let mut got_kernel_info = false;
+                while let Ok(Some(message)) = timeout(kernel_info_timeout, rx.next()).await {
+                    match message {
+                        JupyterMessageContent::KernelInfoReply(_) => {
+                            got_kernel_info = true;
+                        }
+                        _ => {}
+                    }
+                }
+
+                if got_kernel_info {
+                    break;
+                }
+            }
+
+            anyhow::Ok(running_kernel)
+        })
+    }
+
+    fn execute_code(
+        &mut self,
+        entity_id: EntityId,
+        language_name: Arc<str>,
+        code: String,
+        cx: &mut ModelContext<Self>,
+    ) -> impl Future<Output = Result<mpsc::UnboundedReceiver<JupyterMessageContent>>> {
+        let (tx, rx) = mpsc::unbounded();
+
+        let request_tx = self.get_or_launch_kernel(entity_id, language_name, cx);
+
+        async move {
+            let request_tx = request_tx.await?;
+
+            request_tx
+                .unbounded_send(Request {
+                    request: runtimelib::ExecuteRequest {
+                        code,
+                        allow_stdin: false,
+                        silent: false,
+                        store_history: true,
+                        stop_on_error: true,
+                        ..Default::default()
+                    }
+                    .into(),
+                    responses_rx: tx,
+                })
+                .context("Failed to send execution request")?;
+
+            Ok(rx)
+        }
+    }
+
+    pub fn global(cx: &AppContext) -> Option<Model<Self>> {
+        cx.try_global::<RuntimeManagerGlobal>()
+            .map(|runtime_manager| runtime_manager.0.clone())
+    }
+
+    pub fn set_global(runtime_manager: Model<Self>, cx: &mut AppContext) {
+        cx.set_global(RuntimeManagerGlobal(runtime_manager));
+    }
+
+    pub fn remove_global(cx: &mut AppContext) {
+        if RuntimeManager::global(cx).is_some() {
+            cx.remove_global::<RuntimeManagerGlobal>();
+        }
+    }
+}
+
+pub fn get_active_editor(
+    workspace: &mut Workspace,
+    cx: &mut ViewContext<Workspace>,
+) -> Option<View<Editor>> {
+    workspace
+        .active_item(cx)
+        .and_then(|item| item.act_as::<Editor>(cx))
+}
+
+// Gets the active selection in the editor or the current line
+pub fn selection(editor: View<Editor>, cx: &mut ViewContext<Workspace>) -> Range<Anchor> {
+    let editor = editor.read(cx);
+    let selection = editor.selections.newest::<usize>(cx);
+    let buffer = editor.buffer().read(cx).snapshot(cx);
+
+    let range = if selection.is_empty() {
+        let cursor = selection.head();
+
+        let line_start = buffer.offset_to_point(cursor).row;
+        let mut start_offset = buffer.point_to_offset(Point::new(line_start, 0));
+
+        // Iterate backwards to find the start of the line
+        while start_offset > 0 {
+            let ch = buffer.chars_at(start_offset - 1).next().unwrap_or('\0');
+            if ch == '\n' {
+                break;
+            }
+            start_offset -= 1;
+        }
+
+        let mut end_offset = cursor;
+
+        // Iterate forwards to find the end of the line
+        while end_offset < buffer.len() {
+            let ch = buffer.chars_at(end_offset).next().unwrap_or('\0');
+            if ch == '\n' {
+                break;
+            }
+            end_offset += 1;
+        }
+
+        // Create a range from the start to the end of the line
+        start_offset..end_offset
+    } else {
+        selection.range()
+    };
+
+    let anchor_range = buffer.anchor_before(range.start)..buffer.anchor_after(range.end);
+    anchor_range
+}
+
+pub fn run(workspace: &mut Workspace, _: &Run, cx: &mut ViewContext<Workspace>) {
+    let (editor, runtime_manager) = if let (Some(editor), Some(runtime_manager)) =
+        (get_active_editor(workspace, cx), RuntimeManager::global(cx))
+    {
+        (editor, runtime_manager)
+    } else {
+        log::warn!("No active editor or runtime manager found");
+        return;
+    };
+
+    let anchor_range = selection(editor.clone(), cx);
+
+    let buffer = editor.read(cx).buffer().read(cx).snapshot(cx);
+
+    let selected_text = buffer
+        .text_for_range(anchor_range.clone())
+        .collect::<String>();
+
+    let start_language = buffer.language_at(anchor_range.start);
+    let end_language = buffer.language_at(anchor_range.end);
+
+    let language_name = if start_language == end_language {
+        start_language
+            .map(|language| language.code_fence_block_name())
+            .filter(|lang| **lang != *"markdown")
+    } else {
+        // If the selection spans multiple languages, don't run it
+        return;
+    };
+
+    let language_name = if let Some(language_name) = language_name {
+        language_name
+    } else {
+        return;
+    };
+
+    let entity_id = editor.entity_id();
+
+    let execution_view = cx.new_view(|cx| ExecutionView::new(cx));
+
+    // If any block overlaps with the new block, remove it
+    // TODO: When inserting a new block, put it in order so that search is efficient
+    let blocks_to_remove = runtime_manager.update(cx, |runtime_manager, _cx| {
+        // Get the current `EditorRuntimeState` for this runtime_manager, inserting it if it doesn't exist
+        let editor_runtime_state = runtime_manager
+            .editors
+            .entry(editor.downgrade())
+            .or_insert_with(|| EditorRuntimeState { blocks: Vec::new() });
+
+        let mut blocks_to_remove: HashSet<BlockId> = HashSet::default();
+
+        editor_runtime_state.blocks.retain(|block| {
+            if anchor_range.overlaps(&block.code_range, &buffer) {
+                blocks_to_remove.insert(block.block_id);
+                // Drop this block
+                false
+            } else {
+                true
+            }
+        });
+
+        blocks_to_remove
+    });
+
+    let blocks_to_remove = blocks_to_remove.clone();
+
+    let block_id = editor.update(cx, |editor, cx| {
+        editor.remove_blocks(blocks_to_remove, None, cx);
+        let block = BlockProperties {
+            position: anchor_range.end,
+            height: execution_view.num_lines(cx).saturating_add(1),
+            style: BlockStyle::Sticky,
+            render: create_output_area_render(execution_view.clone()),
+            disposition: BlockDisposition::Below,
+        };
+
+        editor.insert_blocks([block], None, cx)[0]
+    });
+
+    let receiver = runtime_manager.update(cx, |runtime_manager, cx| {
+        let editor_runtime_state = runtime_manager
+            .editors
+            .entry(editor.downgrade())
+            .or_insert_with(|| EditorRuntimeState { blocks: Vec::new() });
+
+        let editor_runtime_block = EditorRuntimeBlock {
+            code_range: anchor_range.clone(),
+            block_id,
+            _execution_view: execution_view.clone(),
+            _execution_id: Default::default(),
+        };
+
+        editor_runtime_state
+            .blocks
+            .push(editor_runtime_block.clone());
+
+        runtime_manager.execute_code(entity_id, language_name, selected_text.clone(), cx)
+    });
+
+    cx.spawn(|_this, mut cx| async move {
+        execution_view.update(&mut cx, |execution_view, cx| {
+            execution_view.set_status(ExecutionStatus::ConnectingToKernel, cx);
+        })?;
+        let mut receiver = receiver.await?;
+
+        let execution_view = execution_view.clone();
+        while let Some(content) = receiver.next().await {
+            execution_view.update(&mut cx, |execution_view, cx| {
+                execution_view.push_message(&content, cx)
+            })?;
+
+            editor.update(&mut cx, |editor, cx| {
+                let mut replacements = HashMap::default();
+                replacements.insert(
+                    block_id,
+                    (
+                        Some(execution_view.num_lines(cx).saturating_add(1)),
+                        create_output_area_render(execution_view.clone()),
+                    ),
+                );
+                editor.replace_blocks(replacements, None, cx);
+            })?;
+        }
+        anyhow::Ok(())
+    })
+    .detach_and_log_err(cx);
+}
+
+fn create_output_area_render(execution_view: View<ExecutionView>) -> RenderBlock {
+    let render = move |cx: &mut BlockContext| {
+        let execution_view = execution_view.clone();
+        let text_font = ThemeSettings::get_global(cx).buffer_font.family.clone();
+        // Note: we'll want to use `cx.anchor_x` when someone runs something with no output -- just show a checkmark and not make the full block below the line
+
+        let gutter_width = cx.gutter_dimensions.width;
+
+        h_flex()
+            .w_full()
+            .bg(cx.theme().colors().background)
+            .border_y_1()
+            .border_color(cx.theme().colors().border)
+            .pl(gutter_width)
+            .child(
+                div()
+                    .font_family(text_font)
+                    // .ml(gutter_width)
+                    .mx_1()
+                    .my_2()
+                    .h_full()
+                    .w_full()
+                    .mr(gutter_width)
+                    .child(execution_view),
+            )
+            .into_any_element()
+    };
+
+    Box::new(render)
+}

crates/repl/src/runtime_settings.rs 🔗

@@ -0,0 +1,66 @@
+use schemars::JsonSchema;
+use serde::{Deserialize, Serialize};
+use settings::{Settings, SettingsSources};
+
+#[derive(Copy, Clone, Default, Debug, Serialize, Deserialize, JsonSchema)]
+#[serde(rename_all = "snake_case")]
+pub enum RuntimesDockPosition {
+    Left,
+    #[default]
+    Right,
+    Bottom,
+}
+
+#[derive(Debug, Default)]
+pub struct JupyterSettings {
+    pub enabled: bool,
+    pub dock: RuntimesDockPosition,
+}
+
+#[derive(Clone, Serialize, Deserialize, JsonSchema, Debug)]
+pub struct JupyterSettingsContent {
+    /// Whether the Runtimes feature is enabled.
+    ///
+    /// Default: `false`
+    enabled: Option<bool>,
+    /// Where to dock the runtimes panel.
+    ///
+    /// Default: `right`
+    dock: Option<RuntimesDockPosition>,
+}
+
+impl Default for JupyterSettingsContent {
+    fn default() -> Self {
+        JupyterSettingsContent {
+            enabled: Some(false),
+            dock: Some(RuntimesDockPosition::Right),
+        }
+    }
+}
+
+impl Settings for JupyterSettings {
+    const KEY: Option<&'static str> = Some("jupyter");
+
+    type FileContent = JupyterSettingsContent;
+
+    fn load(
+        sources: SettingsSources<Self::FileContent>,
+        _cx: &mut gpui::AppContext,
+    ) -> anyhow::Result<Self>
+    where
+        Self: Sized,
+    {
+        let mut settings = JupyterSettings::default();
+
+        for value in sources.defaults_and_customizations() {
+            if let Some(enabled) = value.enabled {
+                settings.enabled = enabled;
+            }
+            if let Some(dock) = value.dock {
+                settings.dock = dock;
+            }
+        }
+
+        Ok(settings)
+    }
+}

crates/repl/src/runtimes.rs 🔗

@@ -0,0 +1,329 @@
+use anyhow::{Context as _, Result};
+use collections::HashMap;
+use futures::lock::Mutex;
+use futures::{channel::mpsc, SinkExt as _, StreamExt as _};
+use gpui::{AsyncAppContext, EntityId};
+use project::Fs;
+use runtimelib::{dirs, ConnectionInfo, JupyterKernelspec, JupyterMessage, JupyterMessageContent};
+use smol::{net::TcpListener, process::Command};
+use std::fmt::Debug;
+use std::net::{IpAddr, Ipv4Addr, SocketAddr};
+use std::{path::PathBuf, sync::Arc};
+
+#[derive(Debug)]
+pub struct Request {
+    pub request: runtimelib::JupyterMessageContent,
+    pub responses_rx: mpsc::UnboundedSender<JupyterMessageContent>,
+}
+
+#[derive(Debug, Clone)]
+pub struct RuntimeSpecification {
+    pub name: String,
+    pub path: PathBuf,
+    pub kernelspec: JupyterKernelspec,
+}
+
+impl RuntimeSpecification {
+    #[must_use]
+    fn command(&self, connection_path: &PathBuf) -> Result<Command> {
+        let argv = &self.kernelspec.argv;
+
+        if argv.is_empty() {
+            return Err(anyhow::anyhow!("Empty argv in kernelspec {}", self.name));
+        }
+
+        if argv.len() < 2 {
+            return Err(anyhow::anyhow!("Invalid argv in kernelspec {}", self.name));
+        }
+
+        if !argv.contains(&"{connection_file}".to_string()) {
+            return Err(anyhow::anyhow!(
+                "Missing 'connection_file' in argv in kernelspec {}",
+                self.name
+            ));
+        }
+
+        let mut cmd = Command::new(&argv[0]);
+
+        for arg in &argv[1..] {
+            if arg == "{connection_file}" {
+                cmd.arg(connection_path);
+            } else {
+                cmd.arg(arg);
+            }
+        }
+
+        if let Some(env) = &self.kernelspec.env {
+            cmd.envs(env);
+        }
+
+        Ok(cmd)
+    }
+}
+
+// Find a set of open ports. This creates a listener with port set to 0. The listener will be closed at the end when it goes out of scope.
+// There's a race condition between closing the ports and usage by a kernel, but it's inherent to the Jupyter protocol.
+async fn peek_ports(ip: IpAddr) -> anyhow::Result<[u16; 5]> {
+    let mut addr_zeroport: SocketAddr = SocketAddr::new(ip, 0);
+    addr_zeroport.set_port(0);
+    let mut ports: [u16; 5] = [0; 5];
+    for i in 0..5 {
+        let listener = TcpListener::bind(addr_zeroport).await?;
+        let addr = listener.local_addr()?;
+        ports[i] = addr.port();
+    }
+    Ok(ports)
+}
+
+#[derive(Debug)]
+pub struct RunningKernel {
+    #[allow(unused)]
+    runtime: RuntimeSpecification,
+    #[allow(unused)]
+    process: smol::process::Child,
+    pub request_tx: mpsc::UnboundedSender<Request>,
+}
+
+impl RunningKernel {
+    pub async fn new(
+        runtime: RuntimeSpecification,
+        entity_id: EntityId,
+        fs: Arc<dyn Fs>,
+        cx: AsyncAppContext,
+    ) -> anyhow::Result<Self> {
+        let ip = IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1));
+        let ports = peek_ports(ip).await?;
+
+        let connection_info = ConnectionInfo {
+            transport: "tcp".to_string(),
+            ip: ip.to_string(),
+            stdin_port: ports[0],
+            control_port: ports[1],
+            hb_port: ports[2],
+            shell_port: ports[3],
+            iopub_port: ports[4],
+            signature_scheme: "hmac-sha256".to_string(),
+            key: uuid::Uuid::new_v4().to_string(),
+            kernel_name: Some(format!("zed-{}", runtime.name)),
+        };
+
+        let connection_path = dirs::runtime_dir().join(format!("kernel-zed-{}.json", entity_id));
+        let content = serde_json::to_string(&connection_info)?;
+        // write out file to disk for kernel
+        fs.atomic_write(connection_path.clone(), content).await?;
+
+        let mut cmd = runtime.command(&connection_path)?;
+        let process = cmd
+            // .stdout(Stdio::null())
+            // .stderr(Stdio::null())
+            .kill_on_drop(true)
+            .spawn()
+            .context("failed to start the kernel process")?;
+
+        let mut iopub = connection_info.create_client_iopub_connection("").await?;
+        let mut shell = connection_info.create_client_shell_connection().await?;
+
+        // Spawn a background task to handle incoming messages from the kernel as well
+        // as outgoing messages to the kernel
+
+        let child_messages: Arc<
+            Mutex<HashMap<String, mpsc::UnboundedSender<JupyterMessageContent>>>,
+        > = Default::default();
+
+        let (request_tx, mut request_rx) = mpsc::unbounded::<Request>();
+
+        cx.background_executor()
+            .spawn({
+                let child_messages = child_messages.clone();
+
+                async move {
+                    let child_messages = child_messages.clone();
+                    while let Ok(message) = iopub.read().await {
+                        if let Some(parent_header) = message.parent_header {
+                            let child_messages = child_messages.lock().await;
+
+                            let sender = child_messages.get(&parent_header.msg_id);
+
+                            match sender {
+                                Some(mut sender) => {
+                                    sender.send(message.content).await?;
+                                }
+                                None => {}
+                            }
+                        }
+                    }
+
+                    anyhow::Ok(())
+                }
+            })
+            .detach();
+
+        cx.background_executor()
+            .spawn({
+                let child_messages = child_messages.clone();
+                async move {
+                    while let Some(request) = request_rx.next().await {
+                        let rx = request.responses_rx.clone();
+
+                        let request: JupyterMessage = request.request.into();
+                        let msg_id = request.header.msg_id.clone();
+
+                        let mut sender = rx.clone();
+
+                        child_messages
+                            .lock()
+                            .await
+                            .insert(msg_id.clone(), sender.clone());
+
+                        shell.send(request).await?;
+
+                        let response = shell.read().await?;
+
+                        sender.send(response.content).await?;
+                    }
+
+                    anyhow::Ok(())
+                }
+            })
+            .detach();
+
+        Ok(Self {
+            runtime,
+            process,
+            request_tx,
+        })
+    }
+}
+
+async fn read_kernelspec_at(
+    // Path should be a directory to a jupyter kernelspec, as in
+    // /usr/local/share/jupyter/kernels/python3
+    kernel_dir: PathBuf,
+    fs: Arc<dyn Fs>,
+) -> anyhow::Result<RuntimeSpecification> {
+    let path = kernel_dir;
+    let kernel_name = if let Some(kernel_name) = path.file_name() {
+        kernel_name.to_string_lossy().to_string()
+    } else {
+        return Err(anyhow::anyhow!("Invalid kernelspec directory: {:?}", path));
+    };
+
+    if !fs.is_dir(path.as_path()).await {
+        return Err(anyhow::anyhow!("Not a directory: {:?}", path));
+    }
+
+    let expected_kernel_json = path.join("kernel.json");
+    let spec = fs.load(expected_kernel_json.as_path()).await?;
+    let spec = serde_json::from_str::<JupyterKernelspec>(&spec)?;
+
+    Ok(RuntimeSpecification {
+        name: kernel_name,
+        path,
+        kernelspec: spec,
+    })
+}
+
+/// Read a directory of kernelspec directories
+async fn read_kernels_dir(
+    path: PathBuf,
+    fs: Arc<dyn Fs>,
+) -> anyhow::Result<Vec<RuntimeSpecification>> {
+    let mut kernelspec_dirs = fs.read_dir(&path).await?;
+
+    let mut valid_kernelspecs = Vec::new();
+    while let Some(path) = kernelspec_dirs.next().await {
+        match path {
+            Ok(path) => {
+                if fs.is_dir(path.as_path()).await {
+                    let fs = fs.clone();
+                    if let Ok(kernelspec) = read_kernelspec_at(path, fs).await {
+                        valid_kernelspecs.push(kernelspec);
+                    }
+                }
+            }
+            Err(err) => {
+                log::warn!("Error reading kernelspec directory: {:?}", err);
+            }
+        }
+    }
+
+    Ok(valid_kernelspecs)
+}
+
+pub async fn get_runtime_specifications(
+    fs: Arc<dyn Fs>,
+) -> anyhow::Result<Vec<RuntimeSpecification>> {
+    let data_dirs = dirs::data_dirs();
+    let kernel_dirs = data_dirs
+        .iter()
+        .map(|dir| dir.join("kernels"))
+        .map(|path| read_kernels_dir(path, fs.clone()))
+        .collect::<Vec<_>>();
+
+    let kernel_dirs = futures::future::join_all(kernel_dirs).await;
+    let kernel_dirs = kernel_dirs
+        .into_iter()
+        .filter_map(Result::ok)
+        .flatten()
+        .collect::<Vec<_>>();
+
+    Ok(kernel_dirs)
+}
+
+#[cfg(test)]
+mod test {
+    use super::*;
+    use std::path::PathBuf;
+
+    use gpui::TestAppContext;
+    use project::FakeFs;
+    use serde_json::json;
+
+    #[gpui::test]
+    async fn test_get_kernelspecs(cx: &mut TestAppContext) {
+        let fs = FakeFs::new(cx.executor());
+        fs.insert_tree(
+            "/jupyter",
+            json!({
+                ".zed": {
+                    "settings.json": r#"{ "tab_size": 8 }"#,
+                    "tasks.json": r#"[{
+                        "label": "cargo check",
+                        "command": "cargo",
+                        "args": ["check", "--all"]
+                    },]"#,
+                },
+                "kernels": {
+                    "python": {
+                        "kernel.json": r#"{
+                            "display_name": "Python 3",
+                            "language": "python",
+                            "argv": ["python3", "-m", "ipykernel_launcher", "-f", "{connection_file}"],
+                            "env": {}
+                        }"#
+                    },
+                    "deno": {
+                        "kernel.json": r#"{
+                            "display_name": "Deno",
+                            "language": "typescript",
+                            "argv": ["deno", "run", "--unstable", "--allow-net", "--allow-read", "https://deno.land/std/http/file_server.ts", "{connection_file}"],
+                            "env": {}
+                        }"#
+                    }
+                },
+            }),
+        )
+        .await;
+
+        let mut kernels = read_kernels_dir(PathBuf::from("/jupyter/kernels"), fs)
+            .await
+            .unwrap();
+
+        kernels.sort_by(|a, b| a.name.cmp(&b.name));
+
+        assert_eq!(
+            kernels.iter().map(|c| c.name.clone()).collect::<Vec<_>>(),
+            vec!["deno", "python"]
+        );
+    }
+}

crates/repl/src/stdio.rs 🔗

@@ -0,0 +1,394 @@
+use crate::outputs::{ExecutionView, LineHeight};
+use alacritty_terminal::vte::{
+    ansi::{Attr, Color, NamedColor, Rgb},
+    Params, ParamsIter, Parser, Perform,
+};
+use core::iter;
+use gpui::{font, prelude::*, AnyElement, StyledText, TextRun};
+use settings::Settings as _;
+use theme::ThemeSettings;
+use ui::{div, prelude::*, IntoElement, ViewContext, WindowContext};
+
+/// Implements the most basic of terminal output for use by Jupyter outputs
+/// whether:
+///
+/// * stdout
+/// * stderr
+/// * text/plain
+/// * traceback from an error output
+///
+/// Ideally, we would instead use alacritty::vte::Processor to collect the
+/// output and then render up to u8::MAX lines of text. However, it's likely
+/// overkill for 95% of outputs.
+///
+/// Instead, this implementation handles:
+///
+/// * ANSI color codes (background, foreground), including 256 color
+/// * Carriage returns/line feeds
+///
+/// There is no support for cursor movement, clearing the screen, and other text styles
+pub struct TerminalOutput {
+    parser: Parser,
+    handler: TerminalHandler,
+}
+
+impl TerminalOutput {
+    pub fn new() -> Self {
+        Self {
+            parser: Parser::new(),
+            handler: TerminalHandler::new(),
+        }
+    }
+
+    pub fn from(text: &str) -> Self {
+        let mut output = Self::new();
+        output.append_text(text);
+        output
+    }
+
+    pub fn append_text(&mut self, text: &str) {
+        for byte in text.as_bytes() {
+            self.parser.advance(&mut self.handler, *byte);
+        }
+    }
+
+    pub fn render(&self, cx: &ViewContext<ExecutionView>) -> AnyElement {
+        let theme = cx.theme();
+        let buffer_font = ThemeSettings::get_global(cx).buffer_font.family.clone();
+        let mut text_runs = self.handler.text_runs.clone();
+        text_runs.push(self.handler.current_text_run.clone());
+
+        let runs = text_runs
+            .iter()
+            .map(|ansi_run| {
+                let color = terminal_view::terminal_element::convert_color(&ansi_run.fg, theme);
+                let background_color = Some(terminal_view::terminal_element::convert_color(
+                    &ansi_run.bg,
+                    theme,
+                ));
+
+                TextRun {
+                    len: ansi_run.len,
+                    color,
+                    background_color,
+                    underline: Default::default(),
+                    font: font(buffer_font.clone()),
+                    strikethrough: None,
+                }
+            })
+            .collect::<Vec<TextRun>>();
+
+        let text = StyledText::new(self.handler.buffer.trim_end().to_string()).with_runs(runs);
+        div()
+            .font_family(buffer_font)
+            .child(text)
+            .into_any_element()
+    }
+}
+
+impl LineHeight for TerminalOutput {
+    fn num_lines(&self, _cx: &mut WindowContext) -> u8 {
+        // todo!(): Track this over time with our parser and just return it when needed
+        self.handler.buffer.lines().count() as u8
+    }
+}
+
+#[derive(Clone)]
+struct AnsiTextRun {
+    pub len: usize,
+    pub fg: alacritty_terminal::vte::ansi::Color,
+    pub bg: alacritty_terminal::vte::ansi::Color,
+}
+
+impl AnsiTextRun {
+    fn default() -> Self {
+        Self {
+            len: 0,
+            fg: Color::Named(NamedColor::Foreground),
+            bg: Color::Named(NamedColor::Background),
+        }
+    }
+}
+
+struct TerminalHandler {
+    text_runs: Vec<AnsiTextRun>,
+    current_text_run: AnsiTextRun,
+    buffer: String,
+}
+
+impl TerminalHandler {
+    fn new() -> Self {
+        Self {
+            text_runs: Vec::new(),
+            current_text_run: AnsiTextRun {
+                len: 0,
+                fg: Color::Named(NamedColor::Foreground),
+                bg: Color::Named(NamedColor::Background),
+            },
+            buffer: String::new(),
+        }
+    }
+
+    fn add_text(&mut self, c: char) {
+        self.buffer.push(c);
+        self.current_text_run.len += 1;
+    }
+
+    fn reset(&mut self) {
+        if self.current_text_run.len > 0 {
+            self.text_runs.push(self.current_text_run.clone());
+        }
+
+        self.current_text_run = AnsiTextRun::default();
+    }
+
+    fn terminal_attribute(&mut self, attr: Attr) {
+        // println!("[terminal_attribute] attr={:?}", attr);
+        if Attr::Reset == attr {
+            self.reset();
+            return;
+        }
+
+        if self.current_text_run.len > 0 {
+            self.text_runs.push(self.current_text_run.clone());
+        }
+
+        let mut text_run = AnsiTextRun {
+            len: 0,
+            fg: self.current_text_run.fg,
+            bg: self.current_text_run.bg,
+        };
+
+        match attr {
+            Attr::Foreground(color) => text_run.fg = color,
+            Attr::Background(color) => text_run.bg = color,
+            _ => {}
+        }
+
+        self.current_text_run = text_run;
+    }
+
+    fn process_carriage_return(&mut self) {
+        // Find last carriage return's position
+        let last_cr = self.buffer.rfind('\r').unwrap_or(0);
+        self.buffer = self.buffer.chars().take(last_cr).collect();
+
+        // First work through our current text run
+        let mut total_len = self.current_text_run.len;
+        if total_len > last_cr {
+            // We are in the current text run
+            self.current_text_run.len = self.current_text_run.len - last_cr;
+        } else {
+            let mut last_cr_run = 0;
+            // Find the last run before the last carriage return
+            for (i, run) in self.text_runs.iter().enumerate() {
+                total_len += run.len;
+                if total_len > last_cr {
+                    last_cr_run = i;
+                    break;
+                }
+            }
+            self.text_runs = self.text_runs[..last_cr_run].to_vec();
+            self.current_text_run = self.text_runs.pop().unwrap_or(AnsiTextRun::default());
+        }
+
+        self.buffer.push('\r');
+        self.current_text_run.len += 1;
+    }
+}
+
+impl Perform for TerminalHandler {
+    fn print(&mut self, c: char) {
+        // println!("[print] c={:?}", c);
+        self.add_text(c);
+    }
+
+    fn execute(&mut self, byte: u8) {
+        match byte {
+            b'\n' => {
+                self.add_text('\n');
+            }
+            b'\r' => {
+                self.process_carriage_return();
+            }
+            _ => {
+                // Format as hex
+                println!("[execute] byte={:02x}", byte);
+            }
+        }
+    }
+
+    fn hook(&mut self, _params: &Params, _intermediates: &[u8], _ignore: bool, _c: char) {
+        // noop
+        // println!(
+        //     "[hook] params={:?}, intermediates={:?}, c={:?}",
+        //     _params, _intermediates, _c
+        // );
+    }
+
+    fn put(&mut self, _byte: u8) {
+        // noop
+        // println!("[put] byte={:02x}", _byte);
+    }
+
+    fn unhook(&mut self) {
+        // noop
+    }
+
+    fn osc_dispatch(&mut self, _params: &[&[u8]], _bell_terminated: bool) {
+        // noop
+        // println!("[osc_dispatch] params={:?}", _params);
+    }
+
+    fn csi_dispatch(
+        &mut self,
+        params: &alacritty_terminal::vte::Params,
+        intermediates: &[u8],
+        _ignore: bool,
+        action: char,
+    ) {
+        // println!(
+        //     "[csi_dispatch] action={:?}, params={:?}, intermediates={:?}",
+        //     action, params, intermediates
+        // );
+
+        let mut params_iter = params.iter();
+        // Collect colors
+        match (action, intermediates) {
+            ('m', []) => {
+                if params.is_empty() {
+                    self.terminal_attribute(Attr::Reset);
+                } else {
+                    for attr in attrs_from_sgr_parameters(&mut params_iter) {
+                        match attr {
+                            Some(attr) => self.terminal_attribute(attr),
+                            None => return,
+                        }
+                    }
+                }
+            }
+            _ => {}
+        }
+    }
+
+    fn esc_dispatch(&mut self, _intermediates: &[u8], _ignore: bool, _byte: u8) {
+        // noop
+        // println!(
+        //     "[esc_dispatch] intermediates={:?}, byte={:?}",
+        //     _intermediates, _byte
+        // );
+    }
+}
+
+// The following was pulled from vte::ansi
+#[inline]
+fn attrs_from_sgr_parameters(params: &mut ParamsIter<'_>) -> Vec<Option<Attr>> {
+    let mut attrs = Vec::with_capacity(params.size_hint().0);
+
+    while let Some(param) = params.next() {
+        let attr = match param {
+            [0] => Some(Attr::Reset),
+            [1] => Some(Attr::Bold),
+            [2] => Some(Attr::Dim),
+            [3] => Some(Attr::Italic),
+            [4, 0] => Some(Attr::CancelUnderline),
+            [4, 2] => Some(Attr::DoubleUnderline),
+            [4, 3] => Some(Attr::Undercurl),
+            [4, 4] => Some(Attr::DottedUnderline),
+            [4, 5] => Some(Attr::DashedUnderline),
+            [4, ..] => Some(Attr::Underline),
+            [5] => Some(Attr::BlinkSlow),
+            [6] => Some(Attr::BlinkFast),
+            [7] => Some(Attr::Reverse),
+            [8] => Some(Attr::Hidden),
+            [9] => Some(Attr::Strike),
+            [21] => Some(Attr::CancelBold),
+            [22] => Some(Attr::CancelBoldDim),
+            [23] => Some(Attr::CancelItalic),
+            [24] => Some(Attr::CancelUnderline),
+            [25] => Some(Attr::CancelBlink),
+            [27] => Some(Attr::CancelReverse),
+            [28] => Some(Attr::CancelHidden),
+            [29] => Some(Attr::CancelStrike),
+            [30] => Some(Attr::Foreground(Color::Named(NamedColor::Black))),
+            [31] => Some(Attr::Foreground(Color::Named(NamedColor::Red))),
+            [32] => Some(Attr::Foreground(Color::Named(NamedColor::Green))),
+            [33] => Some(Attr::Foreground(Color::Named(NamedColor::Yellow))),
+            [34] => Some(Attr::Foreground(Color::Named(NamedColor::Blue))),
+            [35] => Some(Attr::Foreground(Color::Named(NamedColor::Magenta))),
+            [36] => Some(Attr::Foreground(Color::Named(NamedColor::Cyan))),
+            [37] => Some(Attr::Foreground(Color::Named(NamedColor::White))),
+            [38] => {
+                let mut iter = params.map(|param| param[0]);
+                parse_sgr_color(&mut iter).map(Attr::Foreground)
+            }
+            [38, params @ ..] => handle_colon_rgb(params).map(Attr::Foreground),
+            [39] => Some(Attr::Foreground(Color::Named(NamedColor::Foreground))),
+            [40] => Some(Attr::Background(Color::Named(NamedColor::Black))),
+            [41] => Some(Attr::Background(Color::Named(NamedColor::Red))),
+            [42] => Some(Attr::Background(Color::Named(NamedColor::Green))),
+            [43] => Some(Attr::Background(Color::Named(NamedColor::Yellow))),
+            [44] => Some(Attr::Background(Color::Named(NamedColor::Blue))),
+            [45] => Some(Attr::Background(Color::Named(NamedColor::Magenta))),
+            [46] => Some(Attr::Background(Color::Named(NamedColor::Cyan))),
+            [47] => Some(Attr::Background(Color::Named(NamedColor::White))),
+            [48] => {
+                let mut iter = params.map(|param| param[0]);
+                parse_sgr_color(&mut iter).map(Attr::Background)
+            }
+            [48, params @ ..] => handle_colon_rgb(params).map(Attr::Background),
+            [49] => Some(Attr::Background(Color::Named(NamedColor::Background))),
+            [58] => {
+                let mut iter = params.map(|param| param[0]);
+                parse_sgr_color(&mut iter).map(|color| Attr::UnderlineColor(Some(color)))
+            }
+            [58, params @ ..] => {
+                handle_colon_rgb(params).map(|color| Attr::UnderlineColor(Some(color)))
+            }
+            [59] => Some(Attr::UnderlineColor(None)),
+            [90] => Some(Attr::Foreground(Color::Named(NamedColor::BrightBlack))),
+            [91] => Some(Attr::Foreground(Color::Named(NamedColor::BrightRed))),
+            [92] => Some(Attr::Foreground(Color::Named(NamedColor::BrightGreen))),
+            [93] => Some(Attr::Foreground(Color::Named(NamedColor::BrightYellow))),
+            [94] => Some(Attr::Foreground(Color::Named(NamedColor::BrightBlue))),
+            [95] => Some(Attr::Foreground(Color::Named(NamedColor::BrightMagenta))),
+            [96] => Some(Attr::Foreground(Color::Named(NamedColor::BrightCyan))),
+            [97] => Some(Attr::Foreground(Color::Named(NamedColor::BrightWhite))),
+            [100] => Some(Attr::Background(Color::Named(NamedColor::BrightBlack))),
+            [101] => Some(Attr::Background(Color::Named(NamedColor::BrightRed))),
+            [102] => Some(Attr::Background(Color::Named(NamedColor::BrightGreen))),
+            [103] => Some(Attr::Background(Color::Named(NamedColor::BrightYellow))),
+            [104] => Some(Attr::Background(Color::Named(NamedColor::BrightBlue))),
+            [105] => Some(Attr::Background(Color::Named(NamedColor::BrightMagenta))),
+            [106] => Some(Attr::Background(Color::Named(NamedColor::BrightCyan))),
+            [107] => Some(Attr::Background(Color::Named(NamedColor::BrightWhite))),
+            _ => None,
+        };
+        attrs.push(attr);
+    }
+
+    attrs
+}
+
+/// Handle colon separated rgb color escape sequence.
+#[inline]
+fn handle_colon_rgb(params: &[u16]) -> Option<Color> {
+    let rgb_start = if params.len() > 4 { 2 } else { 1 };
+    let rgb_iter = params[rgb_start..].iter().copied();
+    let mut iter = iter::once(params[0]).chain(rgb_iter);
+
+    parse_sgr_color(&mut iter)
+}
+
+/// Parse a color specifier from list of attributes.
+fn parse_sgr_color(params: &mut dyn Iterator<Item = u16>) -> Option<Color> {
+    match params.next() {
+        Some(2) => Some(Color::Spec(Rgb {
+            r: u8::try_from(params.next()?).ok()?,
+            g: u8::try_from(params.next()?).ok()?,
+            b: u8::try_from(params.next()?).ok()?,
+        })),
+        Some(5) => Some(Color::Indexed(u8::try_from(params.next()?).ok()?)),
+        _ => None,
+    }
+}

crates/terminal_view/src/terminal_element.rs 🔗

@@ -1041,7 +1041,7 @@ fn to_highlighted_range_lines(
 }
 
 /// Converts a 2, 8, or 24 bit color ANSI color to the GPUI equivalent.
-fn convert_color(fg: &terminal::alacritty_terminal::vte::ansi::Color, theme: &Theme) -> Hsla {
+pub fn convert_color(fg: &terminal::alacritty_terminal::vte::ansi::Color, theme: &Theme) -> Hsla {
     let colors = theme.colors();
     match fg {
         // Named and theme defined colors

crates/ui/src/utils/format_distance.rs 🔗

@@ -268,9 +268,11 @@ mod tests {
     #[test]
     fn test_format_distance() {
         let date = DateTimeType::Naive(
+            #[allow(deprecated)]
             NaiveDateTime::from_timestamp_opt(9600, 0).expect("Invalid NaiveDateTime for date"),
         );
         let base_date = DateTimeType::Naive(
+            #[allow(deprecated)]
             NaiveDateTime::from_timestamp_opt(0, 0).expect("Invalid NaiveDateTime for base_date"),
         );
 
@@ -283,9 +285,11 @@ mod tests {
     #[test]
     fn test_format_distance_with_suffix() {
         let date = DateTimeType::Naive(
+            #[allow(deprecated)]
             NaiveDateTime::from_timestamp_opt(9600, 0).expect("Invalid NaiveDateTime for date"),
         );
         let base_date = DateTimeType::Naive(
+            #[allow(deprecated)]
             NaiveDateTime::from_timestamp_opt(0, 0).expect("Invalid NaiveDateTime for base_date"),
         );
 

crates/zed/Cargo.toml 🔗

@@ -78,6 +78,7 @@ quick_action_bar.workspace = true
 recent_projects.workspace = true
 dev_server_projects.workspace = true
 release_channel.workspace = true
+repl.workspace = true
 rope.workspace = true
 search.workspace = true
 serde.workspace = true

crates/zed/src/main.rs 🔗

@@ -221,6 +221,8 @@ fn init_ui(app_state: Arc<AppState>, cx: &mut AppContext) -> Result<()> {
 
     assistant::init(app_state.client.clone(), cx);
 
+    repl::init(app_state.fs.clone(), cx);
+
     cx.observe_global::<SettingsStore>({
         let languages = app_state.languages.clone();
         let http = app_state.client.http_client();