agent_ui: Truncate file path in context completions (#42682)

Scott Churchley created

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

Followed some prior art here: [a similar calculation
](https://github.com/zed-industries/zed/blob/e80b490ac00bfb5c206d3eb3f5c2dec10e742678/crates/file_finder/src/file_finder.rs#L1105)
in `file_finder.rs`

Release Notes:

- improved visibility of long path names in context completions by
truncating on the left when space is insufficient to render the full
path

Before:
<img width="544" height="251" alt="Screenshot of overflowing file paths"
src="https://github.com/user-attachments/assets/8440710d-3f59-4273-92bb-3db85022b57c"
/>

After: 
<img width="544" height="251" alt="Screenshot of truncated file paths"
src="https://github.com/user-attachments/assets/e268ba87-7979-4bc3-8d08-9198b4baea02"
/>

Change summary

crates/agent_ui/src/completion_provider.rs | 55 +++++++++++++++++++++--
crates/editor/src/code_context_menus.rs    | 45 ++++++++++--------
2 files changed, 75 insertions(+), 25 deletions(-)

Detailed changes

crates/agent_ui/src/completion_provider.rs 🔗

@@ -7,7 +7,9 @@ use std::sync::atomic::AtomicBool;
 use acp_thread::MentionUri;
 use agent::{HistoryEntry, HistoryStore};
 use anyhow::Result;
-use editor::{CompletionProvider, Editor, ExcerptId};
+use editor::{
+    CompletionProvider, Editor, ExcerptId, code_context_menus::COMPLETION_MENU_MAX_WIDTH,
+};
 use fuzzy::{PathMatch, StringMatch, StringMatchCandidate};
 use gpui::{App, Entity, Task, WeakEntity};
 use language::{Buffer, CodeLabel, CodeLabelBuilder, HighlightId};
@@ -25,6 +27,7 @@ use ui::prelude::*;
 use util::ResultExt as _;
 use util::paths::PathStyle;
 use util::rel_path::RelPath;
+use util::truncate_and_remove_front;
 use workspace::Workspace;
 
 use crate::AgentPanel;
@@ -336,14 +339,20 @@ impl<T: PromptCompletionProviderDelegate> PromptCompletionProvider<T> {
         mention_set: WeakEntity<MentionSet>,
         workspace: Entity<Workspace>,
         project: Entity<Project>,
+        label_max_chars: usize,
         cx: &mut App,
     ) -> Option<Completion> {
         let path_style = project.read(cx).path_style(cx);
         let (file_name, directory) =
             extract_file_name_and_directory(&project_path.path, path_prefix, path_style);
 
-        let label =
-            build_code_label_for_path(&file_name, directory.as_ref().map(|s| s.as_ref()), None, cx);
+        let label = build_code_label_for_path(
+            &file_name,
+            directory.as_ref().map(|s| s.as_ref()),
+            None,
+            label_max_chars,
+            cx,
+        );
 
         let abs_path = project.read(cx).absolute_path(&project_path, cx)?;
 
@@ -392,6 +401,7 @@ impl<T: PromptCompletionProviderDelegate> PromptCompletionProvider<T> {
         editor: WeakEntity<Editor>,
         mention_set: WeakEntity<MentionSet>,
         workspace: Entity<Workspace>,
+        label_max_chars: usize,
         cx: &mut App,
     ) -> Option<Completion> {
         let project = workspace.read(cx).project().clone();
@@ -414,6 +424,7 @@ impl<T: PromptCompletionProviderDelegate> PromptCompletionProvider<T> {
             &symbol.name,
             Some(&file_name),
             Some(symbol.range.start.0.row + 1),
+            label_max_chars,
             cx,
         );
 
@@ -852,7 +863,7 @@ impl<T: PromptCompletionProviderDelegate> CompletionProvider for PromptCompletio
         buffer: &Entity<Buffer>,
         buffer_position: Anchor,
         _trigger: CompletionContext,
-        _window: &mut Window,
+        window: &mut Window,
         cx: &mut Context<Editor>,
     ) -> Task<Result<Vec<CompletionResponse>>> {
         let state = buffer.update(cx, |buffer, cx| {
@@ -948,6 +959,31 @@ impl<T: PromptCompletionProviderDelegate> CompletionProvider for PromptCompletio
                 let search_task =
                     self.search_mentions(mode, query, Arc::<AtomicBool>::default(), cx);
 
+                // Calculate maximum characters available for the full label (file_name + space + directory)
+                // based on maximum menu width after accounting for padding, spacing, and icon width
+                let label_max_chars = {
+                    // Base06 left padding + Base06 gap + Base06 right padding + icon width
+                    let used_pixels = DynamicSpacing::Base06.px(cx) * 3.0
+                        + IconSize::XSmall.rems() * window.rem_size();
+
+                    let style = window.text_style();
+                    let font_id = window.text_system().resolve_font(&style.font());
+                    let font_size = TextSize::Small.rems(cx).to_pixels(window.rem_size());
+
+                    // Fallback em_width of 10px matches file_finder.rs fallback for TextSize::Small
+                    let em_width = cx
+                        .text_system()
+                        .em_width(font_id, font_size)
+                        .unwrap_or(px(10.0));
+
+                    // Calculate available pixels for text (file_name + directory)
+                    // Using max width since dynamic_width allows the menu to expand up to this
+                    let available_pixels = COMPLETION_MENU_MAX_WIDTH - used_pixels;
+
+                    // Convert to character count (total available for file_name + directory)
+                    (f32::from(available_pixels) / f32::from(em_width)) as usize
+                };
+
                 cx.spawn(async move |_, cx| {
                     let matches = search_task.await;
 
@@ -984,6 +1020,7 @@ impl<T: PromptCompletionProviderDelegate> CompletionProvider for PromptCompletio
                                         mention_set.clone(),
                                         workspace.clone(),
                                         project.clone(),
+                                        label_max_chars,
                                         cx,
                                     )
                                 }
@@ -996,6 +1033,7 @@ impl<T: PromptCompletionProviderDelegate> CompletionProvider for PromptCompletio
                                         editor.clone(),
                                         mention_set.clone(),
                                         workspace.clone(),
+                                        label_max_chars,
                                         cx,
                                     )
                                 }
@@ -1595,6 +1633,7 @@ fn build_code_label_for_path(
     file: &str,
     directory: Option<&str>,
     line_number: Option<u32>,
+    label_max_chars: usize,
     cx: &App,
 ) -> CodeLabel {
     let variable_highlight_id = cx
@@ -1608,7 +1647,13 @@ fn build_code_label_for_path(
     label.push_str(" ", None);
 
     if let Some(directory) = directory {
-        label.push_str(directory, variable_highlight_id);
+        let file_name_chars = file.chars().count();
+        // Account for: file_name + space (ellipsis is handled by truncate_and_remove_front)
+        let directory_max_chars = label_max_chars
+            .saturating_sub(file_name_chars)
+            .saturating_sub(1);
+        let truncated_directory = truncate_and_remove_front(directory, directory_max_chars.max(5));
+        label.push_str(&truncated_directory, variable_highlight_id);
     }
     if let Some(line_number) = line_number {
         label.push_str(&format!(" L{}", line_number), variable_highlight_id);

crates/editor/src/code_context_menus.rs 🔗

@@ -49,6 +49,8 @@ pub const MENU_GAP: Pixels = px(4.);
 pub const MENU_ASIDE_X_PADDING: Pixels = px(16.);
 pub const MENU_ASIDE_MIN_WIDTH: Pixels = px(260.);
 pub const MENU_ASIDE_MAX_WIDTH: Pixels = px(500.);
+pub const COMPLETION_MENU_MIN_WIDTH: Pixels = px(280.);
+pub const COMPLETION_MENU_MAX_WIDTH: Pixels = px(540.);
 
 // Constants for the markdown cache. The purpose of this cache is to reduce flickering due to
 // documentation not yet being parsed.
@@ -907,26 +909,29 @@ impl CompletionsMenu {
                                 })
                             });
 
-                        div().min_w(px(280.)).max_w(px(540.)).child(
-                            ListItem::new(mat.candidate_id)
-                                .inset(true)
-                                .toggle_state(item_ix == selected_item)
-                                .on_click(cx.listener(move |editor, _event, window, cx| {
-                                    cx.stop_propagation();
-                                    if let Some(task) = editor.confirm_completion(
-                                        &ConfirmCompletion {
-                                            item_ix: Some(item_ix),
-                                        },
-                                        window,
-                                        cx,
-                                    ) {
-                                        task.detach_and_log_err(cx)
-                                    }
-                                }))
-                                .start_slot::<AnyElement>(start_slot)
-                                .child(h_flex().overflow_hidden().child(completion_label))
-                                .end_slot::<Label>(documentation_label),
-                        )
+                        div()
+                            .min_w(COMPLETION_MENU_MIN_WIDTH)
+                            .max_w(COMPLETION_MENU_MAX_WIDTH)
+                            .child(
+                                ListItem::new(mat.candidate_id)
+                                    .inset(true)
+                                    .toggle_state(item_ix == selected_item)
+                                    .on_click(cx.listener(move |editor, _event, window, cx| {
+                                        cx.stop_propagation();
+                                        if let Some(task) = editor.confirm_completion(
+                                            &ConfirmCompletion {
+                                                item_ix: Some(item_ix),
+                                            },
+                                            window,
+                                            cx,
+                                        ) {
+                                            task.detach_and_log_err(cx)
+                                        }
+                                    }))
+                                    .start_slot::<AnyElement>(start_slot)
+                                    .child(h_flex().overflow_hidden().child(completion_label))
+                                    .end_slot::<Label>(documentation_label),
+                            )
                     })
                     .collect()
             }),