Cargo.lock 🔗
@@ -401,6 +401,7 @@ dependencies = [
"unindent",
"url",
"util",
+ "uuid",
"watch",
"workspace",
"zed_actions",
Mikayla Maki created
TODO:
- [x] Add inline prompt rating buttons
- [ ] Hook this into our other systems
Release Notes:
- N/A
Cargo.lock | 1
assets/keymaps/default-linux.json | 5
assets/keymaps/default-macos.json | 4
assets/keymaps/default-windows.json | 4
crates/agent_ui/Cargo.toml | 1
crates/agent_ui/src/agent_model_selector.rs | 4
crates/agent_ui/src/buffer_codegen.rs | 20 +
crates/agent_ui/src/inline_prompt_editor.rs | 263 +++++++++++++++++++++-
crates/agent_ui/src/terminal_codegen.rs | 17 +
crates/zed/src/zed.rs | 1
10 files changed, 292 insertions(+), 28 deletions(-)
@@ -401,6 +401,7 @@ dependencies = [
"unindent",
"url",
"util",
+ "uuid",
"watch",
"workspace",
"zed_actions",
@@ -811,7 +811,10 @@
"context": "PromptEditor",
"bindings": {
"ctrl-[": "agent::CyclePreviousInlineAssist",
- "ctrl-]": "agent::CycleNextInlineAssist"
+ "ctrl-]": "agent::CycleNextInlineAssist",
+ "ctrl-shift-enter": "inline_assistant::ThumbsUpResult",
+ "ctrl-shift-backspace": "inline_assistant::ThumbsDownResult"
+
}
},
{
@@ -878,7 +878,9 @@
"bindings": {
"cmd-alt-/": "agent::ToggleModelSelector",
"ctrl-[": "agent::CyclePreviousInlineAssist",
- "ctrl-]": "agent::CycleNextInlineAssist"
+ "ctrl-]": "agent::CycleNextInlineAssist",
+ "cmd-shift-enter": "inline_assistant::ThumbsUpResult",
+ "cmd-shift-backspace": "inline_assistant::ThumbsDownResult"
}
},
{
@@ -816,7 +816,9 @@
"use_key_equivalents": true,
"bindings": {
"ctrl-[": "agent::CyclePreviousInlineAssist",
- "ctrl-]": "agent::CycleNextInlineAssist"
+ "ctrl-]": "agent::CycleNextInlineAssist",
+ "ctrl-shift-enter": "inline_assistant::ThumbsUpResult",
+ "ctrl-shift-delete": "inline_assistant::ThumbsDownResult"
}
},
{
@@ -95,6 +95,7 @@ ui.workspace = true
ui_input.workspace = true
url.workspace = true
util.workspace = true
+uuid.workspace = true
watch.workspace = true
workspace.workspace = true
zed_actions.workspace = true
@@ -63,6 +63,10 @@ impl AgentModelSelector {
pub fn toggle(&self, window: &mut Window, cx: &mut Context<Self>) {
self.menu_handle.toggle(window, cx);
}
+
+ pub fn active_model(&self, cx: &App) -> Option<language_model::ConfiguredModel> {
+ self.selector.read(cx).delegate.active_model(cx)
+ }
}
impl Render for AgentModelSelector {
@@ -119,6 +119,10 @@ impl BufferCodegen {
.push(cx.subscribe(&codegen, |_, _, event, cx| cx.emit(*event)));
}
+ pub fn active_completion(&self, cx: &App) -> Option<String> {
+ self.active_alternative().read(cx).current_completion()
+ }
+
pub fn active_alternative(&self) -> &Entity<CodegenAlternative> {
&self.alternatives[self.active_alternative]
}
@@ -241,6 +245,10 @@ impl BufferCodegen {
pub fn last_equal_ranges<'a>(&self, cx: &'a App) -> &'a [Range<Anchor>] {
self.active_alternative().read(cx).last_equal_ranges()
}
+
+ pub fn selected_text<'a>(&self, cx: &'a App) -> Option<&'a str> {
+ self.active_alternative().read(cx).selected_text()
+ }
}
impl EventEmitter<CodegenEvent> for BufferCodegen {}
@@ -264,6 +272,7 @@ pub struct CodegenAlternative {
line_operations: Vec<LineOperation>,
elapsed_time: Option<f64>,
completion: Option<String>,
+ selected_text: Option<String>,
pub message_id: Option<String>,
pub model_explanation: Option<SharedString>,
}
@@ -323,6 +332,7 @@ impl CodegenAlternative {
range,
elapsed_time: None,
completion: None,
+ selected_text: None,
model_explanation: None,
_subscription: cx.subscribe(&buffer, Self::handle_buffer_event),
}
@@ -608,6 +618,8 @@ impl CodegenAlternative {
.text_for_range(self.range.start..self.range.end)
.collect::<Rope>();
+ self.selected_text = Some(selected_text.to_string());
+
let selection_start = self.range.start.to_point(&snapshot);
// Start with the indentation of the first line in the selection
@@ -868,6 +880,14 @@ impl CodegenAlternative {
cx.notify();
}
+ pub fn current_completion(&self) -> Option<String> {
+ self.completion.clone()
+ }
+
+ pub fn selected_text(&self) -> Option<&str> {
+ self.selected_text.as_deref()
+ }
+
pub fn stop(&mut self, cx: &mut Context<Self>) {
self.last_equal_ranges.clear();
if self.diff.is_empty() {
@@ -8,10 +8,11 @@ use editor::{
ContextMenuOptions, Editor, EditorElement, EditorEvent, EditorMode, EditorStyle, MultiBuffer,
actions::{MoveDown, MoveUp},
};
+use feature_flags::{FeatureFlag, FeatureFlagAppExt};
use fs::Fs;
use gpui::{
- AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, Subscription,
- TextStyle, TextStyleRefinement, WeakEntity, Window,
+ 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};
@@ -19,14 +20,16 @@ use parking_lot::Mutex;
use project::Project;
use prompt_store::PromptStore;
use settings::Settings;
-use std::cmp;
use std::ops::Range;
use std::rc::Rc;
use std::sync::Arc;
+use std::{cmp, mem};
use theme::ThemeSettings;
use ui::utils::WithRemSize;
use ui::{IconButtonShape, KeyBinding, PopoverMenuHandle, Tooltip, prelude::*};
-use workspace::Workspace;
+use uuid::Uuid;
+use workspace::notifications::NotificationId;
+use workspace::{Toast, Workspace};
use zed_actions::agent::ToggleModelSelector;
use crate::agent_model_selector::AgentModelSelector;
@@ -39,6 +42,58 @@ use crate::mention_set::{MentionSet, crease_for_mention};
use crate::terminal_codegen::TerminalCodegen;
use crate::{CycleNextInlineAssist, CyclePreviousInlineAssist, ModelUsageContext};
+actions!(inline_assistant, [ThumbsUpResult, ThumbsDownResult]);
+
+pub struct InlineAssistRatingFeatureFlag;
+
+impl FeatureFlag for InlineAssistRatingFeatureFlag {
+ const NAME: &'static str = "inline-assist-rating";
+
+ fn enabled_for_staff() -> bool {
+ false
+ }
+}
+
+enum RatingState {
+ Pending,
+ GeneratedCompletion(Option<String>),
+ Rated(Uuid),
+}
+
+impl RatingState {
+ fn is_pending(&self) -> bool {
+ matches!(self, RatingState::Pending)
+ }
+
+ fn rating_id(&self) -> Option<Uuid> {
+ match self {
+ RatingState::Pending => None,
+ RatingState::GeneratedCompletion(_) => None,
+ RatingState::Rated(id) => Some(*id),
+ }
+ }
+
+ fn rate(&mut self) -> (Uuid, Option<String>) {
+ let id = Uuid::new_v4();
+ let old_state = mem::replace(self, RatingState::Rated(id));
+ let completion = match old_state {
+ RatingState::Pending => None,
+ RatingState::GeneratedCompletion(completion) => completion,
+ RatingState::Rated(_) => None,
+ };
+
+ (id, completion)
+ }
+
+ fn reset(&mut self) {
+ *self = RatingState::Pending;
+ }
+
+ fn generated_completion(&mut self, generated_completion: Option<String>) {
+ *self = RatingState::GeneratedCompletion(generated_completion);
+ }
+}
+
pub struct PromptEditor<T> {
pub editor: Entity<Editor>,
mode: PromptEditorMode,
@@ -54,6 +109,7 @@ pub struct PromptEditor<T> {
_codegen_subscription: Subscription,
editor_subscriptions: Vec<Subscription>,
show_rate_limit_notice: bool,
+ rated: RatingState,
_phantom: std::marker::PhantomData<T>,
}
@@ -153,6 +209,8 @@ impl<T: 'static> Render for PromptEditor<T> {
.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))
.child(
@@ -429,6 +487,7 @@ impl<T: 'static> PromptEditor<T> {
}
self.edited_since_done = true;
+ self.rated.reset();
cx.notify();
}
EditorEvent::Blurred => {
@@ -516,6 +575,121 @@ impl<T: 'static> PromptEditor<T> {
}
}
+ fn thumbs_up(&mut self, _: &ThumbsUpResult, _window: &mut Window, cx: &mut Context<Self>) {
+ if self.rated.is_pending() {
+ self.toast("Still generating...", None, cx);
+ return;
+ }
+
+ if let Some(rating_id) = self.rated.rating_id() {
+ self.toast("Already rated this completion", Some(rating_id), cx);
+ return;
+ }
+
+ let (rating_id, completion) = self.rated.rate();
+
+ let selected_text = match &self.mode {
+ PromptEditorMode::Buffer { codegen, .. } => {
+ codegen.read(cx).selected_text(cx).map(|s| s.to_string())
+ }
+ PromptEditorMode::Terminal { .. } => None,
+ };
+
+ let model_info = self.model_selector.read(cx).active_model(cx);
+ let model_id = {
+ let Some(configured_model) = model_info else {
+ self.toast("No configured model", None, cx);
+ return;
+ };
+
+ configured_model.model.telemetry_id()
+ };
+
+ let prompt = self.editor.read(cx).text(cx);
+
+ telemetry::event!(
+ "Inline Assistant Rated",
+ rating = "positive",
+ model = model_id,
+ prompt = prompt,
+ completion = completion,
+ selected_text = selected_text,
+ rating_id = rating_id.to_string()
+ );
+
+ cx.notify();
+ }
+
+ fn thumbs_down(&mut self, _: &ThumbsDownResult, _window: &mut Window, cx: &mut Context<Self>) {
+ if self.rated.is_pending() {
+ self.toast("Still generating...", None, cx);
+ return;
+ }
+ if let Some(rating_id) = self.rated.rating_id() {
+ self.toast("Already rated this completion", Some(rating_id), cx);
+ return;
+ }
+
+ let (rating_id, completion) = self.rated.rate();
+
+ let selected_text = match &self.mode {
+ PromptEditorMode::Buffer { codegen, .. } => {
+ codegen.read(cx).selected_text(cx).map(|s| s.to_string())
+ }
+ PromptEditorMode::Terminal { .. } => None,
+ };
+
+ let model_info = self.model_selector.read(cx).active_model(cx);
+ let model_telemetry_id = {
+ let Some(configured_model) = model_info else {
+ self.toast("No configured model", None, cx);
+ return;
+ };
+
+ configured_model.model.telemetry_id()
+ };
+
+ let prompt = self.editor.read(cx).text(cx);
+
+ telemetry::event!(
+ "Inline Assistant Rated",
+ rating = "negative",
+ model = model_telemetry_id,
+ prompt = prompt,
+ completion = completion,
+ selected_text = selected_text,
+ rating_id = rating_id.to_string()
+ );
+
+ 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 {
@@ -621,6 +795,9 @@ impl<T: 'static> PromptEditor<T> {
.into_any_element(),
]
} else {
+ let show_rating_buttons = cx.has_flag::<InlineAssistRatingFeatureFlag>();
+ let rated = self.rated.rating_id().is_some();
+
let accept = IconButton::new("accept", IconName::Check)
.icon_color(Color::Info)
.shape(IconButtonShape::Square)
@@ -632,25 +809,59 @@ impl<T: 'static> PromptEditor<T> {
}))
.into_any_element();
- match &self.mode {
- PromptEditorMode::Terminal { .. } => vec![
- accept,
- IconButton::new("confirm", IconName::PlayFilled)
- .icon_color(Color::Info)
+ let mut buttons = Vec::new();
+
+ if show_rating_buttons {
+ buttons.push(
+ IconButton::new("thumbs-down", IconName::ThumbsDown)
+ .icon_color(if rated { Color::Muted } else { Color::Default })
.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 });
+ .disabled(rated)
+ .tooltip(Tooltip::text("Bad result"))
+ .on_click(cx.listener(|this, _, window, cx| {
+ this.thumbs_down(&ThumbsDownResult, window, cx);
}))
.into_any_element(),
- ],
- PromptEditorMode::Buffer { .. } => vec![accept],
+ );
+
+ buttons.push(
+ IconButton::new("thumbs-up", IconName::ThumbsUp)
+ .icon_color(if rated { Color::Muted } else { Color::Default })
+ .shape(IconButtonShape::Square)
+ .disabled(rated)
+ .tooltip(Tooltip::text("Good result"))
+ .on_click(cx.listener(|this, _, window, cx| {
+ this.thumbs_up(&ThumbsUpResult, 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,
}
}
}
@@ -979,6 +1190,7 @@ impl PromptEditor<BufferCodegen> {
editor_subscriptions: Vec::new(),
show_rate_limit_notice: false,
mode,
+ rated: RatingState::Pending,
_phantom: Default::default(),
};
@@ -989,7 +1201,7 @@ impl PromptEditor<BufferCodegen> {
fn handle_codegen_changed(
&mut self,
- _: Entity<BufferCodegen>,
+ codegen: Entity<BufferCodegen>,
cx: &mut Context<PromptEditor<BufferCodegen>>,
) {
match self.codegen_status(cx) {
@@ -998,10 +1210,13 @@ impl PromptEditor<BufferCodegen> {
.update(cx, |editor, _| editor.set_read_only(false));
}
CodegenStatus::Pending => {
+ self.rated.reset();
self.editor
.update(cx, |editor, _| editor.set_read_only(true));
}
CodegenStatus::Done => {
+ let completion = codegen.read(cx).active_completion(cx);
+ self.rated.generated_completion(completion);
self.edited_since_done = false;
self.editor
.update(cx, |editor, _| editor.set_read_only(false));
@@ -1122,6 +1337,7 @@ impl PromptEditor<TerminalCodegen> {
editor_subscriptions: Vec::new(),
mode,
show_rate_limit_notice: false,
+ rated: RatingState::Pending,
_phantom: Default::default(),
};
this.count_lines(cx);
@@ -1154,17 +1370,20 @@ impl PromptEditor<TerminalCodegen> {
}
}
- fn handle_codegen_changed(&mut self, _: Entity<TerminalCodegen>, cx: &mut Context<Self>) {
+ 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.rated = RatingState::Pending;
self.editor
.update(cx, |editor, _| editor.set_read_only(true));
}
CodegenStatus::Done | CodegenStatus::Error(_) => {
+ self.rated
+ .generated_completion(codegen.read(cx).completion());
self.edited_since_done = false;
self.editor
.update(cx, |editor, _| editor.set_read_only(false));
@@ -135,6 +135,12 @@ impl TerminalCodegen {
cx.notify();
}
+ pub fn completion(&self) -> Option<String> {
+ self.transaction
+ .as_ref()
+ .map(|transaction| transaction.completion.clone())
+ }
+
pub fn stop(&mut self, cx: &mut Context<Self>) {
self.status = CodegenStatus::Done;
self.generation = Task::ready(());
@@ -167,27 +173,32 @@ pub const CLEAR_INPUT: &str = "\x03";
const CARRIAGE_RETURN: &str = "\x0d";
struct TerminalTransaction {
+ completion: String,
terminal: Entity<Terminal>,
}
impl TerminalTransaction {
pub fn start(terminal: Entity<Terminal>) -> Self {
- Self { terminal }
+ Self {
+ completion: String::new(),
+ terminal,
+ }
}
pub fn push(&mut self, hunk: String, cx: &mut App) {
// Ensure that the assistant cannot accidentally execute commands that are streamed into the terminal
let input = Self::sanitize_input(hunk);
+ self.completion.push_str(&input);
self.terminal
.update(cx, |terminal, _| terminal.input(input.into_bytes()));
}
- pub fn undo(&self, cx: &mut App) {
+ pub fn undo(self, cx: &mut App) {
self.terminal
.update(cx, |terminal, _| terminal.input(CLEAR_INPUT.as_bytes()));
}
- pub fn complete(&self, cx: &mut App) {
+ pub fn complete(self, cx: &mut App) {
self.terminal
.update(cx, |terminal, _| terminal.input(CARRIAGE_RETURN.as_bytes()));
}
@@ -4745,6 +4745,7 @@ mod tests {
"git_panel",
"go_to_line",
"icon_theme_selector",
+ "inline_assistant",
"journal",
"keymap_editor",
"keystroke_input",