Skip to main content

kitchen_ui/
chat.rs

1//! The agent chat as a view an app can dock: the conversation (the user's prompts, the agent's
2//! replies, its tool calls) and a prompt box. No network or model code: the pane emits
3//! [`ChatEvent`]s and the app pushes messages in. The conversation is plain data
4//! ([`ChatMessage`], serializable) so an app can record it (a session file) and restore it.
5
6use 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/// One message of the conversation.
23#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
24#[serde(tag = "role", rename_all = "snake_case")]
25pub enum ChatMessage {
26    /// What the user asked.
27    User {
28        /// Plain text (shown as markdown).
29        text: String,
30    },
31    /// What the agent answered (markdown).
32    Agent {
33        /// Markdown.
34        text: String,
35    },
36    /// A command the agent ran.
37    Tool(ToolCall),
38}
39
40/// A command the agent ran: its name, arguments, result and a one-line summary.
41#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
42pub struct ToolCall {
43    /// The command id (`hist.find_cells`).
44    pub name: String,
45    /// What it did, shortly (`2,539 cells`); shown after the name.
46    #[serde(default)]
47    pub summary: String,
48    /// Its arguments.
49    #[serde(default)]
50    pub arguments: Value,
51    /// Its result, once it finished.
52    #[serde(default, skip_serializing_if = "Option::is_none")]
53    pub result: Option<Value>,
54    /// Why it failed, if it did.
55    #[serde(default, skip_serializing_if = "Option::is_none")]
56    pub error: Option<String>,
57}
58
59impl ToolCall {
60    /// A call of `name` with `arguments`, still running.
61    pub fn new(name: impl Into<String>, arguments: Value) -> Self {
62        ToolCall { name: name.into(), arguments, ..Default::default() }
63    }
64
65    /// The same call, finished with `result` and `summary`.
66    pub fn finished(self, result: Value, summary: impl Into<String>) -> Self {
67        ToolCall { result: Some(result), summary: summary.into(), ..self }
68    }
69
70    /// Whether it is still running (no result, no error).
71    pub fn is_running(&self) -> bool {
72        self.result.is_none() && self.error.is_none()
73    }
74
75    /// The collapsed line: "Ran hist.find_cells · 2,539 cells", "Running …", "Failed …".
76    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/// Where the agent works.
87#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
88pub enum Location {
89    /// This computer.
90    #[default]
91    Local,
92    /// A remote machine (its name).
93    Remote(String),
94}
95
96/// What the chips above the prompt show: the workspace, where the agent runs, the model.
97#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
98pub struct ChatContext {
99    /// The workspace or project (empty: no chip).
100    pub workspace: String,
101    /// This computer or a remote one.
102    pub location: Location,
103    /// The model (empty: no chip).
104    pub model: String,
105}
106
107/// What the pane asks of the app.
108#[derive(Clone, Debug, PartialEq, Eq)]
109pub enum ChatEvent {
110    /// The user sent this prompt (already added to the conversation as a user message, and the
111    /// pane shows the agent as running until [`ChatPane::set_running`]`(false)`).
112    Send(String),
113    /// The user asked the running agent to stop.
114    Stop,
115}
116
117/// What a message shows with: its text view, or a tool call's open state and code views.
118enum RowView {
119    Text(Entity<TextView>),
120    Tool { open: bool, views: Option<(Entity<CodeView>, Option<Entity<CodeView>>)> },
121}
122
123/// The agent chat: a scrolling conversation (user, agent and tool-call rows) above a prompt box
124/// with context chips. A tool call is one line ("Ran hist.find_cells · 2,539 cells") that opens
125/// to its JSON arguments and result. Agent text streams in with [`ChatPane::append_agent`].
126///
127/// The prompt: Enter sends, Shift+Enter is a newline; the Send button becomes Stop while the agent
128/// runs. The pane emits [`ChatEvent`]; the app pushes the agent's messages in
129/// ([`ChatPane::push`], [`ChatPane::append_agent`], [`ChatPane::set_tool_result`]) and says when
130/// it is done ([`ChatPane::set_running`]). [`ChatPane::messages`] is the whole conversation.
131pub 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    /// An empty conversation.
145    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    /// The conversation.
156    pub fn messages(&self) -> &[ChatMessage] {
157        &self.messages
158    }
159
160    /// Replaces the conversation (a restored session).
161    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    /// Adds a message; returns its index.
170    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    /// Appends streamed agent text: to the last message when it is the agent's, else as a new
186    /// agent message.
187    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    /// Finishes the tool call at `index`: its result and summary, or why it failed.
202    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            // Built again with the result on the next draw
213            *views = None;
214            cx.notify();
215        }
216    }
217
218    /// Whether the agent is running (the Send button shows Stop).
219    pub fn is_running(&self) -> bool {
220        self.running
221    }
222
223    /// Says whether the agent is running.
224    pub fn set_running(&mut self, running: bool, cx: &mut Context<Self>) {
225        self.running = running;
226        cx.notify();
227    }
228
229    /// The chips above the prompt.
230    pub fn set_context(&mut self, context: ChatContext, cx: &mut Context<Self>) {
231        self.context = context;
232        cx.notify();
233    }
234
235    /// The prompt's text.
236    pub fn prompt_text(&self, cx: &App) -> String {
237        self.prompt.read(cx).value().to_string()
238    }
239
240    /// Puts `text` in the prompt.
241    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    /// Focuses the prompt.
247    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    /// Sends the prompt (as Enter and the Send button do): adds it as a user message, clears the
252    /// box, shows the agent as running and emits [`ChatEvent::Send`]. Nothing while the agent
253    /// runs or when the prompt is blank.
254    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    /// Asks the running agent to stop (emits [`ChatEvent::Stop`]; the app says when it stopped).
269    pub fn stop(&mut self, cx: &mut Context<Self>) {
270        if self.running {
271            cx.emit(ChatEvent::Stop);
272        }
273    }
274
275    /// Opens or closes the tool call at `index`.
276    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}