1use gpui::prelude::*;
27use gpui::{
28 div, px, Context, EventEmitter, FocusHandle, IntoElement, Pixels, ScrollHandle, SharedString,
29 Window,
30};
31
32use super::{AICitation, AIMessage, AIReasoning, AIRole, AISource, AISources, AIThinking};
33use super::{AIToolCall, AIToolStatus};
34use crate::devtools::Probed;
35use crate::theme::{theme, Size};
36
37const FOLLOW_SLACK: f32 = 48.0;
40
41#[derive(Debug, Clone, Default)]
43pub struct AITurnTool {
44 pub name: String,
45 pub status: AIToolStatus,
46 pub arguments: Option<String>,
47 pub result: Option<String>,
48 pub meta: Option<String>,
49 pub open: bool,
52}
53
54impl AITurnTool {
55 pub fn new(name: impl Into<String>) -> Self {
56 AITurnTool {
57 name: name.into(),
58 ..Default::default()
59 }
60 }
61
62 pub fn status(mut self, status: AIToolStatus) -> Self {
63 self.status = status;
64 self
65 }
66
67 pub fn arguments(mut self, arguments: impl Into<String>) -> Self {
68 self.arguments = Some(arguments.into());
69 self
70 }
71
72 pub fn result(mut self, result: impl Into<String>) -> Self {
73 self.result = Some(result.into());
74 self
75 }
76}
77
78#[derive(Debug, Clone, Default)]
80pub struct AITurn {
81 pub role: AIRole,
82 pub body: String,
83 pub reasoning: Option<String>,
85 pub reasoning_open: bool,
86 pub tools: Vec<AITurnTool>,
87 pub sources: Vec<AISource>,
88 pub streaming: bool,
90 pub error: Option<String>,
92 pub name: Option<String>,
94 pub meta: Option<String>,
96}
97
98impl AITurn {
99 pub fn new(role: AIRole, body: impl Into<String>) -> Self {
100 AITurn {
101 role,
102 body: body.into(),
103 ..Default::default()
104 }
105 }
106
107 pub fn user(body: impl Into<String>) -> Self {
108 AITurn::new(AIRole::User, body)
109 }
110
111 pub fn assistant(body: impl Into<String>) -> Self {
112 AITurn::new(AIRole::Assistant, body)
113 }
114
115 pub fn system(body: impl Into<String>) -> Self {
116 AITurn::new(AIRole::System, body)
117 }
118
119 pub fn name(mut self, name: impl Into<String>) -> Self {
120 self.name = Some(name.into());
121 self
122 }
123
124 pub fn meta(mut self, meta: impl Into<String>) -> Self {
125 self.meta = Some(meta.into());
126 self
127 }
128
129 pub fn reasoning(mut self, reasoning: impl Into<String>) -> Self {
130 self.reasoning = Some(reasoning.into());
131 self
132 }
133
134 pub fn sources(mut self, sources: impl IntoIterator<Item = AISource>) -> Self {
135 self.sources = sources.into_iter().collect();
136 self
137 }
138
139 pub fn tools(mut self, tools: impl IntoIterator<Item = AITurnTool>) -> Self {
140 self.tools = tools.into_iter().collect();
141 self
142 }
143}
144
145#[derive(Debug, Clone)]
147pub enum AIChatViewEvent {
148 OpenSource(usize, usize),
150}
151
152pub struct AIChatView {
154 turns: Vec<AITurn>,
155 scroll: ScrollHandle,
156 focus: FocusHandle,
157 follow: bool,
160 pending: Option<SharedString>,
162 empty: Option<SharedString>,
163 size: Size,
164 max_width: Option<f32>,
165 heights: Vec<Pixels>,
169 measured_width: Pixels,
172 virtualize: bool,
174}
175
176impl EventEmitter<AIChatViewEvent> for AIChatView {}
177
178impl AIChatView {
179 pub fn new(cx: &mut Context<Self>) -> Self {
180 AIChatView {
181 turns: Vec::new(),
182 scroll: ScrollHandle::new(),
183 focus: cx.focus_handle(),
184 follow: true,
185 pending: None,
186 empty: None,
187 size: Size::Sm,
188 max_width: None,
189 heights: Vec::new(),
190 measured_width: px(0.0),
191 virtualize: true,
192 }
193 }
194
195 pub fn turns(mut self, turns: impl IntoIterator<Item = AITurn>) -> Self {
197 self.turns = turns.into_iter().collect();
198 self
199 }
200
201 pub fn empty_message(mut self, message: impl Into<SharedString>) -> Self {
203 self.empty = Some(message.into());
204 self
205 }
206
207 pub fn size(mut self, size: Size) -> Self {
208 self.size = size;
209 self
210 }
211
212 pub fn max_width(mut self, width: f32) -> Self {
215 self.max_width = Some(width);
216 self
217 }
218
219 pub fn virtualize(mut self, virtualize: bool) -> Self {
228 self.virtualize = virtualize;
229 self
230 }
231
232 pub fn focus_handle(&self) -> FocusHandle {
233 self.focus.clone()
234 }
235
236 pub fn turn_count(&self) -> usize {
237 self.turns.len()
238 }
239
240 pub fn all(&self) -> &[AITurn] {
241 &self.turns
242 }
243
244 pub fn turn(&self, index: usize) -> Option<&AITurn> {
245 self.turns.get(index)
246 }
247
248 pub fn update_turn(
250 &mut self,
251 index: usize,
252 edit: impl FnOnce(&mut AITurn),
253 cx: &mut Context<Self>,
254 ) {
255 if let Some(turn) = self.turns.get_mut(index) {
256 edit(turn);
257 cx.notify();
258 }
259 }
260
261 pub fn push(&mut self, turn: AITurn, cx: &mut Context<Self>) -> usize {
264 self.turns.push(turn);
265 self.follow = true;
266 cx.notify();
267 self.turns.len() - 1
268 }
269
270 pub fn begin_reply(&mut self, cx: &mut Context<Self>) -> usize {
272 let mut turn = AITurn::assistant(String::new());
273 turn.streaming = true;
274 self.pending = None;
275 self.push(turn, cx)
276 }
277
278 pub fn push_delta(&mut self, delta: &str, cx: &mut Context<Self>) {
281 if let Some(turn) = self.streaming_turn() {
282 turn.body.push_str(delta);
283 cx.notify();
284 }
285 }
286
287 pub fn push_reasoning(&mut self, delta: &str, cx: &mut Context<Self>) {
289 if let Some(turn) = self.streaming_turn() {
290 turn
291 .reasoning
292 .get_or_insert_with(String::new)
293 .push_str(delta);
294 cx.notify();
295 }
296 }
297
298 pub fn end_reply(&mut self, cx: &mut Context<Self>) {
300 if let Some(turn) = self.streaming_turn() {
301 turn.streaming = false;
302 cx.notify();
303 }
304 }
305
306 pub fn fail_reply(&mut self, error: impl Into<String>, cx: &mut Context<Self>) {
309 let error = error.into();
310 if let Some(turn) = self.streaming_turn() {
311 turn.streaming = false;
312 turn.error = Some(error);
313 cx.notify();
314 }
315 }
316
317 pub fn set_pending(&mut self, label: Option<impl Into<SharedString>>, cx: &mut Context<Self>) {
319 self.pending = label.map(Into::into);
320 self.follow = true;
321 cx.notify();
322 }
323
324 pub fn clear(&mut self, cx: &mut Context<Self>) {
326 self.turns.clear();
327 self.pending = None;
328 self.follow = true;
329 cx.notify();
330 }
331
332 pub fn scroll_to_bottom(&mut self, cx: &mut Context<Self>) {
334 self.follow = true;
335 cx.notify();
336 }
337
338 pub fn is_following(&self) -> bool {
340 self.follow
341 }
342
343 #[cfg(test)]
346 pub(crate) fn scroll_extent(&self) -> Pixels {
347 self.scroll.max_offset().height
348 }
349
350 fn streaming_turn(&mut self) -> Option<&mut AITurn> {
351 self.turns.iter_mut().rev().find(|turn| turn.streaming)
352 }
353
354 fn measure(&mut self) -> Vec<bool> {
363 let count = self.turns.len();
364 let viewport = self.scroll.bounds();
365 let resized =
373 self.measured_width > px(0.0) && (viewport.size.width - self.measured_width).abs() > px(0.5);
374 if resized || self.measured_width <= px(0.0) {
375 self.measured_width = viewport.size.width;
376 if resized {
377 self.heights.clear();
378 }
379 }
380 self.heights.resize(count, px(0.0));
381
382 let mut drawn = vec![true; count];
387 let overscan = viewport.size.height.max(px(600.0));
388 let (top, bottom) = (viewport.top() - overscan, viewport.bottom() + overscan);
389 for (index, (height, drawn)) in self.heights.iter_mut().zip(drawn.iter_mut()).enumerate() {
390 let Some(bounds) = self.scroll.bounds_for_item(index) else {
391 continue;
392 };
393 if bounds.size.height > px(0.0) {
394 *height = bounds.size.height;
395 }
396 if resized || !self.virtualize || *height <= px(0.0) {
403 continue;
404 }
405 *drawn = bounds.bottom() >= top && bounds.top() <= bottom;
406 }
407 drawn
408 }
409
410 #[cfg(test)]
413 pub(crate) fn drawn_count(&mut self) -> usize {
414 self.measure().iter().filter(|drawn| **drawn).count()
415 }
416
417 fn distance_from_bottom(&self) -> f32 {
421 let offset = self.scroll.offset().y;
422 let max = self.scroll.max_offset().height;
423 f32::from(max + offset).max(0.0)
424 }
425
426 fn on_scroll(
431 &mut self,
432 _event: &gpui::ScrollWheelEvent,
433 _window: &mut Window,
434 cx: &mut Context<Self>,
435 ) {
436 let following = self.distance_from_bottom() <= FOLLOW_SLACK;
437 if following != self.follow {
438 self.follow = following;
439 cx.notify();
440 }
441 }
442}
443
444impl Render for AIChatView {
445 fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
446 let t = theme(cx);
447 let dimmed = t.dimmed().hsla();
448 let font = t.font_size(self.size);
449 let empty = self.turns.is_empty() && self.pending.is_none();
450 let max_width = self.max_width;
451 let drawn = self.measure();
452
453 let mut rows: Vec<gpui::AnyElement> = Vec::with_capacity(self.turns.len() + 1);
457
458 for (index, turn) in self.turns.iter().enumerate() {
459 if !drawn[index] {
463 rows.push(row(max_width).h(self.heights[index]).into_any_element());
464 continue;
465 }
466 let mut message = AIMessage::new(turn.role, turn.body.clone())
467 .streaming(turn.streaming)
468 .size(self.size);
469 if let Some(name) = &turn.name {
470 message = message.name(name.clone());
471 }
472 if let Some(meta) = &turn.meta {
473 message = message.meta(meta.clone());
474 }
475 if let Some(error) = &turn.error {
476 message = message.error(error.clone());
477 }
478
479 if let Some(reasoning) = &turn.reasoning {
480 let text = if turn.reasoning_open {
485 reasoning.clone()
486 } else {
487 String::new()
488 };
489 message = message.child(
490 div().mt(px(8.0)).child(
491 AIReasoning::new(("guise-ai-reasoning", index), text)
492 .open(turn.reasoning_open)
493 .streaming(turn.streaming)
494 .size(self.size)
495 .on_toggle(cx.listener(move |this, _event, _window, cx| {
496 this.update_turn(index, |turn| turn.reasoning_open = !turn.reasoning_open, cx);
497 })),
498 ),
499 );
500 }
501
502 for (slot, tool) in turn.tools.iter().enumerate() {
503 let mut card = AIToolCall::new(("guise-ai-tool", index * 64 + slot), tool.name.clone())
504 .status(tool.status)
505 .open(tool.open)
506 .expandable(tool.arguments.is_some() || tool.result.is_some())
507 .size(self.size)
508 .on_toggle(cx.listener(move |this, _event, _window, cx| {
509 this.update_turn(
510 index,
511 |turn| {
512 if let Some(tool) = turn.tools.get_mut(slot) {
513 tool.open = !tool.open;
514 }
515 },
516 cx,
517 );
518 }));
519 if tool.open {
522 if let Some(arguments) = &tool.arguments {
523 card = card.arguments(arguments.clone());
524 }
525 if let Some(result) = &tool.result {
526 card = card.result(result.clone());
527 }
528 }
529 if let Some(meta) = &tool.meta {
530 card = card.meta(meta.clone());
531 }
532 message = message.child(div().mt(px(8.0)).child(card));
533 }
534
535 if !turn.sources.is_empty() {
536 let chips = div().flex().flex_row().flex_wrap().gap(px(4.0)).children(
537 turn.sources.iter().enumerate().map(|(slot, source)| {
538 AICitation::new(("guise-ai-cite", index * 64 + slot), slot + 1)
539 .label(source.title.clone())
540 .on_click(cx.listener(move |_this, _event, _window, cx| {
541 cx.emit(AIChatViewEvent::OpenSource(index, slot));
542 }))
543 }),
544 );
545 let view = cx.entity().downgrade();
549 message =
550 message
551 .child(div().mt(px(8.0)).child(chips))
552 .child(div().mt(px(6.0)).child(
553 AISources::new(turn.sources.clone()).excerpts(true).on_open(
554 move |slot, _window, cx| {
555 view
556 .update(cx, |_this, cx| {
557 cx.emit(AIChatViewEvent::OpenSource(index, slot));
558 })
559 .ok();
560 },
561 ),
562 ));
563 }
564
565 rows.push(row(max_width).child(message).into_any_element());
566 }
567
568 if let Some(pending) = self.pending.clone() {
569 rows.push(
570 row(max_width)
571 .child(AIThinking::new().label(pending).size(self.size))
572 .into_any_element(),
573 );
574 }
575
576 if self.follow && !rows.is_empty() {
580 self.scroll.scroll_to_item(rows.len() - 1);
581 }
582
583 div()
584 .id("guise-ai-chatview")
585 .track_focus(&self.focus)
586 .flex()
587 .flex_col()
588 .items_center()
589 .gap(px(18.0))
590 .size_full()
591 .overflow_y_scroll()
592 .track_scroll(&self.scroll)
593 .on_scroll_wheel(cx.listener(Self::on_scroll))
594 .p(px(16.0))
595 .text_size(px(font))
596 .when(empty, |view| {
597 view
598 .justify_center()
599 .child(div().text_color(dimmed).children(self.empty.clone()))
600 })
601 .when(!empty, |view| view.children(rows))
602 .probe("AIChatView")
603 }
604}
605
606fn row(max_width: Option<f32>) -> gpui::Div {
609 let row = div().w_full();
610 match max_width {
611 Some(max) => row.max_w(px(max)),
612 None => row,
613 }
614}