log.rs

  1use std::path::Path;
  2
  3use collections::HashSet;
  4use feature_flags::FeatureFlagAppExt;
  5use gpui::{
  6    App, Empty, Entity, EventEmitter, FocusHandle, Focusable, Global, ListAlignment, ListState,
  7    SharedString, Subscription, Window, actions, list, prelude::*,
  8};
  9use release_channel::ReleaseChannel;
 10use settings::Settings;
 11use ui::prelude::*;
 12use workspace::{Item, Workspace, WorkspaceId, item::ItemEvent};
 13
 14use super::edit_action::EditAction;
 15
 16actions!(debug, [EditTool]);
 17
 18pub fn init(cx: &mut App) {
 19    if cx.is_staff() || ReleaseChannel::global(cx) == ReleaseChannel::Dev {
 20        // Track events even before opening the log
 21        EditToolLog::global(cx);
 22    }
 23
 24    cx.observe_new(|workspace: &mut Workspace, _, _| {
 25        workspace.register_action(|workspace, _: &EditTool, window, cx| {
 26            let viewer = cx.new(EditToolLogViewer::new);
 27            workspace.add_item_to_active_pane(Box::new(viewer), None, true, window, cx)
 28        });
 29    })
 30    .detach();
 31}
 32
 33pub struct GlobalEditToolLog(Entity<EditToolLog>);
 34
 35impl Global for GlobalEditToolLog {}
 36
 37#[derive(Default)]
 38pub struct EditToolLog {
 39    requests: Vec<EditToolRequest>,
 40}
 41
 42#[derive(Clone, Copy, Hash, Eq, PartialEq)]
 43pub struct EditToolRequestId(u32);
 44
 45impl EditToolLog {
 46    pub fn global(cx: &mut App) -> Entity<Self> {
 47        match Self::try_global(cx) {
 48            Some(entity) => entity,
 49            None => {
 50                let entity = cx.new(|_cx| Self::default());
 51                cx.set_global(GlobalEditToolLog(entity.clone()));
 52                entity
 53            }
 54        }
 55    }
 56
 57    pub fn try_global(cx: &App) -> Option<Entity<Self>> {
 58        cx.try_global::<GlobalEditToolLog>()
 59            .map(|log| log.0.clone())
 60    }
 61
 62    pub fn new_request(
 63        &mut self,
 64        instructions: String,
 65        cx: &mut Context<Self>,
 66    ) -> EditToolRequestId {
 67        let id = EditToolRequestId(self.requests.len() as u32);
 68        self.requests.push(EditToolRequest {
 69            id,
 70            instructions,
 71            editor_response: None,
 72            tool_output: None,
 73            parsed_edits: Vec::new(),
 74        });
 75        cx.emit(EditToolLogEvent::Inserted);
 76        id
 77    }
 78
 79    pub fn push_editor_response_chunk(
 80        &mut self,
 81        id: EditToolRequestId,
 82        chunk: &str,
 83        new_actions: &[(EditAction, String)],
 84        cx: &mut Context<Self>,
 85    ) {
 86        if let Some(request) = self.requests.get_mut(id.0 as usize) {
 87            match &mut request.editor_response {
 88                None => {
 89                    request.editor_response = Some(chunk.to_string());
 90                }
 91                Some(response) => {
 92                    response.push_str(chunk);
 93                }
 94            }
 95            request
 96                .parsed_edits
 97                .extend(new_actions.iter().cloned().map(|(action, _)| action));
 98
 99            cx.emit(EditToolLogEvent::Updated);
100        }
101    }
102
103    pub fn set_tool_output(
104        &mut self,
105        id: EditToolRequestId,
106        tool_output: Result<String, String>,
107        cx: &mut Context<Self>,
108    ) {
109        if let Some(request) = self.requests.get_mut(id.0 as usize) {
110            request.tool_output = Some(tool_output);
111            cx.emit(EditToolLogEvent::Updated);
112        }
113    }
114}
115
116enum EditToolLogEvent {
117    Inserted,
118    Updated,
119}
120
121impl EventEmitter<EditToolLogEvent> for EditToolLog {}
122
123pub struct EditToolRequest {
124    id: EditToolRequestId,
125    instructions: String,
126    // we don't use a result here because the error might have occurred after we got a response
127    editor_response: Option<String>,
128    parsed_edits: Vec<EditAction>,
129    tool_output: Option<Result<String, String>>,
130}
131
132pub struct EditToolLogViewer {
133    focus_handle: FocusHandle,
134    log: Entity<EditToolLog>,
135    list_state: ListState,
136    expanded_edits: HashSet<(EditToolRequestId, usize)>,
137    _subscription: Subscription,
138}
139
140impl EditToolLogViewer {
141    pub fn new(cx: &mut Context<Self>) -> Self {
142        let log = EditToolLog::global(cx);
143
144        let subscription = cx.subscribe(&log, Self::handle_log_event);
145
146        Self {
147            focus_handle: cx.focus_handle(),
148            log: log.clone(),
149            list_state: ListState::new(
150                log.read(cx).requests.len(),
151                ListAlignment::Bottom,
152                px(1024.),
153                {
154                    let this = cx.entity().downgrade();
155                    move |ix, window: &mut Window, cx: &mut App| {
156                        this.update(cx, |this, cx| this.render_request(ix, window, cx))
157                            .unwrap()
158                    }
159                },
160            ),
161            expanded_edits: HashSet::default(),
162            _subscription: subscription,
163        }
164    }
165
166    fn handle_log_event(
167        &mut self,
168        _: Entity<EditToolLog>,
169        event: &EditToolLogEvent,
170        cx: &mut Context<Self>,
171    ) {
172        match event {
173            EditToolLogEvent::Inserted => {
174                let count = self.list_state.item_count();
175                self.list_state.splice(count..count, 1);
176            }
177            EditToolLogEvent::Updated => {}
178        }
179
180        cx.notify();
181    }
182
183    fn render_request(
184        &self,
185        index: usize,
186        _window: &mut Window,
187        cx: &mut Context<Self>,
188    ) -> AnyElement {
189        let requests = &self.log.read(cx).requests;
190        let request = &requests[index];
191
192        v_flex()
193            .gap_3()
194            .child(Self::render_section(IconName::ArrowRight, "Tool Input"))
195            .child(request.instructions.clone())
196            .py_5()
197            .when(index + 1 < requests.len(), |element| {
198                element
199                    .border_b_1()
200                    .border_color(cx.theme().colors().border)
201            })
202            .map(|parent| match &request.editor_response {
203                None => {
204                    if request.tool_output.is_none() {
205                        parent.child("...")
206                    } else {
207                        parent
208                    }
209                }
210                Some(response) => parent
211                    .child(Self::render_section(
212                        IconName::ZedAssistant,
213                        "Editor Response",
214                    ))
215                    .child(Label::new(response.clone()).buffer_font(cx)),
216            })
217            .when(!request.parsed_edits.is_empty(), |parent| {
218                parent
219                    .child(Self::render_section(IconName::Microscope, "Parsed Edits"))
220                    .child(
221                        v_flex()
222                            .gap_2()
223                            .children(request.parsed_edits.iter().enumerate().map(
224                                |(index, edit)| {
225                                    self.render_edit_action(edit, request.id, index, cx)
226                                },
227                            )),
228                    )
229            })
230            .when_some(request.tool_output.as_ref(), |parent, output| {
231                parent
232                    .child(Self::render_section(IconName::ArrowLeft, "Tool Output"))
233                    .child(match output {
234                        Ok(output) => Label::new(output.clone()).color(Color::Success),
235                        Err(error) => Label::new(error.clone()).color(Color::Error),
236                    })
237            })
238            .into_any()
239    }
240
241    fn render_section(icon: IconName, title: &'static str) -> AnyElement {
242        h_flex()
243            .gap_1()
244            .child(Icon::new(icon).color(Color::Muted))
245            .child(Label::new(title).size(LabelSize::Small).color(Color::Muted))
246            .into_any()
247    }
248
249    fn render_edit_action(
250        &self,
251        edit_action: &EditAction,
252        request_id: EditToolRequestId,
253        index: usize,
254        cx: &Context<Self>,
255    ) -> AnyElement {
256        let expanded_id = (request_id, index);
257
258        match edit_action {
259            EditAction::Replace {
260                file_path,
261                old,
262                new,
263            } => self
264                .render_edit_action_container(
265                    expanded_id,
266                    &file_path,
267                    [
268                        Self::render_block(IconName::MagnifyingGlass, "Search", old.clone(), cx)
269                            .border_r_1()
270                            .border_color(cx.theme().colors().border)
271                            .into_any(),
272                        Self::render_block(IconName::Replace, "Replace", new.clone(), cx)
273                            .into_any(),
274                    ],
275                    cx,
276                )
277                .into_any(),
278            EditAction::Write { file_path, content } => self
279                .render_edit_action_container(
280                    expanded_id,
281                    &file_path,
282                    [
283                        Self::render_block(IconName::Pencil, "Write", content.clone(), cx)
284                            .into_any(),
285                    ],
286                    cx,
287                )
288                .into_any(),
289        }
290    }
291
292    fn render_edit_action_container(
293        &self,
294        expanded_id: (EditToolRequestId, usize),
295        file_path: &Path,
296        content: impl IntoIterator<Item = AnyElement>,
297        cx: &Context<Self>,
298    ) -> AnyElement {
299        let is_expanded = self.expanded_edits.contains(&expanded_id);
300
301        v_flex()
302            .child(
303                h_flex()
304                    .bg(cx.theme().colors().element_background)
305                    .border_1()
306                    .border_color(cx.theme().colors().border)
307                    .rounded_t_md()
308                    .when(!is_expanded, |el| el.rounded_b_md())
309                    .py_1()
310                    .px_2()
311                    .gap_1()
312                    .child(
313                        ui::Disclosure::new(ElementId::Integer(expanded_id.1), is_expanded)
314                            .on_click(cx.listener(move |this, _ev, _window, cx| {
315                                if is_expanded {
316                                    this.expanded_edits.remove(&expanded_id);
317                                } else {
318                                    this.expanded_edits.insert(expanded_id);
319                                }
320
321                                cx.notify();
322                            })),
323                    )
324                    .child(Label::new(file_path.display().to_string()).size(LabelSize::Small)),
325            )
326            .child(if is_expanded {
327                h_flex()
328                    .border_1()
329                    .border_t_0()
330                    .border_color(cx.theme().colors().border)
331                    .rounded_b_md()
332                    .children(content)
333                    .into_any()
334            } else {
335                Empty.into_any()
336            })
337            .into_any()
338    }
339
340    fn render_block(icon: IconName, title: &'static str, content: String, cx: &App) -> Div {
341        v_flex()
342            .p_1()
343            .gap_1()
344            .flex_1()
345            .h_full()
346            .child(
347                h_flex()
348                    .gap_1()
349                    .child(Icon::new(icon).color(Color::Muted))
350                    .child(Label::new(title).size(LabelSize::Small).color(Color::Muted)),
351            )
352            .font(theme::ThemeSettings::get_global(cx).buffer_font.clone())
353            .text_sm()
354            .child(content)
355            .child(div().flex_1())
356    }
357}
358
359impl EventEmitter<()> for EditToolLogViewer {}
360
361impl Focusable for EditToolLogViewer {
362    fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
363        self.focus_handle.clone()
364    }
365}
366
367impl Item for EditToolLogViewer {
368    type Event = ();
369
370    fn to_item_events(_: &Self::Event, _: impl FnMut(ItemEvent)) {}
371
372    fn tab_content_text(&self, _window: &Window, _cx: &App) -> Option<SharedString> {
373        Some("Edit Tool Log".into())
374    }
375
376    fn telemetry_event_text(&self) -> Option<&'static str> {
377        None
378    }
379
380    fn clone_on_split(
381        &self,
382        _workspace_id: Option<WorkspaceId>,
383        _window: &mut Window,
384        cx: &mut Context<Self>,
385    ) -> Option<Entity<Self>>
386    where
387        Self: Sized,
388    {
389        Some(cx.new(Self::new))
390    }
391}
392
393impl Render for EditToolLogViewer {
394    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
395        if self.list_state.item_count() == 0 {
396            return v_flex()
397                .justify_center()
398                .size_full()
399                .gap_1()
400                .bg(cx.theme().colors().editor_background)
401                .text_center()
402                .text_lg()
403                .child("No requests yet")
404                .child(
405                    div()
406                        .text_ui(cx)
407                        .child("Go ask the assistant to perform some edits"),
408                );
409        }
410
411        v_flex()
412            .p_4()
413            .bg(cx.theme().colors().editor_background)
414            .size_full()
415            .child(list(self.list_state.clone()).flex_grow())
416    }
417}