Skip to main content

ui/components/ai/
agent_chat_input.rs

1use std::sync::Arc;
2
3use gpui::{ClickEvent, SharedString};
4
5use crate::InputGroup;
6use crate::prelude::*;
7
8/// A mention/command trigger at the start of a chat draft (`@` or `/`).
9#[derive(Clone, Copy, Debug, PartialEq, Eq)]
10pub enum TriggerChar {
11    Mention,
12    Command,
13}
14
15impl TriggerChar {
16    fn from_char(c: char) -> Option<Self> {
17        match c {
18            '@' => Some(TriggerChar::Mention),
19            '/' => Some(TriggerChar::Command),
20            _ => None,
21        }
22    }
23
24    fn hint_label(self) -> &'static str {
25        match self {
26            TriggerChar::Mention => "Mentioning…",
27            TriggerChar::Command => "Running command…",
28        }
29    }
30    fn icon(self) -> IconName {
31        match self {
32            TriggerChar::Mention => IconName::AtSign,
33            TriggerChar::Command => IconName::Terminal,
34        }
35    }
36}
37
38/// Detects a leading mention/command trigger in `draft`, returning the
39/// trigger plus the query substring up to the first whitespace. Pure
40/// detection only — resolving candidates is the caller's job.
41fn detect_trigger(draft: &str) -> Option<(TriggerChar, &str)> {
42    let mut chars = draft.chars();
43    let first = chars.next()?;
44    let trigger = TriggerChar::from_char(first)?;
45    let rest = &draft[first.len_utf8()..];
46    // A bare trigger followed by whitespace (e.g. "@ ") is no longer in-progress.
47    if rest.starts_with(char::is_whitespace) {
48        return None;
49    }
50    Some((trigger, rest.split_whitespace().next().unwrap_or("")))
51}
52
53/// A multi-line agent chat input on [`InputGroup`] chrome, with a hint row
54/// for `@`/`/` triggers and a send button. Pure builder — the caller owns
55/// the draft text and `sending` state and reacts via callbacks.
56#[derive(IntoElement, RegisterComponent)]
57pub struct AgentChatInput {
58    id: ElementId,
59    draft: SharedString,
60    placeholder: SharedString,
61    sending: bool,
62    on_send: Option<Arc<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>>,
63    on_trigger: Option<Arc<dyn Fn(TriggerChar, SharedString, &mut Window, &mut App) + 'static>>,
64}
65
66impl AgentChatInput {
67    pub fn new(id: impl Into<ElementId>, draft: impl Into<SharedString>) -> Self {
68        Self {
69            id: id.into(),
70            draft: draft.into(),
71            placeholder: "Ask anything…".into(),
72            sending: false,
73            on_send: None,
74            on_trigger: None,
75        }
76    }
77
78    pub fn placeholder(mut self, placeholder: impl Into<SharedString>) -> Self {
79        self.placeholder = placeholder.into();
80        self
81    }
82    /// Disables the send button and shows a busy state while `true`.
83    pub fn sending(mut self, sending: bool) -> Self {
84        self.sending = sending;
85        self
86    }
87
88    pub fn on_send(
89        mut self,
90        handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
91    ) -> Self {
92        self.on_send = Some(Arc::new(handler));
93        self
94    }
95
96    /// Called during render when `draft` starts a mention/command trigger,
97    /// with the trigger char and the query substring typed so far.
98    pub fn on_trigger(
99        mut self,
100        handler: impl Fn(TriggerChar, SharedString, &mut Window, &mut App) + 'static,
101    ) -> Self {
102        self.on_trigger = Some(Arc::new(handler));
103        self
104    }
105}
106
107impl RenderOnce for AgentChatInput {
108    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
109        let trigger = detect_trigger(&self.draft).map(|(t, q)| (t, q.to_string()));
110
111        if let (Some((trigger, query)), Some(on_trigger)) = (&trigger, &self.on_trigger) {
112            on_trigger(*trigger, query.clone().into(), window, cx);
113        }
114
115        let is_empty = self.draft.is_empty();
116        let content = if is_empty {
117            Label::new(self.placeholder).color(Color::Placeholder)
118        } else {
119            Label::new(self.draft)
120        };
121
122        let disabled = self.sending || is_empty;
123        let send_button = IconButton::new(("send", 0usize), IconName::PlayFilled)
124            .icon_size(IconSize::Small)
125            .disabled(disabled)
126            .when_some(self.on_send, |this, handler| {
127                this.on_click(move |event, window, cx| handler(event, window, cx))
128            });
129
130        v_flex()
131            .id(self.id)
132            .w_full()
133            .gap_1()
134            .when_some(trigger, |this, (trigger, query)| {
135                let icon = Icon::new(trigger.icon())
136                    .size(IconSize::XSmall)
137                    .color(Color::Muted);
138                let label = Label::new(trigger.hint_label())
139                    .size(LabelSize::Small)
140                    .color(Color::Muted);
141                this.child(h_flex().gap_1().px_1().child(icon).child(label).when(
142                    !query.is_empty(),
143                    |this| {
144                        this.child(
145                            Label::new(query)
146                                .size(LabelSize::Small)
147                                .color(Color::Accent),
148                        )
149                    },
150                ))
151            })
152            .child(
153                InputGroup::new(div().w_full().min_h(px(64.)).child(content)).trailing(send_button),
154            )
155    }
156}
157
158impl Component for AgentChatInput {
159    fn scope() -> ComponentScope {
160        ComponentScope::Agent
161    }
162
163    fn preview(_window: &mut Window, _cx: &mut App) -> Option<AnyElement> {
164        let sending = AgentChatInput::new("chat-sending", "Explain this function").sending(true);
165        Some(
166            v_flex()
167                .w_96()
168                .gap_4()
169                .child(single_example(
170                    "Empty",
171                    AgentChatInput::new("chat-empty", "").into_any_element(),
172                ))
173                .child(single_example(
174                    "Mention trigger",
175                    AgentChatInput::new("chat-mention", "@readm").into_any_element(),
176                ))
177                .child(single_example("Sending", sending.into_any_element()))
178                .into_any_element(),
179        )
180    }
181}
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186
187    #[test]
188    fn no_trigger_on_plain_text() {
189        assert_eq!(detect_trigger("hello world"), None);
190    }
191    #[test]
192    fn mention_trigger_detected() {
193        assert_eq!(
194            detect_trigger("@alice"),
195            Some((TriggerChar::Mention, "alice"))
196        );
197    }
198    #[test]
199    fn command_trigger_detected() {
200        assert_eq!(
201            detect_trigger("/run tests"),
202            Some((TriggerChar::Command, "run"))
203        );
204    }
205    #[test]
206    fn bare_trigger_has_empty_query() {
207        assert_eq!(detect_trigger("@"), Some((TriggerChar::Mention, "")));
208    }
209    #[test]
210    fn trigger_followed_by_space_is_not_in_progress() {
211        assert_eq!(detect_trigger("@ hello"), None);
212    }
213    #[test]
214    fn non_trigger_leading_char_ignored() {
215        assert_eq!(detect_trigger("hello @alice"), None);
216    }
217}