Add inline prompt rating (#44230)

TODO:

- [x] Add inline prompt rating buttons
- [ ] Hook this into our other systems

Release Notes:

- N/A
This commit is contained in:
Mikayla Maki
2025-12-10 07:46:04 +00:00
committed by GitHub
parent 22f1655f8f
commit fd2094fa19
10 changed files with 292 additions and 28 deletions
Generated
+1
View File
@@ -401,6 +401,7 @@ dependencies = [
"unindent",
"url",
"util",
"uuid",
"watch",
"workspace",
"zed_actions",
+4 -1
View File
@@ -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"
}
},
{
+3 -1
View File
@@ -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"
}
},
{
+3 -1
View File
@@ -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"
}
},
{
+1
View File
@@ -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 {
+20
View File
@@ -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() {
+241 -22
View File
@@ -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));
+14 -3
View File
@@ -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()));
}
+1
View File
@@ -4745,6 +4745,7 @@ mod tests {
"git_panel",
"go_to_line",
"icon_theme_selector",
"inline_assistant",
"journal",
"keymap_editor",
"keystroke_input",