1use gpui_kit::assets::IconName;
7use gpui_kit::component::button::{Button, ButtonVariants as _};
8use gpui_kit::component::input::{InputEvent, Textarea, TextareaState};
9use gpui_kit::component::tooltip::Tooltip;
10use gpui_kit::component::{Icon, Sizable as _, h_flex, v_flex};
11use gpui_kit::prelude::FluentBuilder as _;
12use gpui_kit::{
13 AnyElement, App, AppContext as _, Context, Entity, EventEmitter, FontWeight, InteractiveElement as _, IntoElement, ParentElement as _, Render,
14 ScrollHandle, SharedString, StatefulInteractiveElement as _, Styled as _, Subscription, TestSupportExt as _, Window, div, px,
15};
16use serde::{Deserialize, Serialize};
17use serde_json::Value;
18
19use crate::theme::{self, color};
20use crate::widgets::{CodeLanguage, CodeView, EmptyState, TextView};
21
22#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
24#[serde(tag = "role", rename_all = "snake_case")]
25pub enum ChatMessage {
26 User {
28 text: String,
30 },
31 Agent {
33 text: String,
35 },
36 Tool(ToolCall),
38}
39
40#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
42pub struct ToolCall {
43 pub name: String,
45 #[serde(default)]
47 pub summary: String,
48 #[serde(default)]
50 pub arguments: Value,
51 #[serde(default, skip_serializing_if = "Option::is_none")]
53 pub result: Option<Value>,
54 #[serde(default, skip_serializing_if = "Option::is_none")]
56 pub error: Option<String>,
57}
58
59impl ToolCall {
60 pub fn new(name: impl Into<String>, arguments: Value) -> Self {
62 ToolCall { name: name.into(), arguments, ..Default::default() }
63 }
64
65 pub fn finished(self, result: Value, summary: impl Into<String>) -> Self {
67 ToolCall { result: Some(result), summary: summary.into(), ..self }
68 }
69
70 pub fn is_running(&self) -> bool {
72 self.result.is_none() && self.error.is_none()
73 }
74
75 pub fn line(&self) -> String {
77 let tail = |s: &str| if s.is_empty() { String::new() } else { format!(" · {s}") };
78 match (&self.result, &self.error) {
79 (_, Some(e)) => format!("Failed {}{}", self.name, tail(e)),
80 (Some(_), None) => format!("Ran {}{}", self.name, tail(&self.summary)),
81 (None, None) => format!("Running {}…", self.name),
82 }
83 }
84}
85
86#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
88pub enum Location {
89 #[default]
91 Local,
92 Remote(String),
94}
95
96#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
98pub struct ChatContext {
99 pub workspace: String,
101 pub location: Location,
103 pub model: String,
105}
106
107#[derive(Clone, Debug, PartialEq, Eq)]
109pub enum ChatEvent {
110 Send(String),
113 Stop,
115}
116
117enum RowView {
119 Text(Entity<TextView>),
120 Tool { open: bool, views: Option<(Entity<CodeView>, Option<Entity<CodeView>>)> },
121}
122
123pub struct ChatPane {
132 messages: Vec<ChatMessage>,
133 rows: Vec<RowView>,
134 context: ChatContext,
135 running: bool,
136 prompt: Entity<TextareaState>,
137 scroll: ScrollHandle,
138 _subs: Vec<Subscription>,
139}
140
141impl EventEmitter<ChatEvent> for ChatPane {}
142
143impl ChatPane {
144 pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
146 let prompt = cx.new(|cx| TextareaState::new(window, cx).auto_grow(1, 8).submit_on_enter(true).placeholder("Ask the agent…"));
147 let sub = cx.subscribe_in(&prompt, window, |this, _, e: &InputEvent, window, cx| {
148 if let InputEvent::PressEnter { shift: false, .. } = e {
149 this.send(window, cx);
150 }
151 });
152 Self { messages: Vec::new(), rows: Vec::new(), context: ChatContext::default(), running: false, prompt, scroll: ScrollHandle::new(), _subs: vec![sub] }
153 }
154
155 pub fn messages(&self) -> &[ChatMessage] {
157 &self.messages
158 }
159
160 pub fn set_messages(&mut self, messages: Vec<ChatMessage>, cx: &mut Context<Self>) {
162 self.messages.clear();
163 self.rows.clear();
164 for m in messages {
165 self.push(m, cx);
166 }
167 }
168
169 pub fn push(&mut self, message: ChatMessage, cx: &mut Context<Self>) -> usize {
171 let row = match &message {
172 ChatMessage::User { text } | ChatMessage::Agent { text } => {
173 let text = text.clone();
174 RowView::Text(cx.new(|cx| TextView::new(text, cx)))
175 }
176 ChatMessage::Tool(_) => RowView::Tool { open: false, views: None },
177 };
178 self.messages.push(message);
179 self.rows.push(row);
180 self.scroll.scroll_to_bottom();
181 cx.notify();
182 self.messages.len() - 1
183 }
184
185 pub fn append_agent(&mut self, text: &str, cx: &mut Context<Self>) {
188 let last = self.messages.len().checked_sub(1);
189 if let Some(i) = last
190 && let (ChatMessage::Agent { text: t }, RowView::Text(view)) = (&mut self.messages[i], &self.rows[i])
191 {
192 t.push_str(text);
193 view.update(cx, |v, cx| v.push_str(text, cx));
194 self.scroll.scroll_to_bottom();
195 cx.notify();
196 return;
197 }
198 self.push(ChatMessage::Agent { text: text.to_string() }, cx);
199 }
200
201 pub fn set_tool_result(&mut self, index: usize, result: Result<(Value, String), String>, cx: &mut Context<Self>) {
203 if let (Some(ChatMessage::Tool(call)), Some(RowView::Tool { views, .. })) = (self.messages.get_mut(index), self.rows.get_mut(index)) {
204 match result {
205 Ok((value, summary)) => {
206 call.result = Some(value);
207 call.summary = summary;
208 call.error = None;
209 }
210 Err(e) => call.error = Some(e),
211 }
212 *views = None;
214 cx.notify();
215 }
216 }
217
218 pub fn is_running(&self) -> bool {
220 self.running
221 }
222
223 pub fn set_running(&mut self, running: bool, cx: &mut Context<Self>) {
225 self.running = running;
226 cx.notify();
227 }
228
229 pub fn set_context(&mut self, context: ChatContext, cx: &mut Context<Self>) {
231 self.context = context;
232 cx.notify();
233 }
234
235 pub fn prompt_text(&self, cx: &App) -> String {
237 self.prompt.read(cx).value().to_string()
238 }
239
240 pub fn set_prompt(&mut self, text: &str, window: &mut Window, cx: &mut Context<Self>) {
242 let text = text.to_string();
243 self.prompt.update(cx, |p, cx| p.set_value(text, window, cx));
244 }
245
246 pub fn focus_prompt(&self, window: &mut Window, cx: &mut Context<Self>) {
248 self.prompt.update(cx, |p, cx| p.focus(window, cx));
249 }
250
251 pub fn send(&mut self, window: &mut Window, cx: &mut Context<Self>) {
255 let text = self.prompt_text(cx);
256 let text = text.trim();
257 if self.running || text.is_empty() {
258 return;
259 }
260 let text = text.to_string();
261 self.prompt.update(cx, |p, cx| p.set_value("", window, cx));
262 self.push(ChatMessage::User { text: text.clone() }, cx);
263 self.running = true;
264 cx.emit(ChatEvent::Send(text));
265 cx.notify();
266 }
267
268 pub fn stop(&mut self, cx: &mut Context<Self>) {
270 if self.running {
271 cx.emit(ChatEvent::Stop);
272 }
273 }
274
275 pub fn toggle_tool(&mut self, index: usize, cx: &mut Context<Self>) {
277 if let Some(RowView::Tool { open, .. }) = self.rows.get_mut(index) {
278 *open = !*open;
279 cx.notify();
280 }
281 }
282
283 fn message_row(&mut self, i: usize, window: &mut Window, cx: &mut Context<Self>) -> AnyElement {
284 let caption = |icon: IconName, who: &'static str| {
285 h_flex()
286 .gap(px(theme::XS))
287 .text_xs()
288 .font_weight(FontWeight::SEMIBOLD)
289 .text_color(color::text_muted())
290 .child(Icon::new(icon).size(px(12.)))
291 .child(who)
292 };
293 match (&self.messages[i], &mut self.rows[i]) {
294 (ChatMessage::User { .. }, RowView::Text(view)) => v_flex()
295 .w_full()
296 .gap(px(theme::XS))
297 .child(caption(IconName::User, "You"))
298 .child(div().w_full().px(px(theme::MD)).py(px(theme::SM)).rounded(px(theme::RADIUS_LG)).bg(color::raised()).child(view.clone()))
299 .into_any_element(),
300 (ChatMessage::Agent { .. }, RowView::Text(view)) => {
301 v_flex().w_full().gap(px(theme::XS)).child(caption(IconName::Bot, "Agent")).child(view.clone()).into_any_element()
302 }
303 (ChatMessage::Tool(call), RowView::Tool { open, views }) => {
304 let (icon, icon_color) = match (&call.result, &call.error) {
305 (_, Some(_)) => (IconName::CircleX, color::danger()),
306 (Some(_), None) => (IconName::SquareTerminal, color::text_muted()),
307 (None, None) => (IconName::LoaderCircle, color::accent_mark()),
308 };
309 let line = h_flex()
310 .id(SharedString::from(format!("chat-tool-{i}")))
311 .w_full()
312 .min_h(px(theme::TARGET_MIN))
313 .px(px(theme::XS))
314 .gap(px(theme::SM))
315 .rounded(px(theme::RADIUS))
316 .cursor_pointer()
317 .hover(|d| d.bg(color::hover()))
318 .child(Icon::new(if *open { IconName::ChevronDown } else { IconName::ChevronRight }).size(px(14.)).text_color(color::text_muted()))
319 .child(Icon::new(icon).size(px(14.)).text_color(icon_color))
320 .child(div().flex_1().min_w_0().truncate().text_sm().text_color(color::text_muted()).child(call.line()))
321 .on_click(cx.listener(move |this, _, _, cx| this.toggle_tool(i, cx)))
322 .test_support();
323 if !*open {
324 return line.into_any_element();
325 }
326 if views.is_none() {
327 let pretty = |v: &Value| serde_json::to_string_pretty(v).unwrap_or_default();
328 let args = pretty(&call.arguments);
329 let result = call.result.as_ref().map(pretty);
330 let a = cx.new(|cx| CodeView::new(CodeLanguage::Json, args, window, cx).max_lines(12));
331 let r = result.map(|r| cx.new(|cx| CodeView::new(CodeLanguage::Json, r, window, cx).max_lines(12)));
332 *views = Some((a, r));
333 }
334 let Some((a, r)) = views.clone() else { return line.into_any_element() };
335 let label = |s: &'static str| div().text_xs().font_weight(FontWeight::SEMIBOLD).text_color(color::text_muted()).child(s.to_uppercase());
336 let error = call.error.clone();
337 v_flex()
338 .w_full()
339 .gap(px(theme::XS))
340 .child(line)
341 .child(
342 v_flex()
343 .w_full()
344 .pl(px(theme::XL))
345 .gap(px(theme::XS))
346 .child(label("Arguments"))
347 .child(a)
348 .when_some(r, |d, r| d.child(label("Result")).child(r))
349 .when_some(error, |d, e| d.child(label("Error")).child(div().text_sm().text_color(color::danger()).child(e))),
350 )
351 .into_any_element()
352 }
353 _ => div().into_any_element(),
354 }
355 }
356
357 fn chips(&self) -> Option<impl IntoElement> {
358 let c = &self.context;
359 let chip = |id: &'static str, icon: IconName, text: String, tip: &'static str| {
360 h_flex()
361 .id(id)
362 .flex_none()
363 .min_h(px(theme::TARGET_MIN))
364 .px(px(theme::SM))
365 .gap(px(theme::XS))
366 .rounded(px(theme::RADIUS_SM))
367 .bg(color::control())
368 .text_xs()
369 .text_color(color::text())
370 .child(Icon::new(icon).size(px(12.)).text_color(color::text_muted()))
371 .child(div().max_w(px(180.)).truncate().child(text))
372 .tooltip(move |window, cx| Tooltip::new(tip).build(window, cx))
373 .into_any_element()
374 };
375 let mut out: Vec<AnyElement> = Vec::new();
376 if !c.workspace.is_empty() {
377 out.push(chip("chat-chip-workspace", IconName::Folder, c.workspace.clone(), "Workspace"));
378 }
379 out.push(match &c.location {
380 Location::Local => chip("chat-chip-location", IconName::HardDrive, "This computer".into(), "The agent runs on this computer"),
381 Location::Remote(host) => chip("chat-chip-location", IconName::Globe, host.clone(), "The agent runs on a remote machine"),
382 });
383 if !c.model.is_empty() {
384 out.push(chip("chat-chip-model", IconName::Cpu, c.model.clone(), "Model"));
385 }
386 Some(h_flex().w_full().flex_wrap().gap(px(theme::XS)).children(out))
387 }
388}
389
390impl Render for ChatPane {
391 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
392 let list: Vec<AnyElement> = (0..self.messages.len()).map(|i| self.message_row(i, window, cx)).collect();
393 let body = if list.is_empty() {
394 div()
395 .flex_1()
396 .min_h_0()
397 .child(EmptyState::new(IconName::Bot, "Ask the agent", "Say what you want done with this workspace. Each command the agent runs shows here, with its arguments and result."))
398 .into_any_element()
399 } else {
400 v_flex()
401 .id("chat-messages")
402 .flex_1()
403 .min_h_0()
404 .w_full()
405 .overflow_y_scroll()
406 .track_scroll(&self.scroll)
407 .p(px(theme::MD))
408 .gap(px(theme::LG))
409 .children(list)
410 .into_any_element()
411 };
412 let action = if self.running {
413 Button::new("chat-stop").small().icon(IconName::Square).label("Stop").tooltip("Stop the agent").on_click(cx.listener(|this, _, _, cx| this.stop(cx)))
414 } else {
415 Button::new("chat-send").small().primary().icon(IconName::ArrowUp).label("Send").tooltip("Send (Enter; Shift+Enter for a new line)").on_click(cx.listener(|this, _, window, cx| this.send(window, cx)))
416 };
417 let footer = v_flex()
418 .flex_none()
419 .w_full()
420 .p(px(theme::SM))
421 .gap(px(theme::SM))
422 .border_t_1()
423 .border_color(color::border())
424 .children(self.chips())
425 .child(
426 v_flex()
427 .w_full()
428 .gap(px(theme::XS))
429 .child(Textarea::new(&self.prompt).w_full())
430 .child(
431 h_flex()
432 .w_full()
433 .gap(px(theme::SM))
434 .child(div().flex_1().min_w_0().truncate().text_xs().text_color(color::text_muted()).child(if self.running { "The agent is working…" } else { "Enter sends · Shift+Enter for a new line" }))
435 .child(action),
436 ),
437 );
438 v_flex().id("chat-pane").size_full().bg(color::panel()).rounded(px(theme::RADIUS_LG)).overflow_hidden().child(body).child(footer)
439 }
440}