1use agent::ThreadStore;
2use agent_settings::AgentSettings;
3use collections::{HashMap, VecDeque};
4use editor::actions::Paste;
5use editor::code_context_menus::CodeContextMenu;
6use editor::display_map::{CreaseId, EditorMargins};
7use editor::{AnchorRangeExt as _, MultiBufferOffset, ToOffset as _};
8use editor::{
9 ContextMenuOptions, Editor, EditorElement, EditorEvent, EditorMode, EditorStyle, MultiBuffer,
10};
11use fs::Fs;
12use gpui::{
13 AnyElement, App, ClipboardItem, Context, Entity, EventEmitter, FocusHandle, Focusable,
14 Subscription, TextStyle, TextStyleRefinement, WeakEntity, Window, actions,
15};
16use language_model::{LanguageModel, LanguageModelRegistry};
17use markdown::{HeadingLevelStyles, Markdown, MarkdownElement, MarkdownStyle};
18use parking_lot::Mutex;
19use project::Project;
20use prompt_store::PromptStore;
21use settings::Settings;
22use std::cmp;
23use std::ops::Range;
24use std::rc::Rc;
25use std::sync::Arc;
26use theme_settings::ThemeSettings;
27use ui::utils::WithRemSize;
28use ui::{IconButtonShape, KeyBinding, PopoverMenuHandle, Tooltip, prelude::*};
29use uuid::Uuid;
30use workspace::notifications::NotificationId;
31use workspace::{Toast, Workspace};
32use zed_actions::{
33 agent::ToggleModelSelector,
34 editor::{MoveDown, MoveUp},
35};
36
37use crate::agent_model_selector::AgentModelSelector;
38use crate::buffer_codegen::{BufferCodegen, CodegenAlternative};
39use crate::completion_provider::{
40 PromptCompletionProvider, PromptCompletionProviderDelegate, PromptContextType,
41};
42use crate::mention_set::paste_images_as_context;
43use crate::mention_set::{MentionSet, crease_for_mention};
44use crate::terminal_codegen::TerminalCodegen;
45use crate::{
46 CycleFavoriteModels, CycleNextInlineAssist, CyclePreviousInlineAssist, ModelUsageContext,
47};
48
49actions!(inline_assistant, [ThumbsUpResult, ThumbsDownResult]);
50
51enum CompletionState {
52 Pending,
53 Generated { completion_text: Option<String> },
54 Rated,
55}
56
57struct SessionState {
58 session_id: Uuid,
59 completion: CompletionState,
60}
61
62pub struct PromptEditor<T> {
63 pub editor: Entity<Editor>,
64 mode: PromptEditorMode,
65 mention_set: Entity<MentionSet>,
66 prompt_store: Option<Entity<PromptStore>>,
67 workspace: WeakEntity<Workspace>,
68 model_selector: Entity<AgentModelSelector>,
69 edited_since_done: bool,
70 prompt_history: VecDeque<String>,
71 prompt_history_ix: Option<usize>,
72 pending_prompt: String,
73 _codegen_subscription: Subscription,
74 editor_subscriptions: Vec<Subscription>,
75 show_rate_limit_notice: bool,
76 session_state: SessionState,
77 _phantom: std::marker::PhantomData<T>,
78}
79
80impl<T: 'static> EventEmitter<PromptEditorEvent> for PromptEditor<T> {}
81
82impl<T: 'static> Render for PromptEditor<T> {
83 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
84 let ui_font_size = ThemeSettings::get_global(cx).ui_font_size(cx);
85 let mut buttons = Vec::new();
86
87 const RIGHT_PADDING: Pixels = px(9.);
88
89 let (left_gutter_width, right_padding, explanation) = match &self.mode {
90 PromptEditorMode::Buffer {
91 id: _,
92 codegen,
93 editor_margins,
94 } => {
95 let codegen = codegen.read(cx);
96
97 if codegen.alternative_count(cx) > 1 {
98 buttons.push(self.render_cycle_controls(codegen, cx));
99 }
100
101 let editor_margins = editor_margins.lock();
102 let gutter = editor_margins.gutter;
103
104 let left_gutter_width = gutter.full_width() + (gutter.margin / 2.0);
105 let right_padding = editor_margins.right + RIGHT_PADDING;
106
107 let active_alternative = codegen.active_alternative().read(cx);
108 let explanation = active_alternative
109 .description
110 .clone()
111 .or_else(|| active_alternative.failure.clone());
112
113 (left_gutter_width, right_padding, explanation)
114 }
115 PromptEditorMode::Terminal { .. } => {
116 // Give the equivalent of the same left-padding that we're using on the right
117 (Pixels::from(40.0), Pixels::from(24.), None)
118 }
119 };
120
121 let bottom_padding = match &self.mode {
122 PromptEditorMode::Buffer { .. } => rems_from_px(2.0),
123 PromptEditorMode::Terminal { .. } => rems_from_px(4.0),
124 };
125
126 buttons.extend(self.render_buttons(window, cx));
127
128 let menu_visible = self.is_completions_menu_visible(cx);
129 let add_context_button = IconButton::new("add-context", IconName::AtSign)
130 .icon_size(IconSize::Small)
131 .icon_color(Color::Muted)
132 .when(!menu_visible, |this| {
133 this.tooltip(move |_window, cx| {
134 Tooltip::with_meta("Add Context", None, "Or type @ to include context", cx)
135 })
136 })
137 .on_click(cx.listener(move |this, _, window, cx| {
138 this.trigger_completion_menu(window, cx);
139 }));
140
141 let markdown = window.use_state(cx, |_, cx| Markdown::new("".into(), None, None, cx));
142
143 if let Some(explanation) = &explanation {
144 markdown.update(cx, |markdown, cx| {
145 markdown.reset(SharedString::from(explanation), cx);
146 });
147 }
148
149 let explanation_label = self
150 .render_markdown(markdown, markdown_style(window, cx))
151 .into_any_element();
152
153 v_flex()
154 .key_context("InlineAssistant")
155 .capture_action(cx.listener(Self::paste))
156 .block_mouse_except_scroll()
157 .size_full()
158 .pt_0p5()
159 .pb(bottom_padding)
160 .pr(right_padding)
161 .gap_0p5()
162 .justify_center()
163 .border_y_1()
164 .border_color(cx.theme().colors().border)
165 .bg(cx.theme().colors().editor_background)
166 .child(
167 h_flex()
168 .on_action(cx.listener(Self::confirm))
169 .on_action(cx.listener(Self::secondary_confirm))
170 .on_action(cx.listener(Self::cancel))
171 .on_action(cx.listener(Self::move_up))
172 .on_action(cx.listener(Self::move_down))
173 .on_action(cx.listener(Self::thumbs_up))
174 .on_action(cx.listener(Self::thumbs_down))
175 .capture_action(cx.listener(Self::cycle_prev))
176 .capture_action(cx.listener(Self::cycle_next))
177 .on_action(cx.listener(|this, _: &ToggleModelSelector, window, cx| {
178 this.model_selector
179 .update(cx, |model_selector, cx| model_selector.toggle(window, cx));
180 }))
181 .on_action(cx.listener(|this, _: &CycleFavoriteModels, window, cx| {
182 this.model_selector.update(cx, |model_selector, cx| {
183 model_selector.cycle_favorite_models(window, cx);
184 });
185 }))
186 .child(
187 WithRemSize::new(ui_font_size)
188 .h_full()
189 .w(left_gutter_width)
190 .flex()
191 .flex_row()
192 .flex_shrink_0()
193 .items_center()
194 .justify_center()
195 .gap_1()
196 .child(self.render_close_button(cx))
197 .map(|el| {
198 let CodegenStatus::Error(error) = self.codegen_status(cx) else {
199 return el;
200 };
201
202 let error_message = SharedString::from(error.to_string());
203 el.child(
204 div()
205 .id("error")
206 .tooltip(Tooltip::text(error_message))
207 .child(
208 Icon::new(IconName::XCircle)
209 .size(IconSize::Small)
210 .color(Color::Error),
211 ),
212 )
213 }),
214 )
215 .child(
216 h_flex()
217 .w_full()
218 .justify_between()
219 .child(div().flex_1().child(self.render_editor(window, cx)))
220 .child(
221 WithRemSize::new(ui_font_size)
222 .flex()
223 .flex_row()
224 .items_center()
225 .gap_1()
226 .child(add_context_button)
227 .child(self.model_selector.clone())
228 .children(buttons),
229 ),
230 ),
231 )
232 .when_some(explanation, |this, _| {
233 this.child(
234 h_flex()
235 .size_full()
236 .justify_center()
237 .child(div().w(left_gutter_width + px(6.)))
238 .child(
239 div()
240 .size_full()
241 .min_w_0()
242 .pt(rems_from_px(3.))
243 .pl_0p5()
244 .flex_1()
245 .border_t_1()
246 .border_color(cx.theme().colors().border_variant)
247 .child(explanation_label),
248 ),
249 )
250 })
251 }
252}
253
254fn markdown_style(window: &Window, cx: &App) -> MarkdownStyle {
255 let theme_settings = ThemeSettings::get_global(cx);
256 let colors = cx.theme().colors();
257 let mut text_style = window.text_style();
258
259 text_style.refine(&TextStyleRefinement {
260 font_family: Some(theme_settings.ui_font.family.clone()),
261 color: Some(colors.text),
262 ..Default::default()
263 });
264
265 MarkdownStyle {
266 base_text_style: text_style.clone(),
267 syntax: cx.theme().syntax().clone(),
268 selection_background_color: colors.element_selection_background,
269 heading_level_styles: Some(HeadingLevelStyles {
270 h1: Some(TextStyleRefinement {
271 font_size: Some(rems(1.15).into()),
272 ..Default::default()
273 }),
274 h2: Some(TextStyleRefinement {
275 font_size: Some(rems(1.1).into()),
276 ..Default::default()
277 }),
278 h3: Some(TextStyleRefinement {
279 font_size: Some(rems(1.05).into()),
280 ..Default::default()
281 }),
282 h4: Some(TextStyleRefinement {
283 font_size: Some(rems(1.).into()),
284 ..Default::default()
285 }),
286 h5: Some(TextStyleRefinement {
287 font_size: Some(rems(0.95).into()),
288 ..Default::default()
289 }),
290 h6: Some(TextStyleRefinement {
291 font_size: Some(rems(0.875).into()),
292 ..Default::default()
293 }),
294 }),
295 inline_code: TextStyleRefinement {
296 font_family: Some(theme_settings.buffer_font.family.clone()),
297 font_fallbacks: theme_settings.buffer_font.fallbacks.clone(),
298 font_features: Some(theme_settings.buffer_font.features.clone()),
299 background_color: Some(colors.editor_foreground.opacity(0.08)),
300 ..Default::default()
301 },
302 ..Default::default()
303 }
304}
305
306impl<T: 'static> Focusable for PromptEditor<T> {
307 fn focus_handle(&self, cx: &App) -> FocusHandle {
308 self.editor.focus_handle(cx)
309 }
310}
311
312impl<T: 'static> PromptEditor<T> {
313 const MAX_LINES: u8 = 8;
314
315 fn codegen_status<'a>(&'a self, cx: &'a App) -> &'a CodegenStatus {
316 match &self.mode {
317 PromptEditorMode::Buffer { codegen, .. } => codegen.read(cx).status(cx),
318 PromptEditorMode::Terminal { codegen, .. } => &codegen.read(cx).status,
319 }
320 }
321
322 fn subscribe_to_editor(&mut self, window: &mut Window, cx: &mut Context<Self>) {
323 self.editor_subscriptions.clear();
324 self.editor_subscriptions.push(cx.subscribe_in(
325 &self.editor,
326 window,
327 Self::handle_prompt_editor_events,
328 ));
329 }
330
331 fn assign_completion_provider(&mut self, cx: &mut Context<Self>) {
332 self.editor.update(cx, |editor, cx| {
333 editor.set_completion_provider(Some(Rc::new(PromptCompletionProvider::new(
334 PromptEditorCompletionProviderDelegate,
335 cx.weak_entity(),
336 self.mention_set.clone(),
337 self.prompt_store.clone(),
338 self.workspace.clone(),
339 ))));
340 });
341 }
342
343 pub fn set_show_cursor_when_unfocused(
344 &mut self,
345 show_cursor_when_unfocused: bool,
346 cx: &mut Context<Self>,
347 ) {
348 self.editor.update(cx, |editor, cx| {
349 editor.set_show_cursor_when_unfocused(show_cursor_when_unfocused, cx)
350 });
351 }
352
353 pub fn unlink(&mut self, window: &mut Window, cx: &mut Context<Self>) {
354 let prompt = self.prompt(cx);
355 let existing_creases = self.editor.update(cx, |editor, cx| {
356 extract_message_creases(editor, &self.mention_set, window, cx)
357 });
358 let focus = self.editor.focus_handle(cx).contains_focused(window, cx);
359 let mut creases = vec![];
360 self.editor = cx.new(|cx| {
361 let mut editor = Editor::auto_height(1, Self::MAX_LINES as usize, window, cx);
362 editor.set_soft_wrap_mode(language::language_settings::SoftWrap::EditorWidth, cx);
363 editor.set_placeholder_text("Add a prompt…", window, cx);
364 editor.set_text(prompt, window, cx);
365 creases = insert_message_creases(&mut editor, &existing_creases, window, cx);
366
367 if focus {
368 window.focus(&editor.focus_handle(cx), cx);
369 }
370 editor
371 });
372
373 self.mention_set.update(cx, |mention_set, _cx| {
374 debug_assert_eq!(
375 creases.len(),
376 mention_set.creases().len(),
377 "Missing creases"
378 );
379
380 let mentions = mention_set
381 .clear()
382 .zip(creases)
383 .map(|((_, value), id)| (id, value))
384 .collect::<HashMap<_, _>>();
385 mention_set.set_mentions(mentions);
386 });
387
388 self.assign_completion_provider(cx);
389 self.subscribe_to_editor(window, cx);
390 }
391
392 pub fn placeholder_text(mode: &PromptEditorMode, window: &mut Window, cx: &mut App) -> String {
393 let action = match mode {
394 PromptEditorMode::Buffer { codegen, .. } => {
395 if codegen.read(cx).is_insertion {
396 "Generate"
397 } else {
398 "Transform"
399 }
400 }
401 PromptEditorMode::Terminal { .. } => "Generate",
402 };
403
404 let agent_panel_keybinding =
405 ui::text_for_action(&zed_actions::assistant::ToggleFocus, window, cx)
406 .map(|keybinding| format!("{keybinding} to chat"))
407 .unwrap_or_default();
408
409 format!("{action}… ({agent_panel_keybinding} ― ↓↑ for history — @ to include context)")
410 }
411
412 pub fn prompt(&self, cx: &App) -> String {
413 self.editor.read(cx).text(cx)
414 }
415
416 fn paste(&mut self, _: &Paste, window: &mut Window, cx: &mut Context<Self>) {
417 if inline_assistant_model_supports_images(cx)
418 && let Some(task) = paste_images_as_context(
419 self.editor.clone(),
420 self.mention_set.clone(),
421 self.workspace.clone(),
422 window,
423 cx,
424 )
425 {
426 task.detach();
427 }
428 }
429
430 fn handle_prompt_editor_events(
431 &mut self,
432 editor: &Entity<Editor>,
433 event: &EditorEvent,
434 window: &mut Window,
435 cx: &mut Context<Self>,
436 ) {
437 match event {
438 EditorEvent::Edited { .. } => {
439 let snapshot = editor.update(cx, |editor, cx| editor.snapshot(window, cx));
440
441 self.mention_set
442 .update(cx, |mention_set, _cx| mention_set.remove_invalid(&snapshot));
443
444 if let Some(workspace) = Workspace::for_window(window, cx) {
445 workspace.update(cx, |workspace, cx| {
446 let is_via_ssh = workspace.project().read(cx).is_via_remote_server();
447
448 workspace
449 .client()
450 .telemetry()
451 .log_edit_event("inline assist", is_via_ssh);
452 });
453 }
454 let prompt = snapshot.text();
455 if self
456 .prompt_history_ix
457 .is_none_or(|ix| self.prompt_history[ix] != prompt)
458 {
459 self.prompt_history_ix.take();
460 self.pending_prompt = prompt;
461 }
462
463 self.edited_since_done = true;
464 self.session_state.completion = CompletionState::Pending;
465 cx.notify();
466 }
467 EditorEvent::Blurred => {
468 if self.show_rate_limit_notice {
469 self.show_rate_limit_notice = false;
470 cx.notify();
471 }
472 }
473 _ => {}
474 }
475 }
476
477 pub fn is_completions_menu_visible(&self, cx: &App) -> bool {
478 self.editor
479 .read(cx)
480 .context_menu()
481 .borrow()
482 .as_ref()
483 .is_some_and(|menu| matches!(menu, CodeContextMenu::Completions(_)) && menu.visible())
484 }
485
486 pub fn trigger_completion_menu(&mut self, window: &mut Window, cx: &mut Context<Self>) {
487 self.editor.update(cx, |editor, cx| {
488 let menu_is_open = editor.context_menu().borrow().as_ref().is_some_and(|menu| {
489 matches!(menu, CodeContextMenu::Completions(_)) && menu.visible()
490 });
491
492 let has_at_sign = {
493 let snapshot = editor.display_snapshot(cx);
494 let cursor = editor.selections.newest::<text::Point>(&snapshot).head();
495 let offset = cursor.to_offset(&snapshot);
496 if offset.0 > 0 {
497 snapshot
498 .buffer_snapshot()
499 .reversed_chars_at(offset)
500 .next()
501 .map(|sign| sign == '@')
502 .unwrap_or(false)
503 } else {
504 false
505 }
506 };
507
508 if menu_is_open && has_at_sign {
509 return;
510 }
511
512 editor.insert("@", window, cx);
513 editor.show_completions(&editor::actions::ShowCompletions, window, cx);
514 });
515 }
516
517 fn cancel(
518 &mut self,
519 _: &editor::actions::Cancel,
520 _window: &mut Window,
521 cx: &mut Context<Self>,
522 ) {
523 match self.codegen_status(cx) {
524 CodegenStatus::Idle | CodegenStatus::Done | CodegenStatus::Error(_) => {
525 cx.emit(PromptEditorEvent::CancelRequested);
526 }
527 CodegenStatus::Pending => {
528 cx.emit(PromptEditorEvent::StopRequested);
529 }
530 }
531 }
532
533 fn confirm(&mut self, _: &menu::Confirm, _window: &mut Window, cx: &mut Context<Self>) {
534 self.handle_confirm(false, cx);
535 }
536
537 fn secondary_confirm(
538 &mut self,
539 _: &menu::SecondaryConfirm,
540 _window: &mut Window,
541 cx: &mut Context<Self>,
542 ) {
543 let execute = matches!(self.mode, PromptEditorMode::Terminal { .. });
544 self.handle_confirm(execute, cx);
545 }
546
547 fn handle_confirm(&mut self, execute: bool, cx: &mut Context<Self>) {
548 match self.codegen_status(cx) {
549 CodegenStatus::Idle => {
550 self.fire_started_telemetry(cx);
551 cx.emit(PromptEditorEvent::StartRequested);
552 }
553 CodegenStatus::Pending => {}
554 CodegenStatus::Done => {
555 if self.edited_since_done {
556 self.fire_started_telemetry(cx);
557 cx.emit(PromptEditorEvent::StartRequested);
558 } else {
559 cx.emit(PromptEditorEvent::ConfirmRequested { execute });
560 }
561 }
562 CodegenStatus::Error(_) => {
563 self.fire_started_telemetry(cx);
564 cx.emit(PromptEditorEvent::StartRequested);
565 }
566 }
567 }
568
569 fn fire_started_telemetry(&self, cx: &Context<Self>) {
570 let Some(model) = LanguageModelRegistry::read_global(cx).inline_assistant_model() else {
571 return;
572 };
573
574 let model_telemetry_id = model.model.telemetry_id();
575 let model_provider_id = model.provider.id().to_string();
576
577 let (kind, language_name) = match &self.mode {
578 PromptEditorMode::Buffer { codegen, .. } => {
579 let codegen = codegen.read(cx);
580 (
581 "inline",
582 codegen.language_name(cx).map(|name| name.to_string()),
583 )
584 }
585 PromptEditorMode::Terminal { .. } => ("inline_terminal", None),
586 };
587
588 telemetry::event!(
589 "Assistant Started",
590 session_id = self.session_state.session_id.to_string(),
591 kind = kind,
592 phase = "started",
593 model = model_telemetry_id,
594 model_provider = model_provider_id,
595 language_name = language_name,
596 );
597 }
598
599 fn thumbs_up(&mut self, _: &ThumbsUpResult, _window: &mut Window, cx: &mut Context<Self>) {
600 match &self.session_state.completion {
601 CompletionState::Pending => {
602 self.toast("Can't rate, still generating...", None, cx);
603 return;
604 }
605 CompletionState::Rated => {
606 self.toast(
607 "Already rated this completion",
608 Some(self.session_state.session_id),
609 cx,
610 );
611 return;
612 }
613 CompletionState::Generated { completion_text } => {
614 let model_info = self.model_selector.read(cx).active_model(cx);
615 let (model_id, use_streaming_tools) = {
616 let Some(configured_model) = model_info else {
617 self.toast("No configured model", None, cx);
618 return;
619 };
620 (
621 configured_model.model.telemetry_id(),
622 CodegenAlternative::use_streaming_tools(
623 configured_model.model.as_ref(),
624 cx,
625 ),
626 )
627 };
628
629 let selected_text = match &self.mode {
630 PromptEditorMode::Buffer { codegen, .. } => {
631 codegen.read(cx).selected_text(cx).map(|s| s.to_string())
632 }
633 PromptEditorMode::Terminal { .. } => None,
634 };
635
636 let prompt = self.editor.read(cx).text(cx);
637
638 let kind = match &self.mode {
639 PromptEditorMode::Buffer { .. } => "inline",
640 PromptEditorMode::Terminal { .. } => "inline_terminal",
641 };
642
643 telemetry::event!(
644 "Inline Assistant Rated",
645 rating = "positive",
646 session_id = self.session_state.session_id.to_string(),
647 kind = kind,
648 model = model_id,
649 prompt = prompt,
650 completion = completion_text,
651 selected_text = selected_text,
652 use_streaming_tools
653 );
654
655 self.session_state.completion = CompletionState::Rated;
656
657 cx.notify();
658 }
659 }
660 }
661
662 fn thumbs_down(&mut self, _: &ThumbsDownResult, _window: &mut Window, cx: &mut Context<Self>) {
663 match &self.session_state.completion {
664 CompletionState::Pending => {
665 self.toast("Can't rate, still generating...", None, cx);
666 return;
667 }
668 CompletionState::Rated => {
669 self.toast(
670 "Already rated this completion",
671 Some(self.session_state.session_id),
672 cx,
673 );
674 return;
675 }
676 CompletionState::Generated { completion_text } => {
677 let model_info = self.model_selector.read(cx).active_model(cx);
678 let (model_telemetry_id, use_streaming_tools) = {
679 let Some(configured_model) = model_info else {
680 self.toast("No configured model", None, cx);
681 return;
682 };
683 (
684 configured_model.model.telemetry_id(),
685 CodegenAlternative::use_streaming_tools(
686 configured_model.model.as_ref(),
687 cx,
688 ),
689 )
690 };
691
692 let selected_text = match &self.mode {
693 PromptEditorMode::Buffer { codegen, .. } => {
694 codegen.read(cx).selected_text(cx).map(|s| s.to_string())
695 }
696 PromptEditorMode::Terminal { .. } => None,
697 };
698
699 let prompt = self.editor.read(cx).text(cx);
700
701 let kind = match &self.mode {
702 PromptEditorMode::Buffer { .. } => "inline",
703 PromptEditorMode::Terminal { .. } => "inline_terminal",
704 };
705
706 telemetry::event!(
707 "Inline Assistant Rated",
708 rating = "negative",
709 session_id = self.session_state.session_id.to_string(),
710 kind = kind,
711 model = model_telemetry_id,
712 prompt = prompt,
713 completion = completion_text,
714 selected_text = selected_text,
715 use_streaming_tools
716 );
717
718 self.session_state.completion = CompletionState::Rated;
719
720 cx.notify();
721 }
722 }
723 }
724
725 fn toast(&mut self, msg: &str, uuid: Option<Uuid>, cx: &mut Context<'_, PromptEditor<T>>) {
726 self.workspace
727 .update(cx, |workspace, cx| {
728 enum InlinePromptRating {}
729 workspace.show_toast(
730 {
731 let mut toast = Toast::new(
732 NotificationId::unique::<InlinePromptRating>(),
733 msg.to_string(),
734 )
735 .autohide();
736
737 if let Some(uuid) = uuid {
738 toast = toast.on_click("Click to copy rating ID", move |_, cx| {
739 cx.write_to_clipboard(ClipboardItem::new_string(uuid.to_string()));
740 });
741 };
742
743 toast
744 },
745 cx,
746 );
747 })
748 .ok();
749 }
750
751 fn move_up(&mut self, _: &MoveUp, window: &mut Window, cx: &mut Context<Self>) {
752 if let Some(ix) = self.prompt_history_ix {
753 if ix > 0 {
754 self.prompt_history_ix = Some(ix - 1);
755 let prompt = self.prompt_history[ix - 1].as_str();
756 self.editor.update(cx, |editor, cx| {
757 editor.set_text(prompt, window, cx);
758 editor.move_to_beginning(&Default::default(), window, cx);
759 });
760 }
761 } else if !self.prompt_history.is_empty() {
762 self.prompt_history_ix = Some(self.prompt_history.len() - 1);
763 let prompt = self.prompt_history[self.prompt_history.len() - 1].as_str();
764 self.editor.update(cx, |editor, cx| {
765 editor.set_text(prompt, window, cx);
766 editor.move_to_beginning(&Default::default(), window, cx);
767 });
768 }
769 }
770
771 fn move_down(&mut self, _: &MoveDown, window: &mut Window, cx: &mut Context<Self>) {
772 if let Some(ix) = self.prompt_history_ix {
773 if ix < self.prompt_history.len() - 1 {
774 self.prompt_history_ix = Some(ix + 1);
775 let prompt = self.prompt_history[ix + 1].as_str();
776 self.editor.update(cx, |editor, cx| {
777 editor.set_text(prompt, window, cx);
778 editor.move_to_end(&Default::default(), window, cx)
779 });
780 } else {
781 self.prompt_history_ix = None;
782 let prompt = self.pending_prompt.as_str();
783 self.editor.update(cx, |editor, cx| {
784 editor.set_text(prompt, window, cx);
785 editor.move_to_end(&Default::default(), window, cx)
786 });
787 }
788 }
789 }
790
791 fn render_buttons(&self, _window: &mut Window, cx: &mut Context<Self>) -> Vec<AnyElement> {
792 let mode = match &self.mode {
793 PromptEditorMode::Buffer { codegen, .. } => {
794 let codegen = codegen.read(cx);
795 if codegen.is_insertion {
796 GenerationMode::Generate
797 } else {
798 GenerationMode::Transform
799 }
800 }
801 PromptEditorMode::Terminal { .. } => GenerationMode::Generate,
802 };
803
804 let codegen_status = self.codegen_status(cx);
805
806 match codegen_status {
807 CodegenStatus::Idle => {
808 vec![
809 Button::new("start", mode.start_label())
810 .label_size(LabelSize::Small)
811 .end_icon(
812 Icon::new(IconName::Return)
813 .size(IconSize::XSmall)
814 .color(Color::Muted),
815 )
816 .on_click(
817 cx.listener(|_, _, _, cx| cx.emit(PromptEditorEvent::StartRequested)),
818 )
819 .into_any_element(),
820 ]
821 }
822 CodegenStatus::Pending => vec![
823 IconButton::new("stop", IconName::Stop)
824 .icon_color(Color::Error)
825 .shape(IconButtonShape::Square)
826 .tooltip(move |_window, cx| {
827 Tooltip::with_meta(
828 mode.tooltip_interrupt(),
829 Some(&menu::Cancel),
830 "Changes won't be discarded",
831 cx,
832 )
833 })
834 .on_click(cx.listener(|_, _, _, cx| cx.emit(PromptEditorEvent::StopRequested)))
835 .into_any_element(),
836 ],
837 CodegenStatus::Done | CodegenStatus::Error(_) => {
838 let has_error = matches!(codegen_status, CodegenStatus::Error(_));
839 if has_error || self.edited_since_done {
840 vec![
841 IconButton::new("restart", IconName::RotateCw)
842 .icon_color(Color::Info)
843 .shape(IconButtonShape::Square)
844 .tooltip(move |_window, cx| {
845 Tooltip::with_meta(
846 mode.tooltip_restart(),
847 Some(&menu::Confirm),
848 "Changes will be discarded",
849 cx,
850 )
851 })
852 .on_click(cx.listener(|_, _, _, cx| {
853 cx.emit(PromptEditorEvent::StartRequested);
854 }))
855 .into_any_element(),
856 ]
857 } else {
858 let rated = matches!(self.session_state.completion, CompletionState::Rated);
859
860 let accept = IconButton::new("accept", IconName::Check)
861 .icon_color(Color::Info)
862 .shape(IconButtonShape::Square)
863 .tooltip(move |_window, cx| {
864 Tooltip::for_action(mode.tooltip_accept(), &menu::Confirm, cx)
865 })
866 .on_click(cx.listener(|_, _, _, cx| {
867 cx.emit(PromptEditorEvent::ConfirmRequested { execute: false });
868 }))
869 .into_any_element();
870
871 let mut buttons = Vec::new();
872
873 if AgentSettings::get_global(cx).enable_feedback {
874 buttons.push(
875 h_flex()
876 .pl_1()
877 .gap_1()
878 .border_l_1()
879 .border_color(cx.theme().colors().border_variant)
880 .child(
881 IconButton::new("thumbs-up", IconName::ThumbsUp)
882 .shape(IconButtonShape::Square)
883 .map(|this| {
884 if rated {
885 this.disabled(true)
886 .icon_color(Color::Disabled)
887 .tooltip(move |_, cx| {
888 Tooltip::with_meta(
889 "Good Result",
890 None,
891 "You already rated this result",
892 cx,
893 )
894 })
895 } else {
896 this.icon_color(Color::Muted).tooltip(
897 move |_, cx| {
898 Tooltip::for_action(
899 "Good Result",
900 &ThumbsUpResult,
901 cx,
902 )
903 },
904 )
905 }
906 })
907 .on_click(cx.listener(|this, _, window, cx| {
908 this.thumbs_up(&ThumbsUpResult, window, cx);
909 })),
910 )
911 .child(
912 IconButton::new("thumbs-down", IconName::ThumbsDown)
913 .shape(IconButtonShape::Square)
914 .map(|this| {
915 if rated {
916 this.disabled(true)
917 .icon_color(Color::Disabled)
918 .tooltip(move |_, cx| {
919 Tooltip::with_meta(
920 "Bad Result",
921 None,
922 "You already rated this result",
923 cx,
924 )
925 })
926 } else {
927 this.icon_color(Color::Muted).tooltip(
928 move |_, cx| {
929 Tooltip::for_action(
930 "Bad Result",
931 &ThumbsDownResult,
932 cx,
933 )
934 },
935 )
936 }
937 })
938 .on_click(cx.listener(|this, _, window, cx| {
939 this.thumbs_down(&ThumbsDownResult, window, cx);
940 })),
941 )
942 .into_any_element(),
943 );
944 }
945
946 buttons.push(accept);
947
948 match &self.mode {
949 PromptEditorMode::Terminal { .. } => {
950 buttons.push(
951 IconButton::new("confirm", IconName::PlayFilled)
952 .icon_color(Color::Info)
953 .shape(IconButtonShape::Square)
954 .tooltip(|_window, cx| {
955 Tooltip::for_action(
956 "Execute Generated Command",
957 &menu::SecondaryConfirm,
958 cx,
959 )
960 })
961 .on_click(cx.listener(|_, _, _, cx| {
962 cx.emit(PromptEditorEvent::ConfirmRequested {
963 execute: true,
964 });
965 }))
966 .into_any_element(),
967 );
968 buttons
969 }
970 PromptEditorMode::Buffer { .. } => buttons,
971 }
972 }
973 }
974 }
975 }
976
977 fn cycle_prev(
978 &mut self,
979 _: &CyclePreviousInlineAssist,
980 _: &mut Window,
981 cx: &mut Context<Self>,
982 ) {
983 match &self.mode {
984 PromptEditorMode::Buffer { codegen, .. } => {
985 codegen.update(cx, |codegen, cx| codegen.cycle_prev(cx));
986 }
987 PromptEditorMode::Terminal { .. } => {
988 // no cycle buttons in terminal mode
989 }
990 }
991 }
992
993 fn cycle_next(&mut self, _: &CycleNextInlineAssist, _: &mut Window, cx: &mut Context<Self>) {
994 match &self.mode {
995 PromptEditorMode::Buffer { codegen, .. } => {
996 codegen.update(cx, |codegen, cx| codegen.cycle_next(cx));
997 }
998 PromptEditorMode::Terminal { .. } => {
999 // no cycle buttons in terminal mode
1000 }
1001 }
1002 }
1003
1004 fn render_close_button(&self, cx: &mut Context<Self>) -> AnyElement {
1005 let focus_handle = self.editor.focus_handle(cx);
1006
1007 IconButton::new("cancel", IconName::Close)
1008 .icon_color(Color::Muted)
1009 .shape(IconButtonShape::Square)
1010 .tooltip({
1011 move |_window, cx| {
1012 Tooltip::for_action_in(
1013 "Close Assistant",
1014 &editor::actions::Cancel,
1015 &focus_handle,
1016 cx,
1017 )
1018 }
1019 })
1020 .on_click(cx.listener(|_, _, _, cx| cx.emit(PromptEditorEvent::CancelRequested)))
1021 .into_any_element()
1022 }
1023
1024 fn render_cycle_controls(&self, codegen: &BufferCodegen, cx: &Context<Self>) -> AnyElement {
1025 let disabled = matches!(codegen.status(cx), CodegenStatus::Idle);
1026
1027 let model_registry = LanguageModelRegistry::read_global(cx);
1028 let default_model = model_registry.default_model().map(|default| default.model);
1029 let alternative_models = model_registry.inline_alternative_models();
1030
1031 let get_model_name = |index: usize| -> String {
1032 let name = |model: &Arc<dyn LanguageModel>| model.name().0.to_string();
1033
1034 match index {
1035 0 => default_model.as_ref().map_or_else(String::new, name),
1036 index if index <= alternative_models.len() => alternative_models
1037 .get(index - 1)
1038 .map_or_else(String::new, name),
1039 _ => String::new(),
1040 }
1041 };
1042
1043 let total_models = alternative_models.len() + 1;
1044
1045 if total_models <= 1 {
1046 return div().into_any_element();
1047 }
1048
1049 let current_index = codegen.active_alternative;
1050 let prev_index = (current_index + total_models - 1) % total_models;
1051 let next_index = (current_index + 1) % total_models;
1052
1053 let prev_model_name = get_model_name(prev_index);
1054 let next_model_name = get_model_name(next_index);
1055
1056 h_flex()
1057 .child(
1058 IconButton::new("previous", IconName::ChevronLeft)
1059 .icon_color(Color::Muted)
1060 .disabled(disabled || current_index == 0)
1061 .shape(IconButtonShape::Square)
1062 .tooltip({
1063 let focus_handle = self.editor.focus_handle(cx);
1064 move |_window, cx| {
1065 cx.new(|cx| {
1066 let mut tooltip = Tooltip::new("Previous Alternative").key_binding(
1067 KeyBinding::for_action_in(
1068 &CyclePreviousInlineAssist,
1069 &focus_handle,
1070 cx,
1071 ),
1072 );
1073 if !disabled && current_index != 0 {
1074 tooltip = tooltip.meta(prev_model_name.clone());
1075 }
1076 tooltip
1077 })
1078 .into()
1079 }
1080 })
1081 .on_click(cx.listener(|this, _, window, cx| {
1082 this.cycle_prev(&CyclePreviousInlineAssist, window, cx);
1083 })),
1084 )
1085 .child(
1086 Label::new(format!(
1087 "{}/{}",
1088 codegen.active_alternative + 1,
1089 codegen.alternative_count(cx)
1090 ))
1091 .size(LabelSize::Small)
1092 .color(if disabled {
1093 Color::Disabled
1094 } else {
1095 Color::Muted
1096 }),
1097 )
1098 .child(
1099 IconButton::new("next", IconName::ChevronRight)
1100 .icon_color(Color::Muted)
1101 .disabled(disabled || current_index == total_models - 1)
1102 .shape(IconButtonShape::Square)
1103 .tooltip({
1104 let focus_handle = self.editor.focus_handle(cx);
1105 move |_window, cx| {
1106 cx.new(|cx| {
1107 let mut tooltip = Tooltip::new("Next Alternative").key_binding(
1108 KeyBinding::for_action_in(
1109 &CycleNextInlineAssist,
1110 &focus_handle,
1111 cx,
1112 ),
1113 );
1114 if !disabled && current_index != total_models - 1 {
1115 tooltip = tooltip.meta(next_model_name.clone());
1116 }
1117 tooltip
1118 })
1119 .into()
1120 }
1121 })
1122 .on_click(cx.listener(|this, _, window, cx| {
1123 this.cycle_next(&CycleNextInlineAssist, window, cx)
1124 })),
1125 )
1126 .into_any_element()
1127 }
1128
1129 fn render_editor(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> AnyElement {
1130 let colors = cx.theme().colors();
1131
1132 div()
1133 .size_full()
1134 .p_2()
1135 .pl_1()
1136 .bg(colors.editor_background)
1137 .child({
1138 let settings = ThemeSettings::get_global(cx);
1139 let font_size = settings.buffer_font_size(cx);
1140
1141 let text_style = TextStyle {
1142 color: colors.editor_foreground,
1143 font_family: settings.buffer_font.family.clone(),
1144 font_features: settings.buffer_font.features.clone(),
1145 font_size: font_size.into(),
1146 line_height: relative(settings.buffer_line_height.value()),
1147 ..Default::default()
1148 };
1149
1150 EditorElement::new(
1151 &self.editor,
1152 EditorStyle {
1153 background: colors.editor_background,
1154 local_player: cx.theme().players().local(),
1155 syntax: cx.theme().syntax().clone(),
1156 text: text_style,
1157 ..Default::default()
1158 },
1159 )
1160 })
1161 .into_any_element()
1162 }
1163
1164 fn render_markdown(&self, markdown: Entity<Markdown>, style: MarkdownStyle) -> MarkdownElement {
1165 MarkdownElement::new(markdown, style)
1166 }
1167}
1168
1169pub enum PromptEditorMode {
1170 Buffer {
1171 id: InlineAssistId,
1172 codegen: Entity<BufferCodegen>,
1173 editor_margins: Arc<Mutex<EditorMargins>>,
1174 },
1175 Terminal {
1176 id: TerminalInlineAssistId,
1177 codegen: Entity<TerminalCodegen>,
1178 height_in_lines: u8,
1179 },
1180}
1181
1182pub enum PromptEditorEvent {
1183 StartRequested,
1184 StopRequested,
1185 ConfirmRequested { execute: bool },
1186 CancelRequested,
1187 Resized { height_in_lines: u8 },
1188}
1189
1190#[derive(Copy, Clone, Default, Debug, PartialEq, Eq, Hash)]
1191pub struct InlineAssistId(pub usize);
1192
1193impl InlineAssistId {
1194 pub fn post_inc(&mut self) -> InlineAssistId {
1195 let id = *self;
1196 self.0 += 1;
1197 id
1198 }
1199}
1200
1201struct PromptEditorCompletionProviderDelegate;
1202
1203fn inline_assistant_model_supports_images(cx: &App) -> bool {
1204 LanguageModelRegistry::read_global(cx)
1205 .inline_assistant_model()
1206 .map_or(false, |m| m.model.supports_images())
1207}
1208
1209impl PromptCompletionProviderDelegate for PromptEditorCompletionProviderDelegate {
1210 fn supported_modes(&self, _cx: &App) -> Vec<PromptContextType> {
1211 vec![
1212 PromptContextType::File,
1213 PromptContextType::Symbol,
1214 PromptContextType::Thread,
1215 PromptContextType::Fetch,
1216 PromptContextType::Rules,
1217 ]
1218 }
1219
1220 fn supports_images(&self, cx: &App) -> bool {
1221 inline_assistant_model_supports_images(cx)
1222 }
1223
1224 fn available_commands(&self, _cx: &App) -> Vec<crate::completion_provider::AvailableCommand> {
1225 Vec::new()
1226 }
1227
1228 fn confirm_command(&self, _cx: &mut App) {}
1229}
1230
1231impl PromptEditor<BufferCodegen> {
1232 pub fn new_buffer(
1233 id: InlineAssistId,
1234 editor_margins: Arc<Mutex<EditorMargins>>,
1235 prompt_history: VecDeque<String>,
1236 prompt_buffer: Entity<MultiBuffer>,
1237 codegen: Entity<BufferCodegen>,
1238 session_id: Uuid,
1239 fs: Arc<dyn Fs>,
1240 thread_store: Entity<ThreadStore>,
1241 prompt_store: Option<Entity<PromptStore>>,
1242 project: WeakEntity<Project>,
1243 workspace: WeakEntity<Workspace>,
1244 window: &mut Window,
1245 cx: &mut Context<PromptEditor<BufferCodegen>>,
1246 ) -> PromptEditor<BufferCodegen> {
1247 let codegen_subscription = cx.observe(&codegen, Self::handle_codegen_changed);
1248 let mode = PromptEditorMode::Buffer {
1249 id,
1250 codegen,
1251 editor_margins,
1252 };
1253
1254 let prompt_editor = cx.new(|cx| {
1255 let mut editor = Editor::new(
1256 EditorMode::AutoHeight {
1257 min_lines: 1,
1258 max_lines: Some(Self::MAX_LINES as usize),
1259 },
1260 prompt_buffer,
1261 None,
1262 window,
1263 cx,
1264 );
1265 editor.set_soft_wrap_mode(language::language_settings::SoftWrap::EditorWidth, cx);
1266 // Since the prompt editors for all inline assistants are linked,
1267 // always show the cursor (even when it isn't focused) because
1268 // typing in one will make what you typed appear in all of them.
1269 editor.set_show_cursor_when_unfocused(true, cx);
1270 editor.set_placeholder_text(&Self::placeholder_text(&mode, window, cx), window, cx);
1271 editor.set_context_menu_options(ContextMenuOptions {
1272 min_entries_visible: 12,
1273 max_entries_visible: 12,
1274 placement: None,
1275 });
1276
1277 editor
1278 });
1279
1280 let mention_set = cx
1281 .new(|_cx| MentionSet::new(project, Some(thread_store.clone()), prompt_store.clone()));
1282
1283 let model_selector_menu_handle = PopoverMenuHandle::default();
1284
1285 let mut this: PromptEditor<BufferCodegen> = PromptEditor {
1286 editor: prompt_editor.clone(),
1287 mention_set,
1288 prompt_store,
1289 workspace,
1290 model_selector: cx.new(|cx| {
1291 AgentModelSelector::new(
1292 fs,
1293 model_selector_menu_handle,
1294 prompt_editor.focus_handle(cx),
1295 ModelUsageContext::InlineAssistant,
1296 window,
1297 cx,
1298 )
1299 }),
1300 edited_since_done: false,
1301 prompt_history,
1302 prompt_history_ix: None,
1303 pending_prompt: String::new(),
1304 _codegen_subscription: codegen_subscription,
1305 editor_subscriptions: Vec::new(),
1306 show_rate_limit_notice: false,
1307 mode,
1308 session_state: SessionState {
1309 session_id,
1310 completion: CompletionState::Pending,
1311 },
1312 _phantom: Default::default(),
1313 };
1314
1315 this.assign_completion_provider(cx);
1316 this.subscribe_to_editor(window, cx);
1317 this
1318 }
1319
1320 fn handle_codegen_changed(
1321 &mut self,
1322 codegen: Entity<BufferCodegen>,
1323 cx: &mut Context<PromptEditor<BufferCodegen>>,
1324 ) {
1325 match self.codegen_status(cx) {
1326 CodegenStatus::Idle => {
1327 self.editor
1328 .update(cx, |editor, _| editor.set_read_only(false));
1329 }
1330 CodegenStatus::Pending => {
1331 self.session_state.completion = CompletionState::Pending;
1332 self.editor
1333 .update(cx, |editor, _| editor.set_read_only(true));
1334 }
1335 CodegenStatus::Done => {
1336 let completion = codegen.read(cx).active_completion(cx);
1337 self.session_state.completion = CompletionState::Generated {
1338 completion_text: completion,
1339 };
1340 self.edited_since_done = false;
1341 self.editor
1342 .update(cx, |editor, _| editor.set_read_only(false));
1343 }
1344 CodegenStatus::Error(_error) => {
1345 self.edited_since_done = false;
1346 self.editor
1347 .update(cx, |editor, _| editor.set_read_only(false));
1348 }
1349 }
1350 }
1351
1352 pub fn id(&self) -> InlineAssistId {
1353 match &self.mode {
1354 PromptEditorMode::Buffer { id, .. } => *id,
1355 PromptEditorMode::Terminal { .. } => unreachable!(),
1356 }
1357 }
1358
1359 pub fn codegen(&self) -> &Entity<BufferCodegen> {
1360 match &self.mode {
1361 PromptEditorMode::Buffer { codegen, .. } => codegen,
1362 PromptEditorMode::Terminal { .. } => unreachable!(),
1363 }
1364 }
1365
1366 pub fn mention_set(&self) -> &Entity<MentionSet> {
1367 &self.mention_set
1368 }
1369
1370 pub fn editor_margins(&self) -> &Arc<Mutex<EditorMargins>> {
1371 match &self.mode {
1372 PromptEditorMode::Buffer { editor_margins, .. } => editor_margins,
1373 PromptEditorMode::Terminal { .. } => unreachable!(),
1374 }
1375 }
1376}
1377
1378#[derive(Copy, Clone, Default, Debug, PartialEq, Eq, Hash)]
1379pub struct TerminalInlineAssistId(pub usize);
1380
1381impl TerminalInlineAssistId {
1382 pub fn post_inc(&mut self) -> TerminalInlineAssistId {
1383 let id = *self;
1384 self.0 += 1;
1385 id
1386 }
1387}
1388
1389impl PromptEditor<TerminalCodegen> {
1390 pub fn new_terminal(
1391 id: TerminalInlineAssistId,
1392 prompt_history: VecDeque<String>,
1393 prompt_buffer: Entity<MultiBuffer>,
1394 codegen: Entity<TerminalCodegen>,
1395 session_id: Uuid,
1396 fs: Arc<dyn Fs>,
1397 thread_store: Entity<ThreadStore>,
1398 prompt_store: Option<Entity<PromptStore>>,
1399 project: WeakEntity<Project>,
1400 workspace: WeakEntity<Workspace>,
1401 window: &mut Window,
1402 cx: &mut Context<Self>,
1403 ) -> Self {
1404 let codegen_subscription = cx.observe(&codegen, Self::handle_codegen_changed);
1405 let mode = PromptEditorMode::Terminal {
1406 id,
1407 codegen,
1408 height_in_lines: 1,
1409 };
1410
1411 let prompt_editor = cx.new(|cx| {
1412 let mut editor = Editor::new(
1413 EditorMode::AutoHeight {
1414 min_lines: 1,
1415 max_lines: Some(Self::MAX_LINES as usize),
1416 },
1417 prompt_buffer,
1418 None,
1419 window,
1420 cx,
1421 );
1422 editor.set_soft_wrap_mode(language::language_settings::SoftWrap::EditorWidth, cx);
1423 editor.set_placeholder_text(&Self::placeholder_text(&mode, window, cx), window, cx);
1424 editor.set_context_menu_options(ContextMenuOptions {
1425 min_entries_visible: 12,
1426 max_entries_visible: 12,
1427 placement: None,
1428 });
1429 editor
1430 });
1431
1432 let mention_set = cx
1433 .new(|_cx| MentionSet::new(project, Some(thread_store.clone()), prompt_store.clone()));
1434
1435 let model_selector_menu_handle = PopoverMenuHandle::default();
1436
1437 let mut this = Self {
1438 editor: prompt_editor.clone(),
1439 mention_set,
1440 prompt_store,
1441 workspace,
1442 model_selector: cx.new(|cx| {
1443 AgentModelSelector::new(
1444 fs,
1445 model_selector_menu_handle.clone(),
1446 prompt_editor.focus_handle(cx),
1447 ModelUsageContext::InlineAssistant,
1448 window,
1449 cx,
1450 )
1451 }),
1452 edited_since_done: false,
1453 prompt_history,
1454 prompt_history_ix: None,
1455 pending_prompt: String::new(),
1456 _codegen_subscription: codegen_subscription,
1457 editor_subscriptions: Vec::new(),
1458 mode,
1459 show_rate_limit_notice: false,
1460 session_state: SessionState {
1461 session_id,
1462 completion: CompletionState::Pending,
1463 },
1464 _phantom: Default::default(),
1465 };
1466 this.count_lines(cx);
1467 this.assign_completion_provider(cx);
1468 this.subscribe_to_editor(window, cx);
1469 this
1470 }
1471
1472 fn count_lines(&mut self, cx: &mut Context<Self>) {
1473 let height_in_lines = cmp::max(
1474 2, // Make the editor at least two lines tall, to account for padding and buttons.
1475 cmp::min(
1476 self.editor
1477 .update(cx, |editor, cx| editor.max_point(cx).row().0 + 1),
1478 Self::MAX_LINES as u32,
1479 ),
1480 ) as u8;
1481
1482 match &mut self.mode {
1483 PromptEditorMode::Terminal {
1484 height_in_lines: current_height,
1485 ..
1486 } => {
1487 if height_in_lines != *current_height {
1488 *current_height = height_in_lines;
1489 cx.emit(PromptEditorEvent::Resized { height_in_lines });
1490 }
1491 }
1492 PromptEditorMode::Buffer { .. } => unreachable!(),
1493 }
1494 }
1495
1496 fn handle_codegen_changed(&mut self, codegen: Entity<TerminalCodegen>, cx: &mut Context<Self>) {
1497 match &self.codegen().read(cx).status {
1498 CodegenStatus::Idle => {
1499 self.editor
1500 .update(cx, |editor, _| editor.set_read_only(false));
1501 }
1502 CodegenStatus::Pending => {
1503 self.session_state.completion = CompletionState::Pending;
1504 self.editor
1505 .update(cx, |editor, _| editor.set_read_only(true));
1506 }
1507 CodegenStatus::Done | CodegenStatus::Error(_) => {
1508 self.session_state.completion = CompletionState::Generated {
1509 completion_text: codegen.read(cx).completion(),
1510 };
1511 self.edited_since_done = false;
1512 self.editor
1513 .update(cx, |editor, _| editor.set_read_only(false));
1514 }
1515 }
1516 }
1517
1518 pub fn mention_set(&self) -> &Entity<MentionSet> {
1519 &self.mention_set
1520 }
1521
1522 pub fn codegen(&self) -> &Entity<TerminalCodegen> {
1523 match &self.mode {
1524 PromptEditorMode::Buffer { .. } => unreachable!(),
1525 PromptEditorMode::Terminal { codegen, .. } => codegen,
1526 }
1527 }
1528
1529 pub fn id(&self) -> TerminalInlineAssistId {
1530 match &self.mode {
1531 PromptEditorMode::Buffer { .. } => unreachable!(),
1532 PromptEditorMode::Terminal { id, .. } => *id,
1533 }
1534 }
1535}
1536
1537pub enum CodegenStatus {
1538 Idle,
1539 Pending,
1540 Done,
1541 Error(anyhow::Error),
1542}
1543
1544/// This is just CodegenStatus without the anyhow::Error, which causes a lifetime issue for rendering the Cancel button.
1545#[derive(Copy, Clone)]
1546pub enum CancelButtonState {
1547 Idle,
1548 Pending,
1549 Done,
1550 Error,
1551}
1552
1553impl Into<CancelButtonState> for &CodegenStatus {
1554 fn into(self) -> CancelButtonState {
1555 match self {
1556 CodegenStatus::Idle => CancelButtonState::Idle,
1557 CodegenStatus::Pending => CancelButtonState::Pending,
1558 CodegenStatus::Done => CancelButtonState::Done,
1559 CodegenStatus::Error(_) => CancelButtonState::Error,
1560 }
1561 }
1562}
1563
1564#[derive(Copy, Clone)]
1565pub enum GenerationMode {
1566 Generate,
1567 Transform,
1568}
1569
1570impl GenerationMode {
1571 fn start_label(self) -> &'static str {
1572 match self {
1573 GenerationMode::Generate => "Generate",
1574 GenerationMode::Transform => "Transform",
1575 }
1576 }
1577 fn tooltip_interrupt(self) -> &'static str {
1578 match self {
1579 GenerationMode::Generate => "Interrupt Generation",
1580 GenerationMode::Transform => "Interrupt Transform",
1581 }
1582 }
1583
1584 fn tooltip_restart(self) -> &'static str {
1585 match self {
1586 GenerationMode::Generate => "Restart Generation",
1587 GenerationMode::Transform => "Restart Transform",
1588 }
1589 }
1590
1591 fn tooltip_accept(self) -> &'static str {
1592 match self {
1593 GenerationMode::Generate => "Accept Generation",
1594 GenerationMode::Transform => "Accept Transform",
1595 }
1596 }
1597}
1598
1599/// Stored information that can be used to resurrect a context crease when creating an editor for a past message.
1600#[derive(Clone, Debug)]
1601struct MessageCrease {
1602 range: Range<MultiBufferOffset>,
1603 icon_path: SharedString,
1604 label: SharedString,
1605}
1606
1607fn extract_message_creases(
1608 editor: &mut Editor,
1609 mention_set: &Entity<MentionSet>,
1610 window: &mut Window,
1611 cx: &mut Context<'_, Editor>,
1612) -> Vec<MessageCrease> {
1613 let creases = mention_set.read(cx).creases();
1614 let snapshot = editor.snapshot(window, cx);
1615 snapshot
1616 .crease_snapshot
1617 .creases()
1618 .filter(|(id, _)| creases.contains(id))
1619 .filter_map(|(_, crease)| {
1620 let metadata = crease.metadata()?.clone();
1621 Some(MessageCrease {
1622 range: crease.range().to_offset(snapshot.buffer()),
1623 label: metadata.label,
1624 icon_path: metadata.icon_path,
1625 })
1626 })
1627 .collect()
1628}
1629
1630fn insert_message_creases(
1631 editor: &mut Editor,
1632 message_creases: &[MessageCrease],
1633 window: &mut Window,
1634 cx: &mut Context<'_, Editor>,
1635) -> Vec<CreaseId> {
1636 let buffer_snapshot = editor.buffer().read(cx).snapshot(cx);
1637 let creases = message_creases
1638 .iter()
1639 .map(|crease| {
1640 let start = buffer_snapshot.anchor_after(crease.range.start);
1641 let end = buffer_snapshot.anchor_before(crease.range.end);
1642 crease_for_mention(
1643 crease.label.clone(),
1644 crease.icon_path.clone(),
1645 None,
1646 start..end,
1647 cx.weak_entity(),
1648 )
1649 })
1650 .collect::<Vec<_>>();
1651 let ids = editor.insert_creases(creases.clone(), cx);
1652 editor.fold_creases(creases, false, window, cx);
1653 ids
1654}
1655
1656#[cfg(test)]
1657mod tests {
1658 use super::*;
1659 use crate::terminal_codegen::TerminalCodegen;
1660 use agent::ThreadStore;
1661 use collections::VecDeque;
1662 use fs::FakeFs;
1663 use gpui::{TestAppContext, VisualTestContext};
1664 use language::Buffer;
1665 use project::Project;
1666 use settings::SettingsStore;
1667 use std::cell::RefCell;
1668 use std::path::Path;
1669 use std::rc::Rc;
1670 use terminal::TerminalBuilder;
1671 use terminal::terminal_settings::CursorShape;
1672 use util::path;
1673 use util::paths::PathStyle;
1674 use uuid::Uuid;
1675
1676 fn init_test(cx: &mut TestAppContext) {
1677 cx.update(|cx| {
1678 let settings_store = SettingsStore::test(cx);
1679 cx.set_global(settings_store);
1680 theme::init(theme::LoadThemes::JustBase, cx);
1681 theme_settings::init(theme::LoadThemes::JustBase, cx);
1682 editor::init(cx);
1683 release_channel::init(semver::Version::new(0, 0, 0), cx);
1684 language_model::LanguageModelRegistry::test(cx);
1685 prompt_store::init(cx);
1686 });
1687 }
1688
1689 fn build_terminal_prompt_editor(
1690 workspace: &Entity<Workspace>,
1691 cx: &mut VisualTestContext,
1692 ) -> Entity<PromptEditor<TerminalCodegen>> {
1693 let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx)));
1694 let fs = FakeFs::new(cx.executor());
1695
1696 let terminal = cx.update(|_window, cx| {
1697 cx.new(|cx| {
1698 TerminalBuilder::new_display_only(
1699 CursorShape::default(),
1700 settings::AlternateScroll::On,
1701 None,
1702 0,
1703 cx.background_executor(),
1704 PathStyle::local(),
1705 )
1706 .unwrap()
1707 .subscribe(cx)
1708 })
1709 });
1710
1711 let session_id = Uuid::new_v4();
1712 let codegen =
1713 cx.update(|_window, cx| cx.new(|_| TerminalCodegen::new(terminal, session_id)));
1714
1715 let prompt_buffer = cx.update(|_window, cx| {
1716 cx.new(|cx| MultiBuffer::singleton(cx.new(|cx| Buffer::local("", cx)), cx))
1717 });
1718
1719 let project = workspace.update(cx, |workspace, _cx| workspace.project().downgrade());
1720
1721 cx.update(|window, cx| {
1722 cx.new(|cx| {
1723 PromptEditor::new_terminal(
1724 TerminalInlineAssistId::default(),
1725 VecDeque::new(),
1726 prompt_buffer,
1727 codegen,
1728 session_id,
1729 fs,
1730 thread_store,
1731 None,
1732 project,
1733 workspace.downgrade(),
1734 window,
1735 cx,
1736 )
1737 })
1738 })
1739 }
1740
1741 #[gpui::test]
1742 async fn test_secondary_confirm_emits_execute_true_in_terminal_mode(cx: &mut TestAppContext) {
1743 init_test(cx);
1744
1745 let fs = FakeFs::new(cx.executor());
1746 fs.insert_tree("/project", serde_json::json!({"file": ""}))
1747 .await;
1748 let project = Project::test(fs, [Path::new(path!("/project"))], cx).await;
1749 let (workspace, cx) =
1750 cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
1751
1752 let prompt_editor = build_terminal_prompt_editor(&workspace, cx);
1753
1754 // Set the codegen status to Done so that confirm logic emits ConfirmRequested.
1755 prompt_editor.update(cx, |editor, cx| {
1756 editor.codegen().update(cx, |codegen, _| {
1757 codegen.status = CodegenStatus::Done;
1758 });
1759 editor.edited_since_done = false;
1760 });
1761
1762 let events: Rc<RefCell<Vec<PromptEditorEvent>>> = Rc::new(RefCell::new(Vec::new()));
1763 let events_clone = events.clone();
1764 cx.update(|_window, cx| {
1765 cx.subscribe(&prompt_editor, move |_, event: &PromptEditorEvent, _cx| {
1766 events_clone.borrow_mut().push(match event {
1767 PromptEditorEvent::ConfirmRequested { execute } => {
1768 PromptEditorEvent::ConfirmRequested { execute: *execute }
1769 }
1770 PromptEditorEvent::StartRequested => PromptEditorEvent::StartRequested,
1771 PromptEditorEvent::StopRequested => PromptEditorEvent::StopRequested,
1772 PromptEditorEvent::CancelRequested => PromptEditorEvent::CancelRequested,
1773 PromptEditorEvent::Resized { height_in_lines } => PromptEditorEvent::Resized {
1774 height_in_lines: *height_in_lines,
1775 },
1776 });
1777 })
1778 .detach();
1779 });
1780
1781 // Dispatch menu::SecondaryConfirm (cmd-enter).
1782 prompt_editor.update(cx, |editor, cx| {
1783 editor.handle_confirm(true, cx);
1784 });
1785
1786 let events = events.borrow();
1787 assert_eq!(events.len(), 1, "Expected exactly one event");
1788 assert!(
1789 matches!(
1790 events[0],
1791 PromptEditorEvent::ConfirmRequested { execute: true }
1792 ),
1793 "Expected ConfirmRequested with execute: true, got {:?}",
1794 match &events[0] {
1795 PromptEditorEvent::ConfirmRequested { execute } =>
1796 format!("ConfirmRequested {{ execute: {} }}", execute),
1797 _ => "other event".to_string(),
1798 }
1799 );
1800 }
1801
1802 #[gpui::test]
1803 async fn test_confirm_emits_execute_false_in_terminal_mode(cx: &mut TestAppContext) {
1804 init_test(cx);
1805
1806 let fs = FakeFs::new(cx.executor());
1807 fs.insert_tree("/project", serde_json::json!({"file": ""}))
1808 .await;
1809 let project = Project::test(fs, [Path::new(path!("/project"))], cx).await;
1810 let (workspace, cx) =
1811 cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
1812
1813 let prompt_editor = build_terminal_prompt_editor(&workspace, cx);
1814
1815 prompt_editor.update(cx, |editor, cx| {
1816 editor.codegen().update(cx, |codegen, _| {
1817 codegen.status = CodegenStatus::Done;
1818 });
1819 editor.edited_since_done = false;
1820 });
1821
1822 let events: Rc<RefCell<Vec<PromptEditorEvent>>> = Rc::new(RefCell::new(Vec::new()));
1823 let events_clone = events.clone();
1824 cx.update(|_window, cx| {
1825 cx.subscribe(&prompt_editor, move |_, event: &PromptEditorEvent, _cx| {
1826 events_clone.borrow_mut().push(match event {
1827 PromptEditorEvent::ConfirmRequested { execute } => {
1828 PromptEditorEvent::ConfirmRequested { execute: *execute }
1829 }
1830 PromptEditorEvent::StartRequested => PromptEditorEvent::StartRequested,
1831 PromptEditorEvent::StopRequested => PromptEditorEvent::StopRequested,
1832 PromptEditorEvent::CancelRequested => PromptEditorEvent::CancelRequested,
1833 PromptEditorEvent::Resized { height_in_lines } => PromptEditorEvent::Resized {
1834 height_in_lines: *height_in_lines,
1835 },
1836 });
1837 })
1838 .detach();
1839 });
1840
1841 // Dispatch menu::Confirm (enter) — should emit execute: false even in terminal mode.
1842 prompt_editor.update(cx, |editor, cx| {
1843 editor.handle_confirm(false, cx);
1844 });
1845
1846 let events = events.borrow();
1847 assert_eq!(events.len(), 1, "Expected exactly one event");
1848 assert!(
1849 matches!(
1850 events[0],
1851 PromptEditorEvent::ConfirmRequested { execute: false }
1852 ),
1853 "Expected ConfirmRequested with execute: false"
1854 );
1855 }
1856}