use gpui::prelude::*;
use gpui::{
div, px, Context, EventEmitter, FocusHandle, IntoElement, Pixels, ScrollHandle, SharedString,
Window,
};
use super::{AICitation, AIMessage, AIReasoning, AIRole, AISource, AISources, AIThinking};
use super::{AIToolCall, AIToolStatus};
use crate::devtools::Probed;
use crate::theme::{theme, Size};
const FOLLOW_SLACK: f32 = 48.0;
#[derive(Debug, Clone, Default)]
pub struct AITurnTool {
pub name: String,
pub status: AIToolStatus,
pub arguments: Option<String>,
pub result: Option<String>,
pub meta: Option<String>,
pub open: bool,
}
impl AITurnTool {
pub fn new(name: impl Into<String>) -> Self {
AITurnTool {
name: name.into(),
..Default::default()
}
}
pub fn status(mut self, status: AIToolStatus) -> Self {
self.status = status;
self
}
pub fn arguments(mut self, arguments: impl Into<String>) -> Self {
self.arguments = Some(arguments.into());
self
}
pub fn result(mut self, result: impl Into<String>) -> Self {
self.result = Some(result.into());
self
}
}
#[derive(Debug, Clone, Default)]
pub struct AITurn {
pub role: AIRole,
pub body: String,
pub reasoning: Option<String>,
pub reasoning_open: bool,
pub tools: Vec<AITurnTool>,
pub sources: Vec<AISource>,
pub streaming: bool,
pub error: Option<String>,
pub name: Option<String>,
pub meta: Option<String>,
}
impl AITurn {
pub fn new(role: AIRole, body: impl Into<String>) -> Self {
AITurn {
role,
body: body.into(),
..Default::default()
}
}
pub fn user(body: impl Into<String>) -> Self {
AITurn::new(AIRole::User, body)
}
pub fn assistant(body: impl Into<String>) -> Self {
AITurn::new(AIRole::Assistant, body)
}
pub fn system(body: impl Into<String>) -> Self {
AITurn::new(AIRole::System, body)
}
pub fn name(mut self, name: impl Into<String>) -> Self {
self.name = Some(name.into());
self
}
pub fn meta(mut self, meta: impl Into<String>) -> Self {
self.meta = Some(meta.into());
self
}
pub fn reasoning(mut self, reasoning: impl Into<String>) -> Self {
self.reasoning = Some(reasoning.into());
self
}
pub fn sources(mut self, sources: impl IntoIterator<Item = AISource>) -> Self {
self.sources = sources.into_iter().collect();
self
}
pub fn tools(mut self, tools: impl IntoIterator<Item = AITurnTool>) -> Self {
self.tools = tools.into_iter().collect();
self
}
}
#[derive(Debug, Clone)]
pub enum AIChatViewEvent {
OpenSource(usize, usize),
}
pub struct AIChatView {
turns: Vec<AITurn>,
scroll: ScrollHandle,
focus: FocusHandle,
follow: bool,
pending: Option<SharedString>,
empty: Option<SharedString>,
size: Size,
max_width: Option<f32>,
heights: Vec<Pixels>,
measured_width: Pixels,
virtualize: bool,
}
impl EventEmitter<AIChatViewEvent> for AIChatView {}
impl AIChatView {
pub fn new(cx: &mut Context<Self>) -> Self {
AIChatView {
turns: Vec::new(),
scroll: ScrollHandle::new(),
focus: cx.focus_handle(),
follow: true,
pending: None,
empty: None,
size: Size::Sm,
max_width: None,
heights: Vec::new(),
measured_width: px(0.0),
virtualize: true,
}
}
pub fn turns(mut self, turns: impl IntoIterator<Item = AITurn>) -> Self {
self.turns = turns.into_iter().collect();
self
}
pub fn empty_message(mut self, message: impl Into<SharedString>) -> Self {
self.empty = Some(message.into());
self
}
pub fn size(mut self, size: Size) -> Self {
self.size = size;
self
}
pub fn max_width(mut self, width: f32) -> Self {
self.max_width = Some(width);
self
}
pub fn virtualize(mut self, virtualize: bool) -> Self {
self.virtualize = virtualize;
self
}
pub fn focus_handle(&self) -> FocusHandle {
self.focus.clone()
}
pub fn turn_count(&self) -> usize {
self.turns.len()
}
pub fn all(&self) -> &[AITurn] {
&self.turns
}
pub fn turn(&self, index: usize) -> Option<&AITurn> {
self.turns.get(index)
}
pub fn update_turn(
&mut self,
index: usize,
edit: impl FnOnce(&mut AITurn),
cx: &mut Context<Self>,
) {
if let Some(turn) = self.turns.get_mut(index) {
edit(turn);
cx.notify();
}
}
pub fn push(&mut self, turn: AITurn, cx: &mut Context<Self>) -> usize {
self.turns.push(turn);
self.follow = true;
cx.notify();
self.turns.len() - 1
}
pub fn begin_reply(&mut self, cx: &mut Context<Self>) -> usize {
let mut turn = AITurn::assistant(String::new());
turn.streaming = true;
self.pending = None;
self.push(turn, cx)
}
pub fn push_delta(&mut self, delta: &str, cx: &mut Context<Self>) {
if let Some(turn) = self.streaming_turn() {
turn.body.push_str(delta);
cx.notify();
}
}
pub fn push_reasoning(&mut self, delta: &str, cx: &mut Context<Self>) {
if let Some(turn) = self.streaming_turn() {
turn
.reasoning
.get_or_insert_with(String::new)
.push_str(delta);
cx.notify();
}
}
pub fn end_reply(&mut self, cx: &mut Context<Self>) {
if let Some(turn) = self.streaming_turn() {
turn.streaming = false;
cx.notify();
}
}
pub fn fail_reply(&mut self, error: impl Into<String>, cx: &mut Context<Self>) {
let error = error.into();
if let Some(turn) = self.streaming_turn() {
turn.streaming = false;
turn.error = Some(error);
cx.notify();
}
}
pub fn set_pending(&mut self, label: Option<impl Into<SharedString>>, cx: &mut Context<Self>) {
self.pending = label.map(Into::into);
self.follow = true;
cx.notify();
}
pub fn clear(&mut self, cx: &mut Context<Self>) {
self.turns.clear();
self.pending = None;
self.follow = true;
cx.notify();
}
pub fn scroll_to_bottom(&mut self, cx: &mut Context<Self>) {
self.follow = true;
cx.notify();
}
pub fn is_following(&self) -> bool {
self.follow
}
#[cfg(test)]
pub(crate) fn scroll_extent(&self) -> Pixels {
self.scroll.max_offset().height
}
fn streaming_turn(&mut self) -> Option<&mut AITurn> {
self.turns.iter_mut().rev().find(|turn| turn.streaming)
}
fn measure(&mut self) -> Vec<bool> {
let count = self.turns.len();
let viewport = self.scroll.bounds();
let resized =
self.measured_width > px(0.0) && (viewport.size.width - self.measured_width).abs() > px(0.5);
if resized || self.measured_width <= px(0.0) {
self.measured_width = viewport.size.width;
if resized {
self.heights.clear();
}
}
self.heights.resize(count, px(0.0));
let mut drawn = vec![true; count];
let overscan = viewport.size.height.max(px(600.0));
let (top, bottom) = (viewport.top() - overscan, viewport.bottom() + overscan);
for (index, (height, drawn)) in self.heights.iter_mut().zip(drawn.iter_mut()).enumerate() {
let Some(bounds) = self.scroll.bounds_for_item(index) else {
continue;
};
if bounds.size.height > px(0.0) {
*height = bounds.size.height;
}
if resized || !self.virtualize || *height <= px(0.0) {
continue;
}
*drawn = bounds.bottom() >= top && bounds.top() <= bottom;
}
drawn
}
#[cfg(test)]
pub(crate) fn drawn_count(&mut self) -> usize {
self.measure().iter().filter(|drawn| **drawn).count()
}
fn distance_from_bottom(&self) -> f32 {
let offset = self.scroll.offset().y;
let max = self.scroll.max_offset().height;
f32::from(max + offset).max(0.0)
}
fn on_scroll(
&mut self,
_event: &gpui::ScrollWheelEvent,
_window: &mut Window,
cx: &mut Context<Self>,
) {
let following = self.distance_from_bottom() <= FOLLOW_SLACK;
if following != self.follow {
self.follow = following;
cx.notify();
}
}
}
impl Render for AIChatView {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let t = theme(cx);
let dimmed = t.dimmed().hsla();
let font = t.font_size(self.size);
let empty = self.turns.is_empty() && self.pending.is_none();
let max_width = self.max_width;
let drawn = self.measure();
let mut rows: Vec<gpui::AnyElement> = Vec::with_capacity(self.turns.len() + 1);
for (index, turn) in self.turns.iter().enumerate() {
if !drawn[index] {
rows.push(row(max_width).h(self.heights[index]).into_any_element());
continue;
}
let mut message = AIMessage::new(turn.role, turn.body.clone())
.streaming(turn.streaming)
.size(self.size);
if let Some(name) = &turn.name {
message = message.name(name.clone());
}
if let Some(meta) = &turn.meta {
message = message.meta(meta.clone());
}
if let Some(error) = &turn.error {
message = message.error(error.clone());
}
if let Some(reasoning) = &turn.reasoning {
let text = if turn.reasoning_open {
reasoning.clone()
} else {
String::new()
};
message = message.child(
div().mt(px(8.0)).child(
AIReasoning::new(("guise-ai-reasoning", index), text)
.open(turn.reasoning_open)
.streaming(turn.streaming)
.size(self.size)
.on_toggle(cx.listener(move |this, _event, _window, cx| {
this.update_turn(index, |turn| turn.reasoning_open = !turn.reasoning_open, cx);
})),
),
);
}
for (slot, tool) in turn.tools.iter().enumerate() {
let mut card = AIToolCall::new(("guise-ai-tool", index * 64 + slot), tool.name.clone())
.status(tool.status)
.open(tool.open)
.expandable(tool.arguments.is_some() || tool.result.is_some())
.size(self.size)
.on_toggle(cx.listener(move |this, _event, _window, cx| {
this.update_turn(
index,
|turn| {
if let Some(tool) = turn.tools.get_mut(slot) {
tool.open = !tool.open;
}
},
cx,
);
}));
if tool.open {
if let Some(arguments) = &tool.arguments {
card = card.arguments(arguments.clone());
}
if let Some(result) = &tool.result {
card = card.result(result.clone());
}
}
if let Some(meta) = &tool.meta {
card = card.meta(meta.clone());
}
message = message.child(div().mt(px(8.0)).child(card));
}
if !turn.sources.is_empty() {
let chips = div().flex().flex_row().flex_wrap().gap(px(4.0)).children(
turn.sources.iter().enumerate().map(|(slot, source)| {
AICitation::new(("guise-ai-cite", index * 64 + slot), slot + 1)
.label(source.title.clone())
.on_click(cx.listener(move |_this, _event, _window, cx| {
cx.emit(AIChatViewEvent::OpenSource(index, slot));
}))
}),
);
let view = cx.entity().downgrade();
message =
message
.child(div().mt(px(8.0)).child(chips))
.child(div().mt(px(6.0)).child(
AISources::new(turn.sources.clone()).excerpts(true).on_open(
move |slot, _window, cx| {
view
.update(cx, |_this, cx| {
cx.emit(AIChatViewEvent::OpenSource(index, slot));
})
.ok();
},
),
));
}
rows.push(row(max_width).child(message).into_any_element());
}
if let Some(pending) = self.pending.clone() {
rows.push(
row(max_width)
.child(AIThinking::new().label(pending).size(self.size))
.into_any_element(),
);
}
if self.follow && !rows.is_empty() {
self.scroll.scroll_to_item(rows.len() - 1);
}
div()
.id("guise-ai-chatview")
.track_focus(&self.focus)
.flex()
.flex_col()
.items_center()
.gap(px(18.0))
.size_full()
.overflow_y_scroll()
.track_scroll(&self.scroll)
.on_scroll_wheel(cx.listener(Self::on_scroll))
.p(px(16.0))
.text_size(px(font))
.when(empty, |view| {
view
.justify_center()
.child(div().text_color(dimmed).children(self.empty.clone()))
})
.when(!empty, |view| view.children(rows))
.probe("AIChatView")
}
}
fn row(max_width: Option<f32>) -> gpui::Div {
let row = div().w_full();
match max_width {
Some(max) => row.max_w(px(max)),
None => row,
}
}