Add initial support for edit predictions via Ollama (#48233)

Closes https://github.com/zed-industries/zed/issues/15968

Release Notes:

- Added the ability to use Ollama as an edit prediction provider

---------

Co-authored-by: Oleksiy Syvokon <oleksiy@zed.dev>
Co-authored-by: Ben Kunkle <ben@zed.dev>
This commit is contained in:
authored and GitHub committed 2026-02-04 01:33:12 +00:00
1 parent eff4b9356b
commit 555c002499
23 files changed
+1025 -48

No files matched your search

Generated
+2
View File
@@ -13957,6 +13957,7 @@ dependencies = [
"regex",
"serde",
"tokio",
"util",
"zed-reqwest",
]
@@ -15214,6 +15215,7 @@ dependencies = [
"heck 0.5.0",
"itertools 0.14.0",
"language",
"language_model",
"language_models",
"log",
"menu",
+5
View File
@@ -1500,6 +1500,11 @@
// to improve the service.
"privacy_mode": false,
},
"ollama": {
"api_url": "http://localhost:11434",
"model": "qwen2.5-coder:7b-base",
"max_output_tokens": 256,
},
// Whether edit predictions are enabled when editing text threads in the agent panel.
// This setting has no effect if globally disabled.
"enabled_in_text_threads": true,
+1
View File
@@ -401,6 +401,7 @@ fn update_command_palette_filter(cx: &mut App) {
}
EditPredictionProvider::Zed
| EditPredictionProvider::Codestral
| EditPredictionProvider::Ollama
| EditPredictionProvider::Sweep
| EditPredictionProvider::Mercury
| EditPredictionProvider::Experimental(_) => {
+40 -13
View File
@@ -56,6 +56,7 @@ pub mod cursor_excerpt;
pub mod example_spec;
mod license_detection;
pub mod mercury;
pub mod ollama;
mod onboarding_modal;
pub mod open_ai_response;
mod prediction;
@@ -76,6 +77,7 @@ use crate::capture_example::{
};
use crate::license_detection::LicenseDetectionWatcher;
use crate::mercury::Mercury;
use crate::ollama::Ollama;
use crate::onboarding_modal::ZedPredictModal;
pub use crate::prediction::EditPrediction;
pub use crate::prediction::EditPredictionId;
@@ -153,6 +155,7 @@ pub struct EditPredictionStore {
edit_prediction_model: EditPredictionModel,
pub sweep_ai: SweepAi,
pub mercury: Mercury,
pub ollama: Ollama,
data_collection_choice: DataCollectionChoice,
reject_predictions_tx: mpsc::UnboundedSender<EditPredictionRejection>,
shown_predictions: VecDeque<EditPrediction>,
@@ -169,6 +172,7 @@ pub enum EditPredictionModel {
},
Sweep,
Mercury,
Ollama,
}
#[derive(Clone)]
@@ -307,17 +311,25 @@ impl ProjectState {
) {
self.cancelled_predictions.insert(pending_prediction.id);
cx.spawn(async move |this, cx| {
let Some(prediction_id) = pending_prediction.task.await else {
return;
};
if pending_prediction.drop_on_cancel {
drop(pending_prediction.task);
} else {
cx.spawn(async move |this, cx| {
let Some(prediction_id) = pending_prediction.task.await else {
return;
};
this.update(cx, |this, _cx| {
this.reject_prediction(prediction_id, EditPredictionRejectReason::Canceled, false);
this.update(cx, |this, _cx| {
this.reject_prediction(
prediction_id,
EditPredictionRejectReason::Canceled,
false,
);
})
.ok();
})
.ok();
})
.detach()
.detach()
}
}
fn active_buffer(
@@ -399,6 +411,9 @@ impl PredictionRequestedBy {
struct PendingPrediction {
id: usize,
task: Task<Option<EditPredictionId>>,
/// If true, the task is dropped immediately on cancel (cancelling the HTTP request).
/// If false, the task is awaited to completion so rejection can be reported.
drop_on_cancel: bool,
}
/// A prediction from the perspective of a buffer.
@@ -632,6 +647,7 @@ impl EditPredictionStore {
},
sweep_ai: SweepAi::new(cx),
mercury: Mercury::new(cx),
ollama: Ollama::new(),
data_collection_choice,
reject_predictions_tx: reject_tx,
@@ -675,6 +691,9 @@ impl EditPredictionStore {
.with_down(IconName::ZedPredictDown)
.with_error(IconName::ZedPredictError)
}
EditPredictionModel::Ollama => {
edit_prediction_types::EditPredictionIconSet::new(IconName::AiOllama)
}
}
}
@@ -1207,7 +1226,7 @@ impl EditPredictionStore {
EditPredictionModel::Sweep => {
sweep_ai::edit_prediction_accepted(self, current_prediction, cx)
}
EditPredictionModel::Mercury => {}
EditPredictionModel::Mercury | EditPredictionModel::Ollama => {}
EditPredictionModel::Zeta1 | EditPredictionModel::Zeta2 { .. } => {
zeta2::edit_prediction_accepted(self, current_prediction, cx)
}
@@ -1347,7 +1366,9 @@ impl EditPredictionStore {
return;
}
}
EditPredictionModel::Sweep | EditPredictionModel::Mercury => return,
EditPredictionModel::Sweep
| EditPredictionModel::Mercury
| EditPredictionModel::Ollama => return,
}
self.reject_predictions_tx
@@ -1536,6 +1557,9 @@ impl EditPredictionStore {
-> Task<Result<Option<(EditPredictionResult, PredictionRequestedBy)>>>
+ 'static,
) {
let is_ollama = self.edit_prediction_model == EditPredictionModel::Ollama;
let drop_on_cancel = is_ollama;
let max_pending_predictions = if is_ollama { 1 } else { 2 };
let project_state = self.get_or_init_project(&project, cx);
let pending_prediction_id = project_state.next_pending_prediction_id;
project_state.next_pending_prediction_id += 1;
@@ -1650,16 +1674,18 @@ impl EditPredictionStore {
new_prediction_id
});
if project_state.pending_predictions.len() <= 1 {
if project_state.pending_predictions.len() < max_pending_predictions {
project_state.pending_predictions.push(PendingPrediction {
id: pending_prediction_id,
task,
drop_on_cancel,
});
} else if project_state.pending_predictions.len() == 2 {
} else {
let pending_prediction = project_state.pending_predictions.pop().unwrap();
project_state.pending_predictions.push(PendingPrediction {
id: pending_prediction_id,
task,
drop_on_cancel,
});
project_state.cancel_pending_prediction(pending_prediction, cx);
}
@@ -1794,6 +1820,7 @@ impl EditPredictionStore {
}
EditPredictionModel::Sweep => self.sweep_ai.request_prediction_with_sweep(inputs, cx),
EditPredictionModel::Mercury => self.mercury.request_prediction(inputs, cx),
EditPredictionModel::Ollama => self.ollama.request_prediction(inputs, cx),
};
cx.spawn(async move |this, cx| {
+371
View File
@@ -0,0 +1,371 @@
use crate::{
EditPredictionId, EditPredictionModelInput, cursor_excerpt,
prediction::EditPredictionResult,
zeta1::{
self, MAX_CONTEXT_TOKENS as ZETA_MAX_CONTEXT_TOKENS,
MAX_EVENT_TOKENS as ZETA_MAX_EVENT_TOKENS,
},
};
use anyhow::{Context as _, Result};
use futures::AsyncReadExt as _;
use gpui::{App, AppContext as _, Entity, Task, http_client};
use language::{
Anchor, Buffer, BufferSnapshot, OffsetRangeExt as _, ToOffset, ToPoint as _,
language_settings::all_language_settings,
};
use language_model::{LanguageModelProviderId, LanguageModelRegistry};
use serde::{Deserialize, Serialize};
use std::{path::Path, sync::Arc, time::Instant};
use zeta_prompt::{
ZetaPromptInput,
zeta1::{EDITABLE_REGION_END_MARKER, format_zeta1_prompt},
};
const FIM_CONTEXT_TOKENS: usize = 512;
pub struct Ollama;
#[derive(Debug, Serialize)]
struct OllamaGenerateRequest {
model: String,
prompt: String,
raw: bool,
stream: bool,
#[serde(skip_serializing_if = "Option::is_none")]
options: Option<OllamaGenerateOptions>,
}
#[derive(Debug, Serialize)]
struct OllamaGenerateOptions {
#[serde(skip_serializing_if = "Option::is_none")]
num_predict: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
temperature: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
stop: Option<Vec<String>>,
}
#[derive(Debug, Deserialize)]
struct OllamaGenerateResponse {
created_at: String,
response: String,
}
pub fn is_available(cx: &App) -> bool {
let ollama_provider_id = LanguageModelProviderId::new("ollama");
LanguageModelRegistry::read_global(cx)
.provider(&ollama_provider_id)
.is_some_and(|provider| provider.is_authenticated(cx))
}
/// Output from the Ollama HTTP request, containing all data needed to create the prediction result.
struct OllamaRequestOutput {
created_at: String,
edits: Vec<(std::ops::Range<Anchor>, Arc<str>)>,
snapshot: BufferSnapshot,
response_received_at: Instant,
inputs: ZetaPromptInput,
buffer: Entity<Buffer>,
buffer_snapshotted_at: Instant,
}
impl Ollama {
pub fn new() -> Self {
Self
}
pub fn request_prediction(
&self,
EditPredictionModelInput {
buffer,
snapshot,
position,
events,
..
}: EditPredictionModelInput,
cx: &mut App,
) -> Task<Result<Option<EditPredictionResult>>> {
let settings = &all_language_settings(None, cx).edit_predictions.ollama;
let Some(model) = settings.model.clone() else {
return Task::ready(Ok(None));
};
let max_output_tokens = settings.max_output_tokens;
let api_url = settings.api_url.clone();
log::debug!("Ollama: Requesting completion (model: {})", model);
let full_path: Arc<Path> = snapshot
.file()
.map(|file| file.full_path(cx))
.unwrap_or_else(|| "untitled".into())
.into();
let http_client = cx.http_client();
let cursor_point = position.to_point(&snapshot);
let buffer_snapshotted_at = Instant::now();
let is_zeta = is_zeta_model(&model);
let result = cx.background_spawn(async move {
// For zeta models, use the dedicated zeta1 functions which handle their own
// range computation with the correct token limits.
let (prompt, stop_tokens, editable_range_override, inputs) = if is_zeta {
let path_str = full_path.to_string_lossy();
let input_excerpt = zeta1::excerpt_for_cursor_position(
cursor_point,
&path_str,
&snapshot,
max_output_tokens as usize,
ZETA_MAX_CONTEXT_TOKENS,
);
let input_events = zeta1::prompt_for_events(&events, ZETA_MAX_EVENT_TOKENS);
let prompt = format_zeta1_prompt(&input_events, &input_excerpt.prompt);
let editable_offset_range = input_excerpt.editable_range.to_offset(&snapshot);
let context_offset_range = input_excerpt.context_range.to_offset(&snapshot);
let stop_tokens = get_zeta_stop_tokens();
let inputs = ZetaPromptInput {
events,
related_files: Vec::new(),
cursor_offset_in_excerpt: cursor_point.to_offset(&snapshot)
- context_offset_range.start,
cursor_path: full_path.clone(),
cursor_excerpt: snapshot
.text_for_range(input_excerpt.context_range.clone())
.collect::<String>()
.into(),
editable_range_in_excerpt: (editable_offset_range.start
- context_offset_range.start)
..(editable_offset_range.end - context_offset_range.start),
excerpt_start_row: Some(input_excerpt.context_range.start.row),
};
(prompt, stop_tokens, Some(editable_offset_range), inputs)
} else {
let (excerpt_range, _) =
cursor_excerpt::editable_and_context_ranges_for_cursor_position(
cursor_point,
&snapshot,
FIM_CONTEXT_TOKENS,
0,
);
let excerpt_offset_range = excerpt_range.to_offset(&snapshot);
let cursor_offset = cursor_point.to_offset(&snapshot);
let inputs = ZetaPromptInput {
events,
related_files: Vec::new(),
cursor_offset_in_excerpt: cursor_offset - excerpt_offset_range.start,
editable_range_in_excerpt: cursor_offset - excerpt_offset_range.start
..cursor_offset - excerpt_offset_range.start,
cursor_path: full_path.clone(),
excerpt_start_row: Some(excerpt_range.start.row),
cursor_excerpt: snapshot
.text_for_range(excerpt_range)
.collect::<String>()
.into(),
};
let prefix = inputs.cursor_excerpt[..inputs.cursor_offset_in_excerpt].to_string();
let suffix = inputs.cursor_excerpt[inputs.cursor_offset_in_excerpt..].to_string();
let prompt = format_fim_prompt(&model, &prefix, &suffix);
let stop_tokens = get_fim_stop_tokens();
(prompt, stop_tokens, None, inputs)
};
let request = OllamaGenerateRequest {
model: model.clone(),
prompt,
raw: true,
stream: false,
options: Some(OllamaGenerateOptions {
num_predict: Some(max_output_tokens),
temperature: Some(0.2),
stop: Some(stop_tokens),
}),
};
let request_body = serde_json::to_string(&request)?;
let http_request = http_client::Request::builder()
.method(http_client::Method::POST)
.uri(format!("{}/api/generate", api_url))
.header("Content-Type", "application/json")
.body(http_client::AsyncBody::from(request_body))?;
let mut response = http_client.send(http_request).await?;
let status = response.status();
log::debug!("Ollama: Response status: {}", status);
if !status.is_success() {
let mut body = String::new();
response.body_mut().read_to_string(&mut body).await?;
return Err(anyhow::anyhow!("Ollama API error: {} - {}", status, body));
}
let mut body = String::new();
response.body_mut().read_to_string(&mut body).await?;
let ollama_response: OllamaGenerateResponse =
serde_json::from_str(&body).context("Failed to parse Ollama response")?;
let response_received_at = Instant::now();
log::debug!(
"Ollama: Completion received ({:.2}s)",
(response_received_at - buffer_snapshotted_at).as_secs_f64()
);
let edits = if is_zeta {
let editable_range =
editable_range_override.expect("zeta model should have editable range");
log::trace!("ollama response: {}", ollama_response.response);
let response = clean_zeta_completion(&ollama_response.response);
match zeta1::parse_edits(&response, editable_range, &snapshot) {
Ok(edits) => edits,
Err(err) => {
log::warn!("Ollama zeta: Failed to parse response: {}", err);
vec![]
}
}
} else {
let completion: Arc<str> = clean_fim_completion(&ollama_response.response).into();
if completion.is_empty() {
vec![]
} else {
let cursor_offset = cursor_point.to_offset(&snapshot);
let anchor = snapshot.anchor_after(cursor_offset);
vec![(anchor..anchor, completion)]
}
};
anyhow::Ok(OllamaRequestOutput {
created_at: ollama_response.created_at,
edits,
snapshot,
response_received_at,
inputs,
buffer,
buffer_snapshotted_at,
})
});
cx.spawn(async move |cx: &mut gpui::AsyncApp| {
let output = result.await.context("Ollama edit prediction failed")?;
anyhow::Ok(Some(
EditPredictionResult::new(
EditPredictionId(output.created_at.into()),
&output.buffer,
&output.snapshot,
output.edits.into(),
None,
output.buffer_snapshotted_at,
output.response_received_at,
output.inputs,
cx,
)
.await,
))
})
}
}
fn is_zeta_model(model: &str) -> bool {
model.to_lowercase().contains("zeta")
}
fn get_zeta_stop_tokens() -> Vec<String> {
vec![EDITABLE_REGION_END_MARKER.to_string(), "```".to_string()]
}
fn format_fim_prompt(model: &str, prefix: &str, suffix: &str) -> String {
let model_base = model.split(':').next().unwrap_or(model);
match model_base {
"codellama" | "code-llama" => {
format!("<PRE> {prefix} <SUF>{suffix} <MID>")
}
"starcoder" | "starcoder2" | "starcoderbase" => {
format!("<fim_prefix>{prefix}<fim_suffix>{suffix}<fim_middle>")
}
"deepseek-coder" | "deepseek-coder-v2" => {
format!("<|fim▁begin|>{prefix}<|fim▁hole|>{suffix}<|fim▁end|>")
}
"qwen2.5-coder" | "qwen-coder" | "qwen" => {
format!("<|fim_prefix|>{prefix}<|fim_suffix|>{suffix}<|fim_middle|>")
}
"codegemma" => {
format!("<|fim_prefix|>{prefix}<|fim_suffix|>{suffix}<|fim_middle|>")
}
"codestral" | "mistral" => {
format!("[SUFFIX]{suffix}[PREFIX]{prefix}")
}
"glm" | "glm-4" | "glm-4.5" => {
format!("<|code_prefix|>{prefix}<|code_suffix|>{suffix}<|code_middle|>")
}
_ => {
format!("<fim_prefix>{prefix}<fim_suffix>{suffix}<fim_middle>")
}
}
}
fn get_fim_stop_tokens() -> Vec<String> {
vec![
"<|endoftext|>".to_string(),
"<|file_separator|>".to_string(),
"<|fim_pad|>".to_string(),
"<|fim_prefix|>".to_string(),
"<|fim_middle|>".to_string(),
"<|fim_suffix|>".to_string(),
"<fim_prefix>".to_string(),
"<fim_middle>".to_string(),
"<fim_suffix>".to_string(),
"<PRE>".to_string(),
"<SUF>".to_string(),
"<MID>".to_string(),
"[PREFIX]".to_string(),
"[SUFFIX]".to_string(),
]
}
fn clean_zeta_completion(mut response: &str) -> &str {
if let Some(last_newline_ix) = response.rfind('\n') {
let last_line = &response[last_newline_ix + 1..];
if EDITABLE_REGION_END_MARKER.starts_with(&last_line) {
response = &response[..last_newline_ix]
}
}
response
}
fn clean_fim_completion(response: &str) -> String {
let mut result = response.to_string();
let end_tokens = [
"<|endoftext|>",
"<|file_separator|>",
"<|fim_pad|>",
"<|fim_prefix|>",
"<|fim_middle|>",
"<|fim_suffix|>",
"<fim_prefix>",
"<fim_middle>",
"<fim_suffix>",
"<PRE>",
"<SUF>",
"<MID>",
"[PREFIX]",
"[SUFFIX]",
];
for token in &end_tokens {
if let Some(pos) = result.find(token) {
result.truncate(pos);
}
}
result
}
+2 -2
View File
@@ -676,9 +676,9 @@ pub enum DiffLine<'a> {
#[derive(Debug, PartialEq)]
pub struct HunkLocation {
start_line_old: u32,
pub start_line_old: u32,
count_old: u32,
start_line_new: u32,
pub start_line_new: u32,
count_new: u32,
}
@@ -76,6 +76,7 @@ impl EditPredictionDelegate for ZedEditPredictionDelegate {
.with_down(IconName::ZedPredictDown)
.with_error(IconName::ZedPredictError)
}
EditPredictionModel::Ollama => EditPredictionIconSet::new(IconName::AiOllama),
}
}
+28 -23
View File
@@ -6,7 +6,7 @@ use crate::{
cursor_excerpt::{editable_and_context_ranges_for_cursor_position, guess_token_count},
prediction::EditPredictionResult,
};
use anyhow::{Context as _, Result};
use anyhow::Result;
use cloud_llm_client::{
PredictEditsBody, PredictEditsGitInfo, PredictEditsRequestTrigger, PredictEditsResponse,
};
@@ -19,12 +19,13 @@ use project::{Project, ProjectPath};
use release_channel::AppVersion;
use text::Bias;
use workspace::notifications::{ErrorMessagePrompt, NotificationId, show_app_notification};
use zeta_prompt::{Event, ZetaPromptInput};
const CURSOR_MARKER: &str = "<|user_cursor_is_here|>";
const START_OF_FILE_MARKER: &str = "<|start_of_file|>";
const EDITABLE_REGION_START_MARKER: &str = "<|editable_region_start|>";
const EDITABLE_REGION_END_MARKER: &str = "<|editable_region_end|>";
use zeta_prompt::{
Event, ZetaPromptInput,
zeta1::{
CURSOR_MARKER, EDITABLE_REGION_END_MARKER, EDITABLE_REGION_START_MARKER,
START_OF_FILE_MARKER,
},
};
pub(crate) const MAX_CONTEXT_TOKENS: usize = 150;
pub(crate) const MAX_REWRITE_TOKENS: usize = 350;
@@ -265,7 +266,7 @@ fn process_completion_response(
let output_excerpt = output_excerpt.clone();
let editable_range = editable_range.clone();
let snapshot = snapshot.clone();
async move { parse_edits(output_excerpt, editable_range, &snapshot) }
async move { parse_edits(output_excerpt.as_ref(), editable_range, &snapshot) }
})
.await?
.into();
@@ -286,8 +287,8 @@ fn process_completion_response(
})
}
fn parse_edits(
output_excerpt: Arc<str>,
pub(crate) fn parse_edits(
output_excerpt: &str,
editable_range: Range<usize>,
snapshot: &BufferSnapshot,
) -> Result<Vec<(Range<Anchor>, Arc<str>)>> {
@@ -297,8 +298,8 @@ fn parse_edits(
.match_indices(EDITABLE_REGION_START_MARKER)
.collect::<Vec<_>>();
anyhow::ensure!(
start_markers.len() == 1,
"expected exactly one start marker, found {}",
start_markers.len() <= 1,
"expected at most one start marker, found {}",
start_markers.len()
);
@@ -306,8 +307,8 @@ fn parse_edits(
.match_indices(EDITABLE_REGION_END_MARKER)
.collect::<Vec<_>>();
anyhow::ensure!(
end_markers.len() == 1,
"expected exactly one end marker, found {}",
end_markers.len() <= 1,
"expected at most one end marker, found {}",
end_markers.len()
);
@@ -320,16 +321,16 @@ fn parse_edits(
sof_markers.len()
);
let codefence_start = start_markers[0].0;
let content = &content[codefence_start..];
let content_start = start_markers
.first()
.map(|e| e.0 + EDITABLE_REGION_START_MARKER.len() + 1) // +1 to skip \n after marker
.unwrap_or(0);
let content_end = end_markers
.first()
.map(|e| e.0.saturating_sub(1)) // -1 to exclude \n before marker
.unwrap_or(content.strip_suffix("\n").unwrap_or(&content).len());
let newline_ix = content.find('\n').context("could not find newline")?;
let content = &content[newline_ix + 1..];
let codefence_end = content
.rfind(&format!("\n{EDITABLE_REGION_END_MARKER}"))
.context("could not find end marker")?;
let new_text = &content[..codefence_end];
let new_text = &content[content_start..content_end];
let old_text = snapshot
.text_for_range(editable_range.clone())
@@ -515,6 +516,10 @@ pub fn gather_context(
})
}
pub(crate) fn prompt_for_events(events: &[Arc<Event>], max_tokens: usize) -> String {
prompt_for_events_impl(events, max_tokens).0
}
fn prompt_for_events_impl(events: &[Arc<Event>], mut remaining_tokens: usize) -> (String, usize) {
let mut result = String::new();
for (ix, event) in events.iter().rev().enumerate() {
@@ -89,9 +89,9 @@ impl Render for EditPredictionButton {
return div().hidden();
}
let all_language_settings = all_language_settings(None, cx);
let language_settings = all_language_settings(None, cx);
match all_language_settings.edit_predictions.provider {
match language_settings.edit_predictions.provider {
EditPredictionProvider::Copilot => {
let Some(copilot) = EditPredictionStore::try_global(cx)
.and_then(|store| store.read(cx).copilot_for_project(&self.project.upgrade()?))
@@ -303,6 +303,60 @@ impl Render for EditPredictionButton {
.with_handle(self.popover_menu_handle.clone()),
)
}
EditPredictionProvider::Ollama => {
let enabled = self.editor_enabled.unwrap_or(true);
let this = cx.weak_entity();
div().child(
PopoverMenu::new("ollama")
.menu(move |window, cx| {
this.update(cx, |this, cx| {
this.build_edit_prediction_context_menu(
EditPredictionProvider::Ollama,
window,
cx,
)
})
.ok()
})
.anchor(Corner::BottomRight)
.trigger_with_tooltip(
IconButton::new("ollama-icon", IconName::AiOllama)
.shape(IconButtonShape::Square)
.when(!enabled, |this| {
this.indicator(Indicator::dot().color(Color::Ignored))
.indicator_border_color(Some(
cx.theme().colors().status_bar_background,
))
}),
move |_window, cx| {
let settings = all_language_settings(None, cx);
let tooltip_meta = match settings
.edit_predictions
.ollama
.model
.as_deref()
{
Some(model) if !model.trim().is_empty() => {
format!("Powered by Ollama ({model})")
}
_ => {
"Ollama model not configured — configure a model before use"
.to_string()
}
};
Tooltip::with_meta(
"Edit Prediction",
Some(&ToggleMenu),
tooltip_meta,
cx,
)
},
)
.with_handle(self.popover_menu_handle.clone()),
)
}
provider @ (EditPredictionProvider::Experimental(_)
| EditPredictionProvider::Zed
| EditPredictionProvider::Sweep
@@ -1318,6 +1372,10 @@ pub fn get_available_providers(cx: &mut App) -> Vec<EditPredictionProvider> {
providers.push(EditPredictionProvider::Codestral);
}
if edit_prediction::ollama::is_available(cx) {
providers.push(EditPredictionProvider::Ollama);
}
if cx.has_flag::<SweepFeatureFlag>()
&& edit_prediction::sweep_ai::sweep_api_token(cx)
.read(cx)
+3 -2
View File
@@ -66,8 +66,9 @@ use syntax_map::{QueryCursorHandle, SyntaxSnapshot};
use task::RunnableTag;
pub use task_context::{ContextLocation, ContextProvider, RunnableRange};
pub use text_diff::{
DiffOptions, apply_diff_patch, line_diff, text_diff, text_diff_with_options, unified_diff,
unified_diff_with_context, unified_diff_with_offsets, word_diff_ranges,
DiffOptions, apply_diff_patch, apply_reversed_diff_patch, line_diff, text_diff,
text_diff_with_options, unified_diff, unified_diff_with_context, unified_diff_with_offsets,
word_diff_ranges,
};
use theme::SyntaxTheme;
pub use toolchain::{
+19
View File
@@ -390,6 +390,8 @@ pub struct EditPredictionSettings {
pub codestral: CodestralSettings,
/// Settings specific to Sweep.
pub sweep: SweepSettings,
/// Settings specific to Ollama.
pub ollama: OllamaSettings,
/// Whether edit predictions are enabled in the assistant panel.
/// This setting has no effect if globally disabled.
pub enabled_in_text_threads: bool,
@@ -448,6 +450,16 @@ pub struct SweepSettings {
pub privacy_mode: bool,
}
#[derive(Clone, Debug, Default)]
pub struct OllamaSettings {
/// Model to use for completions.
pub model: Option<String>,
/// Maximum tokens to generate.
pub max_output_tokens: u32,
/// Custom API URL to use for Ollama.
pub api_url: Arc<str>,
}
impl AllLanguageSettings {
/// Returns the [`LanguageSettings`] for the language with the specified name.
pub fn language<'a>(
@@ -678,6 +690,12 @@ impl settings::Settings for AllLanguageSettings {
let sweep_settings = SweepSettings {
privacy_mode: sweep.privacy_mode.unwrap(),
};
let ollama = edit_predictions.ollama.unwrap();
let ollama_settings = OllamaSettings {
model: ollama.model.map(|m| m.0),
max_output_tokens: ollama.max_output_tokens.unwrap(),
api_url: ollama.api_url.unwrap().into(),
};
let enabled_in_text_threads = edit_predictions.enabled_in_text_threads.unwrap();
@@ -717,6 +735,7 @@ impl settings::Settings for AllLanguageSettings {
copilot: copilot_settings,
codestral: codestral_settings,
sweep: sweep_settings,
ollama: ollama_settings,
enabled_in_text_threads,
examples_dir: edit_predictions.examples_dir,
example_capture_rate: edit_predictions.example_capture_rate,
+17
View File
@@ -301,6 +301,12 @@ pub fn apply_diff_patch(base_text: &str, patch: &str) -> Result<String, anyhow::
result.map_err(|err| anyhow!(err))
}
pub fn apply_reversed_diff_patch(base_text: &str, patch: &str) -> Result<String, anyhow::Error> {
let patch = diffy::Patch::from_str(patch).context("Failed to parse patch")?;
let reversed = patch.reverse();
diffy::apply(base_text, &reversed).map_err(|err| anyhow!(err))
}
fn should_perform_word_diff_within_hunk(
old_row_range: &Range<u32>,
old_byte_range: &Range<usize>,
@@ -462,6 +468,17 @@ mod tests {
assert_eq!(apply_diff_patch(old_text, &patch).unwrap(), new_text);
}
#[test]
fn test_apply_reversed_diff_patch() {
let old_text = "one two\nthree four five\nsix seven eight nine\nten\n";
let new_text = "one two\nthree FOUR five\nsix SEVEN eight nine\nten\nELEVEN\n";
let patch = unified_diff(old_text, new_text);
assert_eq!(
apply_reversed_diff_patch(new_text, &patch).unwrap(),
old_text
);
}
#[test]
fn test_unified_diff_with_offsets() {
let old_text = "foo\nbar\nbaz\n";
+2 -1
View File
@@ -1,4 +1,5 @@
use anyhow::{Context as _, Result};
use anyhow::{Context, Result};
use futures::{AsyncBufReadExt, AsyncReadExt, StreamExt, io::BufReader, stream::BoxStream};
use http_client::{AsyncBody, HttpClient, HttpRequestExt, Method, Request as HttpRequest};
use serde::{Deserialize, Serialize};
+1
View File
@@ -26,6 +26,7 @@ log.workspace = true
tokio = { workspace = true, features = ["rt", "rt-multi-thread"] }
regex.workspace = true
reqwest.workspace = true
util.workspace = true
[dev-dependencies]
gpui.workspace = true
+7 -4
View File
@@ -2,6 +2,8 @@ use std::error::Error;
use std::sync::{LazyLock, OnceLock};
use std::{borrow::Cow, mem, pin::Pin, task::Poll, time::Duration};
use util::defer;
use anyhow::anyhow;
use bytes::{BufMut, Bytes, BytesMut};
use futures::{AsyncRead, FutureExt as _, TryStreamExt as _};
@@ -249,10 +251,11 @@ impl http_client::HttpClient for ReqwestClient {
let handle = self.handle.clone();
async move {
let mut response = handle
.spawn(async { request.send().await })
.await?
.map_err(redact_error)?;
let join_handle = handle.spawn(async { request.send().await });
let abort_handle = join_handle.abort_handle();
let _abort_on_drop = defer(move || abort_handle.abort());
let mut response = join_handle.await?.map_err(redact_error)?;
let headers = mem::take(response.headers_mut());
let mut builder = http::Response::builder()
+49
View File
@@ -84,6 +84,7 @@ pub enum EditPredictionProvider {
Supermaven,
Zed,
Codestral,
Ollama,
Sweep,
Mercury,
Experimental(&'static str),
@@ -104,6 +105,7 @@ impl<'de> Deserialize<'de> for EditPredictionProvider {
Supermaven,
Zed,
Codestral,
Ollama,
Sweep,
Mercury,
Experimental(String),
@@ -115,6 +117,7 @@ impl<'de> Deserialize<'de> for EditPredictionProvider {
Content::Supermaven => EditPredictionProvider::Supermaven,
Content::Zed => EditPredictionProvider::Zed,
Content::Codestral => EditPredictionProvider::Codestral,
Content::Ollama => EditPredictionProvider::Ollama,
Content::Sweep => EditPredictionProvider::Sweep,
Content::Mercury => EditPredictionProvider::Mercury,
Content::Experimental(name)
@@ -142,6 +145,7 @@ impl EditPredictionProvider {
| EditPredictionProvider::Copilot
| EditPredictionProvider::Supermaven
| EditPredictionProvider::Codestral
| EditPredictionProvider::Ollama
| EditPredictionProvider::Sweep
| EditPredictionProvider::Mercury
| EditPredictionProvider::Experimental(_) => false,
@@ -160,6 +164,7 @@ impl EditPredictionProvider {
EXPERIMENTAL_ZETA2_EDIT_PREDICTION_PROVIDER_NAME,
) => Some("Zeta2"),
EditPredictionProvider::None | EditPredictionProvider::Experimental(_) => None,
EditPredictionProvider::Ollama => Some("Ollama"),
}
}
}
@@ -183,6 +188,8 @@ pub struct EditPredictionSettingsContent {
pub codestral: Option<CodestralSettingsContent>,
/// Settings specific to Sweep.
pub sweep: Option<SweepSettingsContent>,
/// Settings specific to Ollama.
pub ollama: Option<OllamaEditPredictionSettingsContent>,
/// Whether edit predictions are enabled in the assistant prompt editor.
/// This has no effect if globally disabled.
pub enabled_in_text_threads: Option<bool>,
@@ -242,6 +249,48 @@ pub struct SweepSettingsContent {
pub privacy_mode: Option<bool>,
}
/// Ollama model name for edit predictions.
#[with_fallible_options]
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, MergeFrom, PartialEq, Eq)]
#[serde(transparent)]
pub struct OllamaModelName(pub String);
impl AsRef<str> for OllamaModelName {
fn as_ref(&self) -> &str {
&self.0
}
}
impl From<String> for OllamaModelName {
fn from(value: String) -> Self {
Self(value)
}
}
impl From<OllamaModelName> for String {
fn from(value: OllamaModelName) -> Self {
value.0
}
}
#[with_fallible_options]
#[derive(Clone, Debug, Default, Serialize, Deserialize, JsonSchema, MergeFrom, PartialEq)]
pub struct OllamaEditPredictionSettingsContent {
/// Model to use for completions.
///
/// Default: none
pub model: Option<OllamaModelName>,
/// Maximum tokens to generate for FIM models.
/// This setting does not apply to sweep models.
///
/// Default: 256
pub max_output_tokens: Option<u32>,
/// Api URL to use for completions.
///
/// Default: "http://localhost:11434"
pub api_url: Option<String>,
}
/// The mode in which edit predictions should be displayed.
#[derive(
Copy,
+1
View File
@@ -31,6 +31,7 @@ fuzzy.workspace = true
gpui.workspace = true
heck.workspace = true
itertools.workspace = true
language_model.workspace = true
language_models.workspace = true
language.workspace = true
log.workspace = true
+2
View File
@@ -3,6 +3,7 @@ mod font_picker;
mod icon_theme_picker;
mod input_field;
mod number_field;
mod ollama_model_picker;
mod section_items;
mod theme_picker;
@@ -11,5 +12,6 @@ pub use font_picker::font_picker;
pub use icon_theme_picker::icon_theme_picker;
pub use input_field::*;
pub use number_field::*;
pub use ollama_model_picker::render_ollama_model_picker;
pub use section_items::*;
pub use theme_picker::theme_picker;
@@ -0,0 +1,236 @@
use std::sync::Arc;
use fuzzy::StringMatch;
use gpui::{AnyElement, App, Context, DismissEvent, ReadGlobal, SharedString, Task, Window, px};
use language_model::{LanguageModelProviderId, LanguageModelRegistry};
use picker::{Picker, PickerDelegate};
use settings::SettingsStore;
use ui::{ListItem, ListItemSpacing, PopoverMenu, prelude::*};
use util::ResultExt;
use crate::{
SettingField, SettingsFieldMetadata, SettingsUiFile, render_picker_trigger_button,
update_settings_file,
};
type OllamaModelPicker = Picker<OllamaModelPickerDelegate>;
struct OllamaModelPickerDelegate {
models: Vec<SharedString>,
filtered_models: Vec<StringMatch>,
selected_index: usize,
on_model_changed: Arc<dyn Fn(SharedString, &mut Window, &mut App) + 'static>,
}
impl OllamaModelPickerDelegate {
fn new(
current_model: SharedString,
on_model_changed: impl Fn(SharedString, &mut Window, &mut App) + 'static,
cx: &mut Context<OllamaModelPicker>,
) -> Self {
let mut models = Self::fetch_ollama_models(cx);
let current_in_list = models.contains(&current_model);
if !current_model.is_empty() && !current_in_list {
models.insert(0, current_model.clone());
}
let selected_index = models
.iter()
.position(|model| *model == current_model)
.unwrap_or(0);
let filtered_models = models
.iter()
.enumerate()
.map(|(index, model)| StringMatch {
candidate_id: index,
string: model.to_string(),
positions: Vec::new(),
score: 0.0,
})
.collect();
Self {
models,
filtered_models,
selected_index,
on_model_changed: Arc::new(on_model_changed),
}
}
fn fetch_ollama_models(cx: &mut App) -> Vec<SharedString> {
let ollama_provider_id = LanguageModelProviderId::new("ollama");
let Some(provider) = LanguageModelRegistry::read_global(cx).provider(&ollama_provider_id)
else {
return Vec::new();
};
// Re-fetch models in case ollama has been started or updated since
// Zed was launched.
provider.authenticate(cx).detach_and_log_err(cx);
let mut models: Vec<SharedString> = provider
.provided_models(cx)
.into_iter()
.map(|model| SharedString::from(model.id().0.to_string()))
.collect();
models.sort();
models
}
}
impl PickerDelegate for OllamaModelPickerDelegate {
type ListItem = AnyElement;
fn match_count(&self) -> usize {
self.filtered_models.len()
}
fn selected_index(&self) -> usize {
self.selected_index
}
fn set_selected_index(
&mut self,
ix: usize,
_: &mut Window,
cx: &mut Context<OllamaModelPicker>,
) {
self.selected_index = ix.min(self.filtered_models.len().saturating_sub(1));
cx.notify();
}
fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
"Search models…".into()
}
fn update_matches(
&mut self,
query: String,
_window: &mut Window,
cx: &mut Context<OllamaModelPicker>,
) -> Task<()> {
let query_lower = query.to_lowercase();
self.filtered_models = self
.models
.iter()
.enumerate()
.filter(|(_, model)| query.is_empty() || model.to_lowercase().contains(&query_lower))
.map(|(index, model)| StringMatch {
candidate_id: index,
string: model.to_string(),
positions: Vec::new(),
score: 0.0,
})
.collect();
self.selected_index = 0;
cx.notify();
Task::ready(())
}
fn confirm(
&mut self,
_secondary: bool,
window: &mut Window,
cx: &mut Context<OllamaModelPicker>,
) {
let Some(model_match) = self.filtered_models.get(self.selected_index) else {
return;
};
(self.on_model_changed)(model_match.string.clone().into(), window, cx);
cx.emit(DismissEvent);
}
fn dismissed(&mut self, window: &mut Window, cx: &mut Context<OllamaModelPicker>) {
cx.defer_in(window, |picker, window, cx| {
picker.set_query("", window, cx);
});
cx.emit(DismissEvent);
}
fn render_match(
&self,
ix: usize,
selected: bool,
_window: &mut Window,
_cx: &mut Context<OllamaModelPicker>,
) -> Option<Self::ListItem> {
let model_match = self.filtered_models.get(ix)?;
Some(
ListItem::new(ix)
.inset(true)
.spacing(ListItemSpacing::Sparse)
.toggle_state(selected)
.child(Label::new(model_match.string.clone()))
.into_any_element(),
)
}
}
pub fn render_ollama_model_picker(
field: SettingField<settings::OllamaModelName>,
file: SettingsUiFile,
_metadata: Option<&SettingsFieldMetadata>,
_window: &mut Window,
cx: &mut App,
) -> AnyElement {
let (_, value) = SettingsStore::global(cx).get_value_from_file(file.to_settings(), field.pick);
let current_value: SharedString = value
.map(|m| m.0.clone().into())
.unwrap_or_else(|| "".into());
PopoverMenu::new("ollama-model-picker")
.trigger(render_picker_trigger_button(
"ollama_model_picker_trigger".into(),
if current_value.is_empty() {
"Select a model…".into()
} else {
current_value.clone()
},
))
.menu(move |window, cx| {
Some(cx.new(|cx| {
let file = file.clone();
let current_value = current_value.clone();
let delegate = OllamaModelPickerDelegate::new(
current_value,
move |model_name, window, cx| {
update_settings_file(
file.clone(),
field.json_path,
window,
cx,
move |settings, _cx| {
(field.write)(
settings,
Some(settings::OllamaModelName(model_name.to_string())),
);
},
)
.log_err();
},
cx,
);
Picker::uniform_list(delegate, window, cx)
.show_scrollbar(true)
.width(rems_from_px(210.))
.max_height(Some(rems(18.).into()))
}))
})
.anchor(gpui::Corner::TopLeft)
.offset(gpui::Point {
x: px(0.0),
y: px(2.0),
})
.with_handle(ui::PopoverMenuHandle::default())
.into_any_element()
}
@@ -12,6 +12,9 @@ use settings::Settings as _;
use ui::{ButtonLink, ConfiguredApiCard, ContextMenu, DropdownMenu, DropdownStyle, prelude::*};
use workspace::AppState;
const OLLAMA_API_URL_PLACEHOLDER: &str = "http://localhost:11434";
const OLLAMA_MODEL_PLACEHOLDER: &str = "qwen2.5-coder:3b-base";
use crate::{
SettingField, SettingItem, SettingsFieldMetadata, SettingsPageItem, SettingsWindow, USER,
components::{SettingsInputField, SettingsSectionHeader},
@@ -61,6 +64,7 @@ pub(crate) fn render_edit_prediction_setup_page(
)
.into_any_element()
}),
Some(render_ollama_provider(settings_window, window, cx).into_any_element()),
Some(
render_api_key_provider(
IconName::AiMistral,
@@ -328,6 +332,135 @@ fn sweep_settings() -> Box<[SettingsPageItem]> {
})])
}
fn render_ollama_provider(
settings_window: &SettingsWindow,
window: &mut Window,
cx: &mut Context<SettingsWindow>,
) -> impl IntoElement {
let ollama_settings = ollama_settings();
let additional_fields = settings_window
.render_sub_page_items_section(ollama_settings.iter().enumerate(), true, window, cx)
.into_any_element();
v_flex()
.id("ollama")
.min_w_0()
.pt_8()
.gap_1p5()
.child(
SettingsSectionHeader::new("Ollama")
.icon(IconName::ZedPredict)
.no_padding(true),
)
.child(
Label::new("Configure the local Ollama server and model used for edit predictions.")
.size(LabelSize::Small)
.color(Color::Muted),
)
.child(div().px_neg_8().child(additional_fields))
}
fn ollama_settings() -> Box<[SettingsPageItem]> {
Box::new([
SettingsPageItem::SettingItem(SettingItem {
title: "API URL",
description: "The base URL of your Ollama server.",
field: Box::new(SettingField {
pick: |settings| {
settings
.project
.all_languages
.edit_predictions
.as_ref()?
.ollama
.as_ref()?
.api_url
.as_ref()
},
write: |settings, value| {
settings
.project
.all_languages
.edit_predictions
.get_or_insert_default()
.ollama
.get_or_insert_default()
.api_url = value;
},
json_path: Some("edit_predictions.ollama.api_url"),
}),
metadata: Some(Box::new(SettingsFieldMetadata {
placeholder: Some(OLLAMA_API_URL_PLACEHOLDER),
..Default::default()
})),
files: USER,
}),
SettingsPageItem::SettingItem(SettingItem {
title: "Model",
description: "The Ollama model to use for edit predictions.",
field: Box::new(SettingField {
pick: |settings| {
settings
.project
.all_languages
.edit_predictions
.as_ref()?
.ollama
.as_ref()?
.model
.as_ref()
},
write: |settings, value| {
settings
.project
.all_languages
.edit_predictions
.get_or_insert_default()
.ollama
.get_or_insert_default()
.model = value;
},
json_path: Some("edit_predictions.ollama.model"),
}),
metadata: Some(Box::new(SettingsFieldMetadata {
placeholder: Some(OLLAMA_MODEL_PLACEHOLDER),
..Default::default()
})),
files: USER,
}),
SettingsPageItem::SettingItem(SettingItem {
title: "Max Output Tokens",
description: "The maximum number of tokens to generate.",
field: Box::new(SettingField {
pick: |settings| {
settings
.project
.all_languages
.edit_predictions
.as_ref()?
.ollama
.as_ref()?
.max_output_tokens
.as_ref()
},
write: |settings, value| {
settings
.project
.all_languages
.edit_predictions
.get_or_insert_default()
.ollama
.get_or_insert_default()
.max_output_tokens = value;
},
json_path: Some("edit_predictions.ollama.max_output_tokens"),
}),
metadata: None,
files: USER,
}),
])
}
fn codestral_settings() -> Box<[SettingsPageItem]> {
Box::new([
SettingsPageItem::SettingItem(SettingItem {
+3 -1
View File
@@ -45,7 +45,8 @@ use zed_actions::{OpenProjectSettings, OpenSettings, OpenSettingsAt};
use crate::components::{
EnumVariantDropdown, NumberField, NumberFieldMode, NumberFieldType, SettingsInputField,
SettingsSectionHeader, font_picker, icon_theme_picker, theme_picker,
SettingsSectionHeader, font_picker, icon_theme_picker, render_ollama_model_picker,
theme_picker,
};
const NAVBAR_CONTAINER_TAB_INDEX: isize = 0;
@@ -535,6 +536,7 @@ fn init_renderers(cx: &mut App) {
.add_basic_renderer::<settings::RelativeLineNumbers>(render_dropdown)
.add_basic_renderer::<settings::WindowDecorations>(render_dropdown)
.add_basic_renderer::<settings::FontSize>(render_editable_number_field)
.add_basic_renderer::<settings::OllamaModelName>(render_ollama_model_picker)
// please semicolon stay on next line
;
}
@@ -193,6 +193,7 @@ fn assign_edit_prediction_provider(
}
value @ (EditPredictionProvider::Experimental(_)
| EditPredictionProvider::Zed
| EditPredictionProvider::Ollama
| EditPredictionProvider::Sweep
| EditPredictionProvider::Mercury) => {
let ep_store = edit_prediction::EditPredictionStore::global(client, &user_store, cx);
@@ -209,6 +210,12 @@ fn assign_edit_prediction_provider(
EditPredictionProvider::Mercury if cx.has_flag::<MercuryFeatureFlag>() => {
edit_prediction::EditPredictionModel::Mercury
}
EditPredictionProvider::Ollama => {
if !edit_prediction::ollama::is_available(cx) {
return false;
}
edit_prediction::EditPredictionModel::Ollama
}
EditPredictionProvider::Experimental(name)
if name == EXPERIMENTAL_ZETA2_EDIT_PREDICTION_PROVIDER_NAME
&& cx.has_flag::<Zeta2FeatureFlag>() =>
+35
View File
@@ -483,6 +483,41 @@ pub mod v0131_git_merge_markers_prefix {
}
}
/// The zeta1 prompt format
pub mod zeta1 {
pub const CURSOR_MARKER: &str = "<|user_cursor_is_here|>";
pub const START_OF_FILE_MARKER: &str = "<|start_of_file|>";
pub const EDITABLE_REGION_START_MARKER: &str = "<|editable_region_start|>";
pub const EDITABLE_REGION_END_MARKER: &str = "<|editable_region_end|>";
const INSTRUCTION_HEADER: &str = concat!(
"### Instruction:\n",
"You are a code completion assistant and your task is to analyze user edits and then rewrite an ",
"excerpt that the user provides, suggesting the appropriate edits within the excerpt, taking ",
"into account the cursor location.\n\n",
"### User Edits:\n\n"
);
const EXCERPT_HEADER: &str = "\n\n### User Excerpt:\n\n";
const RESPONSE_HEADER: &str = "\n\n### Response:\n";
/// Formats a complete zeta1 prompt from the input events and excerpt.
pub fn format_zeta1_prompt(input_events: &str, input_excerpt: &str) -> String {
let mut prompt = String::with_capacity(
INSTRUCTION_HEADER.len()
+ input_events.len()
+ EXCERPT_HEADER.len()
+ input_excerpt.len()
+ RESPONSE_HEADER.len(),
);
prompt.push_str(INSTRUCTION_HEADER);
prompt.push_str(input_events);
prompt.push_str(EXCERPT_HEADER);
prompt.push_str(input_excerpt);
prompt.push_str(RESPONSE_HEADER);
prompt
}
}
#[cfg(test)]
mod tests {
use super::*;