Inline terminal assistant v2 (#21888)

Richard Feldman created

Follow-up to https://github.com/zed-industries/zed/pull/21828 to add it
to the terminal as well.


https://github.com/user-attachments/assets/505d1443-4081-4dd8-9725-17d85532f52d

As with the previous PR, there's plenty of code duplication here; the
plan is to do more code sharing in separate PRs!


Release Notes:

- N/A

Change summary

Cargo.lock                                         |    1 
crates/assistant2/Cargo.toml                       |    1 
crates/assistant2/src/assistant.rs                 |    7 
crates/assistant2/src/inline_assistant.rs          |   21 
crates/assistant2/src/prompts.rs                   |   21 
crates/assistant2/src/terminal_inline_assistant.rs | 1059 ++++++++++++++++
6 files changed, 1,100 insertions(+), 10 deletions(-)

Detailed changes

Cargo.lock πŸ”—

@@ -499,6 +499,7 @@ dependencies = [
  "similar",
  "smol",
  "telemetry_events",
+ "terminal",
  "terminal_view",
  "text",
  "theme",

crates/assistant2/Cargo.toml πŸ”—

@@ -58,6 +58,7 @@ smol.workspace = true
 telemetry_events.workspace = true
 terminal_view.workspace = true
 text.workspace = true
+terminal.workspace = true
 theme.workspace = true
 time.workspace = true
 time_format.workspace = true

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

@@ -7,6 +7,7 @@ mod inline_assistant;
 mod message_editor;
 mod prompts;
 mod streaming_diff;
+mod terminal_inline_assistant;
 mod thread;
 mod thread_history;
 mod thread_store;
@@ -63,6 +64,12 @@ pub fn init(fs: Arc<dyn Fs>, client: Arc<Client>, stdout_is_a_pty: bool, cx: &mu
         client.telemetry().clone(),
         cx,
     );
+    terminal_inline_assistant::init(
+        fs.clone(),
+        prompt_builder.clone(),
+        client.telemetry().clone(),
+        cx,
+    );
 
     feature_gate_assistant2_actions(cx);
 }

crates/assistant2/src/inline_assistant.rs πŸ”—

@@ -2,6 +2,7 @@ use crate::{
     assistant_settings::AssistantSettings,
     prompts::PromptBuilder,
     streaming_diff::{CharOperation, LineDiff, LineOperation, StreamingDiff},
+    terminal_inline_assistant::TerminalInlineAssistant,
     CycleNextInlineAssist, CyclePreviousInlineAssist, ToggleInlineAssist,
 };
 use anyhow::{Context as _, Result};
@@ -207,16 +208,16 @@ impl InlineAssistant {
                 .map_or(false, |provider| provider.is_authenticated(cx))
         };
 
-        let handle_assist = |cx: &mut ViewContext<Workspace>| {
-            match inline_assist_target {
-                InlineAssistTarget::Editor(active_editor) => {
-                    InlineAssistant::update_global(cx, |assistant, cx| {
-                        assistant.assist(&active_editor, Some(cx.view().downgrade()), cx)
-                    })
-                }
-                InlineAssistTarget::Terminal(_active_terminal) => {
-                    // TODO show the terminal inline assistant
-                }
+        let handle_assist = |cx: &mut ViewContext<Workspace>| match inline_assist_target {
+            InlineAssistTarget::Editor(active_editor) => {
+                InlineAssistant::update_global(cx, |assistant, cx| {
+                    assistant.assist(&active_editor, Some(cx.view().downgrade()), cx)
+                })
+            }
+            InlineAssistTarget::Terminal(active_terminal) => {
+                TerminalInlineAssistant::update_global(cx, |assistant, cx| {
+                    assistant.assist(&active_terminal, Some(cx.view().downgrade()), cx)
+                })
             }
         };
 

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

@@ -288,4 +288,25 @@ impl PromptBuilder {
         };
         self.handlebars.lock().render("content_prompt", &context)
     }
+
+    pub fn generate_terminal_assistant_prompt(
+        &self,
+        user_prompt: &str,
+        shell: Option<&str>,
+        working_directory: Option<&str>,
+        latest_output: &[String],
+    ) -> Result<String, RenderError> {
+        let context = TerminalAssistantPromptContext {
+            os: std::env::consts::OS.to_string(),
+            arch: std::env::consts::ARCH.to_string(),
+            shell: shell.map(|s| s.to_string()),
+            working_directory: working_directory.map(|s| s.to_string()),
+            latest_output: latest_output.to_vec(),
+            user_prompt: user_prompt.to_string(),
+        };
+
+        self.handlebars
+            .lock()
+            .render("terminal_assistant_prompt", &context)
+    }
 }

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

@@ -0,0 +1,1059 @@
+use crate::assistant_settings::AssistantSettings;
+use crate::prompts::PromptBuilder;
+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, Global, Model, ModelContext,
+    Subscription, Task, TextStyle, UpdateGlobal, View, WeakView,
+};
+use language::Buffer;
+use language_model::{
+    LanguageModelRegistry, LanguageModelRequest, LanguageModelRequestMessage, Role,
+};
+use language_model_selector::LanguageModelSelector;
+use language_models::report_assistant_event;
+use settings::{update_settings_file, Settings};
+use std::{cmp, sync::Arc, time::Instant};
+use telemetry_events::{AssistantEvent, AssistantKind, AssistantPhase};
+use terminal::Terminal;
+use terminal_view::TerminalView;
+use theme::ThemeSettings;
+use ui::{prelude::*, text_for_action, IconButtonShape, Tooltip};
+use util::ResultExt;
+use workspace::{notifications::NotificationId, Toast, Workspace};
+
+pub fn init(
+    fs: Arc<dyn Fs>,
+    prompt_builder: Arc<PromptBuilder>,
+    telemetry: Arc<Telemetry>,
+    cx: &mut AppContext,
+) {
+    cx.set_global(TerminalInlineAssistant::new(fs, prompt_builder, telemetry));
+}
+
+const DEFAULT_CONTEXT_LINES: usize = 50;
+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>,
+    prompt_builder: Arc<PromptBuilder>,
+}
+
+impl Global for TerminalInlineAssistant {}
+
+impl TerminalInlineAssistant {
+    pub fn new(
+        fs: Arc<dyn Fs>,
+        prompt_builder: Arc<PromptBuilder>,
+        telemetry: Arc<Telemetry>,
+    ) -> Self {
+        Self {
+            next_assist_id: TerminalInlineAssistId::default(),
+            assists: HashMap::default(),
+            prompt_history: VecDeque::default(),
+            telemetry: Some(telemetry),
+            fs,
+            prompt_builder,
+        }
+    }
+
+    pub fn assist(
+        &mut self,
+        terminal_view: &View<TerminalView>,
+        workspace: Option<WeakView<Workspace>>,
+        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| {
+            MultiBuffer::singleton(cx.new_model(|cx| Buffer::local(String::new(), cx)), 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,
+                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,
+            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 { execute } => {
+                self.finish_assist(assist_id, false, *execute, cx);
+            }
+            PromptEditorEvent::CancelRequested => {
+                self.finish_assist(assist_id, true, false, 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 shell = std::env::var("SHELL").ok();
+        let (latest_output, working_directory) = assist
+            .terminal
+            .update(cx, |terminal, cx| {
+                let terminal = terminal.model().read(cx);
+                let latest_output = terminal.last_n_non_empty_lines(DEFAULT_CONTEXT_LINES);
+                let working_directory = terminal
+                    .working_directory()
+                    .map(|path| path.to_string_lossy().to_string());
+                (latest_output, working_directory)
+            })
+            .ok()
+            .unwrap_or_default();
+
+        let prompt = self.prompt_builder.generate_terminal_assistant_prompt(
+            &assist
+                .prompt_editor
+                .clone()
+                .context("invalid assist")?
+                .read(cx)
+                .prompt(cx),
+            shell.as_deref(),
+            working_directory.as_deref(),
+            &latest_output,
+        )?;
+
+        Ok(LanguageModelRequest {
+            messages: vec![LanguageModelRequestMessage {
+                role: Role::User,
+                content: vec![prompt.into()],
+                cache: false,
+            }],
+            tools: Vec::new(),
+            stop: Vec::new(),
+            temperature: None,
+        })
+    }
+
+    fn finish_assist(
+        &mut self,
+        assist_id: TerminalInlineAssistId,
+        undo: bool,
+        execute: 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();
+
+            if let Some(model) = LanguageModelRegistry::read_global(cx).active_model() {
+                let codegen = assist.codegen.read(cx);
+                let executor = cx.background_executor().clone();
+                report_assistant_event(
+                    AssistantEvent {
+                        conversation_id: None,
+                        kind: AssistantKind::InlineTerminal,
+                        message_id: codegen.message_id.clone(),
+                        phase: if undo {
+                            AssistantPhase::Rejected
+                        } else {
+                            AssistantPhase::Accepted
+                        },
+                        model: model.telemetry_id(),
+                        model_provider: model.provider_id().to_string(),
+                        response_latency: None,
+                        error_message: None,
+                        language_name: None,
+                    },
+                    codegen.telemetry.clone(),
+                    cx.http_client(),
+                    model.api_key(cx),
+                    &executor,
+                );
+            }
+
+            assist.codegen.update(cx, |codegen, cx| {
+                if undo {
+                    codegen.undo(cx);
+                } else if execute {
+                    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>>,
+    _subscriptions: Vec<Subscription>,
+}
+
+impl TerminalInlineAssist {
+    pub fn new(
+        assist_id: TerminalInlineAssistId,
+        terminal: &View<TerminalView>,
+        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(),
+            _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::composite::<InlineAssistantError>(
+                                                    assist_id.0,
+                                                );
+
+                                            workspace.show_toast(Toast::new(id, error), cx);
+                                        })
+                                    }
+                                }
+                            }
+
+                            if assist.prompt_editor.is_none() {
+                                this.finish_assist(assist_id, false, false, cx);
+                            }
+                        }
+                    })
+                }),
+            ],
+        }
+    }
+}
+
+enum PromptEditorEvent {
+    StartRequested,
+    StopRequested,
+    ConfirmRequested { execute: bool },
+    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>,
+}
+
+impl EventEmitter<PromptEditorEvent> for PromptEditor {}
+
+impl Render for PromptEditor {
+    fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
+        let status = &self.codegen.read(cx).status;
+        let mut buttons = vec![Button::new("add-context", "Add Context")
+            .style(ButtonStyle::Filled)
+            .icon(IconName::Plus)
+            .icon_position(IconPosition::Start)
+            .into_any_element()];
+
+        buttons.extend(match status {
+            CodegenStatus::Idle => vec![
+                IconButton::new("cancel", IconName::Close)
+                    .icon_color(Color::Muted)
+                    .shape(IconButtonShape::Square)
+                    .tooltip(|cx| Tooltip::for_action("Cancel Assist", &menu::Cancel, cx))
+                    .on_click(cx.listener(|_, _, cx| cx.emit(PromptEditorEvent::CancelRequested)))
+                    .into_any_element(),
+                IconButton::new("start", IconName::SparkleAlt)
+                    .icon_color(Color::Muted)
+                    .shape(IconButtonShape::Square)
+                    .tooltip(|cx| Tooltip::for_action("Generate", &menu::Confirm, cx))
+                    .on_click(cx.listener(|_, _, cx| cx.emit(PromptEditorEvent::StartRequested)))
+                    .into_any_element(),
+            ],
+            CodegenStatus::Pending => vec![
+                IconButton::new("cancel", IconName::Close)
+                    .icon_color(Color::Muted)
+                    .shape(IconButtonShape::Square)
+                    .tooltip(|cx| Tooltip::text("Cancel Assist", cx))
+                    .on_click(cx.listener(|_, _, cx| cx.emit(PromptEditorEvent::CancelRequested)))
+                    .into_any_element(),
+                IconButton::new("stop", IconName::Stop)
+                    .icon_color(Color::Error)
+                    .shape(IconButtonShape::Square)
+                    .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)))
+                    .into_any_element(),
+            ],
+            CodegenStatus::Error(_) | CodegenStatus::Done => {
+                let cancel = IconButton::new("cancel", IconName::Close)
+                    .icon_color(Color::Muted)
+                    .shape(IconButtonShape::Square)
+                    .tooltip(|cx| Tooltip::for_action("Cancel Assist", &menu::Cancel, cx))
+                    .on_click(cx.listener(|_, _, cx| cx.emit(PromptEditorEvent::CancelRequested)))
+                    .into_any_element();
+
+                let has_error = matches!(status, CodegenStatus::Error(_));
+                if has_error || self.edited_since_done {
+                    vec![
+                        cancel,
+                        IconButton::new("restart", IconName::RotateCw)
+                            .icon_color(Color::Info)
+                            .shape(IconButtonShape::Square)
+                            .tooltip(|cx| {
+                                Tooltip::with_meta(
+                                    "Restart Generation",
+                                    Some(&menu::Confirm),
+                                    "Changes will be discarded",
+                                    cx,
+                                )
+                            })
+                            .on_click(cx.listener(|_, _, cx| {
+                                cx.emit(PromptEditorEvent::StartRequested);
+                            }))
+                            .into_any_element(),
+                    ]
+                } else {
+                    vec![
+                        cancel,
+                        IconButton::new("accept", IconName::Check)
+                            .icon_color(Color::Info)
+                            .shape(IconButtonShape::Square)
+                            .tooltip(|cx| {
+                                Tooltip::for_action("Accept Generated Command", &menu::Confirm, cx)
+                            })
+                            .on_click(cx.listener(|_, _, cx| {
+                                cx.emit(PromptEditorEvent::ConfirmRequested { execute: false });
+                            }))
+                            .into_any_element(),
+                        IconButton::new("confirm", IconName::Play)
+                            .icon_color(Color::Info)
+                            .shape(IconButtonShape::Square)
+                            .tooltip(|cx| {
+                                Tooltip::for_action(
+                                    "Execute Generated Command",
+                                    &menu::SecondaryConfirm,
+                                    cx,
+                                )
+                            })
+                            .on_click(cx.listener(|_, _, cx| {
+                                cx.emit(PromptEditorEvent::ConfirmRequested { execute: true });
+                            }))
+                            .into_any_element(),
+                    ]
+                }
+            }
+        });
+
+        h_flex()
+            .bg(cx.theme().colors().editor_background)
+            .border_y_1()
+            .border_color(cx.theme().status().info_border)
+            .py_2()
+            .h_full()
+            .w_full()
+            .on_action(cx.listener(Self::confirm))
+            .on_action(cx.listener(Self::secondary_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(LanguageModelSelector::new(
+                        {
+                            let fs = self.fs.clone();
+                            move |model, cx| {
+                                update_settings_file::<AssistantSettings>(
+                                    fs.clone(),
+                                    cx,
+                                    move |settings, _| settings.set_model(model.clone()),
+                                );
+                            }
+                        },
+                        IconButton::new("context", IconName::SettingsAlt)
+                            .shape(IconButtonShape::Square)
+                            .icon_size(IconSize::Small)
+                            .icon_color(Color::Muted)
+                            .tooltip(move |cx| {
+                                Tooltip::with_meta(
+                                    format!(
+                                        "Using {}",
+                                        LanguageModelRegistry::read_global(cx)
+                                            .active_model()
+                                            .map(|model| model.name().0)
+                                            .unwrap_or_else(|| "No model selected".into()),
+                                    ),
+                                    None,
+                                    "Change Model",
+                                    cx,
+                                )
+                            }),
+                    ))
+                    .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_1().pr_4().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>,
+        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(Self::placeholder_text(cx), cx);
+            editor
+        });
+
+        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,
+        };
+        this.count_lines(cx);
+        this.subscribe_to_editor(cx);
+        this
+    }
+
+    fn placeholder_text(cx: &WindowContext) -> String {
+        let context_keybinding = text_for_action(&crate::ToggleFocus, cx)
+            .map(|keybinding| format!(" β€’ {keybinding} for context"))
+            .unwrap_or_default();
+
+        format!("Generate…{context_keybinding} ↓↑ for history")
+    }
+
+    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_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();
+            }
+            _ => {}
+        }
+    }
+
+    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 => {
+                if self.edited_since_done {
+                    cx.emit(PromptEditorEvent::StartRequested);
+                } else {
+                    cx.emit(PromptEditorEvent::ConfirmRequested { execute: false });
+                }
+            }
+            CodegenStatus::Error(_) => {
+                cx.emit(PromptEditorEvent::StartRequested);
+            }
+        }
+    }
+
+    fn secondary_confirm(&mut self, _: &menu::SecondaryConfirm, cx: &mut ViewContext<Self>) {
+        if matches!(self.codegen.read(cx).status, CodegenStatus::Done) {
+            cx.emit(PromptEditorEvent::ConfirmRequested { execute: true });
+        }
+    }
+
+    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_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.buffer_font.family.clone(),
+            font_fallbacks: settings.buffer_font.fallbacks.clone(),
+            font_size: settings.buffer_font_size.into(),
+            font_weight: settings.buffer_font.weight,
+            line_height: relative(settings.buffer_line_height.value()),
+            ..Default::default()
+        };
+        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 accidentally execute commands that are streamed into the terminal
+        let input = Self::sanitize_input(hunk);
+        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())
+        });
+    }
+
+    fn sanitize_input(input: String) -> String {
+        input.replace(['\r', '\n'], "")
+    }
+}
+
+pub struct Codegen {
+    status: CodegenStatus,
+    telemetry: Option<Arc<Telemetry>>,
+    terminal: Model<Terminal>,
+    generation: Task<()>,
+    message_id: Option<String>,
+    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(()),
+            message_id: None,
+            transaction: None,
+        }
+    }
+
+    pub fn start(&mut self, prompt: LanguageModelRequest, cx: &mut ModelContext<Self>) {
+        let Some(model) = LanguageModelRegistry::read_global(cx).active_model() else {
+            return;
+        };
+
+        let model_api_key = model.api_key(cx);
+        let http_client = cx.http_client();
+        let telemetry = self.telemetry.clone();
+        self.status = CodegenStatus::Pending;
+        self.transaction = Some(TerminalTransaction::start(self.terminal.clone()));
+        self.generation = cx.spawn(|this, mut cx| async move {
+            let model_telemetry_id = model.telemetry_id();
+            let model_provider_id = model.provider_id();
+            let response = model.stream_completion_text(prompt, &cx).await;
+            let generate = async {
+                let message_id = response
+                    .as_ref()
+                    .ok()
+                    .and_then(|response| response.message_id.clone());
+
+                let (mut hunks_tx, mut hunks_rx) = mpsc::channel(1);
+
+                let task = cx.background_executor().spawn({
+                    let message_id = message_id.clone();
+                    let executor = cx.background_executor().clone();
+                    async move {
+                        let mut response_latency = None;
+                        let request_start = Instant::now();
+                        let task = async {
+                            let mut chunks = response?.stream;
+                            while let Some(chunk) = chunks.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());
+                        report_assistant_event(
+                            AssistantEvent {
+                                conversation_id: None,
+                                kind: AssistantKind::InlineTerminal,
+                                message_id,
+                                phase: AssistantPhase::Response,
+                                model: model_telemetry_id,
+                                model_provider: model_provider_id.to_string(),
+                                response_latency,
+                                error_message,
+                                language_name: None,
+                            },
+                            telemetry,
+                            http_client,
+                            model_api_key,
+                            &executor,
+                        );
+
+                        result?;
+                        anyhow::Ok(())
+                    }
+                });
+
+                this.update(&mut cx, |this, _| {
+                    this.message_id = message_id;
+                })?;
+
+                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),
+}