diff --git a/Cargo.lock b/Cargo.lock index f9ce622d49..aa1798732b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -499,6 +499,7 @@ dependencies = [ "similar", "smol", "telemetry_events", + "terminal", "terminal_view", "text", "theme", diff --git a/crates/assistant2/Cargo.toml b/crates/assistant2/Cargo.toml index 4c1193c070..781ed46842 100644 --- a/crates/assistant2/Cargo.toml +++ b/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 diff --git a/crates/assistant2/src/assistant.rs b/crates/assistant2/src/assistant.rs index 8d87a028d3..5a7a88f2f3 100644 --- a/crates/assistant2/src/assistant.rs +++ b/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, client: Arc, 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); } diff --git a/crates/assistant2/src/inline_assistant.rs b/crates/assistant2/src/inline_assistant.rs index 55734ee634..d007c09031 100644 --- a/crates/assistant2/src/inline_assistant.rs +++ b/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| { - 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| 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) + }) } }; diff --git a/crates/assistant2/src/prompts.rs b/crates/assistant2/src/prompts.rs index 77d7b3b8ba..1edfd9cb46 100644 --- a/crates/assistant2/src/prompts.rs +++ b/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 { + 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) + } } diff --git a/crates/assistant2/src/terminal_inline_assistant.rs b/crates/assistant2/src/terminal_inline_assistant.rs new file mode 100644 index 0000000000..146edfe68b --- /dev/null +++ b/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, + prompt_builder: Arc, + telemetry: Arc, + 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, + prompt_history: VecDeque, + telemetry: Option>, + fs: Arc, + prompt_builder: Arc, +} + +impl Global for TerminalInlineAssistant {} + +impl TerminalInlineAssistant { + pub fn new( + fs: Arc, + prompt_builder: Arc, + telemetry: Arc, + ) -> 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, + workspace: Option>, + 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, + 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 { + 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, + prompt_editor: Option>, + codegen: Model, + workspace: Option>, + _subscriptions: Vec, +} + +impl TerminalInlineAssist { + pub fn new( + assist_id: TerminalInlineAssistId, + terminal: &View, + prompt_editor: View, + workspace: Option>, + 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::( + 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, + height_in_lines: u8, + editor: View, + edited_since_done: bool, + prompt_history: VecDeque, + prompt_history_ix: Option, + pending_prompt: String, + codegen: Model, + _codegen_subscription: Subscription, + editor_subscriptions: Vec, +} + +impl EventEmitter for PromptEditor {} + +impl Render for PromptEditor { + fn render(&mut self, cx: &mut ViewContext) -> 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::( + 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, + prompt_buffer: Model, + codegen: Model, + fs: Arc, + cx: &mut ViewContext, + ) -> 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.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) { + 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, cx: &mut ViewContext) { + self.count_lines(cx); + } + + fn handle_prompt_editor_events( + &mut self, + _: View, + event: &EditorEvent, + cx: &mut ViewContext, + ) { + 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, cx: &mut ViewContext) { + 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) { + 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) { + 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) { + if matches!(self.codegen.read(cx).status, CodegenStatus::Done) { + cx.emit(PromptEditorEvent::ConfirmRequested { execute: true }); + } + } + + fn move_up(&mut self, _: &MoveUp, cx: &mut ViewContext) { + 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) { + 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) -> 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 for Codegen {} + +const CLEAR_INPUT: &str = "\x15"; +const CARRIAGE_RETURN: &str = "\x0d"; + +struct TerminalTransaction { + terminal: Model, +} + +impl TerminalTransaction { + pub fn start(terminal: Model) -> 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>, + terminal: Model, + generation: Task<()>, + message_id: Option, + transaction: Option, +} + +impl Codegen { + pub fn new(terminal: Model, telemetry: Option>) -> 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) { + 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.status = CodegenStatus::Done; + self.generation = Task::ready(()); + cx.emit(CodegenEvent::Finished); + cx.notify(); + } + + pub fn complete(&mut self, cx: &mut ModelContext) { + if let Some(transaction) = self.transaction.take() { + transaction.complete(cx); + } + } + + pub fn undo(&mut self, cx: &mut ModelContext) { + if let Some(transaction) = self.transaction.take() { + transaction.undo(cx); + } + } +} + +enum CodegenStatus { + Idle, + Pending, + Done, + Error(anyhow::Error), +}