Skip to main content

gpui_ai/
prompt_bar.rs

1//! Hybrid-controlled prompt composition with native GPUI text editing.
2
3mod model_picker;
4mod suggestions;
5#[cfg(test)]
6mod tests;
7
8use crate::attachment::{AttachmentEvent, AttachmentStrip};
9use crate::context_meter::format_tokens;
10use crate::control::composed_button;
11use crate::cues::{self, Cue};
12use crate::stream::ProgressState;
13use crate::surface::{eyebrow, meta};
14use crate::theme::SemanticStyledExt as _;
15use gpui::{
16    AnyElement, App, AppContext as _, Bounds, Div, ElementId, Entity, EventEmitter, FocusHandle,
17    Focusable, InteractiveElement as _, IntoElement, MouseButton, ParentElement as _, Pixels,
18    Render, Role, SharedString, Stateful, StatefulInteractiveElement as _, Styled, Subscription,
19    Window, deferred, div, prelude::FluentBuilder as _,
20};
21use gpui_base::StyledExt as _;
22use gpui_base::{Align, Button, POPUP_PRIORITY, Positioner};
23use gpui_component::{
24    ActiveTheme as _, ElementExt as _, Icon, IconName, Sizable as _, h_flex,
25    input::{
26        Enter, Escape, InputEvent, MoveDown, MoveEnd, MoveHome, MoveUp, Textarea, TextareaState,
27    },
28    scroll::ScrollableElement as _,
29    v_flex,
30};
31use std::collections::HashSet;
32
33use model_picker::{prompt_model_control, retain_active_model};
34use suggestions::{PromptToken, SuggestionKey, build_submission};
35
36/// A selectable model offered by a [`PromptBar`].
37///
38/// Beyond its stable ID and label a model may carry a provider (options are
39/// grouped by it), a one-line description, and its context window, so the
40/// picker reads like a catalog rather than a bare list.
41#[derive(Debug, Clone, PartialEq, Eq)]
42pub struct PromptModel {
43    id: SharedString,
44    label: SharedString,
45    provider: Option<SharedString>,
46    description: Option<SharedString>,
47    context_window: Option<u64>,
48    disabled: bool,
49}
50
51impl PromptModel {
52    /// Creates an enabled model with stable identity and a visible label.
53    pub fn new(id: impl Into<SharedString>, label: impl Into<SharedString>) -> Self {
54        Self {
55            id: id.into(),
56            label: label.into(),
57            provider: None,
58            description: None,
59            context_window: None,
60            disabled: false,
61        }
62    }
63
64    /// Sets the provider the model is grouped under (for example "Anthropic").
65    pub fn provider(mut self, provider: impl Into<SharedString>) -> Self {
66        self.provider = Some(provider.into());
67        self
68    }
69
70    /// Adds a one-line description shown under the label.
71    pub fn description(mut self, description: impl Into<SharedString>) -> Self {
72        self.description = Some(description.into());
73        self
74    }
75
76    /// Records the context window in tokens, shown compactly (`200K`).
77    pub fn context_window(mut self, tokens: u64) -> Self {
78        self.context_window = Some(tokens);
79        self
80    }
81
82    /// Sets whether the model can be selected.
83    pub fn disabled(mut self, disabled: bool) -> Self {
84        self.disabled = disabled;
85        self
86    }
87
88    /// Returns the provider name, when set.
89    pub fn provider_name(&self) -> Option<&SharedString> {
90        self.provider.as_ref()
91    }
92
93    /// Returns the description, when set.
94    pub fn description_text(&self) -> Option<&SharedString> {
95        self.description.as_ref()
96    }
97
98    /// Returns the context window in tokens, when set.
99    pub fn context_window_tokens(&self) -> Option<u64> {
100        self.context_window
101    }
102
103    /// Returns the stable model identifier.
104    pub fn id(&self) -> &SharedString {
105        &self.id
106    }
107
108    /// Returns the visible model label.
109    pub fn label(&self) -> &SharedString {
110        &self.label
111    }
112}
113
114/// How a composer arranges its action row.
115///
116/// The row holds a leading cluster — the model, attach, and enhance
117/// controls — and the submit control. Where those sit relative to each
118/// other is a composition choice an application makes, not something the
119/// component should decide for it.
120#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
121pub enum PromptActions {
122    /// The cluster leads, the submit control sits at the trailing end.
123    #[default]
124    Split,
125    /// Everything gathers at the leading edge.
126    Leading,
127    /// Everything gathers at the trailing edge.
128    Trailing,
129}
130
131/// What the composer's submit control shows.
132#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
133pub enum PromptSubmit {
134    /// The words "Send" and "Cancel", in a slot wide enough for both.
135    #[default]
136    Label,
137    /// An arrow, square, at the control's own height — a compact composer
138    /// where the affordance is the shape rather than the word.
139    Glyph,
140}
141
142/// One `@`-mention suggestion.
143#[derive(Debug, Clone, PartialEq, Eq)]
144pub struct PromptMention {
145    id: SharedString,
146    label: SharedString,
147}
148
149impl PromptMention {
150    /// Creates a mention with stable identity and insertion text.
151    pub fn new(id: impl Into<SharedString>, label: impl Into<SharedString>) -> Self {
152        Self {
153            id: id.into(),
154            label: label.into(),
155        }
156    }
157
158    /// Returns the stable mention identifier.
159    pub fn id(&self) -> &SharedString {
160        &self.id
161    }
162
163    /// Returns the visible mention label.
164    pub fn label(&self) -> &SharedString {
165        &self.label
166    }
167}
168
169/// One `/` command suggestion.
170#[derive(Debug, Clone, PartialEq, Eq)]
171pub struct PromptCommand {
172    id: SharedString,
173    label: SharedString,
174    description: Option<SharedString>,
175}
176
177impl PromptCommand {
178    /// Creates a command with stable identity and insertion text.
179    pub fn new(id: impl Into<SharedString>, label: impl Into<SharedString>) -> Self {
180        Self {
181            id: id.into(),
182            label: label.into(),
183            description: None,
184        }
185    }
186
187    /// Adds secondary text used by the suggestion filter and accessible name.
188    pub fn description(mut self, description: impl Into<SharedString>) -> Self {
189        self.description = Some(description.into());
190        self
191    }
192
193    /// Returns the stable command identifier.
194    pub fn id(&self) -> &SharedString {
195        &self.id
196    }
197
198    /// Returns the visible command label.
199    pub fn label(&self) -> &SharedString {
200        &self.label
201    }
202}
203
204/// The composer's attachment type: the shared [`Attachment`](crate::attachment::Attachment) so a file keeps
205/// one identity, kind, and thumbnail from the composer into the message.
206pub use crate::attachment::Attachment as PromptAttachment;
207
208/// A trimmed prompt snapshot emitted for application-owned submission work.
209#[derive(Debug, Clone, PartialEq, Eq)]
210pub struct PromptSubmission {
211    text: SharedString,
212    model_id: Option<SharedString>,
213    attachment_ids: Vec<SharedString>,
214}
215
216impl PromptSubmission {
217    /// Returns the submitted prompt text.
218    pub fn text(&self) -> &SharedString {
219        &self.text
220    }
221
222    /// Returns the selected model, when one is configured.
223    pub fn model_id(&self) -> Option<&SharedString> {
224        self.model_id.as_ref()
225    }
226
227    /// Returns stable IDs for the attachments present at submission time.
228    pub fn attachment_ids(&self) -> &[SharedString] {
229        &self.attachment_ids
230    }
231}
232
233/// An interaction emitted by [`PromptBar`].
234#[derive(Debug, Clone, PartialEq, Eq)]
235pub enum PromptBarEvent {
236    /// The native editor draft changed.
237    DraftChanged {
238        /// Stable prompt-bar identifier.
239        id: SharedString,
240        /// Complete current draft.
241        draft: SharedString,
242    },
243    /// The user submitted a non-empty prompt.
244    Submit {
245        /// Stable prompt-bar identifier.
246        id: SharedString,
247        /// Immutable submission snapshot.
248        submission: PromptSubmission,
249    },
250    /// The user requested cancellation of running work.
251    CancelRequested {
252        /// Stable prompt-bar identifier.
253        id: SharedString,
254    },
255    /// The user selected a model.
256    ModelChanged {
257        /// Stable prompt-bar identifier.
258        id: SharedString,
259        /// Stable selected-model identifier.
260        model_id: SharedString,
261    },
262    /// The user inserted a mention.
263    MentionSelected {
264        /// Stable prompt-bar identifier.
265        id: SharedString,
266        /// Stable selected-mention identifier.
267        mention_id: SharedString,
268    },
269    /// The user inserted a command.
270    CommandSelected {
271        /// Stable prompt-bar identifier.
272        id: SharedString,
273        /// Stable selected-command identifier.
274        command_id: SharedString,
275    },
276    /// The user requested an attachment picker.
277    AttachRequested {
278        /// Stable prompt-bar identifier.
279        id: SharedString,
280    },
281    /// The user removed an attachment.
282    AttachmentRemoved {
283        /// Stable prompt-bar identifier.
284        id: SharedString,
285        /// Stable removed-attachment identifier.
286        attachment_id: SharedString,
287    },
288    /// The user requested application-owned prompt enhancement.
289    EnhanceRequested {
290        /// Stable prompt-bar identifier.
291        id: SharedString,
292        /// Complete draft to enhance.
293        draft: SharedString,
294    },
295}
296
297fn prompt_frame(id: &SharedString) -> Stateful<Div> {
298    v_flex()
299        .id(id.clone())
300        .role(Role::Group)
301        .aria_label("Prompt composer")
302        .w_full()
303        .min_w_0()
304}
305
306fn prompt_listbox(id: ElementId, label: &'static str) -> Stateful<Div> {
307    v_flex().id(id).role(Role::ListBox).aria_label(label)
308}
309
310fn prompt_status(id: ElementId, label: SharedString) -> Stateful<Div> {
311    div().id(id).role(Role::Status).aria_label(label)
312}
313
314fn prompt_control(
315    id: impl Into<ElementId>,
316    label: impl Into<SharedString>,
317    cx: &mut App,
318) -> Button {
319    prompt_control_with_tone(id, label, false, cx)
320}
321
322fn prompt_control_with_tone(
323    id: impl Into<ElementId>,
324    label: impl Into<SharedString>,
325    primary: bool,
326    cx: &mut App,
327) -> Button {
328    let label = label.into();
329    prompt_control_shell(id, label.clone(), primary, cx).child(div().child(label))
330}
331
332/// The shared control chrome without a label child, for controls that stage
333/// their own label slot. `label` is still the accessible name.
334fn prompt_control_shell(
335    id: impl Into<ElementId>,
336    label: SharedString,
337    primary: bool,
338    cx: &mut App,
339) -> Button {
340    let tokens = cx.theme().semantic_tokens();
341    let (background, foreground, border, hover, active) = if primary {
342        (
343            cx.theme().button_primary,
344            cx.theme().button_primary_foreground,
345            cx.theme().primary,
346            cx.theme().button_primary_hover,
347            cx.theme().button_primary_active,
348        )
349    } else {
350        (
351            cx.theme().transparent,
352            cx.theme().foreground,
353            cx.theme().transparent,
354            cx.theme().button_hover,
355            cx.theme().button_active,
356        )
357    };
358    composed_button(id, label.clone())
359        .flex()
360        .items_center()
361        .justify_center()
362        .h(crate::sizing::control_md(cx))
363        .px(tokens.spacing.sm)
364        .border_1()
365        .border_color(border)
366        .rounded(tokens.radius.sm)
367        .bg(background)
368        .text_token(tokens.typography.sm)
369        .text_color(foreground)
370        .hover(|style| style.bg(hover))
371        .active(|style| style.bg(active))
372        .focus_visible(|style| style.border_color(cx.theme().ring))
373        .styles(|styles| {
374            styles.disabled(|style| {
375                style
376                    .bg(cx.theme().muted)
377                    .text_color(cx.theme().muted_foreground)
378            })
379        })
380}
381
382/// A native, hybrid-controlled prompt composer.
383///
384/// The entity retains one upstream [`TextareaState`] so IME, selection, cursor,
385/// clipboard, and multiline editing remain native. Applications own catalogs,
386/// attachments, progress, and every asynchronous operation; the component owns
387/// only transient editor, overlay, and focus state.
388///
389/// # Example
390///
391/// ```no_run
392/// # use gpui_ai::prelude::*;
393/// # use gpui::AppContext;
394/// # fn example(window: &mut gpui::Window, cx: &mut gpui::App) {
395/// let prompt = cx.new(|cx| PromptBar::new("assistant-prompt", window, cx));
396/// prompt.update(cx, |prompt, cx| {
397///     prompt.set_models([PromptModel::new("fast", "Fast")], cx);
398/// });
399/// # }
400/// ```
401pub struct PromptBar {
402    /// Styles the caller put on this component, applied to its own frame.
403    ///
404    /// Last, so a caller outranks the component's defaults - the same rule the
405    /// builder components follow. A wrapper `div` cannot stand in for this:
406    /// a background, a border, or an ink set on a wrapper paints around the
407    /// component rather than on it.
408    style: gpui::StyleRefinement,
409    id: SharedString,
410    editor: Entity<TextareaState>,
411    models: Vec<PromptModel>,
412    selected_model: Option<SharedString>,
413    mentions: Vec<PromptMention>,
414    commands: Vec<PromptCommand>,
415    attachments: Vec<PromptAttachment>,
416    progress: ProgressState,
417    last_draft: String,
418    last_cursor: usize,
419    token: Option<PromptToken>,
420    filtered: Vec<SuggestionKey>,
421    active_suggestion: Option<SuggestionKey>,
422    model_menu_open: bool,
423    active_model: Option<SharedString>,
424    model_trigger_bounds: Bounds<Pixels>,
425    model_trigger_rem_size: Pixels,
426    actions: PromptActions,
427    submit: PromptSubmit,
428    _subscriptions: Vec<Subscription>,
429}
430
431impl PromptBar {
432    /// Creates a prompt composer with one retained native textarea entity.
433    pub fn new(
434        id: impl Into<SharedString>,
435        window: &mut Window,
436        cx: &mut gpui::Context<Self>,
437    ) -> Self {
438        let editor = cx.new(|cx| {
439            TextareaState::new(window, cx)
440                .auto_grow(1, 5)
441                .submit_on_enter(true)
442                .placeholder("Message the agent; use @ mentions or / commands")
443        });
444        let subscription = cx.subscribe_in(
445            &editor,
446            window,
447            |this, editor, event: &InputEvent, window, cx| {
448                this.on_input_event(editor, event, window, cx);
449            },
450        );
451        let observation = cx.observe(&editor, |this, editor, cx| {
452            let cursor = editor.read(cx).cursor();
453            if this.last_cursor != cursor {
454                this.last_cursor = cursor;
455                this.refresh_suggestions(cx);
456            }
457        });
458        Self {
459            style: gpui::StyleRefinement::default(),
460            id: id.into(),
461            editor,
462            models: Vec::new(),
463            selected_model: None,
464            mentions: Vec::new(),
465            commands: Vec::new(),
466            attachments: Vec::new(),
467            progress: ProgressState::Pending,
468            last_draft: String::new(),
469            last_cursor: 0,
470            token: None,
471            filtered: Vec::new(),
472            active_suggestion: None,
473            model_menu_open: false,
474            active_model: None,
475            model_trigger_bounds: Bounds::default(),
476            model_trigger_rem_size: window.rem_size(),
477            actions: PromptActions::default(),
478            submit: PromptSubmit::default(),
479            _subscriptions: vec![subscription, observation],
480        }
481    }
482
483    /// Replaces the model catalog while preserving a still-valid selection.
484    ///
485    /// A catalog repeating a stable ID is ignored, atomically.
486    pub fn set_models(
487        &mut self,
488        models: impl IntoIterator<Item = PromptModel>,
489        cx: &mut gpui::Context<Self>,
490    ) {
491        let models: Vec<_> = models.into_iter().collect();
492        if !stable_ids_are_unique(models.iter().map(PromptModel::id)) {
493            return;
494        }
495        if self.models == models {
496            return;
497        }
498        self.models = models;
499        if self.models.is_empty() {
500            self.model_menu_open = false;
501        }
502        if self.selected_model.as_ref().is_none_or(|selected| {
503            !self
504                .models
505                .iter()
506                .any(|model| &model.id == selected && !model.disabled)
507        }) {
508            self.selected_model = self
509                .models
510                .iter()
511                .find(|model| !model.disabled)
512                .map(|model| model.id.clone());
513        }
514        self.active_model = retain_active_model(
515            self.active_model.take(),
516            self.selected_model.as_ref(),
517            &self.models,
518        );
519        cx.notify();
520    }
521
522    /// Applies a valid consumer-controlled model selection.
523    pub fn set_selected_model(
524        &mut self,
525        model_id: impl Into<SharedString>,
526        cx: &mut gpui::Context<Self>,
527    ) {
528        let model_id = model_id.into();
529        if self.selected_model.as_ref() == Some(&model_id)
530            || !self
531                .models
532                .iter()
533                .any(|model| model.id == model_id && !model.disabled)
534        {
535            return;
536        }
537        self.active_model = Some(model_id.clone());
538        self.selected_model = Some(model_id);
539        cx.notify();
540    }
541
542    /// Replaces the `@` mention catalog.
543    ///
544    /// A catalog repeating a stable ID is ignored, atomically.
545    pub fn set_mentions(
546        &mut self,
547        mentions: impl IntoIterator<Item = PromptMention>,
548        cx: &mut gpui::Context<Self>,
549    ) {
550        let mentions: Vec<_> = mentions.into_iter().collect();
551        if !stable_ids_are_unique(mentions.iter().map(PromptMention::id)) {
552            return;
553        }
554        if self.mentions != mentions {
555            self.mentions = mentions;
556            if !self.refresh_suggestions(cx) {
557                cx.notify();
558            }
559        }
560    }
561
562    /// Replaces the `/` command catalog.
563    ///
564    /// A catalog repeating a stable ID is ignored, atomically.
565    pub fn set_commands(
566        &mut self,
567        commands: impl IntoIterator<Item = PromptCommand>,
568        cx: &mut gpui::Context<Self>,
569    ) {
570        let commands: Vec<_> = commands.into_iter().collect();
571        if !stable_ids_are_unique(commands.iter().map(PromptCommand::id)) {
572            return;
573        }
574        if self.commands != commands {
575            self.commands = commands;
576            if !self.refresh_suggestions(cx) {
577                cx.notify();
578            }
579        }
580    }
581
582    /// Replaces application-owned attachments.
583    ///
584    /// A snapshot repeating a stable ID is ignored, atomically.
585    pub fn set_attachments(
586        &mut self,
587        attachments: impl IntoIterator<Item = PromptAttachment>,
588        cx: &mut gpui::Context<Self>,
589    ) {
590        let attachments: Vec<_> = attachments.into_iter().collect();
591        if !stable_ids_are_unique(attachments.iter().map(PromptAttachment::id)) {
592            return;
593        }
594        if self.attachments != attachments {
595            self.attachments = attachments;
596            cx.notify();
597        }
598    }
599
600    /// Chooses how the action row arranges its controls.
601    ///
602    /// The default splits them: the model, attach, and enhance cluster
603    /// leads, and submit sits at the trailing end of the row.
604    pub fn set_actions(&mut self, actions: PromptActions, cx: &mut gpui::Context<Self>) {
605        if self.actions != actions {
606            self.actions = actions;
607            cx.notify();
608        }
609    }
610
611    /// Chooses whether the submit control reads as a word or a glyph.
612    pub fn set_submit(&mut self, submit: PromptSubmit, cx: &mut gpui::Context<Self>) {
613        if self.submit != submit {
614            self.submit = submit;
615            cx.notify();
616        }
617    }
618
619    /// Returns how the action row arranges its controls.
620    pub fn actions(&self) -> PromptActions {
621        self.actions
622    }
623
624    /// Returns what the submit control shows.
625    pub fn submit_appearance(&self) -> PromptSubmit {
626        self.submit
627    }
628
629    /// Replaces the application-owned progress snapshot.
630    pub fn set_progress(&mut self, progress: ProgressState, cx: &mut gpui::Context<Self>) {
631        if self.progress != progress {
632            self.progress = progress;
633            cx.notify();
634        }
635    }
636
637    /// Replaces the native editor draft, placing the caret at its UTF-8 byte end
638    /// without rebuilding the editor entity.
639    pub fn set_draft(
640        &mut self,
641        draft: impl Into<SharedString>,
642        window: &mut Window,
643        cx: &mut gpui::Context<Self>,
644    ) {
645        let draft = draft.into().to_string();
646        if self.last_draft == draft {
647            return;
648        }
649        self.last_draft = draft.clone();
650        let cursor = draft.len();
651        self.last_cursor = cursor;
652        self.editor.update(cx, |editor, cx| {
653            editor.set_value(draft, window, cx);
654            editor.set_selected_range(cursor..cursor, cx);
655        });
656        if !self.refresh_suggestions(cx) {
657            cx.notify();
658        }
659    }
660
661    /// Returns the complete current native-editor draft.
662    pub fn draft(&self, cx: &App) -> SharedString {
663        self.editor.read(cx).value().to_string().into()
664    }
665
666    /// Moves keyboard focus into the retained native textarea.
667    pub fn focus(&self, window: &mut Window, cx: &mut App) {
668        self.focus_handle(cx).focus(window, cx);
669    }
670
671    fn move_active_suggestion(&mut self, direction: isize, cx: &mut gpui::Context<Self>) {
672        if self.filtered.is_empty() {
673            return;
674        }
675        let current = self
676            .active_suggestion
677            .as_ref()
678            .and_then(|active| {
679                self.filtered
680                    .iter()
681                    .position(|candidate| candidate == active)
682            })
683            .unwrap_or(0);
684        let next = if direction < 0 {
685            current.checked_sub(1).unwrap_or(self.filtered.len() - 1)
686        } else {
687            (current + 1) % self.filtered.len()
688        };
689        self.active_suggestion = self.filtered.get(next).cloned();
690        cx.notify();
691    }
692
693    fn capture_suggestion_action(
694        &mut self,
695        direction: Option<isize>,
696        cx: &mut gpui::Context<Self>,
697    ) {
698        if self.filtered.is_empty() {
699            return;
700        }
701        match direction {
702            Some(direction) => self.move_active_suggestion(direction, cx),
703            None => {
704                self.token = None;
705                self.filtered.clear();
706                self.active_suggestion = None;
707                cx.notify();
708            }
709        }
710        cx.stop_propagation();
711    }
712
713    fn capture_move_up(&mut self, _: &MoveUp, _: &mut Window, cx: &mut gpui::Context<Self>) {
714        if self.model_menu_open {
715            self.move_active_model(-1, cx);
716            cx.stop_propagation();
717        } else {
718            self.capture_suggestion_action(Some(-1), cx);
719        }
720    }
721
722    fn capture_move_down(&mut self, _: &MoveDown, _: &mut Window, cx: &mut gpui::Context<Self>) {
723        if self.model_menu_open {
724            self.move_active_model(1, cx);
725            cx.stop_propagation();
726        } else {
727            self.capture_suggestion_action(Some(1), cx);
728        }
729    }
730
731    fn capture_move_home(&mut self, _: &MoveHome, _: &mut Window, cx: &mut gpui::Context<Self>) {
732        if self.model_menu_open {
733            self.move_active_model_to_edge(false, cx);
734            cx.stop_propagation();
735        }
736    }
737
738    fn capture_move_end(&mut self, _: &MoveEnd, _: &mut Window, cx: &mut gpui::Context<Self>) {
739        if self.model_menu_open {
740            self.move_active_model_to_edge(true, cx);
741            cx.stop_propagation();
742        }
743    }
744
745    fn capture_escape(&mut self, _: &Escape, window: &mut Window, cx: &mut gpui::Context<Self>) {
746        let handled = if self.model_menu_open {
747            self.model_menu_open = false;
748            true
749        } else if self.token.is_some() {
750            self.token = None;
751            self.filtered.clear();
752            self.active_suggestion = None;
753            true
754        } else {
755            false
756        };
757        if handled {
758            self.focus(window, cx);
759            cx.notify();
760            cx.stop_propagation();
761        }
762    }
763
764    fn capture_enter(&mut self, action: &Enter, window: &mut Window, cx: &mut gpui::Context<Self>) {
765        if action.shift {
766            return;
767        }
768        if self.model_menu_open {
769            self.confirm_active_model(window, cx);
770        } else if self.token.is_some() && self.active_suggestion.is_some() {
771            self.insert_active_suggestion(window, cx);
772        } else {
773            self.submit(window, cx);
774        }
775        cx.stop_propagation();
776    }
777
778    fn insert_active_suggestion(&mut self, window: &mut Window, cx: &mut gpui::Context<Self>) {
779        let (Some(token), Some(active)) = (self.token.clone(), self.active_suggestion.clone())
780        else {
781            return;
782        };
783        let separator = {
784            let draft = self.editor.read(cx).value();
785            if draft
786                .get(token.range.end..)
787                .and_then(|suffix| suffix.chars().next())
788                .is_none_or(|character| !character.is_whitespace())
789            {
790                " "
791            } else {
792                ""
793            }
794        };
795        let (replacement, event) = match active {
796            SuggestionKey::Mention(id) => {
797                let Some(mention) = self.mentions.iter().find(|mention| mention.id == id) else {
798                    return;
799                };
800                (
801                    format!("@{}{separator}", mention.label),
802                    PromptBarEvent::MentionSelected {
803                        id: self.id.clone(),
804                        mention_id: mention.id.clone(),
805                    },
806                )
807            }
808            SuggestionKey::Command(id) => {
809                let Some(command) = self.commands.iter().find(|command| command.id == id) else {
810                    return;
811                };
812                (
813                    format!("/{}{separator}", command.label),
814                    PromptBarEvent::CommandSelected {
815                        id: self.id.clone(),
816                        command_id: command.id.clone(),
817                    },
818                )
819            }
820        };
821        self.editor.update(cx, |editor, cx| {
822            editor.set_selected_range(token.range, cx);
823            editor.replace(&replacement, window, cx);
824        });
825        self.token = None;
826        self.filtered.clear();
827        self.active_suggestion = None;
828        cx.emit(event);
829        cx.notify();
830    }
831
832    fn submit(&mut self, window: &mut Window, cx: &mut gpui::Context<Self>) {
833        if self.progress == ProgressState::Running {
834            return;
835        }
836        let draft = self.editor.read(cx).value().to_string();
837        let Some(submission) =
838            build_submission(&draft, self.selected_model.clone(), &self.attachments)
839        else {
840            return;
841        };
842        cx.emit(PromptBarEvent::Submit {
843            id: self.id.clone(),
844            submission,
845        });
846        cues::emit(cx, Cue::Submitted);
847        self.last_draft.clear();
848        self.editor.update(cx, |editor, cx| {
849            editor.set_value("", window, cx);
850        });
851        self.token = None;
852        self.filtered.clear();
853        self.active_suggestion = None;
854        cx.notify();
855    }
856}
857
858impl EventEmitter<PromptBarEvent> for PromptBar {}
859
860impl Focusable for PromptBar {
861    fn focus_handle(&self, cx: &App) -> FocusHandle {
862        self.editor.focus_handle(cx)
863    }
864}
865
866impl gpui::Styled for PromptBar {
867    fn style(&mut self) -> &mut gpui::StyleRefinement {
868        &mut self.style
869    }
870}
871
872impl Render for PromptBar {
873    fn render(&mut self, window: &mut Window, cx: &mut gpui::Context<Self>) -> impl IntoElement {
874        let tokens = cx.theme().semantic_tokens();
875        let root_id = self.id.clone();
876        let suggestions = self
877            .filtered
878            .iter()
879            .filter_map(|key| {
880                let label = self.suggestion_label(key)?;
881                let key = key.clone();
882                let selected = self.active_suggestion.as_ref() == Some(&key);
883                Some(
884                    prompt_control(
885                        (
886                            gpui::ElementId::from(root_id.clone()),
887                            format!("suggestion-{key:?}"),
888                        ),
889                        label.clone(),
890                        cx,
891                    )
892                    .role(Role::ListBoxOption)
893                    .selected(selected)
894                    .aria_selected(selected)
895                    .w_full()
896                    .when(selected, |button| button.bg(cx.theme().accent))
897                    .on_click(cx.listener(move |this, _, window, cx| {
898                        this.active_suggestion = Some(key.clone());
899                        this.insert_active_suggestion(window, cx);
900                    })),
901                )
902            })
903            .collect::<Vec<_>>();
904        let attachments = self.attachments.clone();
905        let selected_model_label: SharedString = self
906            .selected_model
907            .as_ref()
908            .and_then(|selected| self.models.iter().find(|model| &model.id == selected))
909            .map(|model| format!("Model: {}", model.label).into())
910            .unwrap_or_else(|| "No models available".into());
911        let draft = self.editor.read(cx).value().to_string();
912        let running = self.progress == ProgressState::Running;
913        // Send and Cancel share one slot; the face the control settles into
914        // fades in once per state, and the state it mounts with is exempt.
915        let submit_ack = crate::motion::acknowledged_state(
916            ElementId::from((ElementId::from(self.id.clone()), "submit-swap")),
917            running as u64,
918            window,
919            cx,
920        );
921        let progress_text = match &self.progress {
922            ProgressState::Pending => None,
923            ProgressState::Running => Some(SharedString::from("Running; cancel is available")),
924            ProgressState::Complete => Some(SharedString::from("Ready for another prompt")),
925            ProgressState::Failed(reason) => Some(format!("Failed: {reason}").into()),
926        };
927
928        prompt_frame(&self.id)
929            .gap(tokens.spacing.sm)
930            .p(tokens.spacing.md)
931            .border_1()
932            .border_color(cx.theme().border)
933            .rounded(tokens.radius.lg)
934            .bg(tokens.colors.surface)
935            .capture_action(cx.listener(Self::capture_enter))
936            .capture_action(cx.listener(Self::capture_move_up))
937            .capture_action(cx.listener(Self::capture_move_down))
938            .capture_action(cx.listener(Self::capture_move_home))
939            .capture_action(cx.listener(Self::capture_move_end))
940            .capture_action(cx.listener(Self::capture_escape))
941            .when(!attachments.is_empty(), |this| {
942                this.child(
943                    AttachmentStrip::new((gpui::ElementId::from(root_id.clone()), "attachments"))
944                        .label("Prompt attachments")
945                        .items(attachments)
946                        .removable(true)
947                        .compact(true)
948                        .on_event(cx.listener(|this, event: &AttachmentEvent, _, cx| {
949                            if let AttachmentEvent::Removed { id } = event {
950                                cx.emit(PromptBarEvent::AttachmentRemoved {
951                                    id: this.id.clone(),
952                                    attachment_id: id.clone(),
953                                });
954                            }
955                        })),
956                )
957            })
958            .child(
959                div()
960                    .debug_selector(|| "prompt-bar-editor".to_owned())
961                    .w_full()
962                    .child(Textarea::new(&self.editor)),
963            )
964            .when(!suggestions.is_empty(), |this| {
965                this.child(
966                    crate::popup::popover_surface(
967                        prompt_listbox(
968                            (gpui::ElementId::from(root_id.clone()), "suggestions").into(),
969                            "Prompt suggestions",
970                        ),
971                        cx,
972                    )
973                    .debug_selector(|| "prompt-bar-suggestions".to_owned())
974                    // A list of mentions and commands is a panel over the
975                    // composer, not part of it. It carried no surface at
976                    // all, so on a dark theme it was invisible: nothing
977                    // said anything had opened.
978                    .p(tokens.spacing.xs)
979                    .max_h(tokens.spacing.xxl + tokens.spacing.xxl + tokens.spacing.xxl)
980                    .overflow_y_scrollbar()
981                    .children(suggestions),
982                )
983            })
984            .when_some(progress_text, |this, progress_text| {
985                this.child(
986                    prompt_status(
987                        (gpui::ElementId::from(root_id.clone()), "progress").into(),
988                        progress_text.clone(),
989                    )
990                    .text_token(tokens.typography.xs)
991                    .text_color(cx.theme().muted_foreground)
992                    .child(progress_text),
993                )
994            })
995            .child(
996                h_flex()
997                    .w_full()
998                    .min_w_0()
999                    .items_center()
1000                    .flex_wrap()
1001                    .gap(tokens.spacing.xs)
1002                    // The arrangement is the application's: a split row
1003                    // leads with the cluster and ends with submit, and the
1004                    // gathered arrangements put everything on one side.
1005                    .map(|row| match self.actions {
1006                        PromptActions::Split => row.justify_between(),
1007                        PromptActions::Leading => row.justify_start(),
1008                        PromptActions::Trailing => row.justify_end(),
1009                    })
1010                    .child(
1011                        h_flex()
1012                            .flex_wrap()
1013                            .items_center()
1014                            .gap(tokens.spacing.xs)
1015                            .child(if self.models.is_empty() {
1016                                prompt_status(
1017                                    (gpui::ElementId::from(root_id.clone()), "model-empty").into(),
1018                                    selected_model_label.clone(),
1019                                )
1020                                .debug_selector(|| "prompt-bar-model-empty".to_owned())
1021                                .text_token(tokens.typography.sm)
1022                                .text_color(cx.theme().muted_foreground)
1023                                .child(selected_model_label)
1024                                .into_any_element()
1025                            } else {
1026                                let prompt = cx.entity();
1027                                div()
1028                                    .on_prepaint(move |bounds, window, cx| {
1029                                        let rem_size = window.rem_size();
1030                                        let changed = prompt.update(cx, |prompt, _| {
1031                                            let changed = prompt.model_trigger_bounds != bounds
1032                                                || prompt.model_trigger_rem_size != rem_size;
1033                                            prompt.model_trigger_bounds = bounds;
1034                                            prompt.model_trigger_rem_size = rem_size;
1035                                            changed
1036                                        });
1037                                        if changed {
1038                                            window.request_animation_frame();
1039                                        }
1040                                    })
1041                                    .child(
1042                                        prompt_model_control(
1043                                            (gpui::ElementId::from(root_id.clone()), "model"),
1044                                            selected_model_label,
1045                                            self.model_menu_open,
1046                                            cx,
1047                                        )
1048                                        .debug_selector(|| "prompt-bar-model-trigger".to_owned())
1049                                        .border_color(cx.theme().border)
1050                                        .when(self.model_menu_open, |button| {
1051                                            button.bg(cx.theme().accent)
1052                                        })
1053                                        .on_mouse_down(MouseButton::Left, |_, _, cx| {
1054                                            cx.stop_propagation();
1055                                        })
1056                                        .on_click(
1057                                            cx.listener(|this, _, window, cx| {
1058                                                this.toggle_model_menu(window, cx);
1059                                            }),
1060                                        ),
1061                                    )
1062                                    .into_any_element()
1063                            })
1064                            .child(
1065                                prompt_control(
1066                                    (gpui::ElementId::from(root_id.clone()), "attach"),
1067                                    "Attach",
1068                                    cx,
1069                                )
1070                                .debug_selector(|| "prompt-bar-attach-control".to_owned())
1071                                .on_click(cx.listener(
1072                                    |this, _, _, cx| {
1073                                        cx.emit(PromptBarEvent::AttachRequested {
1074                                            id: this.id.clone(),
1075                                        });
1076                                    },
1077                                )),
1078                            )
1079                            .child(
1080                                prompt_control(
1081                                    (gpui::ElementId::from(root_id.clone()), "enhance"),
1082                                    "Enhance",
1083                                    cx,
1084                                )
1085                                .debug_selector(|| "prompt-bar-enhance-control".to_owned())
1086                                .disabled(draft.trim().is_empty() || running)
1087                                .on_click(cx.listener(
1088                                    |this, _, _, cx| {
1089                                        let draft = this.editor.read(cx).value().to_string();
1090                                        cx.emit(PromptBarEvent::EnhanceRequested {
1091                                            id: this.id.clone(),
1092                                            draft: draft.into(),
1093                                        });
1094                                    },
1095                                )),
1096                            ),
1097                    )
1098                    .child(
1099                        prompt_control_shell(
1100                            (gpui::ElementId::from(root_id.clone()), "submit"),
1101                            if running {
1102                                "Cancel".into()
1103                            } else {
1104                                "Send".into()
1105                            },
1106                            true,
1107                            cx,
1108                        )
1109                        .map(|button| match self.submit {
1110                            // A glyph composer says what it does with a
1111                            // shape: one square control at its own height,
1112                            // an arrow to send and a square to cancel.
1113                            PromptSubmit::Glyph => button
1114                                .w(crate::sizing::control_lg(cx))
1115                                .px(gpui::Pixels::ZERO)
1116                                .justify_center()
1117                                .rounded(cx.theme().radius_full())
1118                                .child(
1119                                    div().opacity(submit_ack).child(
1120                                        Icon::new(if running {
1121                                            IconName::Close
1122                                        } else {
1123                                            IconName::ArrowUp
1124                                        })
1125                                        .small(),
1126                                    ),
1127                                ),
1128                            // Zero-height ghosts of both faces hold the slot
1129                            // at the widest label, so Send↔Cancel swaps
1130                            // without nudging the composer row.
1131                            PromptSubmit::Label => button.child(
1132                                v_flex()
1133                                    .relative()
1134                                    .items_center()
1135                                    .children(["Send", "Cancel"].map(|ghost| {
1136                                        div()
1137                                            .h(gpui::rems(0.))
1138                                            .overflow_hidden()
1139                                            .opacity(0.)
1140                                            .child(ghost)
1141                                    }))
1142                                    .child(div().opacity(submit_ack).child(if running {
1143                                        "Cancel"
1144                                    } else {
1145                                        "Send"
1146                                    })),
1147                            ),
1148                        })
1149                        .when(running, |button| {
1150                            button.debug_selector(|| "prompt-bar-cancel-control".to_owned())
1151                        })
1152                        .when(!running, |button| {
1153                            button.debug_selector(|| "prompt-bar-send-control".to_owned())
1154                        })
1155                        .disabled(!running && draft.trim().is_empty())
1156                        .on_click(cx.listener(
1157                            move |this, _, window, cx| {
1158                                if running {
1159                                    cx.emit(PromptBarEvent::CancelRequested {
1160                                        id: this.id.clone(),
1161                                    });
1162                                    cues::emit(cx, Cue::Cancelled);
1163                                } else {
1164                                    this.submit(window, cx);
1165                                }
1166                            },
1167                        )),
1168                    ),
1169            )
1170            .when(self.model_menu_open, |this| {
1171                this.child(self.render_model_picker(&root_id, window, cx))
1172            })
1173            .refine_style(&self.style)
1174    }
1175}
1176
1177/// Rejects a catalog that repeats a stable ID.
1178///
1179/// Repeated IDs alias `ElementId`s, so a second entry would silently take the
1180/// first one's focus, hover, and reveal state. [`Chat`](crate::chat::Chat) and
1181/// [`RecordsTable`](crate::records_table::RecordsTable) reject malformed
1182/// controlled snapshots the same way.
1183fn stable_ids_are_unique<'a>(mut ids: impl Iterator<Item = &'a SharedString>) -> bool {
1184    let mut seen = HashSet::new();
1185    ids.all(|id| seen.insert(id))
1186}