terminal_inline_assistant.rs

  1use crate::{
  2    context::load_context,
  3    inline_prompt_editor::{
  4        CodegenStatus, PromptEditor, PromptEditorEvent, TerminalInlineAssistId,
  5    },
  6    terminal_codegen::{CLEAR_INPUT, CodegenEvent, TerminalCodegen},
  7};
  8use agent::HistoryStore;
  9use agent_settings::AgentSettings;
 10use anyhow::{Context as _, Result};
 11use client::telemetry::Telemetry;
 12use cloud_llm_client::CompletionIntent;
 13use collections::{HashMap, VecDeque};
 14use editor::{MultiBuffer, actions::SelectAll};
 15use fs::Fs;
 16use gpui::{App, Entity, Focusable, Global, Subscription, Task, UpdateGlobal, WeakEntity};
 17use language::Buffer;
 18use language_model::{
 19    ConfiguredModel, LanguageModelRegistry, LanguageModelRequest, LanguageModelRequestMessage,
 20    Role, report_assistant_event,
 21};
 22use project::Project;
 23use prompt_store::{PromptBuilder, PromptStore};
 24use std::sync::Arc;
 25use telemetry_events::{AssistantEventData, AssistantKind, AssistantPhase};
 26use terminal_view::TerminalView;
 27use ui::prelude::*;
 28use util::ResultExt;
 29use workspace::{Toast, Workspace, notifications::NotificationId};
 30
 31pub fn init(
 32    fs: Arc<dyn Fs>,
 33    prompt_builder: Arc<PromptBuilder>,
 34    telemetry: Arc<Telemetry>,
 35    cx: &mut App,
 36) {
 37    cx.set_global(TerminalInlineAssistant::new(fs, prompt_builder, telemetry));
 38}
 39
 40const DEFAULT_CONTEXT_LINES: usize = 50;
 41const PROMPT_HISTORY_MAX_LEN: usize = 20;
 42
 43pub struct TerminalInlineAssistant {
 44    next_assist_id: TerminalInlineAssistId,
 45    assists: HashMap<TerminalInlineAssistId, TerminalInlineAssist>,
 46    prompt_history: VecDeque<String>,
 47    telemetry: Option<Arc<Telemetry>>,
 48    fs: Arc<dyn Fs>,
 49    prompt_builder: Arc<PromptBuilder>,
 50}
 51
 52impl Global for TerminalInlineAssistant {}
 53
 54impl TerminalInlineAssistant {
 55    pub fn new(
 56        fs: Arc<dyn Fs>,
 57        prompt_builder: Arc<PromptBuilder>,
 58        telemetry: Arc<Telemetry>,
 59    ) -> Self {
 60        Self {
 61            next_assist_id: TerminalInlineAssistId::default(),
 62            assists: HashMap::default(),
 63            prompt_history: VecDeque::default(),
 64            telemetry: Some(telemetry),
 65            fs,
 66            prompt_builder,
 67        }
 68    }
 69
 70    pub fn assist(
 71        &mut self,
 72        terminal_view: &Entity<TerminalView>,
 73        workspace: WeakEntity<Workspace>,
 74        project: WeakEntity<Project>,
 75        thread_store: Entity<HistoryStore>,
 76        prompt_store: Option<Entity<PromptStore>>,
 77        initial_prompt: Option<String>,
 78        window: &mut Window,
 79        cx: &mut App,
 80    ) {
 81        let terminal = terminal_view.read(cx).terminal().clone();
 82        let assist_id = self.next_assist_id.post_inc();
 83        let prompt_buffer = cx.new(|cx| {
 84            MultiBuffer::singleton(
 85                cx.new(|cx| Buffer::local(initial_prompt.unwrap_or_default(), cx)),
 86                cx,
 87            )
 88        });
 89        let codegen = cx.new(|_| TerminalCodegen::new(terminal, self.telemetry.clone()));
 90
 91        let prompt_editor = cx.new(|cx| {
 92            PromptEditor::new_terminal(
 93                assist_id,
 94                self.prompt_history.clone(),
 95                prompt_buffer.clone(),
 96                codegen,
 97                self.fs.clone(),
 98                thread_store.clone(),
 99                prompt_store.clone(),
100                project.clone(),
101                workspace.clone(),
102                window,
103                cx,
104            )
105        });
106        let prompt_editor_render = prompt_editor.clone();
107        let block = terminal_view::BlockProperties {
108            height: 4,
109            render: Box::new(move |_| prompt_editor_render.clone().into_any_element()),
110        };
111        terminal_view.update(cx, |terminal_view, cx| {
112            terminal_view.set_block_below_cursor(block, window, cx);
113        });
114
115        let terminal_assistant = TerminalInlineAssist::new(
116            assist_id,
117            terminal_view,
118            prompt_editor,
119            workspace.clone(),
120            window,
121            cx,
122        );
123
124        self.assists.insert(assist_id, terminal_assistant);
125
126        self.focus_assist(assist_id, window, cx);
127    }
128
129    fn focus_assist(
130        &mut self,
131        assist_id: TerminalInlineAssistId,
132        window: &mut Window,
133        cx: &mut App,
134    ) {
135        let assist = &self.assists[&assist_id];
136        if let Some(prompt_editor) = assist.prompt_editor.as_ref() {
137            prompt_editor.update(cx, |this, cx| {
138                this.editor.update(cx, |editor, cx| {
139                    window.focus(&editor.focus_handle(cx));
140                    editor.select_all(&SelectAll, window, cx);
141                });
142            });
143        }
144    }
145
146    fn handle_prompt_editor_event(
147        &mut self,
148        prompt_editor: Entity<PromptEditor<TerminalCodegen>>,
149        event: &PromptEditorEvent,
150        window: &mut Window,
151        cx: &mut App,
152    ) {
153        let assist_id = prompt_editor.read(cx).id();
154        match event {
155            PromptEditorEvent::StartRequested => {
156                self.start_assist(assist_id, cx);
157            }
158            PromptEditorEvent::StopRequested => {
159                self.stop_assist(assist_id, cx);
160            }
161            PromptEditorEvent::ConfirmRequested { execute } => {
162                self.finish_assist(assist_id, false, *execute, window, cx);
163            }
164            PromptEditorEvent::CancelRequested => {
165                self.finish_assist(assist_id, true, false, window, cx);
166            }
167            PromptEditorEvent::Resized { height_in_lines } => {
168                self.insert_prompt_editor_into_terminal(assist_id, *height_in_lines, window, cx);
169            }
170        }
171    }
172
173    fn start_assist(&mut self, assist_id: TerminalInlineAssistId, cx: &mut App) {
174        let assist = if let Some(assist) = self.assists.get_mut(&assist_id) {
175            assist
176        } else {
177            return;
178        };
179
180        let Some(user_prompt) = assist
181            .prompt_editor
182            .as_ref()
183            .map(|editor| editor.read(cx).prompt(cx))
184        else {
185            return;
186        };
187
188        self.prompt_history.retain(|prompt| *prompt != user_prompt);
189        self.prompt_history.push_back(user_prompt);
190        if self.prompt_history.len() > PROMPT_HISTORY_MAX_LEN {
191            self.prompt_history.pop_front();
192        }
193
194        assist
195            .terminal
196            .update(cx, |terminal, cx| {
197                terminal
198                    .terminal()
199                    .update(cx, |terminal, _| terminal.input(CLEAR_INPUT.as_bytes()));
200            })
201            .log_err();
202
203        let codegen = assist.codegen.clone();
204        let Some(request_task) = self.request_for_inline_assist(assist_id, cx).log_err() else {
205            return;
206        };
207
208        codegen.update(cx, |codegen, cx| codegen.start(request_task, cx));
209    }
210
211    fn stop_assist(&mut self, assist_id: TerminalInlineAssistId, cx: &mut App) {
212        let assist = if let Some(assist) = self.assists.get_mut(&assist_id) {
213            assist
214        } else {
215            return;
216        };
217
218        assist.codegen.update(cx, |codegen, cx| codegen.stop(cx));
219    }
220
221    fn request_for_inline_assist(
222        &self,
223        assist_id: TerminalInlineAssistId,
224        cx: &mut App,
225    ) -> Result<Task<LanguageModelRequest>> {
226        let ConfiguredModel { model, .. } = LanguageModelRegistry::read_global(cx)
227            .inline_assistant_model()
228            .context("No inline assistant model")?;
229
230        let assist = self.assists.get(&assist_id).context("invalid assist")?;
231
232        let shell = std::env::var("SHELL").ok();
233        let (latest_output, working_directory) = assist
234            .terminal
235            .update(cx, |terminal, cx| {
236                let terminal = terminal.entity().read(cx);
237                let latest_output = terminal.last_n_non_empty_lines(DEFAULT_CONTEXT_LINES);
238                let working_directory = terminal
239                    .working_directory()
240                    .map(|path| path.to_string_lossy().into_owned());
241                (latest_output, working_directory)
242            })
243            .ok()
244            .unwrap_or_default();
245
246        let prompt_editor = assist.prompt_editor.clone().context("invalid assist")?;
247
248        let prompt = self.prompt_builder.generate_terminal_assistant_prompt(
249            &prompt_editor.read(cx).prompt(cx),
250            shell.as_deref(),
251            working_directory.as_deref(),
252            &latest_output,
253        )?;
254
255        let temperature = AgentSettings::temperature_for_model(&model, cx);
256
257        let mention_set = prompt_editor.read(cx).mention_set().clone();
258        let load_context_task = load_context(&mention_set, cx);
259
260        Ok(cx.background_spawn(async move {
261            let mut request_message = LanguageModelRequestMessage {
262                role: Role::User,
263                content: vec![],
264                cache: false,
265                reasoning_details: None,
266            };
267
268            if let Some(context) = load_context_task.await {
269                context.add_to_request_message(&mut request_message);
270            }
271
272            request_message.content.push(prompt.into());
273
274            LanguageModelRequest {
275                thread_id: None,
276                prompt_id: None,
277                mode: None,
278                intent: Some(CompletionIntent::TerminalInlineAssist),
279                messages: vec![request_message],
280                tools: Vec::new(),
281                tool_choice: None,
282                stop: Vec::new(),
283                temperature,
284                thinking_allowed: false,
285            }
286        }))
287    }
288
289    fn finish_assist(
290        &mut self,
291        assist_id: TerminalInlineAssistId,
292        undo: bool,
293        execute: bool,
294        window: &mut Window,
295        cx: &mut App,
296    ) {
297        self.dismiss_assist(assist_id, window, cx);
298
299        if let Some(assist) = self.assists.remove(&assist_id) {
300            assist
301                .terminal
302                .update(cx, |this, cx| {
303                    this.clear_block_below_cursor(cx);
304                    this.focus_handle(cx).focus(window);
305                })
306                .log_err();
307
308            if let Some(ConfiguredModel { model, .. }) =
309                LanguageModelRegistry::read_global(cx).inline_assistant_model()
310            {
311                let codegen = assist.codegen.read(cx);
312                let executor = cx.background_executor().clone();
313                report_assistant_event(
314                    AssistantEventData {
315                        conversation_id: None,
316                        kind: AssistantKind::InlineTerminal,
317                        message_id: codegen.message_id.clone(),
318                        phase: if undo {
319                            AssistantPhase::Rejected
320                        } else {
321                            AssistantPhase::Accepted
322                        },
323                        model: model.telemetry_id(),
324                        model_provider: model.provider_id().to_string(),
325                        response_latency: None,
326                        error_message: None,
327                        language_name: None,
328                    },
329                    codegen.telemetry.clone(),
330                    cx.http_client(),
331                    model.api_key(cx),
332                    &executor,
333                );
334            }
335
336            assist.codegen.update(cx, |codegen, cx| {
337                if undo {
338                    codegen.undo(cx);
339                } else if execute {
340                    codegen.complete(cx);
341                }
342            });
343        }
344    }
345
346    fn dismiss_assist(
347        &mut self,
348        assist_id: TerminalInlineAssistId,
349        window: &mut Window,
350        cx: &mut App,
351    ) -> bool {
352        let Some(assist) = self.assists.get_mut(&assist_id) else {
353            return false;
354        };
355        if assist.prompt_editor.is_none() {
356            return false;
357        }
358        assist.prompt_editor = None;
359        assist
360            .terminal
361            .update(cx, |this, cx| {
362                this.clear_block_below_cursor(cx);
363                this.focus_handle(cx).focus(window);
364            })
365            .is_ok()
366    }
367
368    fn insert_prompt_editor_into_terminal(
369        &mut self,
370        assist_id: TerminalInlineAssistId,
371        height: u8,
372        window: &mut Window,
373        cx: &mut App,
374    ) {
375        if let Some(assist) = self.assists.get_mut(&assist_id)
376            && let Some(prompt_editor) = assist.prompt_editor.as_ref().cloned()
377        {
378            assist
379                .terminal
380                .update(cx, |terminal, cx| {
381                    terminal.clear_block_below_cursor(cx);
382                    let block = terminal_view::BlockProperties {
383                        height,
384                        render: Box::new(move |_| prompt_editor.clone().into_any_element()),
385                    };
386                    terminal.set_block_below_cursor(block, window, cx);
387                })
388                .log_err();
389        }
390    }
391}
392
393struct TerminalInlineAssist {
394    terminal: WeakEntity<TerminalView>,
395    prompt_editor: Option<Entity<PromptEditor<TerminalCodegen>>>,
396    codegen: Entity<TerminalCodegen>,
397    workspace: WeakEntity<Workspace>,
398    _subscriptions: Vec<Subscription>,
399}
400
401impl TerminalInlineAssist {
402    pub fn new(
403        assist_id: TerminalInlineAssistId,
404        terminal: &Entity<TerminalView>,
405        prompt_editor: Entity<PromptEditor<TerminalCodegen>>,
406        workspace: WeakEntity<Workspace>,
407        window: &mut Window,
408        cx: &mut App,
409    ) -> Self {
410        let codegen = prompt_editor.read(cx).codegen().clone();
411        Self {
412            terminal: terminal.downgrade(),
413            prompt_editor: Some(prompt_editor.clone()),
414            codegen: codegen.clone(),
415            workspace,
416            _subscriptions: vec![
417                window.subscribe(&prompt_editor, cx, |prompt_editor, event, window, cx| {
418                    TerminalInlineAssistant::update_global(cx, |this, cx| {
419                        this.handle_prompt_editor_event(prompt_editor, event, window, cx)
420                    })
421                }),
422                window.subscribe(&codegen, cx, move |codegen, event, window, cx| {
423                    TerminalInlineAssistant::update_global(cx, |this, cx| match event {
424                        CodegenEvent::Finished => {
425                            let assist = if let Some(assist) = this.assists.get(&assist_id) {
426                                assist
427                            } else {
428                                return;
429                            };
430
431                            if let CodegenStatus::Error(error) = &codegen.read(cx).status
432                                && assist.prompt_editor.is_none()
433                                && let Some(workspace) = assist.workspace.upgrade()
434                            {
435                                let error = format!("Terminal inline assistant error: {}", error);
436                                workspace.update(cx, |workspace, cx| {
437                                    struct InlineAssistantError;
438
439                                    let id = NotificationId::composite::<InlineAssistantError>(
440                                        assist_id.0,
441                                    );
442
443                                    workspace.show_toast(Toast::new(id, error), cx);
444                                })
445                            }
446
447                            if assist.prompt_editor.is_none() {
448                                this.finish_assist(assist_id, false, false, window, cx);
449                            }
450                        }
451                    })
452                }),
453            ],
454        }
455    }
456}