assistant: Add debug inspector (#16105)

Piotr Osiewicz created

I went with inline approach directly within the panel.
First, enable workflow debugging in the hamburger menu (this works
retroactively as well):


![image](https://github.com/user-attachments/assets/d2ab8edf-bb7b-49a4-8f70-9a6fe94dc7dd)

This enables debug buttons in the header of each step:

![image](https://github.com/user-attachments/assets/3b5d479f-7473-4c41-a2e7-8c10bb71f0ff)
Enabling one pretty-prints the workflow step internals:

![image](https://github.com/user-attachments/assets/e651e826-1270-49ff-8bb6-046c07c006bf)


Release Notes:

- N/A

Change summary

assets/icons/microscope.svg                     |   1 
crates/assistant/src/assistant.rs               |   1 
crates/assistant/src/assistant_panel.rs         | 117 +++++++++
crates/assistant/src/context_inspector.rs       | 223 +++++++++++++++++++
crates/quick_action_bar/src/quick_action_bar.rs |   4 
crates/ui/src/components/context_menu.rs        |  25 +
crates/ui/src/components/icon.rs                |   2 
7 files changed, 354 insertions(+), 19 deletions(-)

Detailed changes

assets/icons/microscope.svg 🔗

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-microscope"><path d="M6 18h8"/><path d="M3 22h18"/><path d="M14 22a7 7 0 1 0 0-14h-1"/><path d="M9 14h2"/><path d="M9 12a2 2 0 0 1-2-2V6h6v4a2 2 0 0 1-2 2Z"/><path d="M12 6V3a1 1 0 0 0-1-1H9a1 1 0 0 0-1 1v3"/></svg>

crates/assistant/src/assistant.rs 🔗

@@ -3,6 +3,7 @@
 pub mod assistant_panel;
 pub mod assistant_settings;
 mod context;
+pub(crate) mod context_inspector;
 pub mod context_store;
 mod inline_assistant;
 mod model_selector;

crates/assistant/src/assistant_panel.rs 🔗

@@ -1,5 +1,6 @@
 use crate::{
     assistant_settings::{AssistantDockPosition, AssistantSettings},
+    context_inspector::ContextInspector,
     humanize_token_count,
     prompt_library::open_prompt_library,
     prompts::PromptBuilder,
@@ -402,13 +403,56 @@ impl AssistantPanel {
                                 } else {
                                     "Zoom In"
                                 };
+                                let weak_pane = cx.view().downgrade();
                                 let menu = ContextMenu::build(cx, |menu, cx| {
-                                    menu.context(pane.focus_handle(cx))
+                                    let menu = menu
+                                        .context(pane.focus_handle(cx))
                                         .action("New Context", Box::new(NewFile))
                                         .action("History", Box::new(DeployHistory))
                                         .action("Prompt Library", Box::new(DeployPromptLibrary))
                                         .action("Configure", Box::new(ShowConfiguration))
-                                        .action(zoom_label, Box::new(ToggleZoom))
+                                        .action(zoom_label, Box::new(ToggleZoom));
+
+                                    if let Some(editor) = pane
+                                        .active_item()
+                                        .and_then(|e| e.downcast::<ContextEditor>())
+                                    {
+                                        let is_enabled = editor.read(cx).debug_inspector.is_some();
+                                        menu.separator().toggleable_entry(
+                                            "Debug Workflows",
+                                            is_enabled,
+                                            IconPosition::End,
+                                            None,
+                                            move |cx| {
+                                                weak_pane
+                                                    .update(cx, |this, cx| {
+                                                        if let Some(context_editor) =
+                                                            this.active_item().and_then(|item| {
+                                                                item.downcast::<ContextEditor>()
+                                                            })
+                                                        {
+                                                            context_editor.update(cx, |this, cx| {
+                                                                if let Some(mut state) =
+                                                                    this.debug_inspector.take()
+                                                                {
+                                                                    state.deactivate(cx);
+                                                                } else {
+                                                                    this.debug_inspector = Some(
+                                                                        ContextInspector::new(
+                                                                            this.editor.clone(),
+                                                                            this.context.clone(),
+                                                                        ),
+                                                                    );
+                                                                }
+                                                            })
+                                                        }
+                                                    })
+                                                    .ok();
+                                            },
+                                        )
+                                    } else {
+                                        menu
+                                    }
                                 });
                                 cx.subscribe(&menu, |pane, _, _: &DismissEvent, _| {
                                     pane.new_item_menu = None;
@@ -1667,6 +1711,7 @@ pub struct ContextEditor {
     active_workflow_step: Option<ActiveWorkflowStep>,
     assistant_panel: WeakView<AssistantPanel>,
     error_message: Option<SharedString>,
+    debug_inspector: Option<ContextInspector>,
 }
 
 const DEFAULT_TAB_TITLE: &str = "New Context";
@@ -1726,6 +1771,7 @@ impl ContextEditor {
             active_workflow_step: None,
             assistant_panel,
             error_message: None,
+            debug_inspector: None,
         };
         this.update_message_headers(cx);
         this.insert_slash_command_output_sections(sections, cx);
@@ -2389,6 +2435,9 @@ impl ContextEditor {
                 blocks_to_remove.insert(step.header_block_id);
                 blocks_to_remove.insert(step.footer_block_id);
             }
+            if let Some(debug) = self.debug_inspector.as_mut() {
+                debug.deactivate_for(step_range, cx);
+            }
         }
         self.editor.update(cx, |editor, cx| {
             editor.remove_blocks(blocks_to_remove, None, cx)
@@ -2415,6 +2464,9 @@ impl ContextEditor {
         let resolved_step = step.status.into_resolved();
         if let Some(existing_step) = self.workflow_steps.get_mut(&step_range) {
             existing_step.resolved_step = resolved_step;
+            if let Some(debug) = self.debug_inspector.as_mut() {
+                debug.refresh(&step_range, cx);
+            }
         } else {
             let start = buffer_snapshot
                 .anchor_in_excerpt(excerpt_id, step_range.start)
@@ -2454,7 +2506,15 @@ impl ContextEditor {
                                     } else {
                                         theme.info_border
                                     };
-
+                                    let debug_header = weak_self
+                                        .update(&mut **cx, |this, _| {
+                                            if let Some(inspector) = this.debug_inspector.as_mut() {
+                                                Some(inspector.is_active(&step_range))
+                                            } else {
+                                                None
+                                            }
+                                        })
+                                        .unwrap_or_default();
                                     div()
                                         .w_full()
                                         .px(cx.gutter_dimensions.full_width())
@@ -2464,14 +2524,52 @@ impl ContextEditor {
                                                 .border_b_1()
                                                 .border_color(border_color)
                                                 .pb_1()
-                                                .justify_end()
+                                                .justify_between()
                                                 .gap_2()
+                                                .children(debug_header.map(|is_active| {
+                                                    h_flex().justify_start().child(
+                                                        Button::new("debug-workflows-toggle", "Debug")
+                                                            .icon_color(Color::Hidden)
+                                                            .color(Color::Hidden)
+                                                            .selected_icon_color(Color::Default)
+                                                            .selected_label_color(Color::Default)
+                                                            .icon(IconName::Microscope)
+                                                            .icon_position(IconPosition::Start)
+                                                            .icon_size(IconSize::Small)
+                                                            .label_size(LabelSize::Small)
+                                                            .selected(is_active)
+                                                            .on_click({
+                                                                let weak_self = weak_self.clone();
+                                                                let step_range = step_range.clone();
+                                                                move |_, cx| {
+                                                                    weak_self
+                                                                        .update(cx, |this, cx| {
+                                                                            if let Some(inspector) =
+                                                                                this.debug_inspector
+                                                                                    .as_mut()
+                                                                            {
+                                                                                if is_active {
+
+                                                                                    inspector.deactivate_for(&step_range, cx);
+                                                                                } else {
+                                                                                    inspector.activate_for_step(step_range.clone(), cx);
+                                                                                }
+                                                                            }
+                                                                        })
+                                                                        .ok();
+                                                                }
+                                                            }),
+                                                    )
+                                                    // .child(h_flex().w_full())
+                                                }))
                                                 .children(current_status.as_ref().map(|status| {
-                                                    status.into_element(
-                                                        step_range.clone(),
-                                                        editor_focus_handle.clone(),
-                                                        weak_self.clone(),
-                                                        cx,
+                                                    h_flex().w_full().justify_end().child(
+                                                        status.into_element(
+                                                            step_range.clone(),
+                                                            editor_focus_handle.clone(),
+                                                            weak_self.clone(),
+                                                            cx,
+                                                        ),
                                                     )
                                                 })),
                                         )
@@ -3787,7 +3885,6 @@ impl Render for ContextEditorToolbarItem {
                     )
                     .child(self.model_summary_editor.clone())
             });
-
         let active_provider = LanguageModelRegistry::read_global(cx).active_provider();
         let active_model = LanguageModelRegistry::read_global(cx).active_model();
 

crates/assistant/src/context_inspector.rs 🔗

@@ -0,0 +1,223 @@
+use std::{ops::Range, sync::Arc};
+
+use collections::{HashMap, HashSet};
+use editor::{
+    display_map::{BlockDisposition, BlockProperties, BlockStyle, CustomBlockId},
+    Editor,
+};
+use gpui::{AppContext, Model, View};
+use text::{ToOffset, ToPoint};
+use ui::{
+    div, h_flex, Color, Element as _, ParentElement as _, Styled, ViewContext, WindowContext,
+};
+
+use crate::{Context, ResolvedWorkflowStep, WorkflowSuggestion};
+
+type StepRange = Range<language::Anchor>;
+
+struct DebugInfo {
+    range: Range<editor::Anchor>,
+    block_id: CustomBlockId,
+}
+
+pub(crate) struct ContextInspector {
+    active_debug_views: HashMap<Range<language::Anchor>, DebugInfo>,
+    context: Model<Context>,
+    editor: View<Editor>,
+}
+
+impl ContextInspector {
+    pub(crate) fn new(editor: View<Editor>, context: Model<Context>) -> Self {
+        Self {
+            editor,
+            context,
+            active_debug_views: Default::default(),
+        }
+    }
+
+    pub(crate) fn is_active(&self, range: &StepRange) -> bool {
+        self.active_debug_views.contains_key(range)
+    }
+
+    pub(crate) fn refresh(&mut self, range: &StepRange, cx: &mut WindowContext<'_>) {
+        if self.deactivate_for(range, cx) {
+            self.activate_for_step(range.clone(), cx);
+        }
+    }
+    fn crease_content(
+        context: &Model<Context>,
+        range: StepRange,
+        cx: &mut AppContext,
+    ) -> Option<Arc<str>> {
+        use std::fmt::Write;
+        let step = context.read(cx).workflow_step_for_range(range)?;
+        let mut output = String::from("\n\n");
+        match &step.status {
+            crate::WorkflowStepStatus::Resolved(ResolvedWorkflowStep { title, suggestions }) => {
+                writeln!(output, "Resolution:").ok()?;
+                writeln!(output, "  {title:?}").ok()?;
+                for (buffer, suggestion_groups) in suggestions {
+                    let buffer = buffer.read(cx);
+                    let buffer_path = buffer
+                        .file()
+                        .and_then(|file| file.path().to_str())
+                        .unwrap_or("untitled");
+                    let snapshot = buffer.text_snapshot();
+                    writeln!(output, "  {buffer_path}:").ok()?;
+                    for group in suggestion_groups {
+                        for suggestion in &group.suggestions {
+                            pretty_print_workflow_suggestion(&mut output, suggestion, &snapshot);
+                        }
+                    }
+                }
+            }
+            crate::WorkflowStepStatus::Pending(_) => {
+                writeln!(output, "Resolution: Pending").ok()?;
+            }
+            crate::WorkflowStepStatus::Error(error) => {
+                writeln!(output, "Resolution: Error").ok()?;
+                writeln!(output, "{error:?}").ok()?;
+            }
+        }
+
+        Some(output.into())
+    }
+    pub(crate) fn activate_for_step(&mut self, range: StepRange, cx: &mut WindowContext<'_>) {
+        let text = Self::crease_content(&self.context, range.clone(), cx)
+            .unwrap_or_else(|| Arc::from("Error fetching debug info"));
+        self.editor.update(cx, |editor, cx| {
+            let buffer = editor.buffer().read(cx).as_singleton()?;
+
+            let text_len = text.len();
+            let snapshot = buffer.update(cx, |this, cx| {
+                this.edit([(range.end..range.end, text)], None, cx);
+                this.text_snapshot()
+            });
+            let start_offset = range.end.to_offset(&snapshot);
+            let end_offset = start_offset + text_len;
+            let multibuffer_snapshot = editor.buffer().read(cx).snapshot(cx);
+            let anchor_before = multibuffer_snapshot.anchor_after(start_offset);
+            let anchor_after = multibuffer_snapshot.anchor_before(end_offset);
+
+            let block_id = editor
+                .insert_blocks(
+                    [BlockProperties {
+                        position: anchor_after,
+                        height: 0,
+                        style: BlockStyle::Sticky,
+                        render: Box::new(move |cx| {
+                            div()
+                                .w_full()
+                                .px(cx.gutter_dimensions.full_width())
+                                .child(
+                                    h_flex()
+                                        .w_full()
+                                        .border_t_1()
+                                        .border_color(Color::Warning.color(cx)),
+                                )
+                                .into_any()
+                        }),
+                        disposition: BlockDisposition::Below,
+                        priority: 0,
+                    }],
+                    None,
+                    cx,
+                )
+                .into_iter()
+                .next()?;
+            let info = DebugInfo {
+                range: anchor_before..anchor_after,
+                block_id,
+            };
+            self.active_debug_views.insert(range, info);
+            Some(())
+        });
+    }
+
+    fn deactivate_impl(editor: &mut Editor, debug_data: DebugInfo, cx: &mut ViewContext<Editor>) {
+        editor.remove_blocks(HashSet::from_iter([debug_data.block_id]), None, cx);
+        editor.edit([(debug_data.range, Arc::<str>::default())], cx)
+    }
+    pub(crate) fn deactivate_for(&mut self, range: &StepRange, cx: &mut WindowContext<'_>) -> bool {
+        if let Some(debug_data) = self.active_debug_views.remove(range) {
+            self.editor.update(cx, |this, cx| {
+                Self::deactivate_impl(this, debug_data, cx);
+            });
+            true
+        } else {
+            false
+        }
+    }
+
+    pub(crate) fn deactivate(&mut self, cx: &mut WindowContext<'_>) {
+        let steps_to_disable = std::mem::take(&mut self.active_debug_views);
+
+        self.editor.update(cx, move |editor, cx| {
+            for (_, debug_data) in steps_to_disable {
+                Self::deactivate_impl(editor, debug_data, cx);
+            }
+        });
+    }
+}
+fn pretty_print_anchor(
+    out: &mut String,
+    anchor: &language::Anchor,
+    snapshot: &text::BufferSnapshot,
+) {
+    use std::fmt::Write;
+    let point = anchor.to_point(snapshot);
+    write!(out, "{}:{}", point.row, point.column).ok();
+}
+fn pretty_print_range(
+    out: &mut String,
+    range: &Range<language::Anchor>,
+    snapshot: &text::BufferSnapshot,
+) {
+    use std::fmt::Write;
+    write!(out, "    Range: ").ok();
+    pretty_print_anchor(out, &range.start, snapshot);
+    write!(out, "..").ok();
+    pretty_print_anchor(out, &range.end, snapshot);
+}
+
+fn pretty_print_workflow_suggestion(
+    out: &mut String,
+    suggestion: &WorkflowSuggestion,
+    snapshot: &text::BufferSnapshot,
+) {
+    use std::fmt::Write;
+    let (range, description, position) = match suggestion {
+        WorkflowSuggestion::Update { range, description } => (Some(range), Some(description), None),
+        WorkflowSuggestion::CreateFile { description } => (None, Some(description), None),
+        WorkflowSuggestion::AppendChild {
+            position,
+            description,
+        }
+        | WorkflowSuggestion::InsertSiblingBefore {
+            position,
+            description,
+        }
+        | WorkflowSuggestion::InsertSiblingAfter {
+            position,
+            description,
+        }
+        | WorkflowSuggestion::PrependChild {
+            position,
+            description,
+        } => (None, Some(description), Some(position)),
+
+        WorkflowSuggestion::Delete { range } => (Some(range), None, None),
+    };
+    if let Some(description) = description {
+        writeln!(out, "    Description: {description}").ok();
+    }
+    if let Some(range) = range {
+        pretty_print_range(out, range, snapshot);
+    }
+    if let Some(position) = position {
+        write!(out, "    Position: ").ok();
+        pretty_print_anchor(out, position, snapshot);
+        write!(out, "\n").ok();
+    }
+    write!(out, "\n").ok();
+}

crates/quick_action_bar/src/quick_action_bar.rs 🔗

@@ -220,6 +220,7 @@ impl Render for QuickActionBar {
                                 menu = menu.toggleable_entry(
                                     "Inlay Hints",
                                     inlay_hints_enabled,
+                                    IconPosition::Start,
                                     Some(editor::actions::ToggleInlayHints.boxed_clone()),
                                     {
                                         let editor = editor.clone();
@@ -238,6 +239,7 @@ impl Render for QuickActionBar {
                             menu = menu.toggleable_entry(
                                 "Inline Git Blame",
                                 git_blame_inline_enabled,
+                                IconPosition::Start,
                                 Some(editor::actions::ToggleGitBlameInline.boxed_clone()),
                                 {
                                     let editor = editor.clone();
@@ -255,6 +257,7 @@ impl Render for QuickActionBar {
                             menu = menu.toggleable_entry(
                                 "Selection Menu",
                                 selection_menu_enabled,
+                                IconPosition::Start,
                                 Some(editor::actions::ToggleSelectionMenu.boxed_clone()),
                                 {
                                     let editor = editor.clone();
@@ -272,6 +275,7 @@ impl Render for QuickActionBar {
                             menu = menu.toggleable_entry(
                                 "Auto Signature Help",
                                 auto_signature_help_enabled,
+                                IconPosition::Start,
                                 Some(editor::actions::ToggleAutoSignatureHelp.boxed_clone()),
                                 {
                                     let editor = editor.clone();

crates/ui/src/components/context_menu.rs 🔗

@@ -16,7 +16,7 @@ enum ContextMenuItem {
     Header(SharedString),
     Label(SharedString),
     Entry {
-        toggled: Option<bool>,
+        toggle: Option<(IconPosition, bool)>,
         label: SharedString,
         icon: Option<IconName>,
         handler: Rc<dyn Fn(Option<&FocusHandle>, &mut WindowContext)>,
@@ -97,7 +97,7 @@ impl ContextMenu {
         handler: impl Fn(&mut WindowContext) + 'static,
     ) -> Self {
         self.items.push(ContextMenuItem::Entry {
-            toggled: None,
+            toggle: None,
             label: label.into(),
             handler: Rc::new(move |_, cx| handler(cx)),
             icon: None,
@@ -110,11 +110,12 @@ impl ContextMenu {
         mut self,
         label: impl Into<SharedString>,
         toggled: bool,
+        position: IconPosition,
         action: Option<Box<dyn Action>>,
         handler: impl Fn(&mut WindowContext) + 'static,
     ) -> Self {
         self.items.push(ContextMenuItem::Entry {
-            toggled: Some(toggled),
+            toggle: Some((position, toggled)),
             label: label.into(),
             handler: Rc::new(move |_, cx| handler(cx)),
             icon: None,
@@ -155,7 +156,7 @@ impl ContextMenu {
 
     pub fn action(mut self, label: impl Into<SharedString>, action: Box<dyn Action>) -> Self {
         self.items.push(ContextMenuItem::Entry {
-            toggled: None,
+            toggle: None,
             label: label.into(),
             action: Some(action.boxed_clone()),
 
@@ -172,7 +173,7 @@ impl ContextMenu {
 
     pub fn link(mut self, label: impl Into<SharedString>, action: Box<dyn Action>) -> Self {
         self.items.push(ContextMenuItem::Entry {
-            toggled: None,
+            toggle: None,
             label: label.into(),
 
             action: Some(action.boxed_clone()),
@@ -354,7 +355,7 @@ impl Render for ContextMenu {
                                     .child(Label::new(label.clone()))
                                     .into_any_element(),
                                 ContextMenuItem::Entry {
-                                    toggled,
+                                    toggle,
                                     label,
                                     handler,
                                     icon,
@@ -376,8 +377,8 @@ impl Render for ContextMenu {
                                     ListItem::new(ix)
                                         .inset(true)
                                         .selected(Some(ix) == self.selected_index)
-                                        .when_some(*toggled, |list_item, toggled| {
-                                            list_item.start_slot(if toggled {
+                                        .when_some(*toggle, |list_item, (position, toggled)| {
+                                            let contents = if toggled {
                                                 v_flex().flex_none().child(
                                                     Icon::new(IconName::Check).color(Color::Accent),
                                                 )
@@ -385,7 +386,13 @@ impl Render for ContextMenu {
                                                 v_flex()
                                                     .flex_none()
                                                     .size(IconSize::default().rems())
-                                            })
+                                            };
+                                            match position {
+                                                IconPosition::Start => {
+                                                    list_item.start_slot(contents)
+                                                }
+                                                IconPosition::End => list_item.end_slot(contents),
+                                            }
                                         })
                                         .child(
                                             h_flex()

crates/ui/src/components/icon.rs 🔗

@@ -203,6 +203,7 @@ pub enum IconName {
     MessageBubbles,
     Mic,
     MicMute,
+    Microscope,
     Minimize,
     Option,
     PageDown,
@@ -366,6 +367,7 @@ impl IconName {
             IconName::MessageBubbles => "icons/conversations.svg",
             IconName::Mic => "icons/mic.svg",
             IconName::MicMute => "icons/mic_mute.svg",
+            IconName::Microscope => "icons/microscope.svg",
             IconName::Minimize => "icons/minimize.svg",
             IconName::Option => "icons/option.svg",
             IconName::PageDown => "icons/page_down.svg",