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