Skip to main content

ui/components/ai/
agent_chat_input.rs

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
15/// Locates the `/`-command token currently being typed: the last
16/// whitespace-delimited word in `text`, if it starts with `/`. Returns the
17/// query substring after the `/` (empty for a bare `/`).
18fn 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
26/// Replaces the in-progress `/`-command token (see [`detect_command_query`])
27/// with `insert_text`, followed by a trailing space.
28fn 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
39/// A multiline agent chat input: a multiline `TextInput` inside `InputGroup`
40/// chrome with a send button, plus a `/`-command [`CompletionPopover`]
41/// triggered by typing `/` at the start of a word. Enter submits (clearing
42/// the draft); Shift/Ctrl/Cmd+Enter inserts a newline.
43///
44/// This input has no cursor-position tracking beyond "typed so far" (see
45/// `TextInput`), so `/`-completion is scoped to the trailing token of the
46/// whole buffer rather than the token under a cursor — a pragmatic v1 limit,
47/// not a full replication of a cursor-aware editor's completion scoping.
48///
49/// Stateful view — create with `cx.new(|cx| AgentChatInput::new(cx, ..))`.
50pub struct AgentChatInput {
51    input: Entity<TextInput>,
52    sending: bool,
53    commands: Vec<CompletionItem>,
54    completion_open: bool,
55    completion_selected_ix: usize,
56    /// Real screen bounds of the input row, captured via an invisible
57    /// `canvas()` measurement child every render and read back on the
58    /// *next* render to position the completion popover (see
59    /// `Combobox::trigger_bounds` for the full rationale).
60    input_bounds: Rc<Cell<Option<Bounds<Pixels>>>>,
61    on_submit: Option<Rc<dyn Fn(SharedString, &mut Window, &mut App) + 'static>>,
62    /// Focus the text field on the next render (once), so opening the input
63    /// leaves the caret ready to type without a click first.
64    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    /// Disables the send button while `true`.
89    pub fn sending(mut self, sending: bool) -> Self {
90        self.sending = sending;
91        self
92    }
93
94    /// Registers a callback fired with the drafted text when the user
95    /// submits (plain Enter, or clicking the send button). The draft is
96    /// cleared immediately after.
97    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    /// Supplies the `/`-command list offered by the completion popover. The
106    /// caller sources these however it likes (e.g. from an agent runtime
107    /// event) — `boltz-ui` takes plain [`CompletionItem`]s only.
108    pub fn set_commands(&mut self, commands: Vec<CompletionItem>, cx: &mut Context<Self>) {
109        self.commands = commands;
110        cx.notify();
111    }
112
113    /// The current draft text.
114    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
268/// Standalone gallery preview for `AgentChatInput` (not registered in the
269/// `Component` catalog since it is a stateful `Entity`, matching
270/// `Combobox`/`Select`'s existing convention in this crate).
271pub 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}