1use std::cell::Cell;
2use std::rc::Rc;
3
4use gpui::{
5 Bounds, Context, Entity, Focusable, KeyDownEvent, MouseButton, Pixels, Render, SharedString,
6 canvas,
7};
8
9use crate::components::ai::completion_popover::{
10 CompletionItem, CompletionPopover, filter_completions,
11};
12use crate::prelude::*;
13use crate::{InputGroup, TextInput};
14
15fn detect_command_query(text: &str) -> Option<&str> {
19 let token_start = text
20 .rfind(char::is_whitespace)
21 .map(|ix| ix + 1)
22 .unwrap_or(0);
23 text[token_start..].strip_prefix('/')
24}
25
26fn apply_completion(text: &str, insert_text: &str) -> String {
29 let token_start = text
30 .rfind(char::is_whitespace)
31 .map(|ix| ix + 1)
32 .unwrap_or(0);
33 let mut result = text[..token_start].to_string();
34 result.push_str(insert_text);
35 result.push(' ');
36 result
37}
38
39pub struct AgentChatInput {
51 input: Entity<TextInput>,
52 sending: bool,
53 commands: Vec<CompletionItem>,
54 completion_open: bool,
55 completion_selected_ix: usize,
56 input_bounds: Rc<Cell<Option<Bounds<Pixels>>>>,
61 on_submit: Option<Rc<dyn Fn(SharedString, &mut Window, &mut App) + 'static>>,
62 focus_pending: bool,
65}
66
67impl AgentChatInput {
68 pub fn new(cx: &mut Context<Self>, placeholder: impl Into<SharedString>) -> Self {
69 let input = cx.new(|cx| {
70 TextInput::new(cx)
71 .multiline(true)
72 .submit_on_enter(true)
73 .placeholder(placeholder)
74 });
75 cx.observe(&input, |_, _, cx| cx.notify()).detach();
76 Self {
77 input,
78 sending: false,
79 commands: Vec::new(),
80 completion_open: false,
81 completion_selected_ix: 0,
82 input_bounds: Rc::new(Cell::new(None)),
83 on_submit: None,
84 focus_pending: true,
85 }
86 }
87
88 pub fn sending(mut self, sending: bool) -> Self {
90 self.sending = sending;
91 self
92 }
93
94 pub fn on_submit(
98 mut self,
99 handler: impl Fn(SharedString, &mut Window, &mut App) + 'static,
100 ) -> Self {
101 self.on_submit = Some(Rc::new(handler));
102 self
103 }
104
105 pub fn set_commands(&mut self, commands: Vec<CompletionItem>, cx: &mut Context<Self>) {
109 self.commands = commands;
110 cx.notify();
111 }
112
113 pub fn text(&self, cx: &App) -> SharedString {
115 self.input.read(cx).text().to_string().into()
116 }
117
118 fn submit(&mut self, window: &mut Window, cx: &mut Context<Self>) {
119 let text = self.input.read(cx).text().to_string();
120 if text.trim().is_empty() {
121 return;
122 }
123 if let Some(on_submit) = self.on_submit.clone() {
124 on_submit(text.into(), window, cx);
125 }
126 self.input.update(cx, |input, cx| input.clear(cx));
127 self.completion_open = false;
128 cx.notify();
129 }
130
131 fn insert_completion(
132 &mut self,
133 insert_text: SharedString,
134 window: &mut Window,
135 cx: &mut Context<Self>,
136 ) {
137 let text = self.input.read(cx).text().to_string();
138 let new_text = apply_completion(&text, &insert_text);
139 self.input
140 .update(cx, |input, cx| input.set_text(new_text, cx));
141 self.completion_open = false;
142 self.completion_selected_ix = 0;
143 let focus_handle = self.input.read(cx).focus_handle(cx);
144 window.focus(&focus_handle, cx);
145 cx.notify();
146 }
147
148 fn handle_key_down(
149 &mut self,
150 event: &KeyDownEvent,
151 window: &mut Window,
152 cx: &mut Context<Self>,
153 ) {
154 let text = self.input.read(cx).text().to_string();
155 let query = detect_command_query(&text).map(str::to_string);
156 self.completion_open = query.is_some();
157
158 if let Some(query) = query {
159 let matched = filter_completions(&self.commands, &query);
160 match event.keystroke.key.as_str() {
161 "up" if !matched.is_empty() => {
162 self.completion_selected_ix = if self.completion_selected_ix == 0 {
163 matched.len() - 1
164 } else {
165 self.completion_selected_ix - 1
166 };
167 }
168 "down" if !matched.is_empty() => {
169 self.completion_selected_ix = (self.completion_selected_ix + 1) % matched.len();
170 }
171 "enter" => {
172 if let Some(item) = matched.get(self.completion_selected_ix) {
173 let insert_text = item.insert_text.clone();
174 self.insert_completion(insert_text, window, cx);
175 }
176 }
177 "escape" => self.completion_open = false,
178 _ => {}
179 }
180 } else {
181 let modifiers = &event.keystroke.modifiers;
182 let plain_enter = event.keystroke.key == "enter"
183 && !modifiers.shift
184 && !modifiers.control
185 && !modifiers.platform;
186 if plain_enter {
187 self.submit(window, cx);
188 }
189 }
190 cx.notify();
191 }
192}
193
194impl Render for AgentChatInput {
195 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
196 if self.focus_pending {
197 self.focus_pending = false;
198 let focus_handle = self.input.read(cx).focus_handle(cx);
199 window.focus(&focus_handle, cx);
200 }
201 let text = self.input.read(cx).text().to_string();
202 let query = detect_command_query(&text).map(str::to_string);
203 let disabled = self.sending || text.trim().is_empty();
204
205 let send_button = IconButton::new("agent-chat-send", IconName::PlayFilled)
206 .icon_size(IconSize::Small)
207 .disabled(disabled)
208 .on_click(cx.listener(|this, _event, window, cx| {
209 this.submit(window, cx);
210 }));
211
212 let input_bounds = self.input_bounds.clone();
213 let focus_handle = self.input.read(cx).focus_handle(cx);
214 let field = div()
215 .id("agent-chat-input-field")
216 .relative()
217 .w_full()
218 .min_h(px(64.))
219 .on_key_down(cx.listener(Self::handle_key_down))
220 .on_mouse_down(MouseButton::Left, {
221 let focus_handle = focus_handle.clone();
222 move |_event, window, cx| {
223 window.focus(&focus_handle, cx);
224 }
225 })
226 .child(self.input.clone())
227 .child(
228 canvas(
229 move |bounds, _window, _cx| input_bounds.set(Some(bounds)),
230 |_bounds, _state, _window, _cx| {},
231 )
232 .absolute()
233 .top_0()
234 .left_0()
235 .size_full(),
236 );
237
238 let mut root = v_flex()
239 .id("agent-chat-input")
240 .w_full()
241 .gap_1()
242 .child(InputGroup::new(field).trailing(send_button));
243
244 if let Some(query) = query
245 && self.completion_open
246 && let Some(bounds) = self.input_bounds.get()
247 {
248 let entity = cx.entity();
249 let popover = CompletionPopover::new(
250 self.commands.clone(),
251 query,
252 self.completion_selected_ix,
253 bounds,
254 move |insert_text, window, cx| {
255 entity.update(cx, |this, cx| {
256 this.insert_completion(insert_text, window, cx)
257 });
258 },
259 )
260 .render(cx);
261 root = root.child(popover);
262 }
263
264 root
265 }
266}
267
268pub fn agent_chat_input_preview(_window: &mut Window, cx: &mut App) -> AnyElement {
272 cx.new(|cx| {
273 let mut input = AgentChatInput::new(cx, "Ask anything…");
274 input.set_commands(
275 vec![
276 CompletionItem::new("help", "/help").description("Show available commands"),
277 CompletionItem::new("explain", "/explain").description("Explain the selection"),
278 CompletionItem::new("tests", "/tests").description("Generate tests"),
279 ],
280 cx,
281 );
282 input
283 })
284 .into_any_element()
285}
286
287#[cfg(test)]
288mod tests {
289 use super::*;
290
291 #[test]
292 fn no_query_on_plain_text() {
293 assert_eq!(detect_command_query("hello world"), None);
294 }
295
296 #[test]
297 fn command_query_detected_at_start() {
298 assert_eq!(detect_command_query("/run"), Some("run"));
299 }
300
301 #[test]
302 fn command_query_detected_after_whitespace() {
303 assert_eq!(detect_command_query("hello /run"), Some("run"));
304 }
305
306 #[test]
307 fn bare_slash_has_empty_query() {
308 assert_eq!(detect_command_query("/"), Some(""));
309 }
310
311 #[test]
312 fn command_followed_by_space_is_not_in_progress() {
313 assert_eq!(detect_command_query("/run tests"), None);
314 }
315
316 #[test]
317 fn apply_completion_replaces_trailing_token() {
318 assert_eq!(apply_completion("hello /ru", "/run"), "hello /run ");
319 }
320
321 #[test]
322 fn apply_completion_on_bare_slash() {
323 assert_eq!(apply_completion("/", "/help"), "/help ");
324 }
325}