sweep_ai.rs

  1mod api;
  2
  3use anyhow::{Context as _, Result};
  4use arrayvec::ArrayVec;
  5use client::telemetry;
  6use collections::HashMap;
  7use feature_flags::FeatureFlag;
  8use futures::AsyncReadExt as _;
  9use gpui::{App, AppContext, Context, Entity, EntityId, Global, Task, WeakEntity};
 10use http_client::{AsyncBody, Method};
 11use language::{Anchor, Buffer, BufferSnapshot, EditPreview, ToOffset as _, ToPoint, text_diff};
 12use project::Project;
 13use release_channel::{AppCommitSha, AppVersion};
 14use std::collections::{VecDeque, hash_map};
 15use std::fmt::{self, Display};
 16use std::mem;
 17use std::{
 18    cmp,
 19    fmt::Write,
 20    ops::Range,
 21    path::Path,
 22    sync::Arc,
 23    time::{Duration, Instant},
 24};
 25use util::ResultExt;
 26use util::rel_path::RelPath;
 27use workspace::Workspace;
 28
 29use crate::api::{AutocompleteRequest, AutocompleteResponse, FileChunk};
 30
 31const BUFFER_CHANGE_GROUPING_INTERVAL: Duration = Duration::from_secs(1);
 32const MAX_EVENT_COUNT: usize = 16;
 33
 34const SWEEP_API_URL: &str = "https://autocomplete.sweep.dev/backend/next_edit_autocomplete";
 35
 36pub struct SweepFeatureFlag;
 37
 38impl FeatureFlag for SweepFeatureFlag {
 39    const NAME: &str = "sweep-ai";
 40
 41    fn enabled_for_staff() -> bool {
 42        false
 43    }
 44}
 45
 46#[derive(Clone)]
 47struct SweepAiGlobal(Entity<SweepAi>);
 48
 49impl Global for SweepAiGlobal {}
 50
 51#[derive(Clone)]
 52pub struct EditPrediction {
 53    id: EditPredictionId,
 54    path: Arc<Path>,
 55    edits: Arc<[(Range<Anchor>, Arc<str>)]>,
 56    snapshot: BufferSnapshot,
 57    edit_preview: EditPreview,
 58}
 59
 60impl EditPrediction {
 61    fn interpolate(&self, new_snapshot: &BufferSnapshot) -> Option<Vec<(Range<Anchor>, Arc<str>)>> {
 62        edit_prediction::interpolate_edits(&self.snapshot, new_snapshot, &self.edits)
 63    }
 64}
 65
 66impl fmt::Debug for EditPrediction {
 67    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
 68        f.debug_struct("EditPrediction")
 69            .field("path", &self.path)
 70            .field("edits", &self.edits)
 71            .finish_non_exhaustive()
 72    }
 73}
 74
 75#[derive(Clone, Default, Debug, PartialEq, Eq, Hash)]
 76pub struct EditPredictionId(String);
 77
 78impl Display for EditPredictionId {
 79    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
 80        write!(f, "{}", self.0)
 81    }
 82}
 83
 84pub struct SweepAi {
 85    projects: HashMap<EntityId, SweepAiProject>,
 86    debug_info: Arc<str>,
 87    api_token: Option<String>,
 88}
 89
 90struct SweepAiProject {
 91    events: VecDeque<Event>,
 92    registered_buffers: HashMap<gpui::EntityId, RegisteredBuffer>,
 93}
 94
 95impl SweepAi {
 96    pub fn global(cx: &mut App) -> Option<Entity<Self>> {
 97        cx.try_global::<SweepAiGlobal>()
 98            .map(|global| global.0.clone())
 99    }
100
101    pub fn register(cx: &mut App) -> Entity<Self> {
102        Self::global(cx).unwrap_or_else(|| {
103            let entity = cx.new(|cx| Self::new(cx));
104            cx.set_global(SweepAiGlobal(entity.clone()));
105            entity
106        })
107    }
108
109    pub fn clear_history(&mut self) {
110        for sweep_ai_project in self.projects.values_mut() {
111            sweep_ai_project.events.clear();
112        }
113    }
114
115    fn new(cx: &mut Context<Self>) -> Self {
116        Self {
117            api_token: std::env::var("SWEEP_AI_TOKEN").ok(),
118            projects: HashMap::default(),
119            debug_info: format!(
120                "Zed v{version} ({sha}) - OS: {os} - Zed v{version}",
121                version = AppVersion::global(cx),
122                sha = AppCommitSha::try_global(cx).map_or("unknown".to_string(), |sha| sha.full()),
123                os = telemetry::os_name(),
124            )
125            .into(),
126        }
127    }
128
129    fn get_or_init_sweep_ai_project(
130        &mut self,
131        project: &Entity<Project>,
132        cx: &mut Context<Self>,
133    ) -> &mut SweepAiProject {
134        let project_id = project.entity_id();
135        match self.projects.entry(project_id) {
136            hash_map::Entry::Occupied(entry) => entry.into_mut(),
137            hash_map::Entry::Vacant(entry) => {
138                cx.observe_release(project, move |this, _, _cx| {
139                    this.projects.remove(&project_id);
140                })
141                .detach();
142                entry.insert(SweepAiProject {
143                    events: VecDeque::with_capacity(MAX_EVENT_COUNT),
144                    registered_buffers: HashMap::default(),
145                })
146            }
147        }
148    }
149
150    fn push_event(sweep_ai_project: &mut SweepAiProject, event: Event) {
151        let events = &mut sweep_ai_project.events;
152
153        if let Some(Event::BufferChange {
154            new_snapshot: last_new_snapshot,
155            timestamp: last_timestamp,
156            ..
157        }) = events.back_mut()
158        {
159            // Coalesce edits for the same buffer when they happen one after the other.
160            let Event::BufferChange {
161                old_snapshot,
162                new_snapshot,
163                timestamp,
164            } = &event;
165
166            if timestamp.duration_since(*last_timestamp) <= BUFFER_CHANGE_GROUPING_INTERVAL
167                && old_snapshot.remote_id() == last_new_snapshot.remote_id()
168                && old_snapshot.version == last_new_snapshot.version
169            {
170                *last_new_snapshot = new_snapshot.clone();
171                *last_timestamp = *timestamp;
172                return;
173            }
174        }
175
176        if events.len() >= MAX_EVENT_COUNT {
177            // These are halved instead of popping to improve prompt caching.
178            events.drain(..MAX_EVENT_COUNT / 2);
179        }
180
181        events.push_back(event);
182    }
183
184    pub fn register_buffer(
185        &mut self,
186        buffer: &Entity<Buffer>,
187        project: &Entity<Project>,
188        cx: &mut Context<Self>,
189    ) {
190        let sweep_ai_project = self.get_or_init_sweep_ai_project(project, cx);
191        Self::register_buffer_impl(sweep_ai_project, buffer, project, cx);
192    }
193
194    fn register_buffer_impl<'a>(
195        sweep_ai_project: &'a mut SweepAiProject,
196        buffer: &Entity<Buffer>,
197        project: &Entity<Project>,
198        cx: &mut Context<Self>,
199    ) -> &'a mut RegisteredBuffer {
200        let buffer_id = buffer.entity_id();
201        match sweep_ai_project.registered_buffers.entry(buffer_id) {
202            hash_map::Entry::Occupied(entry) => entry.into_mut(),
203            hash_map::Entry::Vacant(entry) => {
204                let snapshot = buffer.read(cx).snapshot();
205                let project_entity_id = project.entity_id();
206                entry.insert(RegisteredBuffer {
207                    snapshot,
208                    _subscriptions: [
209                        cx.subscribe(buffer, {
210                            let project = project.downgrade();
211                            move |this, buffer, event, cx| {
212                                if let language::BufferEvent::Edited = event
213                                    && let Some(project) = project.upgrade()
214                                {
215                                    this.report_changes_for_buffer(&buffer, &project, cx);
216                                }
217                            }
218                        }),
219                        cx.observe_release(buffer, move |this, _buffer, _cx| {
220                            let Some(sweep_ai_project) = this.projects.get_mut(&project_entity_id)
221                            else {
222                                return;
223                            };
224                            sweep_ai_project.registered_buffers.remove(&buffer_id);
225                        }),
226                    ],
227                })
228            }
229        }
230    }
231
232    pub fn request_completion(
233        &mut self,
234        workspace: &WeakEntity<Workspace>,
235        project: &Entity<Project>,
236        active_buffer: &Entity<Buffer>,
237        position: language::Anchor,
238        cx: &mut Context<Self>,
239    ) -> Task<Result<Option<EditPrediction>>> {
240        let snapshot = active_buffer.read(cx).snapshot();
241        let debug_info = self.debug_info.clone();
242        let Some(api_token) = self.api_token.clone() else {
243            return Task::ready(Ok(None));
244        };
245        let full_path: Arc<Path> = snapshot
246            .file()
247            .map(|file| file.full_path(cx))
248            .unwrap_or_else(|| "untitled".into())
249            .into();
250
251        let project_file = project::File::from_dyn(snapshot.file());
252        let repo_name = project_file
253            .map(|file| file.worktree.read(cx).root_name_str())
254            .unwrap_or("untitled")
255            .into();
256        let offset = position.to_offset(&snapshot);
257
258        let project_state = self.get_or_init_sweep_ai_project(project, cx);
259        let events = project_state.events.clone();
260        let http_client = cx.http_client();
261
262        let Some(recent_buffers) = workspace
263            .read_with(cx, |workspace, cx| {
264                workspace
265                    .recent_navigation_history_iter(cx)
266                    .filter_map(|(project_path, _)| {
267                        let buffer = project.read(cx).get_open_buffer(&project_path, cx)?;
268
269                        if active_buffer == &buffer {
270                            None
271                        } else {
272                            Some(buffer.read(cx).snapshot())
273                        }
274                    })
275                    .take(3)
276                    .collect::<Vec<_>>()
277            })
278            .log_err()
279        else {
280            return Task::ready(Ok(None));
281        };
282
283        let result = cx.background_spawn({
284            let full_path = full_path.clone();
285            async move {
286                let text = snapshot.text();
287
288                let mut recent_changes = String::new();
289
290                for event in events {
291                    writeln!(&mut recent_changes, "{event}")?;
292                }
293
294                let file_chunks = recent_buffers
295                    .into_iter()
296                    .map(|snapshot| {
297                        let end_point = language::Point::new(30, 0).min(snapshot.max_point());
298                        FileChunk {
299                            content: snapshot
300                                .text_for_range(language::Point::zero()..end_point)
301                                .collect(),
302                            file_path: snapshot
303                                .file()
304                                .map(|f| f.path().as_unix_str())
305                                .unwrap_or("untitled")
306                                .to_string(),
307                            start_line: 0,
308                            end_line: end_point.row as usize,
309                            timestamp: snapshot.file().and_then(|file| {
310                                Some(
311                                    file.disk_state()
312                                        .mtime()?
313                                        .to_seconds_and_nanos_for_persistence()?
314                                        .0,
315                                )
316                            }),
317                        }
318                    })
319                    .collect();
320
321                let request_body = AutocompleteRequest {
322                    debug_info,
323                    repo_name,
324                    file_path: full_path.clone(),
325                    file_contents: text.clone(),
326                    original_file_contents: text,
327                    cursor_position: offset,
328                    recent_changes: recent_changes.clone(),
329                    changes_above_cursor: true,
330                    multiple_suggestions: false,
331                    branch: None,
332                    file_chunks,
333                    retrieval_chunks: vec![],
334                    recent_user_actions: vec![],
335                    // TODO
336                    privacy_mode_enabled: false,
337                };
338
339                let mut buf: Vec<u8> = Vec::new();
340                let writer = brotli::CompressorWriter::new(&mut buf, 4096, 11, 22);
341                serde_json::to_writer(writer, &request_body)?;
342                let body: AsyncBody = buf.into();
343
344                let request = http_client::Request::builder()
345                    .uri(SWEEP_API_URL)
346                    .header("Content-Type", "application/json")
347                    .header("Authorization", format!("Bearer {}", api_token))
348                    .header("Connection", "keep-alive")
349                    .header("Content-Encoding", "br")
350                    .method(Method::POST)
351                    .body(body)?;
352
353                let mut response = http_client.send(request).await?;
354
355                let mut body: Vec<u8> = Vec::new();
356                response.body_mut().read_to_end(&mut body).await?;
357
358                if !response.status().is_success() {
359                    anyhow::bail!(
360                        "Request failed with status: {:?}\nBody: {}",
361                        response.status(),
362                        String::from_utf8_lossy(&body),
363                    );
364                };
365
366                let response: AutocompleteResponse = serde_json::from_slice(&body)?;
367
368                let old_text = snapshot
369                    .text_for_range(response.start_index..response.end_index)
370                    .collect::<String>();
371                let edits = text_diff(&old_text, &response.completion)
372                    .into_iter()
373                    .map(|(range, text)| {
374                        (
375                            snapshot.anchor_after(response.start_index + range.start)
376                                ..snapshot.anchor_before(response.start_index + range.end),
377                            text,
378                        )
379                    })
380                    .collect::<Vec<_>>();
381
382                anyhow::Ok((response.autocomplete_id, edits, snapshot))
383            }
384        });
385
386        let buffer = active_buffer.clone();
387
388        cx.spawn(async move |_, cx| {
389            let (id, edits, old_snapshot) = result.await?;
390
391            if edits.is_empty() {
392                return anyhow::Ok(None);
393            }
394
395            let Some((edits, new_snapshot, preview_task)) =
396                buffer.read_with(cx, |buffer, cx| {
397                    let new_snapshot = buffer.snapshot();
398
399                    let edits: Arc<[(Range<Anchor>, Arc<str>)]> =
400                        edit_prediction::interpolate_edits(&old_snapshot, &new_snapshot, &edits)?
401                            .into();
402                    let preview_task = buffer.preview_edits(edits.clone(), cx);
403
404                    Some((edits, new_snapshot, preview_task))
405                })?
406            else {
407                return anyhow::Ok(None);
408            };
409
410            let prediction = EditPrediction {
411                id: EditPredictionId(id),
412                path: full_path,
413                edits,
414                snapshot: new_snapshot,
415                edit_preview: preview_task.await,
416            };
417
418            anyhow::Ok(Some(prediction))
419        })
420    }
421
422    fn report_changes_for_buffer(
423        &mut self,
424        buffer: &Entity<Buffer>,
425        project: &Entity<Project>,
426        cx: &mut Context<Self>,
427    ) -> BufferSnapshot {
428        let sweep_ai_project = self.get_or_init_sweep_ai_project(project, cx);
429        let registered_buffer = Self::register_buffer_impl(sweep_ai_project, buffer, project, cx);
430
431        let new_snapshot = buffer.read(cx).snapshot();
432        if new_snapshot.version != registered_buffer.snapshot.version {
433            let old_snapshot = mem::replace(&mut registered_buffer.snapshot, new_snapshot.clone());
434            Self::push_event(
435                sweep_ai_project,
436                Event::BufferChange {
437                    old_snapshot,
438                    new_snapshot: new_snapshot.clone(),
439                    timestamp: Instant::now(),
440                },
441            );
442        }
443
444        new_snapshot
445    }
446}
447
448struct RegisteredBuffer {
449    snapshot: BufferSnapshot,
450    _subscriptions: [gpui::Subscription; 2],
451}
452
453#[derive(Clone)]
454pub enum Event {
455    BufferChange {
456        old_snapshot: BufferSnapshot,
457        new_snapshot: BufferSnapshot,
458        timestamp: Instant,
459    },
460}
461
462impl Display for Event {
463    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
464        match self {
465            Event::BufferChange {
466                old_snapshot,
467                new_snapshot,
468                ..
469            } => {
470                let old_path = old_snapshot
471                    .file()
472                    .map(|f| f.path().as_ref())
473                    .unwrap_or(RelPath::unix("untitled").unwrap());
474                let new_path = new_snapshot
475                    .file()
476                    .map(|f| f.path().as_ref())
477                    .unwrap_or(RelPath::unix("untitled").unwrap());
478                if old_path != new_path {
479                    // TODO confirm how to do this for sweep
480                    // writeln!(f, "User renamed {:?} to {:?}\n", old_path, new_path)?;
481                }
482
483                let diff = language::unified_diff(&old_snapshot.text(), &new_snapshot.text());
484                if !diff.is_empty() {
485                    write!(
486                        f,
487                        "File: {}:\n{}\n",
488                        new_path.display(util::paths::PathStyle::Posix),
489                        diff
490                    )?
491                }
492
493                fmt::Result::Ok(())
494            }
495        }
496    }
497}
498
499#[derive(Debug, Clone)]
500struct CurrentEditPrediction {
501    buffer_id: EntityId,
502    completion: EditPrediction,
503}
504
505impl CurrentEditPrediction {
506    fn should_replace_completion(&self, old_completion: &Self, snapshot: &BufferSnapshot) -> bool {
507        if self.buffer_id != old_completion.buffer_id {
508            return true;
509        }
510
511        let Some(old_edits) = old_completion.completion.interpolate(snapshot) else {
512            return true;
513        };
514        let Some(new_edits) = self.completion.interpolate(snapshot) else {
515            return false;
516        };
517
518        if old_edits.len() == 1 && new_edits.len() == 1 {
519            let (old_range, old_text) = &old_edits[0];
520            let (new_range, new_text) = &new_edits[0];
521            new_range == old_range && new_text.starts_with(old_text.as_ref())
522        } else {
523            true
524        }
525    }
526}
527
528struct PendingCompletion {
529    id: usize,
530    _task: Task<()>,
531}
532
533pub struct SweepAiEditPredictionProvider {
534    workspace: WeakEntity<Workspace>,
535    sweep_ai: Entity<SweepAi>,
536    pending_completions: ArrayVec<PendingCompletion, 2>,
537    next_pending_completion_id: usize,
538    current_completion: Option<CurrentEditPrediction>,
539    last_request_timestamp: Instant,
540    project: Entity<Project>,
541}
542
543impl SweepAiEditPredictionProvider {
544    pub const THROTTLE_TIMEOUT: Duration = Duration::from_millis(300);
545
546    pub fn new(
547        sweep_ai: Entity<SweepAi>,
548        workspace: WeakEntity<Workspace>,
549        project: Entity<Project>,
550    ) -> Self {
551        Self {
552            sweep_ai,
553            pending_completions: ArrayVec::new(),
554            next_pending_completion_id: 0,
555            current_completion: None,
556            last_request_timestamp: Instant::now(),
557            project,
558            workspace,
559        }
560    }
561}
562
563impl edit_prediction::EditPredictionProvider for SweepAiEditPredictionProvider {
564    fn name() -> &'static str {
565        "zed-predict"
566    }
567
568    fn display_name() -> &'static str {
569        "Zed's Edit Predictions"
570    }
571
572    fn show_completions_in_menu() -> bool {
573        true
574    }
575
576    fn show_tab_accept_marker() -> bool {
577        true
578    }
579
580    fn is_enabled(
581        &self,
582        _buffer: &Entity<Buffer>,
583        _cursor_position: language::Anchor,
584        cx: &App,
585    ) -> bool {
586        self.sweep_ai.read(cx).api_token.is_some()
587    }
588
589    fn is_refreshing(&self) -> bool {
590        !self.pending_completions.is_empty()
591    }
592
593    fn refresh(
594        &mut self,
595        buffer: Entity<Buffer>,
596        position: language::Anchor,
597        _debounce: bool,
598        cx: &mut Context<Self>,
599    ) {
600        if let Some(current_completion) = self.current_completion.as_ref() {
601            let snapshot = buffer.read(cx).snapshot();
602            if current_completion
603                .completion
604                .interpolate(&snapshot)
605                .is_some()
606            {
607                return;
608            }
609        }
610
611        let pending_completion_id = self.next_pending_completion_id;
612        self.next_pending_completion_id += 1;
613        let last_request_timestamp = self.last_request_timestamp;
614
615        let project = self.project.clone();
616        let workspace = self.workspace.clone();
617        let task = cx.spawn(async move |this, cx| {
618            if let Some(timeout) = (last_request_timestamp + Self::THROTTLE_TIMEOUT)
619                .checked_duration_since(Instant::now())
620            {
621                cx.background_executor().timer(timeout).await;
622            }
623
624            let completion_request = this.update(cx, |this, cx| {
625                this.last_request_timestamp = Instant::now();
626                this.sweep_ai.update(cx, |sweep_ai, cx| {
627                    sweep_ai.request_completion(&workspace, &project, &buffer, position, cx)
628                })
629            });
630
631            let completion = match completion_request {
632                Ok(completion_request) => {
633                    let completion_request = completion_request.await;
634                    completion_request.map(|c| {
635                        c.map(|completion| CurrentEditPrediction {
636                            buffer_id: buffer.entity_id(),
637                            completion,
638                        })
639                    })
640                }
641                Err(error) => Err(error),
642            };
643
644            let Some(new_completion) = completion
645                .context("edit prediction failed")
646                .log_err()
647                .flatten()
648            else {
649                this.update(cx, |this, cx| {
650                    if this.pending_completions[0].id == pending_completion_id {
651                        this.pending_completions.remove(0);
652                    } else {
653                        this.pending_completions.clear();
654                    }
655
656                    cx.notify();
657                })
658                .ok();
659                return;
660            };
661
662            this.update(cx, |this, cx| {
663                if this.pending_completions[0].id == pending_completion_id {
664                    this.pending_completions.remove(0);
665                } else {
666                    this.pending_completions.clear();
667                }
668
669                if let Some(old_completion) = this.current_completion.as_ref() {
670                    let snapshot = buffer.read(cx).snapshot();
671                    if new_completion.should_replace_completion(old_completion, &snapshot) {
672                        this.current_completion = Some(new_completion);
673                    }
674                } else {
675                    this.current_completion = Some(new_completion);
676                }
677
678                cx.notify();
679            })
680            .ok();
681        });
682
683        // We always maintain at most two pending completions. When we already
684        // have two, we replace the newest one.
685        if self.pending_completions.len() <= 1 {
686            self.pending_completions.push(PendingCompletion {
687                id: pending_completion_id,
688                _task: task,
689            });
690        } else if self.pending_completions.len() == 2 {
691            self.pending_completions.pop();
692            self.pending_completions.push(PendingCompletion {
693                id: pending_completion_id,
694                _task: task,
695            });
696        }
697    }
698
699    fn cycle(
700        &mut self,
701        _buffer: Entity<Buffer>,
702        _cursor_position: language::Anchor,
703        _direction: edit_prediction::Direction,
704        _cx: &mut Context<Self>,
705    ) {
706        // Right now we don't support cycling.
707    }
708
709    fn accept(&mut self, _cx: &mut Context<Self>) {
710        self.pending_completions.clear();
711    }
712
713    fn discard(&mut self, _cx: &mut Context<Self>) {
714        self.pending_completions.clear();
715        self.current_completion.take();
716    }
717
718    fn suggest(
719        &mut self,
720        buffer: &Entity<Buffer>,
721        cursor_position: language::Anchor,
722        cx: &mut Context<Self>,
723    ) -> Option<edit_prediction::EditPrediction> {
724        let CurrentEditPrediction {
725            buffer_id,
726            completion,
727            ..
728        } = self.current_completion.as_mut()?;
729
730        // Invalidate previous completion if it was generated for a different buffer.
731        if *buffer_id != buffer.entity_id() {
732            self.current_completion.take();
733            return None;
734        }
735
736        let buffer = buffer.read(cx);
737        let Some(edits) = completion.interpolate(&buffer.snapshot()) else {
738            self.current_completion.take();
739            return None;
740        };
741
742        let cursor_row = cursor_position.to_point(buffer).row;
743        let (closest_edit_ix, (closest_edit_range, _)) =
744            edits.iter().enumerate().min_by_key(|(_, (range, _))| {
745                let distance_from_start = cursor_row.abs_diff(range.start.to_point(buffer).row);
746                let distance_from_end = cursor_row.abs_diff(range.end.to_point(buffer).row);
747                cmp::min(distance_from_start, distance_from_end)
748            })?;
749
750        let mut edit_start_ix = closest_edit_ix;
751        for (range, _) in edits[..edit_start_ix].iter().rev() {
752            let distance_from_closest_edit =
753                closest_edit_range.start.to_point(buffer).row - range.end.to_point(buffer).row;
754            if distance_from_closest_edit <= 1 {
755                edit_start_ix -= 1;
756            } else {
757                break;
758            }
759        }
760
761        let mut edit_end_ix = closest_edit_ix + 1;
762        for (range, _) in &edits[edit_end_ix..] {
763            let distance_from_closest_edit =
764                range.start.to_point(buffer).row - closest_edit_range.end.to_point(buffer).row;
765            if distance_from_closest_edit <= 1 {
766                edit_end_ix += 1;
767            } else {
768                break;
769            }
770        }
771
772        Some(edit_prediction::EditPrediction::Local {
773            id: Some(completion.id.to_string().into()),
774            edits: edits[edit_start_ix..edit_end_ix].to_vec(),
775            edit_preview: Some(completion.edit_preview.clone()),
776        })
777    }
778}