Add terminal inline assistant (#13638)

Bennet Bo Fenner and Antonio created

Release Notes:

- N/A

---------

Co-authored-by: Antonio <antonio@zed.dev>

Change summary

Cargo.lock                                        |    1 
assets/keymaps/default-linux.json                 |    1 
assets/keymaps/default-macos.json                 |    1 
crates/assistant/Cargo.toml                       |    1 
crates/assistant/src/assistant.rs                 |    2 
crates/assistant/src/assistant_panel.rs           |  156 +
crates/assistant/src/prompts.rs                   |   24 
crates/assistant/src/terminal_inline_assistant.rs | 1122 +++++++++++++++++
crates/terminal/src/terminal.rs                   |   29 
crates/terminal_view/src/terminal_element.rs      |  243 ++-
crates/terminal_view/src/terminal_view.rs         |  140 ++
11 files changed, 1,583 insertions(+), 137 deletions(-)

Detailed changes

Cargo.lock πŸ”—

@@ -417,6 +417,7 @@ dependencies = [
  "strsim 0.11.1",
  "strum",
  "telemetry_events",
+ "terminal",
  "terminal_view",
  "theme",
  "tiktoken-rs",

assets/keymaps/default-linux.json πŸ”—

@@ -653,6 +653,7 @@
       "ctrl-insert": "terminal::Copy",
       "shift-ctrl-v": "terminal::Paste",
       "shift-insert": "terminal::Paste",
+      "ctrl-enter": "assistant::InlineAssist",
       "up": ["terminal::SendKeystroke", "up"],
       "pageup": ["terminal::SendKeystroke", "pageup"],
       "down": ["terminal::SendKeystroke", "down"],

assets/keymaps/default-macos.json πŸ”—

@@ -688,6 +688,7 @@
       "cmd-c": "terminal::Copy",
       "cmd-v": "terminal::Paste",
       "cmd-k": "terminal::Clear",
+      "ctrl-enter": "assistant::InlineAssist",
       // Some nice conveniences
       "cmd-backspace": ["terminal::SendText", "\u0015"],
       "cmd-right": ["terminal::SendText", "\u0005"],

crates/assistant/Cargo.toml πŸ”—

@@ -56,6 +56,7 @@ smol.workspace = true
 strsim = "0.11"
 strum.workspace = true
 telemetry_events.workspace = true
+terminal.workspace = true
 terminal_view.workspace = true
 theme.workspace = true
 tiktoken-rs.workspace = true

crates/assistant/src/assistant.rs πŸ”—

@@ -9,6 +9,7 @@ mod prompts;
 mod search;
 mod slash_command;
 mod streaming_diff;
+mod terminal_inline_assistant;
 
 pub use assistant_panel::{AssistantPanel, AssistantPanelEvent};
 use assistant_settings::{AnthropicModel, AssistantSettings, CloudModel, OllamaModel, OpenAiModel};
@@ -289,6 +290,7 @@ pub fn init(fs: Arc<dyn Fs>, client: Arc<Client>, cx: &mut AppContext) {
     register_slash_commands(cx);
     assistant_panel::init(cx);
     inline_assistant::init(fs.clone(), client.telemetry().clone(), cx);
+    terminal_inline_assistant::init(fs.clone(), client.telemetry().clone(), cx);
     RustdocStore::init_global(cx);
 
     CommandPaletteFilter::update_global(cx, |filter, _cx| {

crates/assistant/src/assistant_panel.rs πŸ”—

@@ -7,6 +7,7 @@ use crate::{
         default_command::DefaultSlashCommand, SlashCommandCompletionProvider, SlashCommandLine,
         SlashCommandRegistry,
     },
+    terminal_inline_assistant::TerminalInlineAssistant,
     ApplyEdit, Assist, CompletionProvider, ConfirmCommand, ContextStore, CycleMessageRole,
     InlineAssist, InlineAssistant, LanguageModelRequest, LanguageModelRequestMessage, MessageId,
     MessageMetadata, MessageStatus, ModelSelector, QuoteSelection, ResetKey, Role, SavedContext,
@@ -58,6 +59,7 @@ use std::{
     time::{Duration, Instant},
 };
 use telemetry_events::AssistantKind;
+use terminal_view::{terminal_panel::TerminalPanel, TerminalView};
 use ui::{
     prelude::*, ButtonLike, ContextMenu, Disclosure, ElevationIndex, KeyBinding, ListItem,
     ListItemSpacing, PopoverMenu, PopoverMenuHandle, Tab, TabBar, Tooltip,
@@ -124,6 +126,11 @@ enum SavedContextPickerEvent {
     Confirmed { path: PathBuf },
 }
 
+enum InlineAssistTarget {
+    Editor(View<Editor>, bool),
+    Terminal(View<TerminalView>),
+}
+
 impl EventEmitter<SavedContextPickerEvent> for Picker<SavedContextPickerDelegate> {}
 
 impl SavedContextPickerDelegate {
@@ -369,65 +376,68 @@ impl AssistantPanel {
             return;
         };
 
-        let context_editor = assistant_panel
-            .read(cx)
-            .active_context_editor()
-            .and_then(|editor| {
-                let editor = &editor.read(cx).editor;
-                if editor.read(cx).is_focused(cx) {
-                    Some(editor.clone())
-                } else {
-                    None
-                }
-            });
-
-        let include_context;
-        let active_editor;
-        if let Some(context_editor) = context_editor {
-            active_editor = context_editor;
-            include_context = false;
-        } else if let Some(workspace_editor) = workspace
-            .active_item(cx)
-            .and_then(|item| item.act_as::<Editor>(cx))
-        {
-            active_editor = workspace_editor;
-            include_context = true;
-        } else {
+        let Some(inline_assist_target) =
+            Self::resolve_inline_assist_target(workspace, &assistant_panel, cx)
+        else {
             return;
         };
 
-        if assistant_panel.update(cx, |panel, cx| panel.is_authenticated(cx)) {
-            InlineAssistant::update_global(cx, |assistant, cx| {
-                assistant.assist(
-                    &active_editor,
-                    Some(cx.view().downgrade()),
-                    include_context.then_some(&assistant_panel),
-                    cx,
-                )
-            })
+        if assistant_panel.update(cx, |assistant, cx| assistant.is_authenticated(cx)) {
+            match inline_assist_target {
+                InlineAssistTarget::Editor(active_editor, include_context) => {
+                    InlineAssistant::update_global(cx, |assistant, cx| {
+                        assistant.assist(
+                            &active_editor,
+                            Some(cx.view().downgrade()),
+                            include_context.then_some(&assistant_panel),
+                            cx,
+                        )
+                    })
+                }
+                InlineAssistTarget::Terminal(active_terminal) => {
+                    TerminalInlineAssistant::update_global(cx, |assistant, cx| {
+                        assistant.assist(
+                            &active_terminal,
+                            Some(cx.view().downgrade()),
+                            Some(&assistant_panel),
+                            cx,
+                        )
+                    })
+                }
+            }
         } else {
             let assistant_panel = assistant_panel.downgrade();
             cx.spawn(|workspace, mut cx| async move {
                 assistant_panel
                     .update(&mut cx, |assistant, cx| assistant.authenticate(cx))?
                     .await?;
-                if assistant_panel
-                    .update(&mut cx, |assistant, cx| assistant.is_authenticated(cx))?
-                {
-                    cx.update(|cx| {
-                        let assistant_panel = if include_context {
-                            assistant_panel.upgrade()
-                        } else {
-                            None
-                        };
-                        InlineAssistant::update_global(cx, |assistant, cx| {
-                            assistant.assist(
-                                &active_editor,
-                                Some(workspace),
-                                assistant_panel.as_ref(),
-                                cx,
-                            )
-                        })
+                if assistant_panel.update(&mut cx, |panel, cx| panel.is_authenticated(cx))? {
+                    cx.update(|cx| match inline_assist_target {
+                        InlineAssistTarget::Editor(active_editor, include_context) => {
+                            let assistant_panel = if include_context {
+                                assistant_panel.upgrade()
+                            } else {
+                                None
+                            };
+                            InlineAssistant::update_global(cx, |assistant, cx| {
+                                assistant.assist(
+                                    &active_editor,
+                                    Some(workspace),
+                                    assistant_panel.as_ref(),
+                                    cx,
+                                )
+                            })
+                        }
+                        InlineAssistTarget::Terminal(active_terminal) => {
+                            TerminalInlineAssistant::update_global(cx, |assistant, cx| {
+                                assistant.assist(
+                                    &active_terminal,
+                                    Some(workspace),
+                                    assistant_panel.upgrade().as_ref(),
+                                    cx,
+                                )
+                            })
+                        }
                     })?
                 } else {
                     workspace.update(&mut cx, |workspace, cx| {
@@ -441,6 +451,52 @@ impl AssistantPanel {
         }
     }
 
+    fn resolve_inline_assist_target(
+        workspace: &mut Workspace,
+        assistant_panel: &View<AssistantPanel>,
+        cx: &mut WindowContext,
+    ) -> Option<InlineAssistTarget> {
+        if let Some(terminal_panel) = workspace.panel::<TerminalPanel>(cx) {
+            if terminal_panel
+                .read(cx)
+                .focus_handle(cx)
+                .contains_focused(cx)
+            {
+                if let Some(terminal_view) = terminal_panel
+                    .read(cx)
+                    .pane()
+                    .read(cx)
+                    .active_item()
+                    .and_then(|t| t.downcast::<TerminalView>())
+                {
+                    return Some(InlineAssistTarget::Terminal(terminal_view));
+                }
+            }
+        }
+        let context_editor = assistant_panel
+            .read(cx)
+            .active_context_editor()
+            .and_then(|editor| {
+                let editor = &editor.read(cx).editor;
+                if editor.read(cx).is_focused(cx) {
+                    Some(editor.clone())
+                } else {
+                    None
+                }
+            });
+
+        if let Some(context_editor) = context_editor {
+            Some(InlineAssistTarget::Editor(context_editor, false))
+        } else if let Some(workspace_editor) = workspace
+            .active_item(cx)
+            .and_then(|item| item.act_as::<Editor>(cx))
+        {
+            Some(InlineAssistTarget::Editor(workspace_editor, true))
+        } else {
+            None
+        }
+    }
+
     fn new_context(&mut self, cx: &mut ViewContext<Self>) -> Option<View<ContextEditor>> {
         let workspace = self.workspace.upgrade()?;
 

crates/assistant/src/prompts.rs πŸ”—

@@ -109,3 +109,27 @@ pub fn generate_content_prompt(
 
     Ok(prompt)
 }
+
+pub fn generate_terminal_assistant_prompt(
+    user_prompt: &str,
+    shell: Option<&str>,
+    working_directory: Option<&str>,
+) -> String {
+    let mut prompt = String::new();
+    writeln!(&mut prompt, "You are an expert terminal user.").unwrap();
+    writeln!(&mut prompt, "You will be given a description of a command and you need to respond with a command that matches the description.").unwrap();
+    writeln!(&mut prompt, "Do not include markdown blocks or any other text formatting in your response, always respond with a single command that can be executed in the given shell.").unwrap();
+    if let Some(shell) = shell {
+        writeln!(&mut prompt, "Current shell is '{shell}'.").unwrap();
+    }
+    if let Some(working_directory) = working_directory {
+        writeln!(
+            &mut prompt,
+            "Current working directory is '{working_directory}'."
+        )
+        .unwrap();
+    }
+    writeln!(&mut prompt, "Here is the description of the command:").unwrap();
+    prompt.push_str(user_prompt);
+    prompt
+}

crates/assistant/src/terminal_inline_assistant.rs πŸ”—

@@ -0,0 +1,1122 @@
+use crate::{
+    assistant_settings::AssistantSettings, humanize_token_count,
+    prompts::generate_terminal_assistant_prompt, AssistantPanel, AssistantPanelEvent,
+    CompletionProvider, LanguageModelRequest, LanguageModelRequestMessage, Role,
+};
+use anyhow::{Context as _, Result};
+use client::telemetry::Telemetry;
+use collections::{HashMap, VecDeque};
+use editor::{
+    actions::{MoveDown, MoveUp, SelectAll},
+    Editor, EditorElement, EditorEvent, EditorMode, EditorStyle, MultiBuffer,
+};
+use fs::Fs;
+use futures::{channel::mpsc, SinkExt, StreamExt};
+use gpui::{
+    AppContext, Context, EventEmitter, FocusHandle, FocusableView, FontStyle, FontWeight, Global,
+    Model, ModelContext, Subscription, Task, TextStyle, UpdateGlobal, View, WeakView, WhiteSpace,
+};
+use language::Buffer;
+use settings::{update_settings_file, Settings};
+use std::{
+    cmp,
+    sync::Arc,
+    time::{Duration, Instant},
+};
+use terminal::Terminal;
+use terminal_view::TerminalView;
+use theme::ThemeSettings;
+use ui::{prelude::*, ContextMenu, PopoverMenu, Tooltip};
+use util::ResultExt;
+use workspace::{notifications::NotificationId, Toast, Workspace};
+
+pub fn init(fs: Arc<dyn Fs>, telemetry: Arc<Telemetry>, cx: &mut AppContext) {
+    cx.set_global(TerminalInlineAssistant::new(fs, telemetry));
+}
+
+const PROMPT_HISTORY_MAX_LEN: usize = 20;
+
+#[derive(Copy, Clone, Default, Debug, PartialEq, Eq, Hash)]
+struct TerminalInlineAssistId(usize);
+
+impl TerminalInlineAssistId {
+    fn post_inc(&mut self) -> TerminalInlineAssistId {
+        let id = *self;
+        self.0 += 1;
+        id
+    }
+}
+
+pub struct TerminalInlineAssistant {
+    next_assist_id: TerminalInlineAssistId,
+    assists: HashMap<TerminalInlineAssistId, TerminalInlineAssist>,
+    prompt_history: VecDeque<String>,
+    telemetry: Option<Arc<Telemetry>>,
+    fs: Arc<dyn Fs>,
+}
+
+impl Global for TerminalInlineAssistant {}
+
+impl TerminalInlineAssistant {
+    pub fn new(fs: Arc<dyn Fs>, telemetry: Arc<Telemetry>) -> Self {
+        Self {
+            next_assist_id: TerminalInlineAssistId::default(),
+            assists: HashMap::default(),
+            prompt_history: VecDeque::default(),
+            telemetry: Some(telemetry),
+            fs,
+        }
+    }
+
+    pub fn assist(
+        &mut self,
+        terminal_view: &View<TerminalView>,
+        workspace: Option<WeakView<Workspace>>,
+        assistant_panel: Option<&View<AssistantPanel>>,
+        cx: &mut WindowContext,
+    ) {
+        let terminal = terminal_view.read(cx).terminal().clone();
+        let assist_id = self.next_assist_id.post_inc();
+        let prompt_buffer = cx.new_model(|cx| Buffer::local("", cx));
+        let prompt_buffer = cx.new_model(|cx| MultiBuffer::singleton(prompt_buffer, cx));
+        let codegen = cx.new_model(|_| Codegen::new(terminal, self.telemetry.clone()));
+
+        let prompt_editor = cx.new_view(|cx| {
+            PromptEditor::new(
+                assist_id,
+                self.prompt_history.clone(),
+                prompt_buffer.clone(),
+                codegen,
+                assistant_panel,
+                workspace.clone(),
+                self.fs.clone(),
+                cx,
+            )
+        });
+        let prompt_editor_render = prompt_editor.clone();
+        let block = terminal_view::BlockProperties {
+            height: 2,
+            render: Box::new(move |_| prompt_editor_render.clone().into_any_element()),
+        };
+        terminal_view.update(cx, |terminal_view, cx| {
+            terminal_view.set_block_below_cursor(block, cx);
+        });
+
+        let terminal_assistant = TerminalInlineAssist::new(
+            assist_id,
+            terminal_view,
+            assistant_panel.is_some(),
+            prompt_editor,
+            workspace.clone(),
+            cx,
+        );
+
+        self.assists.insert(assist_id, terminal_assistant);
+
+        self.focus_assist(assist_id, cx);
+    }
+
+    fn focus_assist(&mut self, assist_id: TerminalInlineAssistId, cx: &mut WindowContext) {
+        let assist = &self.assists[&assist_id];
+        if let Some(prompt_editor) = assist.prompt_editor.as_ref() {
+            prompt_editor.update(cx, |this, cx| {
+                this.editor.update(cx, |editor, cx| {
+                    editor.focus(cx);
+                    editor.select_all(&SelectAll, cx);
+                });
+            });
+        }
+    }
+
+    fn handle_prompt_editor_event(
+        &mut self,
+        prompt_editor: View<PromptEditor>,
+        event: &PromptEditorEvent,
+        cx: &mut WindowContext,
+    ) {
+        let assist_id = prompt_editor.read(cx).id;
+        match event {
+            PromptEditorEvent::StartRequested => {
+                self.start_assist(assist_id, cx);
+            }
+            PromptEditorEvent::StopRequested => {
+                self.stop_assist(assist_id, cx);
+            }
+            PromptEditorEvent::ConfirmRequested => {
+                self.finish_assist(assist_id, false, cx);
+            }
+            PromptEditorEvent::CancelRequested => {
+                self.finish_assist(assist_id, true, cx);
+            }
+            PromptEditorEvent::DismissRequested => {
+                self.dismiss_assist(assist_id, cx);
+            }
+            PromptEditorEvent::Resized { height_in_lines } => {
+                self.insert_prompt_editor_into_terminal(assist_id, *height_in_lines, cx);
+            }
+        }
+    }
+
+    fn start_assist(&mut self, assist_id: TerminalInlineAssistId, cx: &mut WindowContext) {
+        let assist = if let Some(assist) = self.assists.get_mut(&assist_id) {
+            assist
+        } else {
+            return;
+        };
+
+        let Some(user_prompt) = assist
+            .prompt_editor
+            .as_ref()
+            .map(|editor| editor.read(cx).prompt(cx))
+        else {
+            return;
+        };
+
+        self.prompt_history.retain(|prompt| *prompt != user_prompt);
+        self.prompt_history.push_back(user_prompt.clone());
+        if self.prompt_history.len() > PROMPT_HISTORY_MAX_LEN {
+            self.prompt_history.pop_front();
+        }
+
+        assist
+            .terminal
+            .update(cx, |terminal, cx| {
+                terminal
+                    .terminal()
+                    .update(cx, |terminal, _| terminal.input(CLEAR_INPUT.to_string()));
+            })
+            .log_err();
+
+        let codegen = assist.codegen.clone();
+        let Some(request) = self.request_for_inline_assist(assist_id, cx).log_err() else {
+            return;
+        };
+
+        codegen.update(cx, |codegen, cx| codegen.start(request, cx));
+    }
+
+    fn stop_assist(&mut self, assist_id: TerminalInlineAssistId, cx: &mut WindowContext) {
+        let assist = if let Some(assist) = self.assists.get_mut(&assist_id) {
+            assist
+        } else {
+            return;
+        };
+
+        assist.codegen.update(cx, |codegen, cx| codegen.stop(cx));
+    }
+
+    fn request_for_inline_assist(
+        &self,
+        assist_id: TerminalInlineAssistId,
+        cx: &mut WindowContext,
+    ) -> Result<LanguageModelRequest> {
+        let assist = self.assists.get(&assist_id).context("invalid assist")?;
+
+        let model = CompletionProvider::global(cx).model();
+
+        let shell = std::env::var("SHELL").ok();
+        let working_directory = assist
+            .terminal
+            .update(cx, |terminal, cx| {
+                terminal
+                    .model()
+                    .read(cx)
+                    .working_directory()
+                    .map(|path| path.to_string_lossy().to_string())
+            })
+            .ok()
+            .flatten();
+
+        let context_request = if assist.include_context {
+            assist.workspace.as_ref().and_then(|workspace| {
+                let workspace = workspace.upgrade()?.read(cx);
+                let assistant_panel = workspace.panel::<AssistantPanel>(cx)?;
+                Some(
+                    assistant_panel
+                        .read(cx)
+                        .active_context(cx)?
+                        .read(cx)
+                        .to_completion_request(cx),
+                )
+            })
+        } else {
+            None
+        };
+
+        let prompt = generate_terminal_assistant_prompt(
+            &assist
+                .prompt_editor
+                .clone()
+                .context("invalid assist")?
+                .read(cx)
+                .prompt(cx),
+            shell.as_deref(),
+            working_directory.as_deref(),
+        );
+
+        let mut messages = Vec::new();
+        if let Some(context_request) = context_request {
+            messages = context_request.messages;
+        }
+
+        messages.push(LanguageModelRequestMessage {
+            role: Role::User,
+            content: prompt,
+        });
+
+        Ok(LanguageModelRequest {
+            model,
+            messages,
+            stop: Vec::new(),
+            temperature: 1.0,
+        })
+    }
+
+    fn finish_assist(
+        &mut self,
+        assist_id: TerminalInlineAssistId,
+        undo: bool,
+        cx: &mut WindowContext,
+    ) {
+        self.dismiss_assist(assist_id, cx);
+
+        if let Some(assist) = self.assists.remove(&assist_id) {
+            assist
+                .terminal
+                .update(cx, |this, cx| {
+                    this.clear_block_below_cursor(cx);
+                    this.focus_handle(cx).focus(cx);
+                })
+                .log_err();
+            assist.codegen.update(cx, |codegen, cx| {
+                if undo {
+                    codegen.undo(cx);
+                } else {
+                    codegen.complete(cx);
+                }
+            });
+        }
+    }
+
+    fn dismiss_assist(
+        &mut self,
+        assist_id: TerminalInlineAssistId,
+        cx: &mut WindowContext,
+    ) -> bool {
+        let Some(assist) = self.assists.get_mut(&assist_id) else {
+            return false;
+        };
+        if assist.prompt_editor.is_none() {
+            return false;
+        }
+        assist.prompt_editor = None;
+        assist
+            .terminal
+            .update(cx, |this, cx| {
+                this.clear_block_below_cursor(cx);
+                this.focus_handle(cx).focus(cx);
+            })
+            .is_ok()
+    }
+
+    fn insert_prompt_editor_into_terminal(
+        &mut self,
+        assist_id: TerminalInlineAssistId,
+        height: u8,
+        cx: &mut WindowContext,
+    ) {
+        if let Some(assist) = self.assists.get_mut(&assist_id) {
+            if let Some(prompt_editor) = assist.prompt_editor.as_ref().cloned() {
+                assist
+                    .terminal
+                    .update(cx, |terminal, cx| {
+                        terminal.clear_block_below_cursor(cx);
+                        let block = terminal_view::BlockProperties {
+                            height,
+                            render: Box::new(move |_| prompt_editor.clone().into_any_element()),
+                        };
+                        terminal.set_block_below_cursor(block, cx);
+                    })
+                    .log_err();
+            }
+        }
+    }
+}
+
+struct TerminalInlineAssist {
+    terminal: WeakView<TerminalView>,
+    prompt_editor: Option<View<PromptEditor>>,
+    codegen: Model<Codegen>,
+    workspace: Option<WeakView<Workspace>>,
+    include_context: bool,
+    _subscriptions: Vec<Subscription>,
+}
+
+impl TerminalInlineAssist {
+    pub fn new(
+        assist_id: TerminalInlineAssistId,
+        terminal: &View<TerminalView>,
+        include_context: bool,
+        prompt_editor: View<PromptEditor>,
+        workspace: Option<WeakView<Workspace>>,
+        cx: &mut WindowContext,
+    ) -> Self {
+        let codegen = prompt_editor.read(cx).codegen.clone();
+        Self {
+            terminal: terminal.downgrade(),
+            prompt_editor: Some(prompt_editor.clone()),
+            codegen: codegen.clone(),
+            workspace: workspace.clone(),
+            include_context,
+            _subscriptions: vec![
+                cx.subscribe(&prompt_editor, |prompt_editor, event, cx| {
+                    TerminalInlineAssistant::update_global(cx, |this, cx| {
+                        this.handle_prompt_editor_event(prompt_editor, event, cx)
+                    })
+                }),
+                cx.subscribe(&codegen, move |codegen, event, cx| {
+                    TerminalInlineAssistant::update_global(cx, |this, cx| match event {
+                        CodegenEvent::Finished => {
+                            let assist = if let Some(assist) = this.assists.get(&assist_id) {
+                                assist
+                            } else {
+                                return;
+                            };
+
+                            if let CodegenStatus::Error(error) = &codegen.read(cx).status {
+                                if assist.prompt_editor.is_none() {
+                                    if let Some(workspace) = assist
+                                        .workspace
+                                        .as_ref()
+                                        .and_then(|workspace| workspace.upgrade())
+                                    {
+                                        let error =
+                                            format!("Terminal inline assistant error: {}", error);
+                                        workspace.update(cx, |workspace, cx| {
+                                            struct InlineAssistantError;
+
+                                            let id =
+                                                NotificationId::identified::<InlineAssistantError>(
+                                                    assist_id.0,
+                                                );
+
+                                            workspace.show_toast(Toast::new(id, error), cx);
+                                        })
+                                    }
+                                }
+                            }
+
+                            if assist.prompt_editor.is_none() {
+                                this.finish_assist(assist_id, false, cx);
+                            }
+                        }
+                    })
+                }),
+            ],
+        }
+    }
+}
+
+enum PromptEditorEvent {
+    StartRequested,
+    StopRequested,
+    ConfirmRequested,
+    CancelRequested,
+    DismissRequested,
+    Resized { height_in_lines: u8 },
+}
+
+struct PromptEditor {
+    id: TerminalInlineAssistId,
+    fs: Arc<dyn Fs>,
+    height_in_lines: u8,
+    editor: View<Editor>,
+    edited_since_done: bool,
+    prompt_history: VecDeque<String>,
+    prompt_history_ix: Option<usize>,
+    pending_prompt: String,
+    codegen: Model<Codegen>,
+    _codegen_subscription: Subscription,
+    editor_subscriptions: Vec<Subscription>,
+    pending_token_count: Task<Result<()>>,
+    token_count: Option<usize>,
+    _token_count_subscriptions: Vec<Subscription>,
+    workspace: Option<WeakView<Workspace>>,
+}
+
+impl EventEmitter<PromptEditorEvent> for PromptEditor {}
+
+impl Render for PromptEditor {
+    fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
+        let fs = self.fs.clone();
+
+        let buttons = match &self.codegen.read(cx).status {
+            CodegenStatus::Idle => {
+                vec![
+                    IconButton::new("cancel", IconName::Close)
+                        .icon_color(Color::Muted)
+                        .size(ButtonSize::None)
+                        .tooltip(|cx| Tooltip::for_action("Cancel Assist", &menu::Cancel, cx))
+                        .on_click(
+                            cx.listener(|_, _, cx| cx.emit(PromptEditorEvent::CancelRequested)),
+                        ),
+                    IconButton::new("start", IconName::Sparkle)
+                        .icon_color(Color::Muted)
+                        .size(ButtonSize::None)
+                        .icon_size(IconSize::XSmall)
+                        .tooltip(|cx| Tooltip::for_action("Generate", &menu::Confirm, cx))
+                        .on_click(
+                            cx.listener(|_, _, cx| cx.emit(PromptEditorEvent::StartRequested)),
+                        ),
+                ]
+            }
+            CodegenStatus::Pending => {
+                vec![
+                    IconButton::new("cancel", IconName::Close)
+                        .icon_color(Color::Muted)
+                        .size(ButtonSize::None)
+                        .tooltip(|cx| Tooltip::text("Cancel Assist", cx))
+                        .on_click(
+                            cx.listener(|_, _, cx| cx.emit(PromptEditorEvent::CancelRequested)),
+                        ),
+                    IconButton::new("stop", IconName::Stop)
+                        .icon_color(Color::Error)
+                        .size(ButtonSize::None)
+                        .icon_size(IconSize::XSmall)
+                        .tooltip(|cx| {
+                            Tooltip::with_meta(
+                                "Interrupt Generation",
+                                Some(&menu::Cancel),
+                                "Changes won't be discarded",
+                                cx,
+                            )
+                        })
+                        .on_click(
+                            cx.listener(|_, _, cx| cx.emit(PromptEditorEvent::StopRequested)),
+                        ),
+                ]
+            }
+            CodegenStatus::Error(_) | CodegenStatus::Done => {
+                vec![
+                    IconButton::new("cancel", IconName::Close)
+                        .icon_color(Color::Muted)
+                        .size(ButtonSize::None)
+                        .tooltip(|cx| Tooltip::for_action("Cancel Assist", &menu::Cancel, cx))
+                        .on_click(
+                            cx.listener(|_, _, cx| cx.emit(PromptEditorEvent::CancelRequested)),
+                        ),
+                    if self.edited_since_done {
+                        IconButton::new("restart", IconName::RotateCw)
+                            .icon_color(Color::Info)
+                            .icon_size(IconSize::XSmall)
+                            .size(ButtonSize::None)
+                            .tooltip(|cx| {
+                                Tooltip::with_meta(
+                                    "Restart Generation",
+                                    Some(&menu::Confirm),
+                                    "Changes will be discarded",
+                                    cx,
+                                )
+                            })
+                            .on_click(cx.listener(|_, _, cx| {
+                                cx.emit(PromptEditorEvent::StartRequested);
+                            }))
+                    } else {
+                        IconButton::new("confirm", IconName::Play)
+                            .icon_color(Color::Info)
+                            .size(ButtonSize::None)
+                            .tooltip(|cx| {
+                                Tooltip::for_action("Execute generated command", &menu::Confirm, cx)
+                            })
+                            .on_click(cx.listener(|_, _, cx| {
+                                cx.emit(PromptEditorEvent::ConfirmRequested);
+                            }))
+                    },
+                ]
+            }
+        };
+
+        h_flex()
+            .bg(cx.theme().colors().editor_background)
+            .border_y_1()
+            .border_color(cx.theme().status().info_border)
+            .py_1p5()
+            .h_full()
+            .w_full()
+            .on_action(cx.listener(Self::confirm))
+            .on_action(cx.listener(Self::cancel))
+            .on_action(cx.listener(Self::move_up))
+            .on_action(cx.listener(Self::move_down))
+            .child(
+                h_flex()
+                    .w_12()
+                    .justify_center()
+                    .gap_2()
+                    .child(
+                        PopoverMenu::new("model-switcher")
+                            .menu(move |cx| {
+                                ContextMenu::build(cx, |mut menu, cx| {
+                                    for model in CompletionProvider::global(cx).available_models(cx)
+                                    {
+                                        menu = menu.custom_entry(
+                                            {
+                                                let model = model.clone();
+                                                move |_| {
+                                                    Label::new(model.display_name())
+                                                        .into_any_element()
+                                                }
+                                            },
+                                            {
+                                                let fs = fs.clone();
+                                                let model = model.clone();
+                                                move |cx| {
+                                                    let model = model.clone();
+                                                    update_settings_file::<AssistantSettings>(
+                                                        fs.clone(),
+                                                        cx,
+                                                        move |settings| settings.set_model(model),
+                                                    );
+                                                }
+                                            },
+                                        );
+                                    }
+                                    menu
+                                })
+                                .into()
+                            })
+                            .trigger(
+                                IconButton::new("context", IconName::Settings)
+                                    .size(ButtonSize::None)
+                                    .icon_size(IconSize::Small)
+                                    .icon_color(Color::Muted)
+                                    .tooltip(move |cx| {
+                                        Tooltip::with_meta(
+                                            format!(
+                                                "Using {}",
+                                                CompletionProvider::global(cx)
+                                                    .model()
+                                                    .display_name()
+                                            ),
+                                            None,
+                                            "Click to Change Model",
+                                            cx,
+                                        )
+                                    }),
+                            )
+                            .anchor(gpui::AnchorCorner::BottomRight),
+                    )
+                    .children(
+                        if let CodegenStatus::Error(error) = &self.codegen.read(cx).status {
+                            let error_message = SharedString::from(error.to_string());
+                            Some(
+                                div()
+                                    .id("error")
+                                    .tooltip(move |cx| Tooltip::text(error_message.clone(), cx))
+                                    .child(
+                                        Icon::new(IconName::XCircle)
+                                            .size(IconSize::Small)
+                                            .color(Color::Error),
+                                    ),
+                            )
+                        } else {
+                            None
+                        },
+                    ),
+            )
+            .child(div().flex_1().child(self.render_prompt_editor(cx)))
+            .child(
+                h_flex()
+                    .gap_2()
+                    .pr_4()
+                    .children(self.render_token_count(cx))
+                    .children(buttons),
+            )
+    }
+}
+
+impl FocusableView for PromptEditor {
+    fn focus_handle(&self, cx: &AppContext) -> FocusHandle {
+        self.editor.focus_handle(cx)
+    }
+}
+
+impl PromptEditor {
+    const MAX_LINES: u8 = 8;
+
+    #[allow(clippy::too_many_arguments)]
+    fn new(
+        id: TerminalInlineAssistId,
+        prompt_history: VecDeque<String>,
+        prompt_buffer: Model<MultiBuffer>,
+        codegen: Model<Codegen>,
+        assistant_panel: Option<&View<AssistantPanel>>,
+        workspace: Option<WeakView<Workspace>>,
+        fs: Arc<dyn Fs>,
+        cx: &mut ViewContext<Self>,
+    ) -> Self {
+        let prompt_editor = cx.new_view(|cx| {
+            let mut editor = Editor::new(
+                EditorMode::AutoHeight {
+                    max_lines: Self::MAX_LINES as usize,
+                },
+                prompt_buffer,
+                None,
+                false,
+                cx,
+            );
+            editor.set_soft_wrap_mode(language::language_settings::SoftWrap::EditorWidth, cx);
+            editor.set_placeholder_text("Add a prompt…", cx);
+            editor
+        });
+
+        let mut token_count_subscriptions = Vec::new();
+        if let Some(assistant_panel) = assistant_panel {
+            token_count_subscriptions
+                .push(cx.subscribe(assistant_panel, Self::handle_assistant_panel_event));
+        }
+
+        let mut this = Self {
+            id,
+            height_in_lines: 1,
+            editor: prompt_editor,
+            edited_since_done: false,
+            prompt_history,
+            prompt_history_ix: None,
+            pending_prompt: String::new(),
+            _codegen_subscription: cx.observe(&codegen, Self::handle_codegen_changed),
+            editor_subscriptions: Vec::new(),
+            codegen,
+            fs,
+            pending_token_count: Task::ready(Ok(())),
+            token_count: None,
+            _token_count_subscriptions: token_count_subscriptions,
+            workspace,
+        };
+        this.count_lines(cx);
+        this.count_tokens(cx);
+        this.subscribe_to_editor(cx);
+        this
+    }
+
+    fn subscribe_to_editor(&mut self, cx: &mut ViewContext<Self>) {
+        self.editor_subscriptions.clear();
+        self.editor_subscriptions
+            .push(cx.observe(&self.editor, Self::handle_prompt_editor_changed));
+        self.editor_subscriptions
+            .push(cx.subscribe(&self.editor, Self::handle_prompt_editor_events));
+    }
+
+    fn prompt(&self, cx: &AppContext) -> String {
+        self.editor.read(cx).text(cx)
+    }
+
+    fn count_lines(&mut self, cx: &mut ViewContext<Self>) {
+        let height_in_lines = cmp::max(
+            2, // Make the editor at least two lines tall, to account for padding and buttons.
+            cmp::min(
+                self.editor
+                    .update(cx, |editor, cx| editor.max_point(cx).row().0 + 1),
+                Self::MAX_LINES as u32,
+            ),
+        ) as u8;
+
+        if height_in_lines != self.height_in_lines {
+            self.height_in_lines = height_in_lines;
+            cx.emit(PromptEditorEvent::Resized { height_in_lines });
+        }
+    }
+
+    fn handle_assistant_panel_event(
+        &mut self,
+        _: View<AssistantPanel>,
+        event: &AssistantPanelEvent,
+        cx: &mut ViewContext<Self>,
+    ) {
+        let AssistantPanelEvent::ContextEdited { .. } = event;
+        self.count_tokens(cx);
+    }
+
+    fn count_tokens(&mut self, cx: &mut ViewContext<Self>) {
+        let assist_id = self.id;
+        self.pending_token_count = cx.spawn(|this, mut cx| async move {
+            cx.background_executor().timer(Duration::from_secs(1)).await;
+            let request =
+                cx.update_global(|inline_assistant: &mut TerminalInlineAssistant, cx| {
+                    inline_assistant.request_for_inline_assist(assist_id, cx)
+                })??;
+
+            let token_count = cx
+                .update(|cx| CompletionProvider::global(cx).count_tokens(request, cx))?
+                .await?;
+            this.update(&mut cx, |this, cx| {
+                this.token_count = Some(token_count);
+                cx.notify();
+            })
+        })
+    }
+
+    fn handle_prompt_editor_changed(&mut self, _: View<Editor>, cx: &mut ViewContext<Self>) {
+        self.count_lines(cx);
+    }
+
+    fn handle_prompt_editor_events(
+        &mut self,
+        _: View<Editor>,
+        event: &EditorEvent,
+        cx: &mut ViewContext<Self>,
+    ) {
+        match event {
+            EditorEvent::Edited { .. } => {
+                let prompt = self.editor.read(cx).text(cx);
+                if self
+                    .prompt_history_ix
+                    .map_or(true, |ix| self.prompt_history[ix] != prompt)
+                {
+                    self.prompt_history_ix.take();
+                    self.pending_prompt = prompt;
+                }
+
+                self.edited_since_done = true;
+                cx.notify();
+            }
+            EditorEvent::BufferEdited => {
+                self.count_tokens(cx);
+            }
+            _ => {}
+        }
+    }
+
+    fn handle_codegen_changed(&mut self, _: Model<Codegen>, cx: &mut ViewContext<Self>) {
+        match &self.codegen.read(cx).status {
+            CodegenStatus::Idle => {
+                self.editor
+                    .update(cx, |editor, _| editor.set_read_only(false));
+            }
+            CodegenStatus::Pending => {
+                self.editor
+                    .update(cx, |editor, _| editor.set_read_only(true));
+            }
+            CodegenStatus::Done | CodegenStatus::Error(_) => {
+                self.edited_since_done = false;
+                self.editor
+                    .update(cx, |editor, _| editor.set_read_only(false));
+            }
+        }
+    }
+
+    fn cancel(&mut self, _: &editor::actions::Cancel, cx: &mut ViewContext<Self>) {
+        match &self.codegen.read(cx).status {
+            CodegenStatus::Idle | CodegenStatus::Done | CodegenStatus::Error(_) => {
+                cx.emit(PromptEditorEvent::CancelRequested);
+            }
+            CodegenStatus::Pending => {
+                cx.emit(PromptEditorEvent::StopRequested);
+            }
+        }
+    }
+
+    fn confirm(&mut self, _: &menu::Confirm, cx: &mut ViewContext<Self>) {
+        match &self.codegen.read(cx).status {
+            CodegenStatus::Idle => {
+                if !self.editor.read(cx).text(cx).trim().is_empty() {
+                    cx.emit(PromptEditorEvent::StartRequested);
+                }
+            }
+            CodegenStatus::Pending => {
+                cx.emit(PromptEditorEvent::DismissRequested);
+            }
+            CodegenStatus::Done | CodegenStatus::Error(_) => {
+                if self.edited_since_done {
+                    cx.emit(PromptEditorEvent::StartRequested);
+                } else {
+                    cx.emit(PromptEditorEvent::ConfirmRequested);
+                }
+            }
+        }
+    }
+
+    fn move_up(&mut self, _: &MoveUp, cx: &mut ViewContext<Self>) {
+        if let Some(ix) = self.prompt_history_ix {
+            if ix > 0 {
+                self.prompt_history_ix = Some(ix - 1);
+                let prompt = self.prompt_history[ix - 1].as_str();
+                self.editor.update(cx, |editor, cx| {
+                    editor.set_text(prompt, cx);
+                    editor.move_to_beginning(&Default::default(), cx);
+                });
+            }
+        } else if !self.prompt_history.is_empty() {
+            self.prompt_history_ix = Some(self.prompt_history.len() - 1);
+            let prompt = self.prompt_history[self.prompt_history.len() - 1].as_str();
+            self.editor.update(cx, |editor, cx| {
+                editor.set_text(prompt, cx);
+                editor.move_to_beginning(&Default::default(), cx);
+            });
+        }
+    }
+
+    fn move_down(&mut self, _: &MoveDown, cx: &mut ViewContext<Self>) {
+        if let Some(ix) = self.prompt_history_ix {
+            if ix < self.prompt_history.len() - 1 {
+                self.prompt_history_ix = Some(ix + 1);
+                let prompt = self.prompt_history[ix + 1].as_str();
+                self.editor.update(cx, |editor, cx| {
+                    editor.set_text(prompt, cx);
+                    editor.move_to_end(&Default::default(), cx)
+                });
+            } else {
+                self.prompt_history_ix = None;
+                let prompt = self.pending_prompt.as_str();
+                self.editor.update(cx, |editor, cx| {
+                    editor.set_text(prompt, cx);
+                    editor.move_to_end(&Default::default(), cx)
+                });
+            }
+        }
+    }
+
+    fn render_token_count(&self, cx: &mut ViewContext<Self>) -> Option<impl IntoElement> {
+        let model = CompletionProvider::global(cx).model();
+        let token_count = self.token_count?;
+        let max_token_count = model.max_token_count();
+
+        let remaining_tokens = max_token_count as isize - token_count as isize;
+        let token_count_color = if remaining_tokens <= 0 {
+            Color::Error
+        } else if token_count as f32 / max_token_count as f32 >= 0.8 {
+            Color::Warning
+        } else {
+            Color::Muted
+        };
+
+        let mut token_count = h_flex()
+            .id("token_count")
+            .gap_0p5()
+            .child(
+                Label::new(humanize_token_count(token_count))
+                    .size(LabelSize::Small)
+                    .color(token_count_color),
+            )
+            .child(Label::new("/").size(LabelSize::Small).color(Color::Muted))
+            .child(
+                Label::new(humanize_token_count(max_token_count))
+                    .size(LabelSize::Small)
+                    .color(Color::Muted),
+            );
+        if let Some(workspace) = self.workspace.clone() {
+            token_count = token_count
+                .tooltip(|cx| {
+                    Tooltip::with_meta(
+                        "Tokens Used by Inline Assistant",
+                        None,
+                        "Click to Open Assistant Panel",
+                        cx,
+                    )
+                })
+                .cursor_pointer()
+                .on_mouse_down(gpui::MouseButton::Left, |_, cx| cx.stop_propagation())
+                .on_click(move |_, cx| {
+                    cx.stop_propagation();
+                    workspace
+                        .update(cx, |workspace, cx| {
+                            workspace.focus_panel::<AssistantPanel>(cx)
+                        })
+                        .ok();
+                });
+        } else {
+            token_count = token_count
+                .cursor_default()
+                .tooltip(|cx| Tooltip::text("Tokens Used by Inline Assistant", cx));
+        }
+
+        Some(token_count)
+    }
+
+    fn render_prompt_editor(&self, cx: &mut ViewContext<Self>) -> impl IntoElement {
+        let settings = ThemeSettings::get_global(cx);
+        let text_style = TextStyle {
+            color: if self.editor.read(cx).read_only(cx) {
+                cx.theme().colors().text_disabled
+            } else {
+                cx.theme().colors().text
+            },
+            font_family: settings.ui_font.family.clone(),
+            font_features: settings.ui_font.features.clone(),
+            font_size: rems(0.875).into(),
+            font_weight: FontWeight::NORMAL,
+            font_style: FontStyle::Normal,
+            line_height: relative(1.3),
+            background_color: None,
+            underline: None,
+            strikethrough: None,
+            white_space: WhiteSpace::Normal,
+        };
+        EditorElement::new(
+            &self.editor,
+            EditorStyle {
+                background: cx.theme().colors().editor_background,
+                local_player: cx.theme().players().local(),
+                text: text_style,
+                ..Default::default()
+            },
+        )
+    }
+}
+
+#[derive(Debug)]
+pub enum CodegenEvent {
+    Finished,
+}
+
+impl EventEmitter<CodegenEvent> for Codegen {}
+
+const CLEAR_INPUT: &str = "\x15";
+const CARRIAGE_RETURN: &str = "\x0d";
+
+struct TerminalTransaction {
+    terminal: Model<Terminal>,
+}
+
+impl TerminalTransaction {
+    pub fn start(terminal: Model<Terminal>) -> Self {
+        Self { terminal }
+    }
+
+    pub fn push(&mut self, hunk: String, cx: &mut AppContext) {
+        // Ensure that the assistant cannot accidently execute commands that are streamed into the terminal
+        let input = hunk.replace(CARRIAGE_RETURN, " ");
+        self.terminal
+            .update(cx, |terminal, _| terminal.input(input));
+    }
+
+    pub fn undo(&self, cx: &mut AppContext) {
+        self.terminal
+            .update(cx, |terminal, _| terminal.input(CLEAR_INPUT.to_string()));
+    }
+
+    pub fn complete(&self, cx: &mut AppContext) {
+        self.terminal.update(cx, |terminal, _| {
+            terminal.input(CARRIAGE_RETURN.to_string())
+        });
+    }
+}
+
+pub struct Codegen {
+    status: CodegenStatus,
+    telemetry: Option<Arc<Telemetry>>,
+    terminal: Model<Terminal>,
+    generation: Task<()>,
+    transaction: Option<TerminalTransaction>,
+}
+
+impl Codegen {
+    pub fn new(terminal: Model<Terminal>, telemetry: Option<Arc<Telemetry>>) -> Self {
+        Self {
+            terminal,
+            telemetry,
+            status: CodegenStatus::Idle,
+            generation: Task::ready(()),
+            transaction: None,
+        }
+    }
+
+    pub fn start(&mut self, prompt: LanguageModelRequest, cx: &mut ModelContext<Self>) {
+        self.status = CodegenStatus::Pending;
+        self.transaction = Some(TerminalTransaction::start(self.terminal.clone()));
+
+        let telemetry = self.telemetry.clone();
+        let model_telemetry_id = prompt.model.telemetry_id();
+        let response = CompletionProvider::global(cx).complete(prompt);
+
+        self.generation = cx.spawn(|this, mut cx| async move {
+            let generate = async {
+                let (mut hunks_tx, mut hunks_rx) = mpsc::channel(1);
+
+                let task = cx.background_executor().spawn(async move {
+                    let mut response_latency = None;
+                    let request_start = Instant::now();
+                    let task = async {
+                        let mut response = response.await?;
+                        while let Some(chunk) = response.next().await {
+                            if response_latency.is_none() {
+                                response_latency = Some(request_start.elapsed());
+                            }
+                            let chunk = chunk?;
+                            hunks_tx.send(chunk).await?;
+                        }
+
+                        anyhow::Ok(())
+                    };
+
+                    let result = task.await;
+
+                    let error_message = result.as_ref().err().map(|error| error.to_string());
+                    if let Some(telemetry) = telemetry {
+                        telemetry.report_assistant_event(
+                            None,
+                            telemetry_events::AssistantKind::Inline,
+                            model_telemetry_id,
+                            response_latency,
+                            error_message,
+                        );
+                    }
+
+                    result?;
+                    anyhow::Ok(())
+                });
+
+                while let Some(hunk) = hunks_rx.next().await {
+                    this.update(&mut cx, |this, cx| {
+                        if let Some(transaction) = &mut this.transaction {
+                            transaction.push(hunk, cx);
+                            cx.notify();
+                        }
+                    })?;
+                }
+
+                task.await?;
+                anyhow::Ok(())
+            };
+
+            let result = generate.await;
+
+            this.update(&mut cx, |this, cx| {
+                if let Err(error) = result {
+                    this.status = CodegenStatus::Error(error);
+                } else {
+                    this.status = CodegenStatus::Done;
+                }
+                cx.emit(CodegenEvent::Finished);
+                cx.notify();
+            })
+            .ok();
+        });
+        cx.notify();
+    }
+
+    pub fn stop(&mut self, cx: &mut ModelContext<Self>) {
+        self.status = CodegenStatus::Done;
+        self.generation = Task::ready(());
+        cx.emit(CodegenEvent::Finished);
+        cx.notify();
+    }
+
+    pub fn complete(&mut self, cx: &mut ModelContext<Self>) {
+        if let Some(transaction) = self.transaction.take() {
+            transaction.complete(cx);
+        }
+    }
+
+    pub fn undo(&mut self, cx: &mut ModelContext<Self>) {
+        if let Some(transaction) = self.transaction.take() {
+            transaction.undo(cx);
+        }
+    }
+}
+
+enum CodegenStatus {
+    Idle,
+    Pending,
+    Done,
+    Error(anyhow::Error),
+}

crates/terminal/src/terminal.rs πŸ”—

@@ -945,6 +945,18 @@ impl Terminal {
         &self.last_content
     }
 
+    pub fn total_lines(&self) -> usize {
+        let term = self.term.clone();
+        let terminal = term.lock_unfair();
+        terminal.total_lines()
+    }
+
+    pub fn viewport_lines(&self) -> usize {
+        let term = self.term.clone();
+        let terminal = term.lock_unfair();
+        terminal.screen_lines()
+    }
+
     //To test:
     //- Activate match on terminal (scrolling and selection)
     //- Editor search snapping behavior
@@ -999,11 +1011,21 @@ impl Terminal {
             .push_back(InternalEvent::Scroll(AlacScroll::Delta(1)));
     }
 
+    pub fn scroll_up_by(&mut self, lines: usize) {
+        self.events
+            .push_back(InternalEvent::Scroll(AlacScroll::Delta(lines as i32)));
+    }
+
     pub fn scroll_line_down(&mut self) {
         self.events
             .push_back(InternalEvent::Scroll(AlacScroll::Delta(-1)));
     }
 
+    pub fn scroll_down_by(&mut self, lines: usize) {
+        self.events
+            .push_back(InternalEvent::Scroll(AlacScroll::Delta(-(lines as i32))));
+    }
+
     pub fn scroll_page_up(&mut self) {
         self.events
             .push_back(InternalEvent::Scroll(AlacScroll::PageUp));
@@ -1436,6 +1458,13 @@ impl Terminal {
         })
     }
 
+    pub fn working_directory(&self) -> Option<PathBuf> {
+        self.pty_info
+            .current
+            .as_ref()
+            .map(|process| process.cwd.clone())
+    }
+
     pub fn title(&self, truncate: bool) -> String {
         const MAX_CHARS: usize = 25;
         match &self.task {

crates/terminal_view/src/terminal_element.rs πŸ”—

@@ -1,11 +1,11 @@
 use editor::{CursorLayout, HighlightedRange, HighlightedRangeLine};
 use gpui::{
-    div, fill, point, px, relative, AnyElement, Bounds, DispatchPhase, Element, ElementId,
-    FocusHandle, Font, FontStyle, FontWeight, GlobalElementId, HighlightStyle, Hitbox, Hsla,
-    InputHandler, InteractiveElement, Interactivity, IntoElement, LayoutId, Model, ModelContext,
-    ModifiersChangedEvent, MouseButton, MouseMoveEvent, Pixels, Point, ShapedLine,
-    StatefulInteractiveElement, StrikethroughStyle, Styled, TextRun, TextStyle, UnderlineStyle,
-    WeakView, WhiteSpace, WindowContext, WindowTextSystem,
+    div, fill, point, px, relative, size, AnyElement, AvailableSpace, Bounds, ContentMask,
+    DispatchPhase, Element, ElementId, FocusHandle, Font, FontStyle, FontWeight, GlobalElementId,
+    HighlightStyle, Hitbox, Hsla, InputHandler, InteractiveElement, Interactivity, IntoElement,
+    LayoutId, Model, ModelContext, ModifiersChangedEvent, MouseButton, MouseMoveEvent, Pixels,
+    Point, ShapedLine, StatefulInteractiveElement, StrikethroughStyle, Styled, TextRun, TextStyle,
+    UnderlineStyle, View, WeakView, WhiteSpace, WindowContext, WindowTextSystem,
 };
 use itertools::Itertools;
 use language::CursorShape;
@@ -24,11 +24,13 @@ use terminal::{
     HoveredWord, IndexedCell, Terminal, TerminalContent, TerminalSize,
 };
 use theme::{ActiveTheme, Theme, ThemeSettings};
-use ui::Tooltip;
+use ui::{ParentElement, Tooltip};
 use workspace::Workspace;
 
-use std::mem;
 use std::{fmt::Debug, ops::RangeInclusive};
+use std::{mem, sync::Arc};
+
+use crate::{BlockContext, BlockProperties, TerminalView};
 
 /// The information generated during layout that is necessary for painting.
 pub struct LayoutState {
@@ -44,6 +46,7 @@ pub struct LayoutState {
     hyperlink_tooltip: Option<AnyElement>,
     gutter: Pixels,
     last_hovered_word: Option<HoveredWord>,
+    block_below_cursor_element: Option<AnyElement>,
 }
 
 /// Helper struct for converting data between Alacritty's cursor points, and displayed cursor points.
@@ -146,12 +149,14 @@ impl LayoutRect {
 /// We need to keep a reference to the view for mouse events, do we need it for any other terminal stuff, or can we move that to connection?
 pub struct TerminalElement {
     terminal: Model<Terminal>,
+    terminal_view: View<TerminalView>,
     workspace: WeakView<Workspace>,
     focus: FocusHandle,
     focused: bool,
     cursor_visible: bool,
     can_navigate_to_selected_word: bool,
     interactivity: Interactivity,
+    block_below_cursor: Option<Arc<BlockProperties>>,
 }
 
 impl InteractiveElement for TerminalElement {
@@ -163,21 +168,26 @@ impl InteractiveElement for TerminalElement {
 impl StatefulInteractiveElement for TerminalElement {}
 
 impl TerminalElement {
+    #[allow(clippy::too_many_arguments)]
     pub fn new(
         terminal: Model<Terminal>,
+        terminal_view: View<TerminalView>,
         workspace: WeakView<Workspace>,
         focus: FocusHandle,
         focused: bool,
         cursor_visible: bool,
         can_navigate_to_selected_word: bool,
+        block_below_cursor: Option<Arc<BlockProperties>>,
     ) -> TerminalElement {
         TerminalElement {
             terminal,
+            terminal_view,
             workspace,
             focused,
             focus: focus.clone(),
             cursor_visible,
             can_navigate_to_selected_word,
+            block_below_cursor,
             interactivity: Default::default(),
         }
         .track_focus(&focus)
@@ -192,7 +202,7 @@ impl TerminalElement {
         // terminal_theme: &TerminalStyle,
         text_system: &WindowTextSystem,
         hyperlink: Option<(HighlightStyle, &RangeInclusive<AlacPoint>)>,
-        cx: &WindowContext<'_>,
+        cx: &WindowContext,
     ) -> (Vec<LayoutCell>, Vec<LayoutRect>) {
         let theme = cx.theme();
         let mut cells = vec![];
@@ -491,12 +501,14 @@ impl TerminalElement {
             ),
         );
         self.interactivity.on_scroll_wheel({
-            let terminal = terminal.clone();
+            let terminal_view = self.terminal_view.downgrade();
             move |e, cx| {
-                terminal.update(cx, |terminal, cx| {
-                    terminal.scroll_wheel(e, origin);
-                    cx.notify();
-                })
+                terminal_view
+                    .update(cx, |terminal_view, cx| {
+                        terminal_view.scroll_wheel(e, origin, cx);
+                        cx.notify();
+                    })
+                    .ok();
             }
         });
 
@@ -538,6 +550,26 @@ impl TerminalElement {
             );
         }
     }
+
+    fn rem_size(&self, cx: &WindowContext) -> Option<Pixels> {
+        let settings = ThemeSettings::get_global(cx).clone();
+        let buffer_font_size = settings.buffer_font_size(cx);
+        let rem_size_scale = {
+            // Our default UI font size is 14px on a 16px base scale.
+            // This means the default UI font size is 0.875rems.
+            let default_font_size_scale = 14. / ui::BASE_REM_SIZE_IN_PX;
+
+            // We then determine the delta between a single rem and the default font
+            // size scale.
+            let default_font_size_delta = 1. - default_font_size_scale;
+
+            // Finally, we add this delta to 1rem to get the scale factor that
+            // should be used to scale up the UI.
+            1. + default_font_size_delta
+        };
+
+        Some(buffer_font_size * rem_size_scale)
+    }
 }
 
 impl Element for TerminalElement {
@@ -558,6 +590,7 @@ impl Element for TerminalElement {
             .request_layout(global_id, cx, |mut style, cx| {
                 style.size.width = relative(1.).into();
                 style.size.height = relative(1.).into();
+                // style.overflow = point(Overflow::Hidden, Overflow::Hidden);
                 let layout_id = cx.request_layout(style, None);
 
                 layout_id
@@ -572,6 +605,7 @@ impl Element for TerminalElement {
         _: &mut Self::RequestLayoutState,
         cx: &mut WindowContext,
     ) -> Self::PrepaintState {
+        let rem_size = self.rem_size(cx);
         self.interactivity
             .prepaint(global_id, bounds, bounds.size, cx, |_, _, hitbox, cx| {
                 let hitbox = hitbox.unwrap();
@@ -675,8 +709,9 @@ impl Element for TerminalElement {
                     }
                 });
 
+                let scroll_top = self.terminal_view.read(cx).scroll_top;
                 let hyperlink_tooltip = last_hovered_word.clone().map(|hovered_word| {
-                    let offset = bounds.origin + Point::new(gutter, px(0.));
+                    let offset = bounds.origin + point(gutter, px(0.)) - point(px(0.), scroll_top);
                     let mut element = div()
                         .size_full()
                         .id("terminal-element")
@@ -695,6 +730,8 @@ impl Element for TerminalElement {
                     cursor,
                     ..
                 } = &self.terminal.read(cx).last_content;
+                let mode = *mode;
+                let display_offset = *display_offset;
 
                 // searches, highlights to a single range representations
                 let mut relative_highlighted_ranges = Vec::new();
@@ -723,7 +760,7 @@ impl Element for TerminalElement {
                 let cursor = if let AlacCursorShape::Hidden = cursor.shape {
                     None
                 } else {
-                    let cursor_point = DisplayCursor::from(cursor.point, *display_offset);
+                    let cursor_point = DisplayCursor::from(cursor.point, display_offset);
                     let cursor_text = {
                         let str_trxt = cursor_char.to_string();
                         let len = str_trxt.len();
@@ -768,6 +805,37 @@ impl Element for TerminalElement {
                     )
                 };
 
+                let block_below_cursor_element = if let Some(block) = &self.block_below_cursor {
+                    let terminal = self.terminal.read(cx);
+                    if terminal.last_content.display_offset == 0 {
+                        let target_line = terminal.last_content.cursor.point.line.0 + 1;
+                        let render = &block.render;
+                        let mut block_cx = BlockContext {
+                            context: cx,
+                            dimensions,
+                        };
+                        let element = render(&mut block_cx);
+                        let mut element = div().occlude().child(element).into_any_element();
+                        let available_space = size(
+                            AvailableSpace::Definite(dimensions.width() + gutter),
+                            AvailableSpace::Definite(
+                                block.height as f32 * dimensions.line_height(),
+                            ),
+                        );
+                        let origin = bounds.origin
+                            + point(px(0.), target_line as f32 * dimensions.line_height())
+                            - point(px(0.), scroll_top);
+                        cx.with_rem_size(rem_size, |cx| {
+                            element.prepaint_as_root(origin, available_space, cx);
+                        });
+                        Some(element)
+                    } else {
+                        None
+                    }
+                } else {
+                    None
+                };
+
                 LayoutState {
                     hitbox,
                     cells,
@@ -776,11 +844,12 @@ impl Element for TerminalElement {
                     dimensions,
                     rects,
                     relative_highlighted_ranges,
-                    mode: *mode,
-                    display_offset: *display_offset,
+                    mode,
+                    display_offset,
                     hyperlink_tooltip,
                     gutter,
                     last_hovered_word,
+                    block_below_cursor_element,
                 }
             })
     }
@@ -793,82 +862,92 @@ impl Element for TerminalElement {
         layout: &mut Self::PrepaintState,
         cx: &mut WindowContext<'_>,
     ) {
-        cx.paint_quad(fill(bounds, layout.background_color));
-        let origin = bounds.origin + Point::new(layout.gutter, px(0.));
-
-        let terminal_input_handler = TerminalInputHandler {
-            terminal: self.terminal.clone(),
-            cursor_bounds: layout
-                .cursor
-                .as_ref()
-                .map(|cursor| cursor.bounding_rect(origin)),
-            workspace: self.workspace.clone(),
-        };
+        cx.with_content_mask(Some(ContentMask { bounds }), |cx| {
+            let scroll_top = self.terminal_view.read(cx).scroll_top;
 
-        self.register_mouse_listeners(origin, layout.mode, &layout.hitbox, cx);
-        if self.can_navigate_to_selected_word && layout.last_hovered_word.is_some() {
-            cx.set_cursor_style(gpui::CursorStyle::PointingHand, &layout.hitbox);
-        } else {
-            cx.set_cursor_style(gpui::CursorStyle::IBeam, &layout.hitbox);
-        }
+            cx.paint_quad(fill(bounds, layout.background_color));
+            let origin =
+                bounds.origin + Point::new(layout.gutter, px(0.)) - Point::new(px(0.), scroll_top);
 
-        let cursor = layout.cursor.take();
-        let hyperlink_tooltip = layout.hyperlink_tooltip.take();
-        self.interactivity
-            .paint(global_id, bounds, Some(&layout.hitbox), cx, |_, cx| {
-                cx.handle_input(&self.focus, terminal_input_handler);
-
-                cx.on_key_event({
-                    let this = self.terminal.clone();
-                    move |event: &ModifiersChangedEvent, phase, cx| {
-                        if phase != DispatchPhase::Bubble {
-                            return;
-                        }
+            let terminal_input_handler = TerminalInputHandler {
+                terminal: self.terminal.clone(),
+                cursor_bounds: layout
+                    .cursor
+                    .as_ref()
+                    .map(|cursor| cursor.bounding_rect(origin)),
+                workspace: self.workspace.clone(),
+            };
 
-                        let handled =
-                            this.update(cx, |term, _| term.try_modifiers_change(&event.modifiers));
+            self.register_mouse_listeners(origin, layout.mode, &layout.hitbox, cx);
+            if self.can_navigate_to_selected_word && layout.last_hovered_word.is_some() {
+                cx.set_cursor_style(gpui::CursorStyle::PointingHand, &layout.hitbox);
+            } else {
+                cx.set_cursor_style(gpui::CursorStyle::IBeam, &layout.hitbox);
+            }
 
-                        if handled {
-                            cx.refresh();
+            let cursor = layout.cursor.take();
+            let hyperlink_tooltip = layout.hyperlink_tooltip.take();
+            let block_below_cursor_element = layout.block_below_cursor_element.take();
+            self.interactivity
+                .paint(global_id, bounds, Some(&layout.hitbox), cx, |_, cx| {
+                    cx.handle_input(&self.focus, terminal_input_handler);
+
+                    cx.on_key_event({
+                        let this = self.terminal.clone();
+                        move |event: &ModifiersChangedEvent, phase, cx| {
+                            if phase != DispatchPhase::Bubble {
+                                return;
+                            }
+
+                            let handled = this
+                                .update(cx, |term, _| term.try_modifiers_change(&event.modifiers));
+
+                            if handled {
+                                cx.refresh();
+                            }
                         }
-                    }
-                });
+                    });
 
-                for rect in &layout.rects {
-                    rect.paint(origin, &layout, cx);
-                }
+                    for rect in &layout.rects {
+                        rect.paint(origin, &layout, cx);
+                    }
 
-                for (relative_highlighted_range, color) in layout.relative_highlighted_ranges.iter()
-                {
-                    if let Some((start_y, highlighted_range_lines)) =
-                        to_highlighted_range_lines(relative_highlighted_range, &layout, origin)
+                    for (relative_highlighted_range, color) in
+                        layout.relative_highlighted_ranges.iter()
                     {
-                        let hr = HighlightedRange {
-                            start_y, //Need to change this
-                            line_height: layout.dimensions.line_height,
-                            lines: highlighted_range_lines,
-                            color: *color,
-                            //Copied from editor. TODO: move to theme or something
-                            corner_radius: 0.15 * layout.dimensions.line_height,
-                        };
-                        hr.paint(bounds, cx);
+                        if let Some((start_y, highlighted_range_lines)) =
+                            to_highlighted_range_lines(relative_highlighted_range, &layout, origin)
+                        {
+                            let hr = HighlightedRange {
+                                start_y,
+                                line_height: layout.dimensions.line_height,
+                                lines: highlighted_range_lines,
+                                color: *color,
+                                corner_radius: 0.15 * layout.dimensions.line_height,
+                            };
+                            hr.paint(bounds, cx);
+                        }
                     }
-                }
 
-                for cell in &layout.cells {
-                    cell.paint(origin, &layout, bounds, cx);
-                }
+                    for cell in &layout.cells {
+                        cell.paint(origin, &layout, bounds, cx);
+                    }
 
-                if self.cursor_visible {
-                    if let Some(mut cursor) = cursor {
-                        cursor.paint(origin, cx);
+                    if self.cursor_visible {
+                        if let Some(mut cursor) = cursor {
+                            cursor.paint(origin, cx);
+                        }
                     }
-                }
 
-                if let Some(mut element) = hyperlink_tooltip {
-                    element.paint(cx);
-                }
-            });
+                    if let Some(mut element) = block_below_cursor_element {
+                        element.paint(cx);
+                    }
+
+                    if let Some(mut element) = hyperlink_tooltip {
+                        element.paint(cx);
+                    }
+                });
+        });
     }
 }
 
@@ -951,7 +1030,7 @@ impl InputHandler for TerminalInputHandler {
     }
 }
 
-fn is_blank(cell: &IndexedCell) -> bool {
+pub fn is_blank(cell: &IndexedCell) -> bool {
     if cell.c != ' ' {
         return false;
     }

crates/terminal_view/src/terminal_view.rs πŸ”—

@@ -8,12 +8,12 @@ use futures::{stream::FuturesUnordered, StreamExt};
 use gpui::{
     anchored, deferred, div, impl_actions, AnyElement, AppContext, DismissEvent, EventEmitter,
     FocusHandle, FocusableView, KeyContext, KeyDownEvent, Keystroke, Model, MouseButton,
-    MouseDownEvent, Pixels, Render, Styled, Subscription, Task, View, VisualContext, WeakView,
+    MouseDownEvent, Pixels, Render, ScrollWheelEvent, Styled, Subscription, Task, View,
+    VisualContext, WeakView,
 };
 use language::Bias;
 use persistence::TERMINAL_DB;
 use project::{search::SearchQuery, Fs, LocalWorktree, Metadata, Project};
-use settings::SettingsStore;
 use task::TerminalWorkDir;
 use terminal::{
     alacritty_terminal::{
@@ -23,8 +23,9 @@ use terminal::{
     terminal_settings::{TerminalBlink, TerminalSettings, WorkingDirectory},
     Clear, Copy, Event, MaybeNavigationTarget, Paste, ScrollLineDown, ScrollLineUp, ScrollPageDown,
     ScrollPageUp, ScrollToBottom, ScrollToTop, ShowCharacterPalette, TaskStatus, Terminal,
+    TerminalSize,
 };
-use terminal_element::TerminalElement;
+use terminal_element::{is_blank, TerminalElement};
 use ui::{h_flex, prelude::*, ContextMenu, Icon, IconName, Label, Tooltip};
 use util::{paths::PathLikeWithPosition, ResultExt};
 use workspace::{
@@ -39,10 +40,11 @@ use workspace::{
 use anyhow::Context;
 use dirs::home_dir;
 use serde::Deserialize;
-use settings::Settings;
+use settings::{Settings, SettingsStore};
 use smol::Timer;
 
 use std::{
+    cmp,
     ops::RangeInclusive,
     path::{Path, PathBuf},
     sync::Arc,
@@ -79,6 +81,16 @@ pub fn init(cx: &mut AppContext) {
     .detach();
 }
 
+pub struct BlockProperties {
+    pub height: u8,
+    pub render: Box<dyn Send + Fn(&mut BlockContext) -> AnyElement>,
+}
+
+pub struct BlockContext<'a, 'b> {
+    pub context: &'b mut WindowContext<'a>,
+    pub dimensions: TerminalSize,
+}
+
 ///A terminal view, maintains the PTY's file handles and communicates with the terminal
 pub struct TerminalView {
     terminal: Model<Terminal>,
@@ -94,6 +106,8 @@ pub struct TerminalView {
     can_navigate_to_selected_word: bool,
     workspace_id: Option<WorkspaceId>,
     show_title: bool,
+    block_below_cursor: Option<Arc<BlockProperties>>,
+    scroll_top: Pixels,
     _subscriptions: Vec<Subscription>,
     _terminal_subscriptions: Vec<Subscription>,
 }
@@ -170,6 +184,8 @@ impl TerminalView {
             can_navigate_to_selected_word: false,
             workspace_id,
             show_title: TerminalSettings::get_global(cx).toolbar.title,
+            block_below_cursor: None,
+            scroll_top: Pixels::ZERO,
             _subscriptions: vec![
                 focus_in,
                 focus_out,
@@ -248,27 +264,123 @@ impl TerminalView {
     }
 
     fn clear(&mut self, _: &Clear, cx: &mut ViewContext<Self>) {
+        self.scroll_top = px(0.);
         self.terminal.update(cx, |term, _| term.clear());
         cx.notify();
     }
 
+    fn max_scroll_top(&self, cx: &AppContext) -> Pixels {
+        let terminal = self.terminal.read(cx);
+
+        let Some(block) = self.block_below_cursor.as_ref() else {
+            return Pixels::ZERO;
+        };
+
+        let line_height = terminal.last_content().size.line_height;
+        let mut terminal_lines = terminal.total_lines();
+        let viewport_lines = terminal.viewport_lines();
+        if terminal.total_lines() == terminal.viewport_lines() {
+            let mut last_line = None;
+            for cell in terminal.last_content.cells.iter().rev() {
+                if !is_blank(cell) {
+                    break;
+                }
+
+                let last_line = last_line.get_or_insert(cell.point.line);
+                if *last_line != cell.point.line {
+                    terminal_lines -= 1;
+                }
+                *last_line = cell.point.line;
+            }
+        }
+
+        let max_scroll_top_in_lines =
+            (block.height as usize).saturating_sub(viewport_lines.saturating_sub(terminal_lines));
+
+        max_scroll_top_in_lines as f32 * line_height
+    }
+
+    fn scroll_wheel(
+        &mut self,
+        event: &ScrollWheelEvent,
+        origin: gpui::Point<Pixels>,
+        cx: &mut ViewContext<Self>,
+    ) {
+        let terminal_content = self.terminal.read(cx).last_content();
+
+        if self.block_below_cursor.is_some() && terminal_content.display_offset == 0 {
+            let line_height = terminal_content.size.line_height;
+            let y_delta = event.delta.pixel_delta(line_height).y;
+            if y_delta < Pixels::ZERO || self.scroll_top > Pixels::ZERO {
+                self.scroll_top = cmp::max(
+                    Pixels::ZERO,
+                    cmp::min(self.scroll_top - y_delta, self.max_scroll_top(cx)),
+                );
+                cx.notify();
+                return;
+            }
+        }
+
+        self.terminal
+            .update(cx, |term, _| term.scroll_wheel(event, origin));
+    }
+
     fn scroll_line_up(&mut self, _: &ScrollLineUp, cx: &mut ViewContext<Self>) {
+        let terminal_content = self.terminal.read(cx).last_content();
+        if self.block_below_cursor.is_some()
+            && terminal_content.display_offset == 0
+            && self.scroll_top > Pixels::ZERO
+        {
+            let line_height = terminal_content.size.line_height;
+            self.scroll_top = cmp::max(self.scroll_top - line_height, Pixels::ZERO);
+            return;
+        }
+
         self.terminal.update(cx, |term, _| term.scroll_line_up());
         cx.notify();
     }
 
     fn scroll_line_down(&mut self, _: &ScrollLineDown, cx: &mut ViewContext<Self>) {
+        let terminal_content = self.terminal.read(cx).last_content();
+        if self.block_below_cursor.is_some() && terminal_content.display_offset == 0 {
+            let max_scroll_top = self.max_scroll_top(cx);
+            if self.scroll_top < max_scroll_top {
+                let line_height = terminal_content.size.line_height;
+                self.scroll_top = cmp::min(self.scroll_top + line_height, max_scroll_top);
+            }
+            return;
+        }
+
         self.terminal.update(cx, |term, _| term.scroll_line_down());
         cx.notify();
     }
 
     fn scroll_page_up(&mut self, _: &ScrollPageUp, cx: &mut ViewContext<Self>) {
-        self.terminal.update(cx, |term, _| term.scroll_page_up());
+        if self.scroll_top == Pixels::ZERO {
+            self.terminal.update(cx, |term, _| term.scroll_page_up());
+        } else {
+            let line_height = self.terminal.read(cx).last_content.size.line_height();
+            let visible_block_lines = (self.scroll_top / line_height) as usize;
+            let viewport_lines = self.terminal.read(cx).viewport_lines();
+            let visible_content_lines = viewport_lines - visible_block_lines;
+
+            if visible_block_lines >= viewport_lines {
+                self.scroll_top = ((visible_block_lines - viewport_lines) as f32) * line_height;
+            } else {
+                self.scroll_top = px(0.);
+                self.terminal
+                    .update(cx, |term, _| term.scroll_up_by(visible_content_lines));
+            }
+        }
         cx.notify();
     }
 
     fn scroll_page_down(&mut self, _: &ScrollPageDown, cx: &mut ViewContext<Self>) {
         self.terminal.update(cx, |term, _| term.scroll_page_down());
+        let terminal = self.terminal.read(cx);
+        if terminal.last_content().display_offset < terminal.viewport_lines() {
+            self.scroll_top = self.max_scroll_top(cx);
+        }
         cx.notify();
     }
 
@@ -279,6 +391,9 @@ impl TerminalView {
 
     fn scroll_to_bottom(&mut self, _: &ScrollToBottom, cx: &mut ViewContext<Self>) {
         self.terminal.update(cx, |term, _| term.scroll_to_bottom());
+        if self.block_below_cursor.is_some() {
+            self.scroll_top = self.max_scroll_top(cx);
+        }
         cx.notify();
     }
 
@@ -337,6 +452,18 @@ impl TerminalView {
         &self.terminal
     }
 
+    pub fn set_block_below_cursor(&mut self, block: BlockProperties, cx: &mut ViewContext<Self>) {
+        self.block_below_cursor = Some(Arc::new(block));
+        self.scroll_to_bottom(&ScrollToBottom, cx);
+        cx.notify();
+    }
+
+    pub fn clear_block_below_cursor(&mut self, cx: &mut ViewContext<Self>) {
+        self.block_below_cursor = None;
+        self.scroll_top = Pixels::ZERO;
+        cx.notify();
+    }
+
     fn next_blink_epoch(&mut self) -> usize {
         self.blink_epoch += 1;
         self.blink_epoch
@@ -761,6 +888,7 @@ impl TerminalView {
 impl Render for TerminalView {
     fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
         let terminal_handle = self.terminal.clone();
+        let terminal_view_handle = cx.view().clone();
 
         let focused = self.focus_handle.is_focused(cx);
 
@@ -796,11 +924,13 @@ impl Render for TerminalView {
                 // TODO: Oddly this wrapper div is needed for TerminalElement to not steal events from the context menu
                 div().size_full().child(TerminalElement::new(
                     terminal_handle,
+                    terminal_view_handle,
                     self.workspace.clone(),
                     self.focus_handle.clone(),
                     focused,
                     self.should_show_cursor(focused, cx),
                     self.can_navigate_to_selected_word,
+                    self.block_below_cursor.clone(),
                 )),
             )
             .children(self.context_menu.as_ref().map(|(menu, position, _)| {