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