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}
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    /// Disables the send button while `true`.
84    pub fn sending(mut self, sending: bool) -> Self {
85        self.sending = sending;
86        self
87    }
88
89    /// Registers a callback fired with the drafted text when the user
90    /// submits (plain Enter, or clicking the send button). The draft is
91    /// cleared immediately after.
92    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    /// Supplies the `/`-command list offered by the completion popover. The
101    /// caller sources these however it likes (e.g. from an agent runtime
102    /// event) — `boltz-ui` takes plain [`CompletionItem`]s only.
103    pub fn set_commands(&mut self, commands: Vec<CompletionItem>, cx: &mut Context<Self>) {
104        self.commands = commands;
105        cx.notify();
106    }
107
108    /// The current draft text.
109    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
251/// Standalone gallery preview for `AgentChatInput` (not registered in the
252/// `Component` catalog since it is a stateful `Entity`, matching
253/// `Combobox`/`Select`'s existing convention in this crate).
254pub 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}