Estimate time spent navigating and editing code

Michael Sloan created

Change summary

Cargo.lock                                      |   1 
crates/cloud_llm_client/src/cloud_llm_client.rs |  13 +
crates/editor/src/editor.rs                     |   5 
crates/zeta/Cargo.toml                          |   2 
crates/zeta/src/zeta.rs                         | 178 +++++++++++++++++-
5 files changed, 182 insertions(+), 17 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -20793,6 +20793,7 @@ dependencies = [
  "language_model",
  "log",
  "menu",
+ "multi_buffer",
  "postage",
  "project",
  "rand 0.8.5",

crates/cloud_llm_client/src/cloud_llm_client.rs 🔗

@@ -202,6 +202,15 @@ pub struct PredictEditsRecentFile {
     pub cursor_point: Point,
     /// Milliseconds between the editor for this file being active and the request time.
     pub active_to_now_ms: u32,
+    /// Number of times the editor for this file was activated.
+    pub activation_count: u32,
+    /// Rough estimate of milliseconds the user was editing the file.
+    pub cumulative_time_editing_ms: u32,
+    /// Rough estimate of milliseconds the user was navigating within the file.
+    pub cumulative_time_navigating_ms: u32,
+    /// Whether the file is a multibuffer.
+    #[serde(skip_serializing_if = "is_default", default)]
+    pub is_multibuffer: bool,
 }
 
 #[derive(Debug, Clone, Serialize, Deserialize)]
@@ -373,6 +382,10 @@ pub struct UsageData {
     pub limit: UsageLimit,
 }
 
+fn is_default<T: Default + PartialEq>(value: &T) -> bool {
+    *value == T::default()
+}
+
 #[cfg(test)]
 mod tests {
     use pretty_assertions::assert_eq;

crates/editor/src/editor.rs 🔗

@@ -2746,11 +2746,6 @@ impl Editor {
         self.buffer.read(cx).read(cx).file_at(point).cloned()
     }
 
-    pub fn cursor_buffer_point(&self, cx: &mut App) -> Option<(Entity<Buffer>, Point, ExcerptId)> {
-        let cursor = self.selections.newest::<Point>(cx).head();
-        self.buffer.read(cx).point_to_buffer_point(cursor, cx)
-    }
-
     pub fn active_excerpt(
         &self,
         cx: &App,

crates/zeta/Cargo.toml 🔗

@@ -21,6 +21,7 @@ ai_onboarding.workspace = true
 anyhow.workspace = true
 arrayvec.workspace = true
 client.workspace = true
+clock.workspace = true
 cloud_llm_client.workspace = true
 collections.workspace = true
 command_palette_hooks.workspace = true
@@ -38,6 +39,7 @@ language.workspace = true
 language_model.workspace = true
 log.workspace = true
 menu.workspace = true
+multi_buffer.workspace = true
 postage.workspace = true
 project.workspace = true
 rand.workspace = true

crates/zeta/src/zeta.rs 🔗

@@ -35,6 +35,7 @@ use language::{
     Anchor, Buffer, BufferSnapshot, EditPreview, File, OffsetRangeExt, ToOffset, ToPoint, text_diff,
 };
 use language_model::{LlmApiToken, RefreshLlmTokenListener};
+use multi_buffer::MultiBufferPoint;
 use project::{Project, ProjectPath};
 use release_channel::AppVersion;
 use settings::WorktreeId;
@@ -87,6 +88,12 @@ const MAX_DIAGNOSTICS_BYTES: usize = 4096;
 /// Maximum number of edit predictions to store for feedback.
 const MAX_SHOWN_COMPLETION_COUNT: usize = 50;
 
+/// Interval between polls tracking time editing files.
+const ACTIVITY_POLL_INTERVAL: Duration = Duration::from_secs(10);
+
+/// Interval between polls of whether data collection is enabled, when it is disabled.
+const DISABLED_ACTIVITY_POLL_INTERVAL: Duration = Duration::from_secs(60 * 5);
+
 actions!(
     edit_prediction,
     [
@@ -242,11 +249,23 @@ pub struct Zeta {
     user_store: Entity<UserStore>,
     license_detection_watchers: HashMap<WorktreeId, Rc<LicenseDetectionWatcher>>,
     recent_editors: VecDeque<RecentEditor>,
+    last_activity_state: Option<ActivityState>,
+    _activity_poll_task: Option<Task<Result<()>>>,
 }
 
 struct RecentEditor {
     editor: WeakEntity<Editor>,
     last_active_at: Instant,
+    activation_count: u32,
+    cumulative_time_editing: Duration,
+    cumulative_time_navigating: Duration,
+}
+
+#[derive(Debug)]
+struct ActivityState {
+    scroll_position: gpui::Point<f32>,
+    cursor_point: MultiBufferPoint,
+    singleton_version: Option<clock::Global>,
 }
 
 impl Zeta {
@@ -298,14 +317,40 @@ impl Zeta {
         let data_collection_choice = Self::load_data_collection_choices();
         let data_collection_choice = cx.new(|_| data_collection_choice);
 
+        let mut activity_poll_task = None;
+
         if let Some(workspace) = &workspace {
-            cx.subscribe(workspace, |this, _workspace, event, cx| match event {
-                workspace::Event::ActiveItemChanged => {
-                    this.handle_active_workspace_item_changed(cx)
+            let project = workspace.read(cx).project().clone();
+            cx.subscribe(&project, |this, _project, event, cx| match event {
+                project::Event::ActiveEntryChanged(entry_id) => {
+                    this.handle_active_project_entry_changed(cx)
                 }
                 _ => {}
             })
             .detach();
+
+            // TODO: ideally this would attend to window focus when tracking time, and pause the
+            // loop for efficiency when not focused.
+            activity_poll_task = Some(cx.spawn(async move |this, cx| {
+                let mut instant_before_delay = None;
+                loop {
+                    let data_collection_is_enabled = this.read_with(cx, |this, cx| {
+                        this.data_collection_choice.read(cx).is_enabled()
+                    })?;
+                    let interval = if data_collection_is_enabled {
+                        ACTIVITY_POLL_INTERVAL
+                    } else {
+                        instant_before_delay = None;
+                        DISABLED_ACTIVITY_POLL_INTERVAL
+                    };
+                    cx.background_executor().timer(interval).await;
+                    this.update(cx, |this, cx| {
+                        let now = Instant::now();
+                        this.handle_activity_poll(instant_before_delay, now, cx);
+                        instant_before_delay = Some(now);
+                    })?
+                }
+            }));
         }
 
         Self {
@@ -336,6 +381,8 @@ impl Zeta {
             license_detection_watchers: HashMap::default(),
             user_store,
             recent_editors: VecDeque::new(),
+            last_activity_state: None,
+            _activity_poll_task: activity_poll_task,
         }
     }
 
@@ -1268,9 +1315,10 @@ and then another
         })
     }
 
-    fn handle_active_workspace_item_changed(&mut self, cx: &Context<Self>) {
+    fn handle_active_project_entry_changed(&mut self, cx: &mut Context<Self>) {
         if !self.data_collection_choice.read(cx).is_enabled() {
             self.recent_editors.clear();
+            self.last_activity_state = None;
             return;
         }
         if let Some(active_editor) = self
@@ -1284,20 +1332,35 @@ and then another
             .flatten()
         {
             let now = Instant::now();
+            let editor = active_editor.downgrade();
+            let existing_recent_editor = if let Some(existing_ix) = self
+                .recent_editors
+                .iter()
+                .rposition(|recent| &recent.editor == &editor)
+            {
+                if existing_ix + 1 != self.recent_editors.len() {
+                    self.last_activity_state = None;
+                }
+                self.recent_editors.remove(existing_ix)
+            } else {
+                None
+            };
             let new_recent = RecentEditor {
                 editor: active_editor.downgrade(),
                 last_active_at: now,
+                activation_count: existing_recent_editor
+                    .as_ref()
+                    .map_or(0, |recent| recent.activation_count + 1),
+                cumulative_time_navigating: existing_recent_editor
+                    .as_ref()
+                    .map_or(Duration::ZERO, |recent| recent.cumulative_time_navigating),
+                cumulative_time_editing: existing_recent_editor
+                    .map_or(Duration::ZERO, |recent| recent.cumulative_time_editing),
             };
-            if let Some(existing_ix) = self
-                .recent_editors
-                .iter()
-                .rposition(|recent| &recent.editor == &new_recent.editor)
-            {
-                self.recent_editors.remove(existing_ix);
-            }
             // filter out rapid changes in active item, particularly since this can happen rapidly when
             // a workspace is loaded.
             if let Some(previous_recent) = self.recent_editors.back_mut()
+                && previous_recent.activation_count == 1
                 && now.duration_since(previous_recent.last_active_at)
                     < MIN_TIME_BETWEEN_RECENT_FILES
             {
@@ -1311,6 +1374,80 @@ and then another
         }
     }
 
+    fn handle_activity_poll(
+        &mut self,
+        instant_before_delay: Option<Instant>,
+        now: Instant,
+        cx: &mut Context<Self>,
+    ) {
+        if !self.data_collection_choice.read(cx).is_enabled() {
+            self.last_activity_state = None;
+            return;
+        }
+        if let Some(recent_editor) = self.recent_editors.back()
+            && let Some(editor) = recent_editor.editor.upgrade()
+        {
+            let (scroll_position, cursor_point, singleton_version) =
+                editor.update(cx, |editor, cx| {
+                    let scroll_position = editor.scroll_position(cx);
+                    let cursor_point = editor.selections.newest(cx).head();
+                    let singleton_version = editor
+                        .buffer()
+                        .read(cx)
+                        .as_singleton()
+                        .map(|singleton_buffer| singleton_buffer.read(cx).version());
+                    (scroll_position, cursor_point, singleton_version)
+                });
+
+            let navigated = if let Some(last_activity_state) = &self.last_activity_state {
+                last_activity_state.scroll_position != scroll_position
+                    || last_activity_state.cursor_point != cursor_point
+            } else {
+                false
+            };
+
+            let edited = if let Some(singleton_version) = &singleton_version
+                && let Some(last_activity_state) = &self.last_activity_state
+                && let Some(last_singleton_version) = &last_activity_state.singleton_version
+            {
+                singleton_version.changed_since(last_singleton_version)
+            } else {
+                false
+            };
+
+            self.last_activity_state = Some(ActivityState {
+                scroll_position,
+                cursor_point,
+                singleton_version,
+            });
+
+            let prior_recent_editor = if self.recent_editors.len() > 1 {
+                Some(&self.recent_editors[self.recent_editors.len() - 2])
+            } else {
+                None
+            };
+            let additional_time: Option<Duration> =
+                instant_before_delay.map(|instant_before_delay| {
+                    now.duration_since(prior_recent_editor.map_or(
+                        instant_before_delay,
+                        |prior_recent_editor| {
+                            prior_recent_editor.last_active_at.max(instant_before_delay)
+                        },
+                    ))
+                });
+
+            if let Some(additional_time) = additional_time {
+                let recent_editor = self.recent_editors.back_mut().unwrap();
+                if navigated {
+                    recent_editor.cumulative_time_navigating += additional_time;
+                }
+                if edited {
+                    recent_editor.cumulative_time_editing += additional_time;
+                }
+            }
+        }
+    }
+
     fn recent_files(
         &mut self,
         now: &Instant,
@@ -1330,7 +1467,10 @@ and then another
                 .editor
                 .update(cx, |editor, cx| {
                     maybe!({
-                        let (buffer, cursor_point, _) = editor.cursor_buffer_point(cx)?;
+                        let cursor = editor.selections.newest::<MultiBufferPoint>(cx).head();
+                        let multibuffer = editor.buffer().read(cx);
+                        let (buffer, cursor_point, _) =
+                            multibuffer.point_to_buffer_point(cursor, cx)?;
                         let file = buffer.read(cx).file()?;
                         if !file_is_eligible_for_collection(file.as_ref()) {
                             return None;
@@ -1359,10 +1499,24 @@ and then another
                             .as_millis()
                             .try_into()
                             .ok()?;
+                        let cumulative_time_editing_ms = recent_editor
+                            .cumulative_time_editing
+                            .as_millis()
+                            .try_into()
+                            .ok()?;
+                        let cumulative_time_navigating_ms = recent_editor
+                            .cumulative_time_navigating
+                            .as_millis()
+                            .try_into()
+                            .ok()?;
                         results.push(PredictEditsRecentFile {
                             path: repo_path_str.to_string(),
                             cursor_point: to_cloud_llm_client_point(cursor_point),
                             active_to_now_ms,
+                            activation_count: recent_editor.activation_count,
+                            cumulative_time_editing_ms,
+                            cumulative_time_navigating_ms,
+                            is_multibuffer: !multibuffer.is_singleton(),
                         });
                         Some(())
                     })