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}