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