mirror of
https://github.com/zed-industries/zed.git
synced 2026-06-01 03:14:56 +07:00
Removes unused `@rule` mentions and unused APIs from `prompt_store` Follow up to #58067 Release Notes: - N/A
1827 lines
68 KiB
Rust
1827 lines
68 KiB
Rust
use agent::ThreadStore;
|
|
use agent_settings::AgentSettings;
|
|
use collections::{HashMap, VecDeque};
|
|
use editor::actions::Paste;
|
|
use editor::code_context_menus::CodeContextMenu;
|
|
use editor::display_map::{CreaseId, EditorMargins};
|
|
use editor::{AnchorRangeExt as _, MultiBufferOffset, ToOffset as _};
|
|
use editor::{
|
|
ContextMenuOptions, Editor, EditorElement, EditorEvent, EditorMode, EditorStyle, MultiBuffer,
|
|
};
|
|
use fs::Fs;
|
|
use gpui::{
|
|
AnyElement, App, ClipboardItem, Context, Entity, EventEmitter, FocusHandle, Focusable,
|
|
Subscription, TextStyle, TextStyleRefinement, WeakEntity, Window, actions,
|
|
};
|
|
use language_model::{LanguageModel, LanguageModelRegistry};
|
|
use markdown::{HeadingLevelStyles, Markdown, MarkdownElement, MarkdownStyle};
|
|
use parking_lot::Mutex;
|
|
use project::Project;
|
|
use settings::Settings;
|
|
use std::cmp;
|
|
use std::ops::Range;
|
|
use std::rc::Rc;
|
|
use std::sync::Arc;
|
|
use theme_settings::ThemeSettings;
|
|
use ui::utils::WithRemSize;
|
|
use ui::{IconButtonShape, KeyBinding, PopoverMenuHandle, Tooltip, prelude::*};
|
|
use uuid::Uuid;
|
|
use workspace::notifications::NotificationId;
|
|
use workspace::{Toast, Workspace};
|
|
use zed_actions::{
|
|
agent::ToggleModelSelector,
|
|
editor::{MoveDown, MoveUp},
|
|
};
|
|
|
|
use crate::agent_model_selector::AgentModelSelector;
|
|
use crate::buffer_codegen::{BufferCodegen, CodegenAlternative};
|
|
use crate::completion_provider::{
|
|
PromptCompletionProvider, PromptCompletionProviderDelegate, PromptContextType,
|
|
};
|
|
use crate::mention_set::paste_images_as_context;
|
|
use crate::mention_set::{MentionSet, crease_for_mention};
|
|
use crate::terminal_codegen::TerminalCodegen;
|
|
use crate::{
|
|
CycleFavoriteModels, CycleNextInlineAssist, CyclePreviousInlineAssist, ModelUsageContext,
|
|
};
|
|
|
|
actions!(inline_assistant, [ThumbsUpResult, ThumbsDownResult]);
|
|
|
|
enum CompletionState {
|
|
Pending,
|
|
Generated { completion_text: Option<String> },
|
|
Rated,
|
|
}
|
|
|
|
struct SessionState {
|
|
session_id: Uuid,
|
|
completion: CompletionState,
|
|
}
|
|
|
|
pub struct PromptEditor<T> {
|
|
pub editor: Entity<Editor>,
|
|
mode: PromptEditorMode,
|
|
mention_set: Entity<MentionSet>,
|
|
workspace: WeakEntity<Workspace>,
|
|
model_selector: Entity<AgentModelSelector>,
|
|
edited_since_done: bool,
|
|
prompt_history: VecDeque<String>,
|
|
prompt_history_ix: Option<usize>,
|
|
pending_prompt: String,
|
|
_codegen_subscription: Subscription,
|
|
editor_subscriptions: Vec<Subscription>,
|
|
show_rate_limit_notice: bool,
|
|
session_state: SessionState,
|
|
_phantom: std::marker::PhantomData<T>,
|
|
}
|
|
|
|
impl<T: 'static> EventEmitter<PromptEditorEvent> for PromptEditor<T> {}
|
|
|
|
impl<T: 'static> Render for PromptEditor<T> {
|
|
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
|
let ui_font_size = ThemeSettings::get_global(cx).ui_font_size(cx);
|
|
let mut buttons = Vec::new();
|
|
|
|
const RIGHT_PADDING: Pixels = px(9.);
|
|
|
|
let (left_gutter_width, right_padding, explanation) = match &self.mode {
|
|
PromptEditorMode::Buffer {
|
|
id: _,
|
|
codegen,
|
|
editor_margins,
|
|
} => {
|
|
let codegen = codegen.read(cx);
|
|
|
|
if codegen.alternative_count(cx) > 1 {
|
|
buttons.push(self.render_cycle_controls(codegen, cx));
|
|
}
|
|
|
|
let editor_margins = editor_margins.lock();
|
|
let gutter = editor_margins.gutter;
|
|
|
|
let left_gutter_width = gutter.full_width() + (gutter.margin / 2.0);
|
|
let right_padding = editor_margins.right + RIGHT_PADDING;
|
|
|
|
let active_alternative = codegen.active_alternative().read(cx);
|
|
let explanation = active_alternative
|
|
.description
|
|
.clone()
|
|
.or_else(|| active_alternative.failure.clone());
|
|
|
|
(left_gutter_width, right_padding, explanation)
|
|
}
|
|
PromptEditorMode::Terminal { .. } => {
|
|
// Give the equivalent of the same left-padding that we're using on the right
|
|
(Pixels::from(40.0), Pixels::from(24.), None)
|
|
}
|
|
};
|
|
|
|
let bottom_padding = match &self.mode {
|
|
PromptEditorMode::Buffer { .. } => rems_from_px(2.0),
|
|
PromptEditorMode::Terminal { .. } => rems_from_px(4.0),
|
|
};
|
|
|
|
buttons.extend(self.render_buttons(window, cx));
|
|
|
|
let menu_visible = self.is_completions_menu_visible(cx);
|
|
let add_context_button = IconButton::new("add-context", IconName::AtSign)
|
|
.icon_size(IconSize::Small)
|
|
.icon_color(Color::Muted)
|
|
.when(!menu_visible, |this| {
|
|
this.tooltip(move |_window, cx| {
|
|
Tooltip::with_meta("Add Context", None, "Or type @ to include context", cx)
|
|
})
|
|
})
|
|
.on_click(cx.listener(move |this, _, window, cx| {
|
|
this.trigger_completion_menu(window, cx);
|
|
}));
|
|
|
|
let markdown = window.use_state(cx, |_, cx| Markdown::new("".into(), None, None, cx));
|
|
|
|
if let Some(explanation) = &explanation {
|
|
markdown.update(cx, |markdown, cx| {
|
|
markdown.reset(SharedString::from(explanation), cx);
|
|
});
|
|
}
|
|
|
|
let explanation_label = self
|
|
.render_markdown(markdown, markdown_style(window, cx))
|
|
.into_any_element();
|
|
|
|
v_flex()
|
|
.key_context("InlineAssistant")
|
|
.capture_action(cx.listener(Self::paste))
|
|
.block_mouse_except_scroll()
|
|
.size_full()
|
|
.pt_0p5()
|
|
.pb(bottom_padding)
|
|
.pr(right_padding)
|
|
.gap_0p5()
|
|
.justify_center()
|
|
.border_y_1()
|
|
.border_color(cx.theme().colors().border)
|
|
.bg(cx.theme().colors().editor_background)
|
|
.child(
|
|
h_flex()
|
|
.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))
|
|
.on_action(cx.listener(Self::thumbs_up))
|
|
.on_action(cx.listener(Self::thumbs_down))
|
|
.capture_action(cx.listener(Self::cycle_prev))
|
|
.capture_action(cx.listener(Self::cycle_next))
|
|
.on_action(cx.listener(|this, _: &ToggleModelSelector, window, cx| {
|
|
this.model_selector
|
|
.update(cx, |model_selector, cx| model_selector.toggle(window, cx));
|
|
}))
|
|
.on_action(cx.listener(|this, _: &CycleFavoriteModels, window, cx| {
|
|
this.model_selector.update(cx, |model_selector, cx| {
|
|
model_selector.cycle_favorite_models(window, cx);
|
|
});
|
|
}))
|
|
.child(
|
|
WithRemSize::new(ui_font_size)
|
|
.h_full()
|
|
.w(left_gutter_width)
|
|
.flex()
|
|
.flex_row()
|
|
.flex_shrink_0()
|
|
.items_center()
|
|
.justify_center()
|
|
.gap_1()
|
|
.child(self.render_close_button(cx))
|
|
.map(|el| {
|
|
let CodegenStatus::Error(error) = self.codegen_status(cx) else {
|
|
return el;
|
|
};
|
|
|
|
let error_message = SharedString::from(error.to_string());
|
|
el.child(
|
|
div()
|
|
.id("error")
|
|
.tooltip(Tooltip::text(error_message))
|
|
.child(
|
|
Icon::new(IconName::XCircle)
|
|
.size(IconSize::Small)
|
|
.color(Color::Error),
|
|
),
|
|
)
|
|
}),
|
|
)
|
|
.child(
|
|
h_flex()
|
|
.w_full()
|
|
.justify_between()
|
|
.child(div().flex_1().child(self.render_editor(window, cx)))
|
|
.child(
|
|
WithRemSize::new(ui_font_size)
|
|
.flex()
|
|
.flex_row()
|
|
.items_center()
|
|
.gap_1()
|
|
.child(add_context_button)
|
|
.child(self.model_selector.clone())
|
|
.children(buttons),
|
|
),
|
|
),
|
|
)
|
|
.when_some(explanation, |this, _| {
|
|
this.child(
|
|
h_flex()
|
|
.size_full()
|
|
.justify_center()
|
|
.child(div().w(left_gutter_width + px(6.)))
|
|
.child(
|
|
div()
|
|
.size_full()
|
|
.min_w_0()
|
|
.pt(rems_from_px(3.))
|
|
.pl_0p5()
|
|
.flex_1()
|
|
.border_t_1()
|
|
.border_color(cx.theme().colors().border_variant)
|
|
.child(explanation_label),
|
|
),
|
|
)
|
|
})
|
|
}
|
|
}
|
|
|
|
fn markdown_style(window: &Window, cx: &App) -> MarkdownStyle {
|
|
let theme_settings = ThemeSettings::get_global(cx);
|
|
let colors = cx.theme().colors();
|
|
let mut text_style = window.text_style();
|
|
|
|
text_style.refine(&TextStyleRefinement {
|
|
font_family: Some(theme_settings.ui_font.family.clone()),
|
|
color: Some(colors.text),
|
|
..Default::default()
|
|
});
|
|
|
|
MarkdownStyle {
|
|
base_text_style: text_style.clone(),
|
|
syntax: cx.theme().syntax().clone(),
|
|
selection_background_color: colors.element_selection_background,
|
|
heading_level_styles: Some(HeadingLevelStyles {
|
|
h1: Some(TextStyleRefinement {
|
|
font_size: Some(rems(1.15).into()),
|
|
..Default::default()
|
|
}),
|
|
h2: Some(TextStyleRefinement {
|
|
font_size: Some(rems(1.1).into()),
|
|
..Default::default()
|
|
}),
|
|
h3: Some(TextStyleRefinement {
|
|
font_size: Some(rems(1.05).into()),
|
|
..Default::default()
|
|
}),
|
|
h4: Some(TextStyleRefinement {
|
|
font_size: Some(rems(1.).into()),
|
|
..Default::default()
|
|
}),
|
|
h5: Some(TextStyleRefinement {
|
|
font_size: Some(rems(0.95).into()),
|
|
..Default::default()
|
|
}),
|
|
h6: Some(TextStyleRefinement {
|
|
font_size: Some(rems(0.875).into()),
|
|
..Default::default()
|
|
}),
|
|
}),
|
|
inline_code: TextStyleRefinement {
|
|
font_family: Some(theme_settings.buffer_font.family.clone()),
|
|
font_fallbacks: theme_settings.buffer_font.fallbacks.clone(),
|
|
font_features: Some(theme_settings.buffer_font.features.clone()),
|
|
background_color: Some(colors.editor_foreground.opacity(0.08)),
|
|
..Default::default()
|
|
},
|
|
..Default::default()
|
|
}
|
|
}
|
|
|
|
impl<T: 'static> Focusable for PromptEditor<T> {
|
|
fn focus_handle(&self, cx: &App) -> FocusHandle {
|
|
self.editor.focus_handle(cx)
|
|
}
|
|
}
|
|
|
|
impl<T: 'static> PromptEditor<T> {
|
|
const MAX_LINES: u8 = 8;
|
|
|
|
fn codegen_status<'a>(&'a self, cx: &'a App) -> &'a CodegenStatus {
|
|
match &self.mode {
|
|
PromptEditorMode::Buffer { codegen, .. } => codegen.read(cx).status(cx),
|
|
PromptEditorMode::Terminal { codegen, .. } => &codegen.read(cx).status,
|
|
}
|
|
}
|
|
|
|
fn subscribe_to_editor(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
|
self.editor_subscriptions.clear();
|
|
self.editor_subscriptions.push(cx.subscribe_in(
|
|
&self.editor,
|
|
window,
|
|
Self::handle_prompt_editor_events,
|
|
));
|
|
}
|
|
|
|
fn assign_completion_provider(&mut self, cx: &mut Context<Self>) {
|
|
self.editor.update(cx, |editor, cx| {
|
|
editor.set_completion_provider(Some(Rc::new(PromptCompletionProvider::new(
|
|
PromptEditorCompletionProviderDelegate,
|
|
cx.weak_entity(),
|
|
self.mention_set.clone(),
|
|
self.workspace.clone(),
|
|
))));
|
|
});
|
|
}
|
|
|
|
pub fn set_show_cursor_when_unfocused(
|
|
&mut self,
|
|
show_cursor_when_unfocused: bool,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
self.editor.update(cx, |editor, cx| {
|
|
editor.set_show_cursor_when_unfocused(show_cursor_when_unfocused, cx)
|
|
});
|
|
}
|
|
|
|
pub fn unlink(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
|
let prompt = self.prompt(cx);
|
|
let existing_creases = self.editor.update(cx, |editor, cx| {
|
|
extract_message_creases(editor, &self.mention_set, window, cx)
|
|
});
|
|
let focus = self.editor.focus_handle(cx).contains_focused(window, cx);
|
|
let mut creases = vec![];
|
|
self.editor = cx.new(|cx| {
|
|
let mut editor = Editor::auto_height(1, Self::MAX_LINES as usize, window, cx);
|
|
editor.set_soft_wrap_mode(language::language_settings::SoftWrap::EditorWidth, cx);
|
|
editor.set_placeholder_text("Add a prompt…", window, cx);
|
|
editor.set_text(prompt, window, cx);
|
|
creases = insert_message_creases(&mut editor, &existing_creases, window, cx);
|
|
|
|
if focus {
|
|
window.focus(&editor.focus_handle(cx), cx);
|
|
}
|
|
editor
|
|
});
|
|
|
|
self.mention_set.update(cx, |mention_set, _cx| {
|
|
debug_assert_eq!(
|
|
creases.len(),
|
|
mention_set.creases().len(),
|
|
"Missing creases"
|
|
);
|
|
|
|
let mentions = mention_set
|
|
.clear()
|
|
.zip(creases)
|
|
.map(|((_, value), id)| (id, value))
|
|
.collect::<HashMap<_, _>>();
|
|
mention_set.set_mentions(mentions);
|
|
});
|
|
|
|
self.assign_completion_provider(cx);
|
|
self.subscribe_to_editor(window, cx);
|
|
}
|
|
|
|
pub fn placeholder_text(mode: &PromptEditorMode, window: &mut Window, cx: &mut App) -> String {
|
|
let action = match mode {
|
|
PromptEditorMode::Buffer { codegen, .. } => {
|
|
if codegen.read(cx).is_insertion {
|
|
"Generate"
|
|
} else {
|
|
"Transform"
|
|
}
|
|
}
|
|
PromptEditorMode::Terminal { .. } => "Generate",
|
|
};
|
|
|
|
let agent_panel_keybinding =
|
|
ui::text_for_action(&zed_actions::assistant::ToggleFocus, window, cx)
|
|
.map(|keybinding| format!("{keybinding} to chat"))
|
|
.unwrap_or_default();
|
|
|
|
format!("{action}… ({agent_panel_keybinding} ― ↓↑ for history — @ to include context)")
|
|
}
|
|
|
|
pub fn prompt(&self, cx: &App) -> String {
|
|
self.editor.read(cx).text(cx)
|
|
}
|
|
|
|
fn paste(&mut self, _: &Paste, window: &mut Window, cx: &mut Context<Self>) {
|
|
if inline_assistant_model_supports_images(cx)
|
|
&& let Some(task) = paste_images_as_context(
|
|
self.editor.clone(),
|
|
self.mention_set.clone(),
|
|
self.workspace.clone(),
|
|
window,
|
|
cx,
|
|
)
|
|
{
|
|
task.detach();
|
|
}
|
|
}
|
|
|
|
fn handle_prompt_editor_events(
|
|
&mut self,
|
|
editor: &Entity<Editor>,
|
|
event: &EditorEvent,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
match event {
|
|
EditorEvent::Edited { .. } => {
|
|
let snapshot = editor.update(cx, |editor, cx| editor.snapshot(window, cx));
|
|
|
|
self.mention_set
|
|
.update(cx, |mention_set, _cx| mention_set.remove_invalid(&snapshot));
|
|
|
|
if let Some(workspace) = Workspace::for_window(window, cx) {
|
|
workspace.update(cx, |workspace, cx| {
|
|
let is_via_ssh = workspace.project().read(cx).is_via_remote_server();
|
|
|
|
workspace
|
|
.client()
|
|
.telemetry()
|
|
.log_edit_event("inline assist", is_via_ssh);
|
|
});
|
|
}
|
|
let prompt = snapshot.text();
|
|
if self
|
|
.prompt_history_ix
|
|
.is_none_or(|ix| self.prompt_history[ix] != prompt)
|
|
{
|
|
self.prompt_history_ix.take();
|
|
self.pending_prompt = prompt;
|
|
}
|
|
|
|
self.edited_since_done = true;
|
|
self.session_state.completion = CompletionState::Pending;
|
|
cx.notify();
|
|
}
|
|
EditorEvent::Blurred => {
|
|
if self.show_rate_limit_notice {
|
|
self.show_rate_limit_notice = false;
|
|
cx.notify();
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
pub fn is_completions_menu_visible(&self, cx: &App) -> bool {
|
|
self.editor
|
|
.read(cx)
|
|
.context_menu()
|
|
.borrow()
|
|
.as_ref()
|
|
.is_some_and(|menu| matches!(menu, CodeContextMenu::Completions(_)) && menu.visible())
|
|
}
|
|
|
|
pub fn trigger_completion_menu(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
|
self.editor.update(cx, |editor, cx| {
|
|
let menu_is_open = editor.context_menu().borrow().as_ref().is_some_and(|menu| {
|
|
matches!(menu, CodeContextMenu::Completions(_)) && menu.visible()
|
|
});
|
|
|
|
let has_at_sign = {
|
|
let snapshot = editor.display_snapshot(cx);
|
|
let cursor = editor.selections.newest::<text::Point>(&snapshot).head();
|
|
let offset = cursor.to_offset(&snapshot);
|
|
if offset.0 > 0 {
|
|
snapshot
|
|
.buffer_snapshot()
|
|
.reversed_chars_at(offset)
|
|
.next()
|
|
.map(|sign| sign == '@')
|
|
.unwrap_or(false)
|
|
} else {
|
|
false
|
|
}
|
|
};
|
|
|
|
if menu_is_open && has_at_sign {
|
|
return;
|
|
}
|
|
|
|
editor.insert("@", window, cx);
|
|
editor.show_completions(&editor::actions::ShowCompletions, window, cx);
|
|
});
|
|
}
|
|
|
|
fn cancel(
|
|
&mut self,
|
|
_: &editor::actions::Cancel,
|
|
_window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
match self.codegen_status(cx) {
|
|
CodegenStatus::Idle | CodegenStatus::Done | CodegenStatus::Error(_) => {
|
|
cx.emit(PromptEditorEvent::CancelRequested);
|
|
}
|
|
CodegenStatus::Pending => {
|
|
cx.emit(PromptEditorEvent::StopRequested);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn confirm(&mut self, _: &menu::Confirm, _window: &mut Window, cx: &mut Context<Self>) {
|
|
self.handle_confirm(false, cx);
|
|
}
|
|
|
|
fn secondary_confirm(
|
|
&mut self,
|
|
_: &menu::SecondaryConfirm,
|
|
_window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
let execute = matches!(self.mode, PromptEditorMode::Terminal { .. });
|
|
self.handle_confirm(execute, cx);
|
|
}
|
|
|
|
fn handle_confirm(&mut self, execute: bool, cx: &mut Context<Self>) {
|
|
match self.codegen_status(cx) {
|
|
CodegenStatus::Idle => {
|
|
self.fire_started_telemetry(cx);
|
|
cx.emit(PromptEditorEvent::StartRequested);
|
|
}
|
|
CodegenStatus::Pending => {}
|
|
CodegenStatus::Done => {
|
|
if self.edited_since_done {
|
|
self.fire_started_telemetry(cx);
|
|
cx.emit(PromptEditorEvent::StartRequested);
|
|
} else {
|
|
cx.emit(PromptEditorEvent::ConfirmRequested { execute });
|
|
}
|
|
}
|
|
CodegenStatus::Error(_) => {
|
|
self.fire_started_telemetry(cx);
|
|
cx.emit(PromptEditorEvent::StartRequested);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn fire_started_telemetry(&self, cx: &Context<Self>) {
|
|
let Some(model) = LanguageModelRegistry::read_global(cx).inline_assistant_model() else {
|
|
return;
|
|
};
|
|
|
|
let model_telemetry_id = model.model.telemetry_id();
|
|
let model_provider_id = model.provider.id().to_string();
|
|
|
|
let (kind, language_name) = match &self.mode {
|
|
PromptEditorMode::Buffer { codegen, .. } => {
|
|
let codegen = codegen.read(cx);
|
|
(
|
|
"inline",
|
|
codegen.language_name(cx).map(|name| name.to_string()),
|
|
)
|
|
}
|
|
PromptEditorMode::Terminal { .. } => ("inline_terminal", None),
|
|
};
|
|
|
|
telemetry::event!(
|
|
"Assistant Started",
|
|
session_id = self.session_state.session_id.to_string(),
|
|
kind = kind,
|
|
phase = "started",
|
|
model = model_telemetry_id,
|
|
model_provider = model_provider_id,
|
|
language_name = language_name,
|
|
);
|
|
}
|
|
|
|
fn thumbs_up(&mut self, _: &ThumbsUpResult, _window: &mut Window, cx: &mut Context<Self>) {
|
|
match &self.session_state.completion {
|
|
CompletionState::Pending => {
|
|
self.toast("Can't rate, still generating...", None, cx);
|
|
return;
|
|
}
|
|
CompletionState::Rated => {
|
|
self.toast(
|
|
"Already rated this completion",
|
|
Some(self.session_state.session_id),
|
|
cx,
|
|
);
|
|
return;
|
|
}
|
|
CompletionState::Generated { completion_text } => {
|
|
let model_info = self.model_selector.read(cx).active_model(cx);
|
|
let (model_id, use_streaming_tools) = {
|
|
let Some(configured_model) = model_info else {
|
|
self.toast("No configured model", None, cx);
|
|
return;
|
|
};
|
|
(
|
|
configured_model.model.telemetry_id(),
|
|
CodegenAlternative::use_streaming_tools(
|
|
configured_model.model.as_ref(),
|
|
cx,
|
|
),
|
|
)
|
|
};
|
|
|
|
let selected_text = match &self.mode {
|
|
PromptEditorMode::Buffer { codegen, .. } => {
|
|
codegen.read(cx).selected_text(cx).map(|s| s.to_string())
|
|
}
|
|
PromptEditorMode::Terminal { .. } => None,
|
|
};
|
|
|
|
let prompt = self.editor.read(cx).text(cx);
|
|
|
|
let kind = match &self.mode {
|
|
PromptEditorMode::Buffer { .. } => "inline",
|
|
PromptEditorMode::Terminal { .. } => "inline_terminal",
|
|
};
|
|
|
|
telemetry::event!(
|
|
"Inline Assistant Rated",
|
|
rating = "positive",
|
|
session_id = self.session_state.session_id.to_string(),
|
|
kind = kind,
|
|
model = model_id,
|
|
prompt = prompt,
|
|
completion = completion_text,
|
|
selected_text = selected_text,
|
|
use_streaming_tools
|
|
);
|
|
|
|
self.session_state.completion = CompletionState::Rated;
|
|
|
|
cx.notify();
|
|
}
|
|
}
|
|
}
|
|
|
|
fn thumbs_down(&mut self, _: &ThumbsDownResult, _window: &mut Window, cx: &mut Context<Self>) {
|
|
match &self.session_state.completion {
|
|
CompletionState::Pending => {
|
|
self.toast("Can't rate, still generating...", None, cx);
|
|
return;
|
|
}
|
|
CompletionState::Rated => {
|
|
self.toast(
|
|
"Already rated this completion",
|
|
Some(self.session_state.session_id),
|
|
cx,
|
|
);
|
|
return;
|
|
}
|
|
CompletionState::Generated { completion_text } => {
|
|
let model_info = self.model_selector.read(cx).active_model(cx);
|
|
let (model_telemetry_id, use_streaming_tools) = {
|
|
let Some(configured_model) = model_info else {
|
|
self.toast("No configured model", None, cx);
|
|
return;
|
|
};
|
|
(
|
|
configured_model.model.telemetry_id(),
|
|
CodegenAlternative::use_streaming_tools(
|
|
configured_model.model.as_ref(),
|
|
cx,
|
|
),
|
|
)
|
|
};
|
|
|
|
let selected_text = match &self.mode {
|
|
PromptEditorMode::Buffer { codegen, .. } => {
|
|
codegen.read(cx).selected_text(cx).map(|s| s.to_string())
|
|
}
|
|
PromptEditorMode::Terminal { .. } => None,
|
|
};
|
|
|
|
let prompt = self.editor.read(cx).text(cx);
|
|
|
|
let kind = match &self.mode {
|
|
PromptEditorMode::Buffer { .. } => "inline",
|
|
PromptEditorMode::Terminal { .. } => "inline_terminal",
|
|
};
|
|
|
|
telemetry::event!(
|
|
"Inline Assistant Rated",
|
|
rating = "negative",
|
|
session_id = self.session_state.session_id.to_string(),
|
|
kind = kind,
|
|
model = model_telemetry_id,
|
|
prompt = prompt,
|
|
completion = completion_text,
|
|
selected_text = selected_text,
|
|
use_streaming_tools
|
|
);
|
|
|
|
self.session_state.completion = CompletionState::Rated;
|
|
|
|
cx.notify();
|
|
}
|
|
}
|
|
}
|
|
|
|
fn toast(&mut self, msg: &str, uuid: Option<Uuid>, cx: &mut Context<'_, PromptEditor<T>>) {
|
|
self.workspace
|
|
.update(cx, |workspace, cx| {
|
|
enum InlinePromptRating {}
|
|
workspace.show_toast(
|
|
{
|
|
let mut toast = Toast::new(
|
|
NotificationId::unique::<InlinePromptRating>(),
|
|
msg.to_string(),
|
|
)
|
|
.autohide();
|
|
|
|
if let Some(uuid) = uuid {
|
|
toast = toast.on_click("Click to copy rating ID", move |_, cx| {
|
|
cx.write_to_clipboard(ClipboardItem::new_string(uuid.to_string()));
|
|
});
|
|
};
|
|
|
|
toast
|
|
},
|
|
cx,
|
|
);
|
|
})
|
|
.ok();
|
|
}
|
|
|
|
fn move_up(&mut self, _: &MoveUp, window: &mut Window, cx: &mut Context<Self>) {
|
|
if let Some(ix) = self.prompt_history_ix {
|
|
if ix > 0 {
|
|
self.prompt_history_ix = Some(ix - 1);
|
|
let prompt = self.prompt_history[ix - 1].as_str();
|
|
self.editor.update(cx, |editor, cx| {
|
|
editor.set_text(prompt, window, cx);
|
|
editor.move_to_beginning(&Default::default(), window, 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, window, cx);
|
|
editor.move_to_beginning(&Default::default(), window, cx);
|
|
});
|
|
}
|
|
}
|
|
|
|
fn move_down(&mut self, _: &MoveDown, window: &mut Window, cx: &mut Context<Self>) {
|
|
if let Some(ix) = self.prompt_history_ix {
|
|
if ix < self.prompt_history.len() - 1 {
|
|
self.prompt_history_ix = Some(ix + 1);
|
|
let prompt = self.prompt_history[ix + 1].as_str();
|
|
self.editor.update(cx, |editor, cx| {
|
|
editor.set_text(prompt, window, cx);
|
|
editor.move_to_end(&Default::default(), window, cx)
|
|
});
|
|
} else {
|
|
self.prompt_history_ix = None;
|
|
let prompt = self.pending_prompt.as_str();
|
|
self.editor.update(cx, |editor, cx| {
|
|
editor.set_text(prompt, window, cx);
|
|
editor.move_to_end(&Default::default(), window, cx)
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
fn render_buttons(&self, _window: &mut Window, cx: &mut Context<Self>) -> Vec<AnyElement> {
|
|
let mode = match &self.mode {
|
|
PromptEditorMode::Buffer { codegen, .. } => {
|
|
let codegen = codegen.read(cx);
|
|
if codegen.is_insertion {
|
|
GenerationMode::Generate
|
|
} else {
|
|
GenerationMode::Transform
|
|
}
|
|
}
|
|
PromptEditorMode::Terminal { .. } => GenerationMode::Generate,
|
|
};
|
|
|
|
let codegen_status = self.codegen_status(cx);
|
|
|
|
match codegen_status {
|
|
CodegenStatus::Idle => {
|
|
vec![
|
|
Button::new("start", mode.start_label())
|
|
.label_size(LabelSize::Small)
|
|
.end_icon(
|
|
Icon::new(IconName::Return)
|
|
.size(IconSize::XSmall)
|
|
.color(Color::Muted),
|
|
)
|
|
.on_click(
|
|
cx.listener(|_, _, _, cx| cx.emit(PromptEditorEvent::StartRequested)),
|
|
)
|
|
.into_any_element(),
|
|
]
|
|
}
|
|
CodegenStatus::Pending => vec![
|
|
IconButton::new("stop", IconName::Stop)
|
|
.icon_color(Color::Error)
|
|
.shape(IconButtonShape::Square)
|
|
.tooltip(move |_window, cx| {
|
|
Tooltip::with_meta(
|
|
mode.tooltip_interrupt(),
|
|
Some(&menu::Cancel),
|
|
"Changes won't be discarded",
|
|
cx,
|
|
)
|
|
})
|
|
.on_click(cx.listener(|_, _, _, cx| cx.emit(PromptEditorEvent::StopRequested)))
|
|
.into_any_element(),
|
|
],
|
|
CodegenStatus::Done | CodegenStatus::Error(_) => {
|
|
let has_error = matches!(codegen_status, CodegenStatus::Error(_));
|
|
if has_error || self.edited_since_done {
|
|
vec![
|
|
IconButton::new("restart", IconName::RotateCw)
|
|
.icon_color(Color::Info)
|
|
.shape(IconButtonShape::Square)
|
|
.tooltip(move |_window, cx| {
|
|
Tooltip::with_meta(
|
|
mode.tooltip_restart(),
|
|
Some(&menu::Confirm),
|
|
"Changes will be discarded",
|
|
cx,
|
|
)
|
|
})
|
|
.on_click(cx.listener(|_, _, _, cx| {
|
|
cx.emit(PromptEditorEvent::StartRequested);
|
|
}))
|
|
.into_any_element(),
|
|
]
|
|
} else {
|
|
let rated = matches!(self.session_state.completion, CompletionState::Rated);
|
|
|
|
let accept = IconButton::new("accept", IconName::Check)
|
|
.icon_color(Color::Info)
|
|
.shape(IconButtonShape::Square)
|
|
.tooltip(move |_window, cx| {
|
|
Tooltip::for_action(mode.tooltip_accept(), &menu::Confirm, cx)
|
|
})
|
|
.on_click(cx.listener(|_, _, _, cx| {
|
|
cx.emit(PromptEditorEvent::ConfirmRequested { execute: false });
|
|
}))
|
|
.into_any_element();
|
|
|
|
let mut buttons = Vec::new();
|
|
|
|
if AgentSettings::get_global(cx).enable_feedback {
|
|
buttons.push(
|
|
h_flex()
|
|
.pl_1()
|
|
.gap_1()
|
|
.border_l_1()
|
|
.border_color(cx.theme().colors().border_variant)
|
|
.child(
|
|
IconButton::new("thumbs-up", IconName::ThumbsUp)
|
|
.shape(IconButtonShape::Square)
|
|
.map(|this| {
|
|
if rated {
|
|
this.disabled(true)
|
|
.icon_color(Color::Disabled)
|
|
.tooltip(move |_, cx| {
|
|
Tooltip::with_meta(
|
|
"Good Result",
|
|
None,
|
|
"You already rated this result",
|
|
cx,
|
|
)
|
|
})
|
|
} else {
|
|
this.icon_color(Color::Muted).tooltip(
|
|
move |_, cx| {
|
|
Tooltip::for_action(
|
|
"Good Result",
|
|
&ThumbsUpResult,
|
|
cx,
|
|
)
|
|
},
|
|
)
|
|
}
|
|
})
|
|
.on_click(cx.listener(|this, _, window, cx| {
|
|
this.thumbs_up(&ThumbsUpResult, window, cx);
|
|
})),
|
|
)
|
|
.child(
|
|
IconButton::new("thumbs-down", IconName::ThumbsDown)
|
|
.shape(IconButtonShape::Square)
|
|
.map(|this| {
|
|
if rated {
|
|
this.disabled(true)
|
|
.icon_color(Color::Disabled)
|
|
.tooltip(move |_, cx| {
|
|
Tooltip::with_meta(
|
|
"Bad Result",
|
|
None,
|
|
"You already rated this result",
|
|
cx,
|
|
)
|
|
})
|
|
} else {
|
|
this.icon_color(Color::Muted).tooltip(
|
|
move |_, cx| {
|
|
Tooltip::for_action(
|
|
"Bad Result",
|
|
&ThumbsDownResult,
|
|
cx,
|
|
)
|
|
},
|
|
)
|
|
}
|
|
})
|
|
.on_click(cx.listener(|this, _, window, cx| {
|
|
this.thumbs_down(&ThumbsDownResult, window, cx);
|
|
})),
|
|
)
|
|
.into_any_element(),
|
|
);
|
|
}
|
|
|
|
buttons.push(accept);
|
|
|
|
match &self.mode {
|
|
PromptEditorMode::Terminal { .. } => {
|
|
buttons.push(
|
|
IconButton::new("confirm", IconName::PlayFilled)
|
|
.icon_color(Color::Info)
|
|
.shape(IconButtonShape::Square)
|
|
.tooltip(|_window, cx| {
|
|
Tooltip::for_action(
|
|
"Execute Generated Command",
|
|
&menu::SecondaryConfirm,
|
|
cx,
|
|
)
|
|
})
|
|
.on_click(cx.listener(|_, _, _, cx| {
|
|
cx.emit(PromptEditorEvent::ConfirmRequested {
|
|
execute: true,
|
|
});
|
|
}))
|
|
.into_any_element(),
|
|
);
|
|
buttons
|
|
}
|
|
PromptEditorMode::Buffer { .. } => buttons,
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn cycle_prev(
|
|
&mut self,
|
|
_: &CyclePreviousInlineAssist,
|
|
_: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
match &self.mode {
|
|
PromptEditorMode::Buffer { codegen, .. } => {
|
|
codegen.update(cx, |codegen, cx| codegen.cycle_prev(cx));
|
|
}
|
|
PromptEditorMode::Terminal { .. } => {
|
|
// no cycle buttons in terminal mode
|
|
}
|
|
}
|
|
}
|
|
|
|
fn cycle_next(&mut self, _: &CycleNextInlineAssist, _: &mut Window, cx: &mut Context<Self>) {
|
|
match &self.mode {
|
|
PromptEditorMode::Buffer { codegen, .. } => {
|
|
codegen.update(cx, |codegen, cx| codegen.cycle_next(cx));
|
|
}
|
|
PromptEditorMode::Terminal { .. } => {
|
|
// no cycle buttons in terminal mode
|
|
}
|
|
}
|
|
}
|
|
|
|
fn render_close_button(&self, cx: &mut Context<Self>) -> AnyElement {
|
|
let focus_handle = self.editor.focus_handle(cx);
|
|
|
|
IconButton::new("cancel", IconName::Close)
|
|
.icon_color(Color::Muted)
|
|
.shape(IconButtonShape::Square)
|
|
.tooltip({
|
|
move |_window, cx| {
|
|
Tooltip::for_action_in(
|
|
"Close Assistant",
|
|
&editor::actions::Cancel,
|
|
&focus_handle,
|
|
cx,
|
|
)
|
|
}
|
|
})
|
|
.on_click(cx.listener(|_, _, _, cx| cx.emit(PromptEditorEvent::CancelRequested)))
|
|
.into_any_element()
|
|
}
|
|
|
|
fn render_cycle_controls(&self, codegen: &BufferCodegen, cx: &Context<Self>) -> AnyElement {
|
|
let disabled = matches!(codegen.status(cx), CodegenStatus::Idle);
|
|
|
|
let model_registry = LanguageModelRegistry::read_global(cx);
|
|
let default_model = model_registry.default_model().map(|default| default.model);
|
|
let alternative_models = model_registry.inline_alternative_models();
|
|
|
|
let get_model_name = |index: usize| -> String {
|
|
let name = |model: &Arc<dyn LanguageModel>| model.name().0.to_string();
|
|
|
|
match index {
|
|
0 => default_model.as_ref().map_or_else(String::new, name),
|
|
index if index <= alternative_models.len() => alternative_models
|
|
.get(index - 1)
|
|
.map_or_else(String::new, name),
|
|
_ => String::new(),
|
|
}
|
|
};
|
|
|
|
let total_models = alternative_models.len() + 1;
|
|
|
|
if total_models <= 1 {
|
|
return div().into_any_element();
|
|
}
|
|
|
|
let current_index = codegen.active_alternative;
|
|
let prev_index = (current_index + total_models - 1) % total_models;
|
|
let next_index = (current_index + 1) % total_models;
|
|
|
|
let prev_model_name = get_model_name(prev_index);
|
|
let next_model_name = get_model_name(next_index);
|
|
|
|
h_flex()
|
|
.child(
|
|
IconButton::new("previous", IconName::ChevronLeft)
|
|
.icon_color(Color::Muted)
|
|
.disabled(disabled || current_index == 0)
|
|
.shape(IconButtonShape::Square)
|
|
.tooltip({
|
|
let focus_handle = self.editor.focus_handle(cx);
|
|
move |_window, cx| {
|
|
cx.new(|cx| {
|
|
let mut tooltip = Tooltip::new("Previous Alternative").key_binding(
|
|
KeyBinding::for_action_in(
|
|
&CyclePreviousInlineAssist,
|
|
&focus_handle,
|
|
cx,
|
|
),
|
|
);
|
|
if !disabled && current_index != 0 {
|
|
tooltip = tooltip.meta(prev_model_name.clone());
|
|
}
|
|
tooltip
|
|
})
|
|
.into()
|
|
}
|
|
})
|
|
.on_click(cx.listener(|this, _, window, cx| {
|
|
this.cycle_prev(&CyclePreviousInlineAssist, window, cx);
|
|
})),
|
|
)
|
|
.child(
|
|
Label::new(format!(
|
|
"{}/{}",
|
|
codegen.active_alternative + 1,
|
|
codegen.alternative_count(cx)
|
|
))
|
|
.size(LabelSize::Small)
|
|
.color(if disabled {
|
|
Color::Disabled
|
|
} else {
|
|
Color::Muted
|
|
}),
|
|
)
|
|
.child(
|
|
IconButton::new("next", IconName::ChevronRight)
|
|
.icon_color(Color::Muted)
|
|
.disabled(disabled || current_index == total_models - 1)
|
|
.shape(IconButtonShape::Square)
|
|
.tooltip({
|
|
let focus_handle = self.editor.focus_handle(cx);
|
|
move |_window, cx| {
|
|
cx.new(|cx| {
|
|
let mut tooltip = Tooltip::new("Next Alternative").key_binding(
|
|
KeyBinding::for_action_in(
|
|
&CycleNextInlineAssist,
|
|
&focus_handle,
|
|
cx,
|
|
),
|
|
);
|
|
if !disabled && current_index != total_models - 1 {
|
|
tooltip = tooltip.meta(next_model_name.clone());
|
|
}
|
|
tooltip
|
|
})
|
|
.into()
|
|
}
|
|
})
|
|
.on_click(cx.listener(|this, _, window, cx| {
|
|
this.cycle_next(&CycleNextInlineAssist, window, cx)
|
|
})),
|
|
)
|
|
.into_any_element()
|
|
}
|
|
|
|
fn render_editor(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> AnyElement {
|
|
let colors = cx.theme().colors();
|
|
|
|
div()
|
|
.size_full()
|
|
.p_2()
|
|
.pl_1()
|
|
.bg(colors.editor_background)
|
|
.child({
|
|
let settings = ThemeSettings::get_global(cx);
|
|
let font_size = settings.buffer_font_size(cx);
|
|
|
|
let text_style = TextStyle {
|
|
color: colors.editor_foreground,
|
|
font_family: settings.buffer_font.family.clone(),
|
|
font_features: settings.buffer_font.features.clone(),
|
|
font_size: font_size.into(),
|
|
line_height: relative(settings.buffer_line_height.value()),
|
|
..Default::default()
|
|
};
|
|
|
|
EditorElement::new(
|
|
&self.editor,
|
|
EditorStyle {
|
|
background: colors.editor_background,
|
|
local_player: cx.theme().players().local(),
|
|
syntax: cx.theme().syntax().clone(),
|
|
text: text_style,
|
|
..Default::default()
|
|
},
|
|
)
|
|
})
|
|
.into_any_element()
|
|
}
|
|
|
|
fn render_markdown(&self, markdown: Entity<Markdown>, style: MarkdownStyle) -> MarkdownElement {
|
|
MarkdownElement::new(markdown, style)
|
|
.image_resolver(|dest_url| crate::resolve_agent_image(dest_url, &[]))
|
|
}
|
|
}
|
|
|
|
pub enum PromptEditorMode {
|
|
Buffer {
|
|
id: InlineAssistId,
|
|
codegen: Entity<BufferCodegen>,
|
|
editor_margins: Arc<Mutex<EditorMargins>>,
|
|
},
|
|
Terminal {
|
|
id: TerminalInlineAssistId,
|
|
codegen: Entity<TerminalCodegen>,
|
|
height_in_lines: u8,
|
|
},
|
|
}
|
|
|
|
pub enum PromptEditorEvent {
|
|
StartRequested,
|
|
StopRequested,
|
|
ConfirmRequested { execute: bool },
|
|
CancelRequested,
|
|
Resized { height_in_lines: u8 },
|
|
}
|
|
|
|
#[derive(Copy, Clone, Default, Debug, PartialEq, Eq, Hash)]
|
|
pub struct InlineAssistId(pub usize);
|
|
|
|
impl InlineAssistId {
|
|
pub fn post_inc(&mut self) -> InlineAssistId {
|
|
let id = *self;
|
|
self.0 += 1;
|
|
id
|
|
}
|
|
}
|
|
|
|
struct PromptEditorCompletionProviderDelegate;
|
|
|
|
fn inline_assistant_model_supports_images(cx: &App) -> bool {
|
|
LanguageModelRegistry::read_global(cx)
|
|
.inline_assistant_model()
|
|
.map_or(false, |m| m.model.supports_images())
|
|
}
|
|
|
|
impl PromptCompletionProviderDelegate for PromptEditorCompletionProviderDelegate {
|
|
fn supported_modes(&self, _cx: &App) -> Vec<PromptContextType> {
|
|
vec![
|
|
PromptContextType::File,
|
|
PromptContextType::Symbol,
|
|
PromptContextType::Thread,
|
|
PromptContextType::Fetch,
|
|
PromptContextType::Skill,
|
|
]
|
|
}
|
|
|
|
fn supports_images(&self, cx: &App) -> bool {
|
|
inline_assistant_model_supports_images(cx)
|
|
}
|
|
|
|
fn available_commands(&self, _cx: &App) -> Vec<crate::completion_provider::AvailableCommand> {
|
|
Vec::new()
|
|
}
|
|
|
|
fn confirm_command(&self, _cx: &mut App) {}
|
|
}
|
|
|
|
impl PromptEditor<BufferCodegen> {
|
|
pub fn new_buffer(
|
|
id: InlineAssistId,
|
|
editor_margins: Arc<Mutex<EditorMargins>>,
|
|
prompt_history: VecDeque<String>,
|
|
prompt_buffer: Entity<MultiBuffer>,
|
|
codegen: Entity<BufferCodegen>,
|
|
session_id: Uuid,
|
|
fs: Arc<dyn Fs>,
|
|
thread_store: Entity<ThreadStore>,
|
|
project: WeakEntity<Project>,
|
|
workspace: WeakEntity<Workspace>,
|
|
window: &mut Window,
|
|
cx: &mut Context<PromptEditor<BufferCodegen>>,
|
|
) -> PromptEditor<BufferCodegen> {
|
|
let codegen_subscription = cx.observe(&codegen, Self::handle_codegen_changed);
|
|
let mode = PromptEditorMode::Buffer {
|
|
id,
|
|
codegen,
|
|
editor_margins,
|
|
};
|
|
|
|
let prompt_editor = cx.new(|cx| {
|
|
let mut editor = Editor::new(
|
|
EditorMode::AutoHeight {
|
|
min_lines: 1,
|
|
max_lines: Some(Self::MAX_LINES as usize),
|
|
},
|
|
prompt_buffer,
|
|
None,
|
|
window,
|
|
cx,
|
|
);
|
|
editor.set_soft_wrap_mode(language::language_settings::SoftWrap::EditorWidth, cx);
|
|
// Since the prompt editors for all inline assistants are linked,
|
|
// always show the cursor (even when it isn't focused) because
|
|
// typing in one will make what you typed appear in all of them.
|
|
editor.set_show_cursor_when_unfocused(true, cx);
|
|
editor.set_placeholder_text(&Self::placeholder_text(&mode, window, cx), window, cx);
|
|
editor.set_context_menu_options(ContextMenuOptions {
|
|
min_entries_visible: 12,
|
|
max_entries_visible: 12,
|
|
placement: None,
|
|
});
|
|
|
|
editor
|
|
});
|
|
|
|
let mention_set = cx.new(|_cx| MentionSet::new(project, Some(thread_store.clone())));
|
|
|
|
let model_selector_menu_handle = PopoverMenuHandle::default();
|
|
|
|
let mut this: PromptEditor<BufferCodegen> = PromptEditor {
|
|
editor: prompt_editor.clone(),
|
|
mention_set,
|
|
workspace,
|
|
model_selector: cx.new(|cx| {
|
|
AgentModelSelector::new(
|
|
fs,
|
|
model_selector_menu_handle,
|
|
prompt_editor.focus_handle(cx),
|
|
ModelUsageContext::InlineAssistant,
|
|
window,
|
|
cx,
|
|
)
|
|
}),
|
|
edited_since_done: false,
|
|
prompt_history,
|
|
prompt_history_ix: None,
|
|
pending_prompt: String::new(),
|
|
_codegen_subscription: codegen_subscription,
|
|
editor_subscriptions: Vec::new(),
|
|
show_rate_limit_notice: false,
|
|
mode,
|
|
session_state: SessionState {
|
|
session_id,
|
|
completion: CompletionState::Pending,
|
|
},
|
|
_phantom: Default::default(),
|
|
};
|
|
|
|
this.assign_completion_provider(cx);
|
|
this.subscribe_to_editor(window, cx);
|
|
this
|
|
}
|
|
|
|
fn handle_codegen_changed(
|
|
&mut self,
|
|
codegen: Entity<BufferCodegen>,
|
|
cx: &mut Context<PromptEditor<BufferCodegen>>,
|
|
) {
|
|
match self.codegen_status(cx) {
|
|
CodegenStatus::Idle => {
|
|
self.editor
|
|
.update(cx, |editor, _| editor.set_read_only(false));
|
|
}
|
|
CodegenStatus::Pending => {
|
|
self.session_state.completion = CompletionState::Pending;
|
|
self.editor
|
|
.update(cx, |editor, _| editor.set_read_only(true));
|
|
}
|
|
CodegenStatus::Done => {
|
|
let completion = codegen.read(cx).active_completion(cx);
|
|
self.session_state.completion = CompletionState::Generated {
|
|
completion_text: completion,
|
|
};
|
|
self.edited_since_done = false;
|
|
self.editor
|
|
.update(cx, |editor, _| editor.set_read_only(false));
|
|
}
|
|
CodegenStatus::Error(_error) => {
|
|
self.edited_since_done = false;
|
|
self.editor
|
|
.update(cx, |editor, _| editor.set_read_only(false));
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn id(&self) -> InlineAssistId {
|
|
match &self.mode {
|
|
PromptEditorMode::Buffer { id, .. } => *id,
|
|
PromptEditorMode::Terminal { .. } => unreachable!(),
|
|
}
|
|
}
|
|
|
|
pub fn codegen(&self) -> &Entity<BufferCodegen> {
|
|
match &self.mode {
|
|
PromptEditorMode::Buffer { codegen, .. } => codegen,
|
|
PromptEditorMode::Terminal { .. } => unreachable!(),
|
|
}
|
|
}
|
|
|
|
pub fn mention_set(&self) -> &Entity<MentionSet> {
|
|
&self.mention_set
|
|
}
|
|
|
|
pub fn editor_margins(&self) -> &Arc<Mutex<EditorMargins>> {
|
|
match &self.mode {
|
|
PromptEditorMode::Buffer { editor_margins, .. } => editor_margins,
|
|
PromptEditorMode::Terminal { .. } => unreachable!(),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Copy, Clone, Default, Debug, PartialEq, Eq, Hash)]
|
|
pub struct TerminalInlineAssistId(pub usize);
|
|
|
|
impl TerminalInlineAssistId {
|
|
pub fn post_inc(&mut self) -> TerminalInlineAssistId {
|
|
let id = *self;
|
|
self.0 += 1;
|
|
id
|
|
}
|
|
}
|
|
|
|
impl PromptEditor<TerminalCodegen> {
|
|
pub fn new_terminal(
|
|
id: TerminalInlineAssistId,
|
|
prompt_history: VecDeque<String>,
|
|
prompt_buffer: Entity<MultiBuffer>,
|
|
codegen: Entity<TerminalCodegen>,
|
|
session_id: Uuid,
|
|
fs: Arc<dyn Fs>,
|
|
thread_store: Entity<ThreadStore>,
|
|
project: WeakEntity<Project>,
|
|
workspace: WeakEntity<Workspace>,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) -> Self {
|
|
let codegen_subscription = cx.observe(&codegen, Self::handle_codegen_changed);
|
|
let mode = PromptEditorMode::Terminal {
|
|
id,
|
|
codegen,
|
|
height_in_lines: 1,
|
|
};
|
|
|
|
let prompt_editor = cx.new(|cx| {
|
|
let mut editor = Editor::new(
|
|
EditorMode::AutoHeight {
|
|
min_lines: 1,
|
|
max_lines: Some(Self::MAX_LINES as usize),
|
|
},
|
|
prompt_buffer,
|
|
None,
|
|
window,
|
|
cx,
|
|
);
|
|
editor.set_soft_wrap_mode(language::language_settings::SoftWrap::EditorWidth, cx);
|
|
editor.set_placeholder_text(&Self::placeholder_text(&mode, window, cx), window, cx);
|
|
editor.set_context_menu_options(ContextMenuOptions {
|
|
min_entries_visible: 12,
|
|
max_entries_visible: 12,
|
|
placement: None,
|
|
});
|
|
editor
|
|
});
|
|
|
|
let mention_set = cx.new(|_cx| MentionSet::new(project, Some(thread_store.clone())));
|
|
|
|
let model_selector_menu_handle = PopoverMenuHandle::default();
|
|
|
|
let mut this = Self {
|
|
editor: prompt_editor.clone(),
|
|
mention_set,
|
|
workspace,
|
|
model_selector: cx.new(|cx| {
|
|
AgentModelSelector::new(
|
|
fs,
|
|
model_selector_menu_handle.clone(),
|
|
prompt_editor.focus_handle(cx),
|
|
ModelUsageContext::InlineAssistant,
|
|
window,
|
|
cx,
|
|
)
|
|
}),
|
|
edited_since_done: false,
|
|
prompt_history,
|
|
prompt_history_ix: None,
|
|
pending_prompt: String::new(),
|
|
_codegen_subscription: codegen_subscription,
|
|
editor_subscriptions: Vec::new(),
|
|
mode,
|
|
show_rate_limit_notice: false,
|
|
session_state: SessionState {
|
|
session_id,
|
|
completion: CompletionState::Pending,
|
|
},
|
|
_phantom: Default::default(),
|
|
};
|
|
this.count_lines(cx);
|
|
this.assign_completion_provider(cx);
|
|
this.subscribe_to_editor(window, cx);
|
|
this
|
|
}
|
|
|
|
fn count_lines(&mut self, cx: &mut Context<Self>) {
|
|
let height_in_lines = cmp::max(
|
|
2, // Make the editor at least two lines tall, to account for padding and buttons.
|
|
cmp::min(
|
|
self.editor
|
|
.update(cx, |editor, cx| editor.max_point(cx).row().0 + 1),
|
|
Self::MAX_LINES as u32,
|
|
),
|
|
) as u8;
|
|
|
|
match &mut self.mode {
|
|
PromptEditorMode::Terminal {
|
|
height_in_lines: current_height,
|
|
..
|
|
} => {
|
|
if height_in_lines != *current_height {
|
|
*current_height = height_in_lines;
|
|
cx.emit(PromptEditorEvent::Resized { height_in_lines });
|
|
}
|
|
}
|
|
PromptEditorMode::Buffer { .. } => unreachable!(),
|
|
}
|
|
}
|
|
|
|
fn handle_codegen_changed(&mut self, codegen: Entity<TerminalCodegen>, cx: &mut Context<Self>) {
|
|
match &self.codegen().read(cx).status {
|
|
CodegenStatus::Idle => {
|
|
self.editor
|
|
.update(cx, |editor, _| editor.set_read_only(false));
|
|
}
|
|
CodegenStatus::Pending => {
|
|
self.session_state.completion = CompletionState::Pending;
|
|
self.editor
|
|
.update(cx, |editor, _| editor.set_read_only(true));
|
|
}
|
|
CodegenStatus::Done | CodegenStatus::Error(_) => {
|
|
self.session_state.completion = CompletionState::Generated {
|
|
completion_text: codegen.read(cx).completion(),
|
|
};
|
|
self.edited_since_done = false;
|
|
self.editor
|
|
.update(cx, |editor, _| editor.set_read_only(false));
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn mention_set(&self) -> &Entity<MentionSet> {
|
|
&self.mention_set
|
|
}
|
|
|
|
pub fn codegen(&self) -> &Entity<TerminalCodegen> {
|
|
match &self.mode {
|
|
PromptEditorMode::Buffer { .. } => unreachable!(),
|
|
PromptEditorMode::Terminal { codegen, .. } => codegen,
|
|
}
|
|
}
|
|
|
|
pub fn id(&self) -> TerminalInlineAssistId {
|
|
match &self.mode {
|
|
PromptEditorMode::Buffer { .. } => unreachable!(),
|
|
PromptEditorMode::Terminal { id, .. } => *id,
|
|
}
|
|
}
|
|
}
|
|
|
|
pub enum CodegenStatus {
|
|
Idle,
|
|
Pending,
|
|
Done,
|
|
Error(anyhow::Error),
|
|
}
|
|
|
|
#[derive(Copy, Clone)]
|
|
pub enum GenerationMode {
|
|
Generate,
|
|
Transform,
|
|
}
|
|
|
|
impl GenerationMode {
|
|
fn start_label(self) -> &'static str {
|
|
match self {
|
|
GenerationMode::Generate => "Generate",
|
|
GenerationMode::Transform => "Transform",
|
|
}
|
|
}
|
|
fn tooltip_interrupt(self) -> &'static str {
|
|
match self {
|
|
GenerationMode::Generate => "Interrupt Generation",
|
|
GenerationMode::Transform => "Interrupt Transform",
|
|
}
|
|
}
|
|
|
|
fn tooltip_restart(self) -> &'static str {
|
|
match self {
|
|
GenerationMode::Generate => "Restart Generation",
|
|
GenerationMode::Transform => "Restart Transform",
|
|
}
|
|
}
|
|
|
|
fn tooltip_accept(self) -> &'static str {
|
|
match self {
|
|
GenerationMode::Generate => "Accept Generation",
|
|
GenerationMode::Transform => "Accept Transform",
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Stored information that can be used to resurrect a context crease when creating an editor for a past message.
|
|
#[derive(Clone, Debug)]
|
|
struct MessageCrease {
|
|
range: Range<MultiBufferOffset>,
|
|
icon_path: SharedString,
|
|
label: SharedString,
|
|
}
|
|
|
|
fn extract_message_creases(
|
|
editor: &mut Editor,
|
|
mention_set: &Entity<MentionSet>,
|
|
window: &mut Window,
|
|
cx: &mut Context<'_, Editor>,
|
|
) -> Vec<MessageCrease> {
|
|
let creases = mention_set.read(cx).creases();
|
|
let snapshot = editor.snapshot(window, cx);
|
|
snapshot
|
|
.crease_snapshot
|
|
.creases()
|
|
.filter(|(id, _)| creases.contains(id))
|
|
.filter_map(|(_, crease)| {
|
|
let metadata = crease.metadata()?.clone();
|
|
Some(MessageCrease {
|
|
range: crease.range().to_offset(snapshot.buffer()),
|
|
label: metadata.label,
|
|
icon_path: metadata.icon_path,
|
|
})
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn insert_message_creases(
|
|
editor: &mut Editor,
|
|
message_creases: &[MessageCrease],
|
|
window: &mut Window,
|
|
cx: &mut Context<'_, Editor>,
|
|
) -> Vec<CreaseId> {
|
|
let buffer_snapshot = editor.buffer().read(cx).snapshot(cx);
|
|
let creases = message_creases
|
|
.iter()
|
|
.map(|crease| {
|
|
let start = buffer_snapshot.anchor_after(crease.range.start);
|
|
let end = buffer_snapshot.anchor_before(crease.range.end);
|
|
crease_for_mention(
|
|
crease.label.clone(),
|
|
crease.icon_path.clone(),
|
|
None,
|
|
start..end,
|
|
cx.weak_entity(),
|
|
)
|
|
})
|
|
.collect::<Vec<_>>();
|
|
let ids = editor.insert_creases(creases.clone(), cx);
|
|
editor.fold_creases(creases, false, window, cx);
|
|
ids
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::terminal_codegen::TerminalCodegen;
|
|
use agent::ThreadStore;
|
|
use collections::VecDeque;
|
|
use fs::FakeFs;
|
|
use gpui::{TestAppContext, VisualTestContext};
|
|
use language::Buffer;
|
|
use project::Project;
|
|
use settings::SettingsStore;
|
|
use std::cell::RefCell;
|
|
use std::path::Path;
|
|
use std::rc::Rc;
|
|
use terminal::TerminalBuilder;
|
|
use terminal::terminal_settings::CursorShape;
|
|
use util::path;
|
|
use util::paths::PathStyle;
|
|
use uuid::Uuid;
|
|
|
|
fn init_test(cx: &mut TestAppContext) {
|
|
cx.update(|cx| {
|
|
let settings_store = SettingsStore::test(cx);
|
|
cx.set_global(settings_store);
|
|
theme::init(theme::LoadThemes::JustBase, cx);
|
|
theme_settings::init(theme::LoadThemes::JustBase, cx);
|
|
editor::init(cx);
|
|
release_channel::init(semver::Version::new(0, 0, 0), cx);
|
|
language_model::LanguageModelRegistry::test(cx);
|
|
prompt_store::init(cx);
|
|
});
|
|
}
|
|
|
|
fn build_terminal_prompt_editor(
|
|
workspace: &Entity<Workspace>,
|
|
cx: &mut VisualTestContext,
|
|
) -> Entity<PromptEditor<TerminalCodegen>> {
|
|
let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx)));
|
|
let fs = FakeFs::new(cx.executor());
|
|
|
|
let terminal = cx.update(|_window, cx| {
|
|
cx.new(|cx| {
|
|
TerminalBuilder::new_display_only(
|
|
CursorShape::default(),
|
|
settings::AlternateScroll::On,
|
|
None,
|
|
0,
|
|
cx.background_executor(),
|
|
PathStyle::local(),
|
|
)
|
|
.unwrap()
|
|
.subscribe(cx)
|
|
})
|
|
});
|
|
|
|
let session_id = Uuid::new_v4();
|
|
let codegen =
|
|
cx.update(|_window, cx| cx.new(|_| TerminalCodegen::new(terminal, session_id)));
|
|
|
|
let prompt_buffer = cx.update(|_window, cx| {
|
|
cx.new(|cx| MultiBuffer::singleton(cx.new(|cx| Buffer::local("", cx)), cx))
|
|
});
|
|
|
|
let project = workspace.update(cx, |workspace, _cx| workspace.project().downgrade());
|
|
|
|
cx.update(|window, cx| {
|
|
cx.new(|cx| {
|
|
PromptEditor::new_terminal(
|
|
TerminalInlineAssistId::default(),
|
|
VecDeque::new(),
|
|
prompt_buffer,
|
|
codegen,
|
|
session_id,
|
|
fs,
|
|
thread_store,
|
|
project,
|
|
workspace.downgrade(),
|
|
window,
|
|
cx,
|
|
)
|
|
})
|
|
})
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_secondary_confirm_emits_execute_true_in_terminal_mode(cx: &mut TestAppContext) {
|
|
init_test(cx);
|
|
|
|
let fs = FakeFs::new(cx.executor());
|
|
fs.insert_tree("/project", serde_json::json!({"file": ""}))
|
|
.await;
|
|
let project = Project::test(fs, [Path::new(path!("/project"))], cx).await;
|
|
let (workspace, cx) =
|
|
cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
|
|
|
|
let prompt_editor = build_terminal_prompt_editor(&workspace, cx);
|
|
|
|
// Set the codegen status to Done so that confirm logic emits ConfirmRequested.
|
|
prompt_editor.update(cx, |editor, cx| {
|
|
editor.codegen().update(cx, |codegen, _| {
|
|
codegen.status = CodegenStatus::Done;
|
|
});
|
|
editor.edited_since_done = false;
|
|
});
|
|
|
|
let events: Rc<RefCell<Vec<PromptEditorEvent>>> = Rc::new(RefCell::new(Vec::new()));
|
|
let events_clone = events.clone();
|
|
cx.update(|_window, cx| {
|
|
cx.subscribe(&prompt_editor, move |_, event: &PromptEditorEvent, _cx| {
|
|
events_clone.borrow_mut().push(match event {
|
|
PromptEditorEvent::ConfirmRequested { execute } => {
|
|
PromptEditorEvent::ConfirmRequested { execute: *execute }
|
|
}
|
|
PromptEditorEvent::StartRequested => PromptEditorEvent::StartRequested,
|
|
PromptEditorEvent::StopRequested => PromptEditorEvent::StopRequested,
|
|
PromptEditorEvent::CancelRequested => PromptEditorEvent::CancelRequested,
|
|
PromptEditorEvent::Resized { height_in_lines } => PromptEditorEvent::Resized {
|
|
height_in_lines: *height_in_lines,
|
|
},
|
|
});
|
|
})
|
|
.detach();
|
|
});
|
|
|
|
// Dispatch menu::SecondaryConfirm (cmd-enter).
|
|
prompt_editor.update(cx, |editor, cx| {
|
|
editor.handle_confirm(true, cx);
|
|
});
|
|
|
|
let events = events.borrow();
|
|
assert_eq!(events.len(), 1, "Expected exactly one event");
|
|
assert!(
|
|
matches!(
|
|
events[0],
|
|
PromptEditorEvent::ConfirmRequested { execute: true }
|
|
),
|
|
"Expected ConfirmRequested with execute: true, got {:?}",
|
|
match &events[0] {
|
|
PromptEditorEvent::ConfirmRequested { execute } =>
|
|
format!("ConfirmRequested {{ execute: {} }}", execute),
|
|
_ => "other event".to_string(),
|
|
}
|
|
);
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_confirm_emits_execute_false_in_terminal_mode(cx: &mut TestAppContext) {
|
|
init_test(cx);
|
|
|
|
let fs = FakeFs::new(cx.executor());
|
|
fs.insert_tree("/project", serde_json::json!({"file": ""}))
|
|
.await;
|
|
let project = Project::test(fs, [Path::new(path!("/project"))], cx).await;
|
|
let (workspace, cx) =
|
|
cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
|
|
|
|
let prompt_editor = build_terminal_prompt_editor(&workspace, cx);
|
|
|
|
prompt_editor.update(cx, |editor, cx| {
|
|
editor.codegen().update(cx, |codegen, _| {
|
|
codegen.status = CodegenStatus::Done;
|
|
});
|
|
editor.edited_since_done = false;
|
|
});
|
|
|
|
let events: Rc<RefCell<Vec<PromptEditorEvent>>> = Rc::new(RefCell::new(Vec::new()));
|
|
let events_clone = events.clone();
|
|
cx.update(|_window, cx| {
|
|
cx.subscribe(&prompt_editor, move |_, event: &PromptEditorEvent, _cx| {
|
|
events_clone.borrow_mut().push(match event {
|
|
PromptEditorEvent::ConfirmRequested { execute } => {
|
|
PromptEditorEvent::ConfirmRequested { execute: *execute }
|
|
}
|
|
PromptEditorEvent::StartRequested => PromptEditorEvent::StartRequested,
|
|
PromptEditorEvent::StopRequested => PromptEditorEvent::StopRequested,
|
|
PromptEditorEvent::CancelRequested => PromptEditorEvent::CancelRequested,
|
|
PromptEditorEvent::Resized { height_in_lines } => PromptEditorEvent::Resized {
|
|
height_in_lines: *height_in_lines,
|
|
},
|
|
});
|
|
})
|
|
.detach();
|
|
});
|
|
|
|
// Dispatch menu::Confirm (enter) — should emit execute: false even in terminal mode.
|
|
prompt_editor.update(cx, |editor, cx| {
|
|
editor.handle_confirm(false, cx);
|
|
});
|
|
|
|
let events = events.borrow();
|
|
assert_eq!(events.len(), 1, "Expected exactly one event");
|
|
assert!(
|
|
matches!(
|
|
events[0],
|
|
PromptEditorEvent::ConfirmRequested { execute: false }
|
|
),
|
|
"Expected ConfirmRequested with execute: false"
|
|
);
|
|
}
|
|
}
|