edit prediction: Context debug view (#38435)

Adds a `dev: open edit prediction context` action that opens a new
workspace pane that displays the excerpts and snippets that would be
included in the edit prediction request.

Release Notes:

- N/A

---------

Co-authored-by: Bennet <bennet@zed.dev>
This commit is contained in:
Agus Zubiaga
2025-09-18 15:09:44 -03:00
committed by GitHub
co-authored by Bennet
parent 55d130a166
commit df50b5c14a
15 changed files with 583 additions and 19 deletions
Generated
+28
View File
@@ -5197,6 +5197,33 @@ dependencies = [
"zlog",
]
[[package]]
name = "edit_prediction_tools"
version = "0.1.0"
dependencies = [
"clap",
"collections",
"edit_prediction_context",
"editor",
"futures 0.3.31",
"gpui",
"indoc",
"language",
"log",
"pretty_assertions",
"project",
"serde",
"serde_json",
"settings",
"text",
"ui",
"ui_input",
"util",
"workspace",
"workspace-hack",
"zlog",
]
[[package]]
name = "editor"
version = "0.1.0"
@@ -21217,6 +21244,7 @@ dependencies = [
"debugger_ui",
"diagnostics",
"edit_prediction_button",
"edit_prediction_tools",
"editor",
"env_logger 0.11.8",
"extension",
+2
View File
@@ -58,6 +58,7 @@ members = [
"crates/edit_prediction",
"crates/edit_prediction_button",
"crates/edit_prediction_context",
"crates/edit_prediction_tools",
"crates/editor",
"crates/eval",
"crates/explorer_command_injector",
@@ -314,6 +315,7 @@ image_viewer = { path = "crates/image_viewer" }
edit_prediction = { path = "crates/edit_prediction" }
edit_prediction_button = { path = "crates/edit_prediction_button" }
edit_prediction_context = { path = "crates/edit_prediction_context" }
edit_prediction_tools = { path = "crates/edit_prediction_tools" }
inspector_ui = { path = "crates/inspector_ui" }
install_cli = { path = "crates/install_cli" }
jj = { path = "crates/jj" }
@@ -41,14 +41,14 @@ impl Declaration {
}
}
pub fn project_entry_id(&self) -> Option<ProjectEntryId> {
pub fn project_entry_id(&self) -> ProjectEntryId {
match self {
Declaration::File {
project_entry_id, ..
} => Some(*project_entry_id),
} => *project_entry_id,
Declaration::Buffer {
project_entry_id, ..
} => Some(*project_entry_id),
} => *project_entry_id,
}
}
@@ -119,15 +119,13 @@ pub fn scored_snippets(
)
})
} else {
// TODO should we prefer the current file instead?
Some((false, 0, declaration))
Some((false, u32::MAX, declaration))
}
}
Declaration::File { .. } => {
// TODO should we prefer the current file instead?
// We can assume that a file declaration is in a different file,
// because the current one must be open
Some((false, 0, declaration))
Some((false, u32::MAX, declaration))
}
})
.sorted_by_key(|&(_, distance, _)| distance)
@@ -6,8 +6,12 @@ mod reference;
mod syntax_index;
mod text_similarity;
use std::time::Instant;
pub use declaration::{BufferDeclaration, Declaration, FileDeclaration, Identifier};
pub use declaration_scoring::SnippetStyle;
pub use excerpt::{EditPredictionExcerpt, EditPredictionExcerptOptions, EditPredictionExcerptText};
use gpui::{App, AppContext as _, Entity, Task};
use language::BufferSnapshot;
pub use reference::references_in_excerpt;
@@ -16,10 +20,12 @@ use text::{Point, ToOffset as _};
use crate::declaration_scoring::{ScoredSnippet, scored_snippets};
#[derive(Debug)]
pub struct EditPredictionContext {
pub excerpt: EditPredictionExcerpt,
pub excerpt_text: EditPredictionExcerptText,
pub snippets: Vec<ScoredSnippet>,
pub retrieval_duration: std::time::Duration,
}
impl EditPredictionContext {
@@ -29,14 +35,14 @@ impl EditPredictionContext {
excerpt_options: EditPredictionExcerptOptions,
syntax_index: Entity<SyntaxIndex>,
cx: &mut App,
) -> Task<Self> {
) -> Task<Option<Self>> {
let start = Instant::now();
let index_state = syntax_index.read_with(cx, |index, _cx| index.state().clone());
cx.background_spawn(async move {
let index_state = index_state.lock().await;
let excerpt =
EditPredictionExcerpt::select_from_buffer(cursor_point, &buffer, &excerpt_options)
.unwrap();
EditPredictionExcerpt::select_from_buffer(cursor_point, &buffer, &excerpt_options)?;
let excerpt_text = excerpt.text(&buffer);
let references = references_in_excerpt(&excerpt, &excerpt_text, &buffer);
let cursor_offset = cursor_point.to_offset(&buffer);
@@ -50,11 +56,12 @@ impl EditPredictionContext {
&buffer,
);
Self {
Some(Self {
excerpt,
excerpt_text,
snippets,
}
retrieval_duration: start.elapsed(),
})
})
}
}
@@ -107,7 +114,8 @@ mod tests {
cx,
)
})
.await;
.await
.unwrap();
assert_eq!(context.snippets.len(), 1);
assert_eq!(context.snippets[0].identifier.name.as_ref(), "process_data");
@@ -38,7 +38,7 @@ pub struct EditPredictionExcerpt {
pub size: usize,
}
#[derive(Clone)]
#[derive(Debug, Clone)]
pub struct EditPredictionExcerptText {
pub body: String,
pub parent_signatures: Vec<String>,
@@ -89,7 +89,8 @@ pub fn identifiers_in_range(
}
let identifier_text =
&range_text[node_range.start - range.start..node_range.end - range.start];
// TODO we changed this to saturating_sub for now, but we should fix the actually issue
&range_text[node_range.start.saturating_sub(range.start)..node_range.end.saturating_sub(range.start)];
references.push(Reference {
identifier: Identifier {
name: identifier_text.into(),
@@ -9,7 +9,7 @@ use project::worktree_store::{WorktreeStore, WorktreeStoreEvent};
use project::{PathChange, Project, ProjectEntryId, ProjectPath};
use slotmap::SlotMap;
use text::BufferId;
use util::{ResultExt as _, debug_panic, some_or_debug_panic};
use util::{debug_panic, some_or_debug_panic};
use crate::declaration::{
BufferDeclaration, Declaration, DeclarationId, FileDeclaration, Identifier,
@@ -332,7 +332,7 @@ impl SyntaxIndex {
let language = language_registry
.language_for_file_path(&project_path.path)
.await
.log_err();
.ok();
let buffer = cx.new(|cx| {
let mut buffer = Buffer::local(loaded_file.text, cx);
+41
View File
@@ -0,0 +1,41 @@
[package]
name = "edit_prediction_tools"
version = "0.1.0"
edition.workspace = true
publish.workspace = true
license = "GPL-3.0-or-later"
[lints]
workspace = true
[lib]
path = "src/edit_prediction_tools.rs"
[dependencies]
edit_prediction_context.workspace = true
collections.workspace = true
editor.workspace = true
gpui.workspace = true
language.workspace = true
log.workspace = true
project.workspace = true
serde.workspace = true
text.workspace = true
ui.workspace = true
ui_input.workspace = true
workspace-hack.workspace = true
workspace.workspace = true
[dev-dependencies]
clap.workspace = true
futures.workspace = true
gpui = { workspace = true, features = ["test-support"] }
indoc.workspace = true
language = { workspace = true, features = ["test-support"] }
pretty_assertions.workspace = true
project = {workspace= true, features = ["test-support"]}
serde_json.workspace = true
settings = {workspace= true, features = ["test-support"]}
text = { workspace = true, features = ["test-support"] }
util = { workspace = true, features = ["test-support"] }
zlog.workspace = true
+1
View File
@@ -0,0 +1 @@
../../LICENSE-GPL
@@ -0,0 +1,457 @@
use std::{
collections::hash_map::Entry,
ffi::OsStr,
path::{Path, PathBuf},
str::FromStr,
sync::Arc,
time::Duration,
};
use collections::HashMap;
use editor::{Editor, EditorEvent, EditorMode, ExcerptRange, MultiBuffer};
use gpui::{
Entity, EventEmitter, FocusHandle, Focusable, Subscription, Task, WeakEntity, actions,
prelude::*,
};
use language::{Buffer, DiskState};
use project::{Project, WorktreeId};
use text::ToPoint;
use ui::prelude::*;
use ui_input::SingleLineInput;
use workspace::{Item, SplitDirection, Workspace};
use edit_prediction_context::{
EditPredictionContext, EditPredictionExcerptOptions, SnippetStyle, SyntaxIndex,
};
actions!(
dev,
[
/// Opens the language server protocol logs viewer.
OpenEditPredictionContext
]
);
pub fn init(cx: &mut App) {
cx.observe_new(move |workspace: &mut Workspace, _, _cx| {
workspace.register_action(
move |workspace, _: &OpenEditPredictionContext, window, cx| {
let workspace_entity = cx.entity();
let project = workspace.project();
let active_editor = workspace.active_item_as::<Editor>(cx);
workspace.split_item(
SplitDirection::Right,
Box::new(cx.new(|cx| {
EditPredictionTools::new(
&workspace_entity,
&project,
active_editor,
window,
cx,
)
})),
window,
cx,
);
},
);
})
.detach();
}
pub struct EditPredictionTools {
focus_handle: FocusHandle,
project: Entity<Project>,
last_context: Option<ContextState>,
max_bytes_input: Entity<SingleLineInput>,
min_bytes_input: Entity<SingleLineInput>,
cursor_context_ratio_input: Entity<SingleLineInput>,
// TODO move to project or provider?
syntax_index: Entity<SyntaxIndex>,
last_editor: WeakEntity<Editor>,
_active_editor_subscription: Option<Subscription>,
_edit_prediction_context_task: Task<()>,
}
struct ContextState {
context_editor: Entity<Editor>,
retrieval_duration: Duration,
}
impl EditPredictionTools {
pub fn new(
workspace: &Entity<Workspace>,
project: &Entity<Project>,
active_editor: Option<Entity<Editor>>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
cx.subscribe_in(workspace, window, |this, workspace, event, window, cx| {
if let workspace::Event::ActiveItemChanged = event {
if let Some(editor) = workspace.read(cx).active_item_as::<Editor>(cx) {
this._active_editor_subscription = Some(cx.subscribe_in(
&editor,
window,
|this, editor, event, window, cx| {
if let EditorEvent::SelectionsChanged { .. } = event {
this.update_context(editor, window, cx);
}
},
));
this.update_context(&editor, window, cx);
} else {
this._active_editor_subscription = None;
}
}
})
.detach();
let syntax_index = cx.new(|cx| SyntaxIndex::new(project, cx));
let number_input = |label: &'static str,
value: &'static str,
window: &mut Window,
cx: &mut Context<Self>|
-> Entity<SingleLineInput> {
let input = cx.new(|cx| {
let input = SingleLineInput::new(window, cx, "")
.label(label)
.label_min_width(px(64.));
input.set_text(value, window, cx);
input
});
cx.subscribe_in(
&input.read(cx).editor().clone(),
window,
|this, _, event, window, cx| {
if let EditorEvent::BufferEdited = event
&& let Some(editor) = this.last_editor.upgrade()
{
this.update_context(&editor, window, cx);
}
},
)
.detach();
input
};
let mut this = Self {
focus_handle: cx.focus_handle(),
project: project.clone(),
last_context: None,
max_bytes_input: number_input("Max Bytes", "512", window, cx),
min_bytes_input: number_input("Min Bytes", "128", window, cx),
cursor_context_ratio_input: number_input("Cursor Context Ratio", "0.5", window, cx),
syntax_index,
last_editor: WeakEntity::new_invalid(),
_active_editor_subscription: None,
_edit_prediction_context_task: Task::ready(()),
};
if let Some(editor) = active_editor {
this.update_context(&editor, window, cx);
}
this
}
fn update_context(
&mut self,
editor: &Entity<Editor>,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.last_editor = editor.downgrade();
let editor = editor.read(cx);
let buffer = editor.buffer().clone();
let cursor_position = editor.selections.newest_anchor().start;
let Some(buffer) = buffer.read(cx).buffer_for_anchor(cursor_position, cx) else {
self.last_context.take();
return;
};
let current_buffer_snapshot = buffer.read(cx).snapshot();
let cursor_position = cursor_position
.text_anchor
.to_point(&current_buffer_snapshot);
let language = current_buffer_snapshot.language().cloned();
let Some(worktree_id) = self
.project
.read(cx)
.worktrees(cx)
.next()
.map(|worktree| worktree.read(cx).id())
else {
log::error!("Open a worktree to use edit prediction debug view");
self.last_context.take();
return;
};
self._edit_prediction_context_task = cx.spawn_in(window, {
let language_registry = self.project.read(cx).languages().clone();
async move |this, cx| {
cx.background_executor()
.timer(Duration::from_millis(50))
.await;
let Ok(task) = this.update(cx, |this, cx| {
fn number_input_value<T: FromStr + Default>(
input: &Entity<SingleLineInput>,
cx: &App,
) -> T {
input
.read(cx)
.editor()
.read(cx)
.text(cx)
.parse::<T>()
.unwrap_or_default()
}
let options = EditPredictionExcerptOptions {
max_bytes: number_input_value(&this.max_bytes_input, cx),
min_bytes: number_input_value(&this.min_bytes_input, cx),
target_before_cursor_over_total_bytes: number_input_value(
&this.cursor_context_ratio_input,
cx,
),
// TODO Display and add to options
include_parent_signatures: false,
};
EditPredictionContext::gather(
cursor_position,
current_buffer_snapshot,
options,
this.syntax_index.clone(),
cx,
)
}) else {
this.update(cx, |this, _cx| {
this.last_context.take();
})
.ok();
return;
};
let Some(context) = task.await else {
// TODO: Display message
this.update(cx, |this, _cx| {
this.last_context.take();
})
.ok();
return;
};
let mut languages = HashMap::default();
for snippet in context.snippets.iter() {
let lang_id = snippet.declaration.identifier().language_id;
if let Entry::Vacant(entry) = languages.entry(lang_id) {
// Most snippets are gonna be the same language,
// so we think it's fine to do this sequentially for now
entry.insert(language_registry.language_for_id(lang_id).await.ok());
}
}
this.update_in(cx, |this, window, cx| {
let context_editor = cx.new(|cx| {
let multibuffer = cx.new(|cx| {
let mut multibuffer = MultiBuffer::new(language::Capability::ReadOnly);
let excerpt_file = Arc::new(ExcerptMetadataFile {
title: PathBuf::from("Cursor Excerpt").into(),
worktree_id,
});
let excerpt_buffer = cx.new(|cx| {
let mut buffer = Buffer::local(context.excerpt_text.body, cx);
buffer.set_language(language, cx);
buffer.file_updated(excerpt_file, cx);
buffer
});
multibuffer.push_excerpts(
excerpt_buffer,
[ExcerptRange::new(text::Anchor::MIN..text::Anchor::MAX)],
cx,
);
for snippet in context.snippets {
let path = this
.project
.read(cx)
.path_for_entry(snippet.declaration.project_entry_id(), cx);
let snippet_file = Arc::new(ExcerptMetadataFile {
title: PathBuf::from(format!(
"{} (Score density: {})",
path.map(|p| p.path.to_string_lossy().to_string())
.unwrap_or_else(|| "".to_string()),
snippet.score_density(SnippetStyle::Declaration)
))
.into(),
worktree_id,
});
let excerpt_buffer = cx.new(|cx| {
let mut buffer =
Buffer::local(snippet.declaration.item_text().0, cx);
buffer.file_updated(snippet_file, cx);
if let Some(language) =
languages.get(&snippet.declaration.identifier().language_id)
{
buffer.set_language(language.clone(), cx);
}
buffer
});
multibuffer.push_excerpts(
excerpt_buffer,
[ExcerptRange::new(text::Anchor::MIN..text::Anchor::MAX)],
cx,
);
}
multibuffer
});
Editor::new(EditorMode::full(), multibuffer, None, window, cx)
});
this.last_context = Some(ContextState {
context_editor,
retrieval_duration: context.retrieval_duration,
});
cx.notify();
})
.ok();
}
});
}
}
impl Focusable for EditPredictionTools {
fn focus_handle(&self, _cx: &App) -> FocusHandle {
self.focus_handle.clone()
}
}
impl Item for EditPredictionTools {
type Event = ();
fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString {
"Edit Prediction Context Debug View".into()
}
fn tab_icon(&self, _window: &Window, _cx: &App) -> Option<Icon> {
Some(Icon::new(IconName::ZedPredict))
}
}
impl EventEmitter<()> for EditPredictionTools {}
impl Render for EditPredictionTools {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
v_flex()
.size_full()
.bg(cx.theme().colors().editor_background)
.child(
h_flex()
.items_start()
.w_full()
.child(
v_flex()
.flex_1()
.p_4()
.gap_2()
.child(Headline::new("Excerpt Options").size(HeadlineSize::Small))
.child(
h_flex()
.gap_2()
.child(self.max_bytes_input.clone())
.child(self.min_bytes_input.clone())
.child(self.cursor_context_ratio_input.clone()),
),
)
.child(ui::Divider::vertical())
.when_some(self.last_context.as_ref(), |this, last_context| {
this.child(
v_flex()
.p_4()
.gap_2()
.min_w(px(160.))
.child(Headline::new("Stats").size(HeadlineSize::Small))
.child(
h_flex()
.gap_1()
.child(
Label::new("Time to retrieve")
.color(Color::Muted)
.size(LabelSize::Small),
)
.child(
Label::new(
if last_context.retrieval_duration.as_micros()
> 1000
{
format!(
"{} ms",
last_context.retrieval_duration.as_millis()
)
} else {
format!(
"{} µs",
last_context.retrieval_duration.as_micros()
)
},
)
.size(LabelSize::Small),
),
),
)
}),
)
.children(self.last_context.as_ref().map(|c| c.context_editor.clone()))
}
}
// Using same approach as commit view
struct ExcerptMetadataFile {
title: Arc<Path>,
worktree_id: WorktreeId,
}
impl language::File for ExcerptMetadataFile {
fn as_local(&self) -> Option<&dyn language::LocalFile> {
None
}
fn disk_state(&self) -> DiskState {
DiskState::New
}
fn path(&self) -> &Arc<Path> {
&self.title
}
fn full_path(&self, _: &App) -> PathBuf {
self.title.as_ref().into()
}
fn file_name<'a>(&'a self, _: &'a App) -> &'a OsStr {
self.title.file_name().unwrap()
}
fn worktree_id(&self, _: &App) -> WorktreeId {
self.worktree_id
}
fn to_proto(&self, _: &App) -> language::proto::File {
unimplemented!()
}
fn is_private(&self) -> bool {
false
}
}
+18
View File
@@ -646,6 +646,24 @@ impl LanguageRegistry {
async move { rx.await? }
}
pub async fn language_for_id(self: &Arc<Self>, id: LanguageId) -> Result<Arc<Language>> {
let available_language = {
let state = self.state.read();
let Some(available_language) = state
.available_languages
.iter()
.find(|lang| lang.id == id)
.cloned()
else {
anyhow::bail!(LanguageNotFound);
};
available_language
};
self.load_language(&available_language).await?
}
pub fn language_name_for_extension(self: &Arc<Self>, extension: &str) -> Option<LanguageName> {
self.state.try_read().and_then(|state| {
state
+10 -2
View File
@@ -7,7 +7,7 @@
use component::{example_group, single_example};
use editor::{Editor, EditorElement, EditorStyle};
use gpui::{App, Entity, FocusHandle, Focusable, FontStyle, Hsla, TextStyle};
use gpui::{App, Entity, FocusHandle, Focusable, FontStyle, Hsla, Length, TextStyle};
use settings::Settings;
use std::sync::Arc;
use theme::ThemeSettings;
@@ -42,6 +42,8 @@ pub struct SingleLineInput {
start_icon: Option<IconName>,
/// Whether the text field is disabled.
disabled: bool,
/// The minimum width of for the input
min_width: Length,
}
impl Focusable for SingleLineInput {
@@ -67,6 +69,7 @@ impl SingleLineInput {
editor,
start_icon: None,
disabled: false,
min_width: px(192.).into(),
}
}
@@ -85,6 +88,11 @@ impl SingleLineInput {
self
}
pub fn label_min_width(mut self, width: impl Into<Length>) -> Self {
self.min_width = width.into();
self
}
pub fn set_disabled(&mut self, disabled: bool, cx: &mut Context<Self>) {
self.disabled = disabled;
self.editor
@@ -167,7 +175,7 @@ impl Render for SingleLineInput {
})
.child(
h_flex()
.min_w_48()
.min_w(self.min_width)
.min_h_8()
.w_full()
.px_2()
+1
View File
@@ -52,6 +52,7 @@ debugger_tools.workspace = true
debugger_ui.workspace = true
diagnostics.workspace = true
editor.workspace = true
edit_prediction_tools.workspace = true
env_logger.workspace = true
extension.workspace = true
extension_host.workspace = true
+1
View File
@@ -549,6 +549,7 @@ pub fn main() {
language_models::init(app_state.user_store.clone(), app_state.client.clone(), cx);
agent_settings::init(cx);
acp_tools::init(cx);
edit_prediction_tools::init(cx);
web_search::init(cx);
web_search_providers::init(app_state.client.clone(), cx);
snippet_provider::init(cx);