1use std::sync::Arc;
2
3use gpui::{ClickEvent, SharedString};
4
5use crate::InputGroup;
6use crate::prelude::*;
7
8#[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
38fn 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 if rest.starts_with(char::is_whitespace) {
48 return None;
49 }
50 Some((trigger, rest.split_whitespace().next().unwrap_or("")))
51}
52
53#[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 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 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}