diff --git a/Cargo.lock b/Cargo.lock index ab1caded99a7c03cc35cb773b8beffc55eb7c698..72e72da8290872318da251914d6cf9d794ee130b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", diff --git a/assets/settings/default.json b/assets/settings/default.json index 77b34d94cf47937e1172c3ebe5c386526ab5c6fa..da0bd07fc2166c83a401b3a7558c8372b7158993 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -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, diff --git a/crates/agent_ui/src/agent_ui.rs b/crates/agent_ui/src/agent_ui.rs index cd3247f53b4bb4667c5b82be9b49ce6792bd5852..2996069af931733a82bcb4d58ba4a09b7bc2f401 100644 --- a/crates/agent_ui/src/agent_ui.rs +++ b/crates/agent_ui/src/agent_ui.rs @@ -401,6 +401,7 @@ fn update_command_palette_filter(cx: &mut App) { } EditPredictionProvider::Zed | EditPredictionProvider::Codestral + | EditPredictionProvider::Ollama | EditPredictionProvider::Sweep | EditPredictionProvider::Mercury | EditPredictionProvider::Experimental(_) => { diff --git a/crates/edit_prediction/src/edit_prediction.rs b/crates/edit_prediction/src/edit_prediction.rs index 0dab9974ed848c91908b7b2d00fcde2a3da7c3fe..d71f8d066b761c83da73fc0fcf08d31c11ed4b39 100644 --- a/crates/edit_prediction/src/edit_prediction.rs +++ b/crates/edit_prediction/src/edit_prediction.rs @@ -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, shown_predictions: VecDeque, @@ -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>, + /// 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>> + '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| { diff --git a/crates/edit_prediction/src/ollama.rs b/crates/edit_prediction/src/ollama.rs new file mode 100644 index 0000000000000000000000000000000000000000..5aa3ad20d5c61f50b6e669ebfe795ca6bff13a92 --- /dev/null +++ b/crates/edit_prediction/src/ollama.rs @@ -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, +} + +#[derive(Debug, Serialize)] +struct OllamaGenerateOptions { + #[serde(skip_serializing_if = "Option::is_none")] + num_predict: Option, + #[serde(skip_serializing_if = "Option::is_none")] + temperature: Option, + #[serde(skip_serializing_if = "Option::is_none")] + stop: Option>, +} + +#[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, Arc)>, + snapshot: BufferSnapshot, + response_received_at: Instant, + inputs: ZetaPromptInput, + buffer: Entity, + 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>> { + 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 = 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::() + .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::() + .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 = 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 { + 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!("
 {prefix} {suffix} ")
+        }
+        "starcoder" | "starcoder2" | "starcoderbase" => {
+            format!("{prefix}{suffix}")
+        }
+        "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!("{prefix}{suffix}")
+        }
+    }
+}
+
+fn get_fim_stop_tokens() -> Vec {
+    vec![
+        "<|endoftext|>".to_string(),
+        "<|file_separator|>".to_string(),
+        "<|fim_pad|>".to_string(),
+        "<|fim_prefix|>".to_string(),
+        "<|fim_middle|>".to_string(),
+        "<|fim_suffix|>".to_string(),
+        "".to_string(),
+        "".to_string(),
+        "".to_string(),
+        "
".to_string(),
+        "".to_string(),
+        "".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|>",
+        "",
+        "",
+        "",
+        "
",
+        "",
+        "",
+        "[PREFIX]",
+        "[SUFFIX]",
+    ];
+
+    for token in &end_tokens {
+        if let Some(pos) = result.find(token) {
+            result.truncate(pos);
+        }
+    }
+
+    result
+}
diff --git a/crates/edit_prediction/src/udiff.rs b/crates/edit_prediction/src/udiff.rs
index f0d55b6899a47e6366e8fef0a7e0d6faaa63c32a..00664b2561ab3749515bf22bf5abacf27dbfa315 100644
--- a/crates/edit_prediction/src/udiff.rs
+++ b/crates/edit_prediction/src/udiff.rs
@@ -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,
 }
 
diff --git a/crates/edit_prediction/src/zed_edit_prediction_delegate.rs b/crates/edit_prediction/src/zed_edit_prediction_delegate.rs
index 02c8fcc031b5ad93d9f7ad907dfa6705228fbac0..cf7ecfb0a64952a8041aa1c463893b06414bbadf 100644
--- a/crates/edit_prediction/src/zed_edit_prediction_delegate.rs
+++ b/crates/edit_prediction/src/zed_edit_prediction_delegate.rs
@@ -76,6 +76,7 @@ impl EditPredictionDelegate for ZedEditPredictionDelegate {
                     .with_down(IconName::ZedPredictDown)
                     .with_error(IconName::ZedPredictError)
             }
+            EditPredictionModel::Ollama => EditPredictionIconSet::new(IconName::AiOllama),
         }
     }
 
diff --git a/crates/edit_prediction/src/zeta1.rs b/crates/edit_prediction/src/zeta1.rs
index b74de410b4b93dfd425760f5c52e12cf045c6dfa..c7b093edec197f6e161e755f8fd3c429528badfc 100644
--- a/crates/edit_prediction/src/zeta1.rs
+++ b/crates/edit_prediction/src/zeta1.rs
@@ -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,
+pub(crate) fn parse_edits(
+    output_excerpt: &str,
     editable_range: Range,
     snapshot: &BufferSnapshot,
 ) -> Result, Arc)>> {
@@ -297,8 +298,8 @@ fn parse_edits(
         .match_indices(EDITABLE_REGION_START_MARKER)
         .collect::>();
     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::>();
     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 newline_ix = content.find('\n').context("could not find newline")?;
-    let content = &content[newline_ix + 1..];
+    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 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], max_tokens: usize) -> String {
+    prompt_for_events_impl(events, max_tokens).0
+}
+
 fn prompt_for_events_impl(events: &[Arc], mut remaining_tokens: usize) -> (String, usize) {
     let mut result = String::new();
     for (ix, event) in events.iter().rev().enumerate() {
diff --git a/crates/edit_prediction_ui/src/edit_prediction_button.rs b/crates/edit_prediction_ui/src/edit_prediction_button.rs
index 08f334862d2717bb626714a0a995f85b5a07c512..1087264825d0887080f2942943760046b5185c22 100644
--- a/crates/edit_prediction_ui/src/edit_prediction_button.rs
+++ b/crates/edit_prediction_ui/src/edit_prediction_button.rs
@@ -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 {
         providers.push(EditPredictionProvider::Codestral);
     }
 
+    if edit_prediction::ollama::is_available(cx) {
+        providers.push(EditPredictionProvider::Ollama);
+    }
+
     if cx.has_flag::()
         && edit_prediction::sweep_ai::sweep_api_token(cx)
             .read(cx)
diff --git a/crates/language/src/language.rs b/crates/language/src/language.rs
index bfdffabf31142ca297608a62d2692288b82e696d..9332df9a3f13b2033d6480945a872b50b6188096 100644
--- a/crates/language/src/language.rs
+++ b/crates/language/src/language.rs
@@ -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::{
diff --git a/crates/language/src/language_settings.rs b/crates/language/src/language_settings.rs
index e81124ed6d85f544295bb15db3296ba2621fdb7d..042ef3051597acbfb62200c6150458ab2baae55e 100644
--- a/crates/language/src/language_settings.rs
+++ b/crates/language/src/language_settings.rs
@@ -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,
+    /// Maximum tokens to generate.
+    pub max_output_tokens: u32,
+    /// Custom API URL to use for Ollama.
+    pub api_url: Arc,
+}
+
 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,
diff --git a/crates/language/src/text_diff.rs b/crates/language/src/text_diff.rs
index 96108fc33ae03f2d83e50e2df9433854a608cac5..c46796827242f0a483c0b31416b89f3e73fa14e8 100644
--- a/crates/language/src/text_diff.rs
+++ b/crates/language/src/text_diff.rs
@@ -301,6 +301,12 @@ pub fn apply_diff_patch(base_text: &str, patch: &str) -> Result Result {
+    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,
     old_byte_range: &Range,
@@ -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";
diff --git a/crates/ollama/src/ollama.rs b/crates/ollama/src/ollama.rs
index f6614379fa999883405a20d17328c61d7da448f2..ede174654cf76299e7cc09b07612c92a9e3af70f 100644
--- a/crates/ollama/src/ollama.rs
+++ b/crates/ollama/src/ollama.rs
@@ -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};
diff --git a/crates/reqwest_client/Cargo.toml b/crates/reqwest_client/Cargo.toml
index 7fd50237d9dc257f0ee7fe75134cd0456ad4928f..2f23ed3072f4d21d1ff053cb829931ae407f6d5b 100644
--- a/crates/reqwest_client/Cargo.toml
+++ b/crates/reqwest_client/Cargo.toml
@@ -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
diff --git a/crates/reqwest_client/src/reqwest_client.rs b/crates/reqwest_client/src/reqwest_client.rs
index 8a1ee45e1cc5364600342d587e6b8c084b5d195a..7c8ab84bd40fa76075a8cd377e942a5c73094b22 100644
--- a/crates/reqwest_client/src/reqwest_client.rs
+++ b/crates/reqwest_client/src/reqwest_client.rs
@@ -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()
diff --git a/crates/settings_content/src/language.rs b/crates/settings_content/src/language.rs
index d3a6a726428f1a3c8679142c7940698084f0bcdc..7d802b432a45d6ede897051019befa9e50174978 100644
--- a/crates/settings_content/src/language.rs
+++ b/crates/settings_content/src/language.rs
@@ -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,
     /// Settings specific to Sweep.
     pub sweep: Option,
+    /// Settings specific to Ollama.
+    pub ollama: Option,
     /// Whether edit predictions are enabled in the assistant prompt editor.
     /// This has no effect if globally disabled.
     pub enabled_in_text_threads: Option,
@@ -242,6 +249,48 @@ pub struct SweepSettingsContent {
     pub privacy_mode: Option,
 }
 
+/// 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 for OllamaModelName {
+    fn as_ref(&self) -> &str {
+        &self.0
+    }
+}
+
+impl From for OllamaModelName {
+    fn from(value: String) -> Self {
+        Self(value)
+    }
+}
+
+impl From 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,
+    /// Maximum tokens to generate for FIM models.
+    /// This setting does not apply to sweep models.
+    ///
+    /// Default: 256
+    pub max_output_tokens: Option,
+    /// Api URL to use for completions.
+    ///
+    /// Default: "http://localhost:11434"
+    pub api_url: Option,
+}
+
 /// The mode in which edit predictions should be displayed.
 #[derive(
     Copy,
diff --git a/crates/settings_ui/Cargo.toml b/crates/settings_ui/Cargo.toml
index 81fb7503697f33c4aa3716807369a5327c32e114..1a8984d50e07d8c8254a5d4ba8b0a9596f5ea1c6 100644
--- a/crates/settings_ui/Cargo.toml
+++ b/crates/settings_ui/Cargo.toml
@@ -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
diff --git a/crates/settings_ui/src/components.rs b/crates/settings_ui/src/components.rs
index e122aebe249262c517a01815582eb3f7da44a8f2..4c29754f1122aee210b1d3db6618abfaa541d04c 100644
--- a/crates/settings_ui/src/components.rs
+++ b/crates/settings_ui/src/components.rs
@@ -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;
diff --git a/crates/settings_ui/src/components/ollama_model_picker.rs b/crates/settings_ui/src/components/ollama_model_picker.rs
new file mode 100644
index 0000000000000000000000000000000000000000..fbd571009fdce71cb635234b9421256c2f6bca58
--- /dev/null
+++ b/crates/settings_ui/src/components/ollama_model_picker.rs
@@ -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;
+
+struct OllamaModelPickerDelegate {
+    models: Vec,
+    filtered_models: Vec,
+    selected_index: usize,
+    on_model_changed: Arc,
+}
+
+impl OllamaModelPickerDelegate {
+    fn new(
+        current_model: SharedString,
+        on_model_changed: impl Fn(SharedString, &mut Window, &mut App) + 'static,
+        cx: &mut Context,
+    ) -> Self {
+        let mut models = Self::fetch_ollama_models(cx);
+
+        let current_in_list = models.contains(¤t_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 {
+        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 = 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,
+    ) {
+        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 {
+        "Search models…".into()
+    }
+
+    fn update_matches(
+        &mut self,
+        query: String,
+        _window: &mut Window,
+        cx: &mut Context,
+    ) -> 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,
+    ) {
+        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) {
+        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,
+    ) -> Option {
+        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,
+    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()
+}
diff --git a/crates/settings_ui/src/pages/edit_prediction_provider_setup.rs b/crates/settings_ui/src/pages/edit_prediction_provider_setup.rs
index 64fd4e376352f4ccc2fe8978c02b8c7fe3dc0c3e..1cbd56f5ab9333a4fc069c269ffa7d2f44467121 100644
--- a/crates/settings_ui/src/pages/edit_prediction_provider_setup.rs
+++ b/crates/settings_ui/src/pages/edit_prediction_provider_setup.rs
@@ -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,
+) -> 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 {
diff --git a/crates/settings_ui/src/settings_ui.rs b/crates/settings_ui/src/settings_ui.rs
index d43b0a6853242294f1219b70f13660491f93767a..306e229ae89ac069c0ba261940f5b7b0c1597ff6 100644
--- a/crates/settings_ui/src/settings_ui.rs
+++ b/crates/settings_ui/src/settings_ui.rs
@@ -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::(render_dropdown)
         .add_basic_renderer::(render_dropdown)
         .add_basic_renderer::(render_editable_number_field)
+        .add_basic_renderer::(render_ollama_model_picker)
         // please semicolon stay on next line
         ;
 }
diff --git a/crates/zed/src/zed/edit_prediction_registry.rs b/crates/zed/src/zed/edit_prediction_registry.rs
index 521da62b16946106e4adef54085980f106e988eb..f13ae32c51f76cab794aa0c0bb886c8fc4ad8c7e 100644
--- a/crates/zed/src/zed/edit_prediction_registry.rs
+++ b/crates/zed/src/zed/edit_prediction_registry.rs
@@ -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::() => {
                             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::() =>
diff --git a/crates/zeta_prompt/src/zeta_prompt.rs b/crates/zeta_prompt/src/zeta_prompt.rs
index e1c2b7fd23b91326581dd5767d0fbb09c2e6cb32..8799d680287aefbcd8a4740eb3b558f9cd62ccb8 100644
--- a/crates/zeta_prompt/src/zeta_prompt.rs
+++ b/crates/zeta_prompt/src/zeta_prompt.rs
@@ -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::*;