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