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.reasoning
291 .get_or_insert_with(String::new)
292 .push_str(delta);
293 cx.notify();
294 }
295 }
296
297 pub fn end_reply(&mut self, cx: &mut Context<Self>) {
299 if let Some(turn) = self.streaming_turn() {
300 turn.streaming = false;
301 cx.notify();
302 }
303 }
304
305 pub fn fail_reply(&mut self, error: impl Into<String>, cx: &mut Context<Self>) {
308 let error = error.into();
309 if let Some(turn) = self.streaming_turn() {
310 turn.streaming = false;
311 turn.error = Some(error);
312 cx.notify();
313 }
314 }
315
316 pub fn set_pending(&mut self, label: Option<impl Into<SharedString>>, cx: &mut Context<Self>) {
318 self.pending = label.map(Into::into);
319 self.follow = true;
320 cx.notify();
321 }
322
323 pub fn clear(&mut self, cx: &mut Context<Self>) {
325 self.turns.clear();
326 self.pending = None;
327 self.follow = true;
328 cx.notify();
329 }
330
331 pub fn scroll_to_bottom(&mut self, cx: &mut Context<Self>) {
333 self.follow = true;
334 cx.notify();
335 }
336
337 pub fn is_following(&self) -> bool {
339 self.follow
340 }
341
342 #[cfg(test)]
345 pub(crate) fn scroll_extent(&self) -> Pixels {
346 self.scroll.max_offset().height
347 }
348
349 fn streaming_turn(&mut self) -> Option<&mut AITurn> {
350 self.turns.iter_mut().rev().find(|turn| turn.streaming)
351 }
352
353 fn measure(&mut self) -> Vec<bool> {
362 let count = self.turns.len();
363 let viewport = self.scroll.bounds();
364 let resized = self.measured_width > px(0.0)
372 && (viewport.size.width - self.measured_width).abs() > px(0.5);
373 if resized || self.measured_width <= px(0.0) {
374 self.measured_width = viewport.size.width;
375 if resized {
376 self.heights.clear();
377 }
378 }
379 self.heights.resize(count, px(0.0));
380
381 let mut drawn = vec![true; count];
386 let overscan = viewport.size.height.max(px(600.0));
387 let (top, bottom) = (viewport.top() - overscan, viewport.bottom() + overscan);
388 for (index, (height, drawn)) in self.heights.iter_mut().zip(drawn.iter_mut()).enumerate() {
389 let Some(bounds) = self.scroll.bounds_for_item(index) else {
390 continue;
391 };
392 if bounds.size.height > px(0.0) {
393 *height = bounds.size.height;
394 }
395 if resized || !self.virtualize || *height <= px(0.0) {
402 continue;
403 }
404 *drawn = bounds.bottom() >= top && bounds.top() <= bottom;
405 }
406 drawn
407 }
408
409 #[cfg(test)]
412 pub(crate) fn drawn_count(&mut self) -> usize {
413 self.measure().iter().filter(|drawn| **drawn).count()
414 }
415
416 fn distance_from_bottom(&self) -> f32 {
420 let offset = self.scroll.offset().y;
421 let max = self.scroll.max_offset().height;
422 f32::from(max + offset).max(0.0)
423 }
424
425 fn on_scroll(
430 &mut self,
431 _event: &gpui::ScrollWheelEvent,
432 _window: &mut Window,
433 cx: &mut Context<Self>,
434 ) {
435 let following = self.distance_from_bottom() <= FOLLOW_SLACK;
436 if following != self.follow {
437 self.follow = following;
438 cx.notify();
439 }
440 }
441}
442
443impl Render for AIChatView {
444 fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
445 let t = theme(cx);
446 let dimmed = t.dimmed().hsla();
447 let font = t.font_size(self.size);
448 let empty = self.turns.is_empty() && self.pending.is_none();
449 let max_width = self.max_width;
450 let drawn = self.measure();
451
452 let mut rows: Vec<gpui::AnyElement> = Vec::with_capacity(self.turns.len() + 1);
456
457 for (index, turn) in self.turns.iter().enumerate() {
458 if !drawn[index] {
462 rows.push(row(max_width).h(self.heights[index]).into_any_element());
463 continue;
464 }
465 let mut message = AIMessage::new(turn.role, turn.body.clone())
466 .streaming(turn.streaming)
467 .size(self.size);
468 if let Some(name) = &turn.name {
469 message = message.name(name.clone());
470 }
471 if let Some(meta) = &turn.meta {
472 message = message.meta(meta.clone());
473 }
474 if let Some(error) = &turn.error {
475 message = message.error(error.clone());
476 }
477
478 if let Some(reasoning) = &turn.reasoning {
479 let text = if turn.reasoning_open {
484 reasoning.clone()
485 } else {
486 String::new()
487 };
488 message = message.child(
489 div().mt(px(8.0)).child(
490 AIReasoning::new(("guise-ai-reasoning", index), text)
491 .open(turn.reasoning_open)
492 .streaming(turn.streaming)
493 .size(self.size)
494 .on_toggle(cx.listener(move |this, _event, _window, cx| {
495 this.update_turn(
496 index,
497 |turn| turn.reasoning_open = !turn.reasoning_open,
498 cx,
499 );
500 })),
501 ),
502 );
503 }
504
505 for (slot, tool) in turn.tools.iter().enumerate() {
506 let mut card =
507 AIToolCall::new(("guise-ai-tool", index * 64 + slot), tool.name.clone())
508 .status(tool.status)
509 .open(tool.open)
510 .expandable(tool.arguments.is_some() || tool.result.is_some())
511 .size(self.size)
512 .on_toggle(cx.listener(move |this, _event, _window, cx| {
513 this.update_turn(
514 index,
515 |turn| {
516 if let Some(tool) = turn.tools.get_mut(slot) {
517 tool.open = !tool.open;
518 }
519 },
520 cx,
521 );
522 }));
523 if tool.open {
526 if let Some(arguments) = &tool.arguments {
527 card = card.arguments(arguments.clone());
528 }
529 if let Some(result) = &tool.result {
530 card = card.result(result.clone());
531 }
532 }
533 if let Some(meta) = &tool.meta {
534 card = card.meta(meta.clone());
535 }
536 message = message.child(div().mt(px(8.0)).child(card));
537 }
538
539 if !turn.sources.is_empty() {
540 let chips = div().flex().flex_row().flex_wrap().gap(px(4.0)).children(
541 turn.sources.iter().enumerate().map(|(slot, source)| {
542 AICitation::new(("guise-ai-cite", index * 64 + slot), slot + 1)
543 .label(source.title.clone())
544 .on_click(cx.listener(move |_this, _event, _window, cx| {
545 cx.emit(AIChatViewEvent::OpenSource(index, slot));
546 }))
547 }),
548 );
549 let view = cx.entity().downgrade();
553 message =
554 message
555 .child(div().mt(px(8.0)).child(chips))
556 .child(div().mt(px(6.0)).child(
557 AISources::new(turn.sources.clone()).excerpts(true).on_open(
558 move |slot, _window, cx| {
559 view.update(cx, |_this, cx| {
560 cx.emit(AIChatViewEvent::OpenSource(index, slot));
561 })
562 .ok();
563 },
564 ),
565 ));
566 }
567
568 rows.push(row(max_width).child(message).into_any_element());
569 }
570
571 if let Some(pending) = self.pending.clone() {
572 rows.push(
573 row(max_width)
574 .child(AIThinking::new().label(pending).size(self.size))
575 .into_any_element(),
576 );
577 }
578
579 if self.follow && !rows.is_empty() {
583 self.scroll.scroll_to_item(rows.len() - 1);
584 }
585
586 div()
587 .id("guise-ai-chatview")
588 .track_focus(&self.focus)
589 .flex()
590 .flex_col()
591 .items_center()
592 .gap(px(18.0))
593 .size_full()
594 .overflow_y_scroll()
595 .track_scroll(&self.scroll)
596 .on_scroll_wheel(cx.listener(Self::on_scroll))
597 .p(px(16.0))
598 .text_size(px(font))
599 .when(empty, |view| {
600 view.justify_center()
601 .child(div().text_color(dimmed).children(self.empty.clone()))
602 })
603 .when(!empty, |view| view.children(rows))
604 .probe("AIChatView")
605 }
606}
607
608fn row(max_width: Option<f32>) -> gpui::Div {
611 let row = div().w_full();
612 match max_width {
613 Some(max) => row.max_w(px(max)),
614 None => row,
615 }
616}