Skip to repository content

tenant.openagents/omega

No repository description is available.

OpenAgents Git authority 2026-07-28T06:31:58.910Z Public web read
NIP-34 coordinate30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omega
MaintainersHidden in public view
References2 branches · 1 tag
Read-only clonegit clone https://openagents.com/git/tenant.openagents/omega.git
Browse files

keystroke_input.rs

1525 lines · 55.8 KB · rust
1use gpui::{
2    Animation, AnimationExt, Context, EventEmitter, FocusHandle, Focusable, FontWeight, KeyContext,
3    KeybindingKeystroke, Keystroke, Modifiers, ModifiersChangedEvent, Subscription, Task, actions,
4};
5use ui::{
6    ActiveTheme as _, Color, IconButton, IconButtonShape, IconName, IconSize, Label, LabelSize,
7    ParentElement as _, Render, Styled as _, Tooltip, Window, prelude::*,
8};
9
10actions!(
11    keystroke_input,
12    [
13        /// Starts recording keystrokes
14        StartRecording,
15        /// Stops recording keystrokes
16        StopRecording,
17        /// Clears the recorded keystrokes
18        ClearKeystrokes,
19    ]
20);
21
22const KEY_CONTEXT_VALUE: &str = "KeystrokeInput";
23
24const CLOSE_KEYSTROKE_CAPTURE_END_TIMEOUT: std::time::Duration =
25    std::time::Duration::from_millis(300);
26
27enum CloseKeystrokeResult {
28    Partial,
29    Close,
30    None,
31}
32
33impl PartialEq for CloseKeystrokeResult {
34    fn eq(&self, other: &Self) -> bool {
35        matches!(
36            (self, other),
37            (CloseKeystrokeResult::Partial, CloseKeystrokeResult::Partial)
38                | (CloseKeystrokeResult::Close, CloseKeystrokeResult::Close)
39                | (CloseKeystrokeResult::None, CloseKeystrokeResult::None)
40        )
41    }
42}
43
44pub struct KeystrokeInput {
45    keystrokes: Vec<KeybindingKeystroke>,
46    placeholder_keystrokes: Option<Vec<KeybindingKeystroke>>,
47    outer_focus_handle: FocusHandle,
48    inner_focus_handle: FocusHandle,
49    intercept_subscription: Option<Subscription>,
50    _focus_subscriptions: [Subscription; 2],
51    search: bool,
52    /// The sequence of close keystrokes being typed
53    close_keystrokes: Option<Vec<Keystroke>>,
54    close_keystrokes_start: Option<usize>,
55    previous_modifiers: Modifiers,
56    /// In order to support inputting keystrokes that end with a prefix of the
57    /// close keybind keystrokes, we clear the close keystroke capture info
58    /// on a timeout after a close keystroke is pressed
59    ///
60    /// e.g. if close binding is `esc esc esc` and user wants to search for
61    /// `ctrl-g esc`, after entering the `ctrl-g esc`, hitting `esc` twice would
62    /// stop recording because of the sequence of three escapes making it
63    /// impossible to search for anything ending in `esc`
64    clear_close_keystrokes_timer: Option<Task<()>>,
65    #[cfg(test)]
66    recording: bool,
67    pub actions_slot: Option<AnyElement>,
68}
69
70impl KeystrokeInput {
71    const KEYSTROKE_COUNT_MAX: usize = 3;
72
73    pub fn new(
74        placeholder_keystrokes: Option<Vec<KeybindingKeystroke>>,
75        window: &mut Window,
76        cx: &mut Context<Self>,
77    ) -> Self {
78        let outer_focus_handle = cx.focus_handle();
79        let inner_focus_handle = cx.focus_handle();
80        let _focus_subscriptions = [
81            cx.on_focus_in(&inner_focus_handle, window, Self::on_inner_focus_in),
82            cx.on_focus_out(&inner_focus_handle, window, Self::on_inner_focus_out),
83        ];
84        Self {
85            keystrokes: Vec::new(),
86            placeholder_keystrokes,
87            inner_focus_handle,
88            outer_focus_handle,
89            intercept_subscription: None,
90            _focus_subscriptions,
91            search: false,
92            close_keystrokes: None,
93            close_keystrokes_start: None,
94            previous_modifiers: Modifiers::default(),
95            clear_close_keystrokes_timer: None,
96            #[cfg(test)]
97            recording: false,
98            actions_slot: None,
99        }
100    }
101
102    pub fn set_keystrokes(&mut self, keystrokes: Vec<KeybindingKeystroke>, cx: &mut Context<Self>) {
103        self.keystrokes = keystrokes;
104        self.keystrokes_changed(cx);
105    }
106
107    pub fn set_search(&mut self, search: bool) {
108        self.search = search;
109    }
110
111    pub fn keystrokes(&self) -> &[KeybindingKeystroke] {
112        if let Some(placeholders) = self.placeholder_keystrokes.as_ref()
113            && self.keystrokes.is_empty()
114        {
115            return placeholders;
116        }
117        if !self.search
118            && self
119                .keystrokes
120                .last()
121                .is_some_and(|last| last.key().is_empty())
122        {
123            return &self.keystrokes[..self.keystrokes.len() - 1];
124        }
125        &self.keystrokes
126    }
127
128    fn dummy(modifiers: Modifiers) -> KeybindingKeystroke {
129        KeybindingKeystroke::from_keystroke(Keystroke {
130            modifiers,
131            key: "".to_string(),
132            key_char: None,
133        })
134    }
135
136    fn keystrokes_changed(&self, cx: &mut Context<Self>) {
137        cx.emit(());
138        cx.notify();
139    }
140
141    fn key_context() -> KeyContext {
142        let mut key_context = KeyContext::default();
143        key_context.add(KEY_CONTEXT_VALUE);
144        key_context
145    }
146
147    fn determine_stop_recording_binding(window: &mut Window) -> Option<gpui::KeyBinding> {
148        if cfg!(test) {
149            Some(gpui::KeyBinding::new(
150                "escape escape escape",
151                StopRecording,
152                Some(KEY_CONTEXT_VALUE),
153            ))
154        } else {
155            window.highest_precedence_binding_for_action_in_context(
156                &StopRecording,
157                Self::key_context(),
158            )
159        }
160    }
161
162    fn upsert_close_keystrokes_start(&mut self, start: usize, cx: &mut Context<Self>) {
163        if self.close_keystrokes_start.is_some() {
164            return;
165        }
166        self.close_keystrokes_start = Some(start);
167        self.update_clear_close_keystrokes_timer(cx);
168    }
169
170    fn update_clear_close_keystrokes_timer(&mut self, cx: &mut Context<Self>) {
171        self.clear_close_keystrokes_timer = Some(cx.spawn(async |this, cx| {
172            cx.background_executor()
173                .timer(CLOSE_KEYSTROKE_CAPTURE_END_TIMEOUT)
174                .await;
175            this.update(cx, |this, _cx| {
176                this.end_close_keystrokes_capture();
177            })
178            .ok();
179        }));
180    }
181
182    /// Interrupt the capture of close keystrokes, but do not clear the close keystrokes
183    /// from the input
184    fn end_close_keystrokes_capture(&mut self) -> Option<usize> {
185        self.close_keystrokes.take();
186        self.clear_close_keystrokes_timer.take();
187        self.close_keystrokes_start.take()
188    }
189
190    fn handle_possible_close_keystroke(
191        &mut self,
192        keystroke: &Keystroke,
193        window: &mut Window,
194        cx: &mut Context<Self>,
195    ) -> CloseKeystrokeResult {
196        let Some(keybind_for_close_action) = Self::determine_stop_recording_binding(window) else {
197            log::trace!("No keybinding to stop recording keystrokes in keystroke input");
198            self.end_close_keystrokes_capture();
199            return CloseKeystrokeResult::None;
200        };
201        let action_keystrokes = keybind_for_close_action.keystrokes();
202
203        if let Some(mut close_keystrokes) = self.close_keystrokes.take() {
204            let mut index = 0;
205
206            while index < action_keystrokes.len() && index < close_keystrokes.len() {
207                if !close_keystrokes[index].should_match(&action_keystrokes[index]) {
208                    break;
209                }
210                index += 1;
211            }
212            if index == close_keystrokes.len() {
213                if index >= action_keystrokes.len() {
214                    self.end_close_keystrokes_capture();
215                    return CloseKeystrokeResult::None;
216                }
217                if keystroke.should_match(&action_keystrokes[index]) {
218                    close_keystrokes.push(keystroke.clone());
219                    if close_keystrokes.len() == action_keystrokes.len() {
220                        return CloseKeystrokeResult::Close;
221                    } else {
222                        self.close_keystrokes = Some(close_keystrokes);
223                        self.update_clear_close_keystrokes_timer(cx);
224                        return CloseKeystrokeResult::Partial;
225                    }
226                } else {
227                    self.end_close_keystrokes_capture();
228                    return CloseKeystrokeResult::None;
229                }
230            }
231        } else if let Some(first_action_keystroke) = action_keystrokes.first()
232            && keystroke.should_match(first_action_keystroke)
233        {
234            self.close_keystrokes = Some(vec![keystroke.clone()]);
235            return CloseKeystrokeResult::Partial;
236        }
237        self.end_close_keystrokes_capture();
238        CloseKeystrokeResult::None
239    }
240
241    fn on_modifiers_changed(
242        &mut self,
243        event: &ModifiersChangedEvent,
244        window: &mut Window,
245        cx: &mut Context<Self>,
246    ) {
247        cx.stop_propagation();
248        let keystrokes_len = self.keystrokes.len();
249
250        if self.previous_modifiers.modified()
251            && event.modifiers.is_subset_of(&self.previous_modifiers)
252        {
253            self.previous_modifiers &= event.modifiers;
254            return;
255        }
256        self.keystrokes_changed(cx);
257
258        if let Some(last) = self.keystrokes.last_mut()
259            && last.key().is_empty()
260            && keystrokes_len <= Self::KEYSTROKE_COUNT_MAX
261        {
262            if !self.search && !event.modifiers.modified() {
263                self.keystrokes.pop();
264                return;
265            }
266            if self.search {
267                if self.previous_modifiers.modified() {
268                    let modifiers = *last.modifiers() | event.modifiers;
269                    last.set_modifiers(modifiers);
270                } else {
271                    self.keystrokes.push(Self::dummy(event.modifiers));
272                }
273                self.previous_modifiers |= event.modifiers;
274            } else {
275                last.set_modifiers(event.modifiers);
276                return;
277            }
278        } else if keystrokes_len < Self::KEYSTROKE_COUNT_MAX {
279            self.keystrokes.push(Self::dummy(event.modifiers));
280            if self.search {
281                self.previous_modifiers |= event.modifiers;
282            }
283        }
284        if keystrokes_len >= Self::KEYSTROKE_COUNT_MAX {
285            self.clear_keystrokes(&ClearKeystrokes, window, cx);
286        }
287    }
288
289    fn handle_keystroke(
290        &mut self,
291        keystroke: &Keystroke,
292        window: &mut Window,
293        cx: &mut Context<Self>,
294    ) {
295        cx.stop_propagation();
296
297        let close_keystroke_result = self.handle_possible_close_keystroke(keystroke, window, cx);
298        if close_keystroke_result == CloseKeystrokeResult::Close {
299            self.stop_recording(&StopRecording, window, cx);
300            return;
301        }
302
303        let keystroke = KeybindingKeystroke::new_with_mapper(
304            keystroke.clone(),
305            false,
306            cx.keyboard_mapper().as_ref(),
307        );
308        if let Some(last) = self.keystrokes.last()
309            && last.key().is_empty()
310            && (!self.search || self.previous_modifiers.modified())
311        {
312            self.keystrokes.pop();
313        }
314
315        if close_keystroke_result == CloseKeystrokeResult::Partial {
316            self.upsert_close_keystrokes_start(self.keystrokes.len(), cx);
317            if self.keystrokes.len() >= Self::KEYSTROKE_COUNT_MAX {
318                return;
319            }
320        }
321
322        if self.keystrokes.len() >= Self::KEYSTROKE_COUNT_MAX {
323            self.clear_keystrokes(&ClearKeystrokes, window, cx);
324            return;
325        }
326
327        self.keystrokes.push(keystroke);
328        self.keystrokes_changed(cx);
329
330        // The reason we use the real modifiers from the window instead of the keystroke's modifiers
331        // is that for keystrokes like `ctrl-$` the modifiers reported by keystroke is `ctrl` which
332        // is wrong, it should be `ctrl-shift`. The window's modifiers are always correct.
333        let real_modifiers = window.modifiers();
334        if self.search {
335            self.previous_modifiers = real_modifiers;
336            return;
337        }
338        if self.keystrokes.len() < Self::KEYSTROKE_COUNT_MAX && real_modifiers.modified() {
339            self.keystrokes.push(Self::dummy(real_modifiers));
340        }
341    }
342
343    fn on_inner_focus_in(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
344        if self.intercept_subscription.is_none() {
345            let listener = cx.listener(|this, event: &gpui::KeystrokeEvent, window, cx| {
346                this.handle_keystroke(&event.keystroke, window, cx);
347            });
348            self.intercept_subscription = Some(cx.intercept_keystrokes(listener))
349        }
350    }
351
352    fn on_inner_focus_out(
353        &mut self,
354        _event: gpui::FocusOutEvent,
355        _window: &mut Window,
356        cx: &mut Context<Self>,
357    ) {
358        self.intercept_subscription.take();
359        cx.notify();
360    }
361
362    fn render_keystrokes(&self, is_recording: bool) -> impl Iterator<Item = Div> {
363        let keystrokes = if let Some(placeholders) = self.placeholder_keystrokes.as_ref()
364            && self.keystrokes.is_empty()
365        {
366            if is_recording {
367                &[]
368            } else {
369                placeholders.as_slice()
370            }
371        } else {
372            &self.keystrokes
373        };
374        keystrokes.iter().map(move |keystroke| {
375            h_flex().children(ui::render_keybinding_keystroke(
376                keystroke,
377                Some(Color::Default),
378                Some(rems(0.875).into()),
379                ui::PlatformStyle::platform(),
380                false,
381            ))
382        })
383    }
384
385    pub fn start_recording(
386        &mut self,
387        _: &StartRecording,
388        window: &mut Window,
389        cx: &mut Context<Self>,
390    ) {
391        window.focus(&self.inner_focus_handle, cx);
392        self.clear_keystrokes(&ClearKeystrokes, window, cx);
393        self.previous_modifiers = window.modifiers();
394        #[cfg(test)]
395        {
396            self.recording = true;
397        }
398        cx.stop_propagation();
399    }
400
401    pub fn stop_recording(
402        &mut self,
403        _: &StopRecording,
404        window: &mut Window,
405        cx: &mut Context<Self>,
406    ) {
407        if !self.is_recording(window) {
408            return;
409        }
410        window.focus(&self.outer_focus_handle, cx);
411        if let Some(close_keystrokes_start) = self.close_keystrokes_start.take()
412            && close_keystrokes_start < self.keystrokes.len()
413        {
414            self.keystrokes.drain(close_keystrokes_start..);
415            self.keystrokes_changed(cx);
416        }
417        self.end_close_keystrokes_capture();
418        #[cfg(test)]
419        {
420            self.recording = false;
421        }
422        cx.notify();
423    }
424
425    pub fn clear_keystrokes(
426        &mut self,
427        _: &ClearKeystrokes,
428        _window: &mut Window,
429        cx: &mut Context<Self>,
430    ) {
431        self.keystrokes.clear();
432        self.keystrokes_changed(cx);
433        self.end_close_keystrokes_capture();
434    }
435
436    fn is_recording(&self, window: &Window) -> bool {
437        #[cfg(test)]
438        {
439            if true {
440                // in tests, we just need a simple bool that is toggled on start and stop recording
441                return self.recording;
442            }
443        }
444        // however, in the real world, checking if the inner focus handle is focused
445        // is a much more reliable check, as the intercept keystroke handlers are installed
446        // on focus of the inner focus handle, thereby ensuring our recording state does
447        // not get de-synced
448        self.inner_focus_handle.is_focused(window)
449    }
450
451    pub fn actions_slot(mut self, action: impl IntoElement) -> Self {
452        self.actions_slot = Some(action.into_any_element());
453        self
454    }
455}
456
457impl EventEmitter<()> for KeystrokeInput {}
458
459impl Focusable for KeystrokeInput {
460    fn focus_handle(&self, _cx: &gpui::App) -> FocusHandle {
461        self.outer_focus_handle.clone()
462    }
463}
464
465impl Render for KeystrokeInput {
466    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
467        let colors = cx.theme().colors();
468        let is_focused = self.outer_focus_handle.contains_focused(window, cx);
469        let is_recording = self.is_recording(window);
470
471        let width = rems_from_px(64.);
472
473        let recording_bg_color = colors
474            .editor_background
475            .blend(colors.text_accent.opacity(0.1));
476
477        let recording_pulse = |color: Color| {
478            Icon::new(IconName::Circle)
479                .size(IconSize::Small)
480                .color(Color::Error)
481                .with_animation(
482                    "recording-pulse",
483                    Animation::new(std::time::Duration::from_secs(2))
484                        .repeat()
485                        .with_easing(gpui::pulsating_between(0.4, 0.8)),
486                    {
487                        let color = color.color(cx);
488                        move |this, delta| this.color(Color::Custom(color.opacity(delta)))
489                    },
490                )
491        };
492
493        let recording_indicator = h_flex()
494            .h_4()
495            .pr_1()
496            .gap_0p5()
497            .border_1()
498            .border_color(colors.border)
499            .bg(colors
500                .editor_background
501                .blend(colors.text_accent.opacity(0.1)))
502            .rounded_sm()
503            .child(recording_pulse(Color::Error))
504            .child(
505                Label::new("REC")
506                    .size(LabelSize::XSmall)
507                    .weight(FontWeight::SEMIBOLD)
508                    .color(Color::Error),
509            );
510
511        let search_indicator = h_flex()
512            .h_4()
513            .pr_1()
514            .gap_0p5()
515            .border_1()
516            .border_color(colors.border)
517            .bg(colors
518                .editor_background
519                .blend(colors.text_accent.opacity(0.1)))
520            .rounded_sm()
521            .child(recording_pulse(Color::Accent))
522            .child(
523                Label::new("SEARCH")
524                    .size(LabelSize::XSmall)
525                    .weight(FontWeight::SEMIBOLD)
526                    .color(Color::Accent),
527            );
528
529        let record_icon = if self.search {
530            IconName::MagnifyingGlass
531        } else {
532            IconName::PlayFilled
533        };
534
535        h_flex()
536            .id("keystroke-input")
537            .track_focus(&self.outer_focus_handle)
538            .key_context(Self::key_context())
539            .on_action(cx.listener(Self::start_recording))
540            .on_action(cx.listener(Self::clear_keystrokes))
541            .py_2()
542            .px_3()
543            .gap_2()
544            .min_h_10()
545            .w_full()
546            .flex_1()
547            .justify_between()
548            .rounded_md()
549            .overflow_hidden()
550            .map(|this| {
551                if is_recording {
552                    this.bg(recording_bg_color)
553                } else {
554                    this.bg(colors.editor_background)
555                }
556            })
557            .border_1()
558            .map(|this| {
559                if is_focused {
560                    this.border_color(colors.border_focused)
561                } else {
562                    this.border_color(colors.border_variant)
563                }
564            })
565            .child(
566                h_flex()
567                    .w(width)
568                    .gap_0p5()
569                    .justify_start()
570                    .flex_none()
571                    .when(is_recording, |this| {
572                        this.map(|this| {
573                            if self.search {
574                                this.child(search_indicator)
575                            } else {
576                                this.child(recording_indicator)
577                            }
578                        })
579                    }),
580            )
581            .child(
582                h_flex()
583                    .id("keystroke-input-inner")
584                    .track_focus(&self.inner_focus_handle)
585                    .on_modifiers_changed(cx.listener(Self::on_modifiers_changed))
586                    .when(!self.search, |this| {
587                        this.focus(|mut style| {
588                            style.border_color = Some(colors.border_focused);
589                            style
590                        })
591                    })
592                    .size_full()
593                    .min_w_0()
594                    .justify_center()
595                    .flex_wrap()
596                    .gap_1()
597                    .children(self.render_keystrokes(is_recording)),
598            )
599            .child(
600                h_flex()
601                    .w(width)
602                    .gap_0p5()
603                    .justify_end()
604                    .flex_none()
605                    .map(|this| {
606                        if is_recording {
607                            this.child(
608                                IconButton::new("stop-record-btn", IconName::Stop)
609                                    .shape(IconButtonShape::Square)
610                                    .map(|this| {
611                                        this.tooltip(Tooltip::for_action_title(
612                                            if self.search {
613                                                "Stop Searching"
614                                            } else {
615                                                "Stop Recording"
616                                            },
617                                            &StopRecording,
618                                        ))
619                                    })
620                                    .icon_color(Color::Error)
621                                    .on_click(cx.listener(|this, _event, window, cx| {
622                                        this.stop_recording(&StopRecording, window, cx);
623                                    })),
624                            )
625                        } else {
626                            this.child(
627                                IconButton::new("record-btn", record_icon)
628                                    .shape(IconButtonShape::Square)
629                                    .map(|this| {
630                                        this.tooltip(Tooltip::for_action_title(
631                                            if self.search {
632                                                "Start Searching"
633                                            } else {
634                                                "Start Recording"
635                                            },
636                                            &StartRecording,
637                                        ))
638                                    })
639                                    .when(!is_focused, |this| this.icon_color(Color::Muted))
640                                    .on_click(cx.listener(|this, _event, window, cx| {
641                                        this.start_recording(&StartRecording, window, cx);
642                                    })),
643                            )
644                        }
645                    })
646                    .when_some(self.actions_slot.take(), |this, action| this.child(action))
647                    .when(is_recording, |this| {
648                        this.child(
649                            IconButton::new("clear-btn", IconName::Backspace)
650                                .shape(IconButtonShape::Square)
651                                .tooltip(move |_, cx| {
652                                    Tooltip::with_meta(
653                                        "Clear Keystrokes",
654                                        Some(&ClearKeystrokes),
655                                        "Hit it three times to execute",
656                                        cx,
657                                    )
658                                })
659                                .when(!is_focused, |this| this.icon_color(Color::Muted))
660                                .on_click(cx.listener(|this, _event, window, cx| {
661                                    this.clear_keystrokes(&ClearKeystrokes, window, cx);
662                                })),
663                        )
664                    }),
665            )
666    }
667}
668
669#[cfg(test)]
670mod tests {
671    use super::*;
672    use fs::FakeFs;
673    use gpui::{Entity, TestAppContext, VisualTestContext};
674    use itertools::Itertools as _;
675    use project::Project;
676    use settings::SettingsStore;
677    use workspace::MultiWorkspace;
678
679    pub struct KeystrokeInputTestHelper {
680        input: Entity<KeystrokeInput>,
681        current_modifiers: Modifiers,
682        cx: VisualTestContext,
683    }
684
685    impl KeystrokeInputTestHelper {
686        /// Creates a new test helper with default settings
687        pub fn new(mut cx: VisualTestContext) -> Self {
688            let input = cx.new_window_entity(|window, cx| KeystrokeInput::new(None, window, cx));
689
690            let mut helper = Self {
691                input,
692                current_modifiers: Modifiers::default(),
693                cx,
694            };
695
696            helper.start_recording();
697            helper
698        }
699
700        /// Sets search mode on the input
701        pub fn with_search_mode(&mut self, search: bool) -> &mut Self {
702            self.input.update(&mut self.cx, |input, _| {
703                input.set_search(search);
704            });
705            self
706        }
707
708        /// Sends a keystroke event based on string description
709        /// Examples: "a", "ctrl-a", "cmd-shift-z", "escape"
710        #[track_caller]
711        pub fn send_keystroke(&mut self, keystroke_input: &str) -> &mut Self {
712            self.expect_is_recording(true);
713            let keystroke_str = if keystroke_input.ends_with('-') {
714                format!("{}_", keystroke_input)
715            } else {
716                keystroke_input.to_string()
717            };
718
719            let mut keystroke = Keystroke::parse(&keystroke_str)
720                .unwrap_or_else(|_| panic!("Invalid keystroke: {}", keystroke_input));
721
722            // Remove the dummy key if we added it for modifier-only keystrokes
723            if keystroke_input.ends_with('-') && keystroke_str.ends_with("_") {
724                keystroke.key = "".to_string();
725            }
726
727            // Combine current modifiers with keystroke modifiers
728            keystroke.modifiers |= self.current_modifiers;
729            let real_modifiers = keystroke.modifiers;
730            keystroke = to_gpui_keystroke(keystroke);
731
732            self.update_input(|input, window, cx| {
733                window.set_modifiers(real_modifiers);
734                input.handle_keystroke(&keystroke, window, cx);
735            });
736
737            // Don't update current_modifiers for keystrokes with actual keys
738            if keystroke.key.is_empty() {
739                self.current_modifiers = keystroke.modifiers;
740            }
741            self
742        }
743
744        /// Sends a modifier change event based on string description
745        /// Examples: "+ctrl", "-ctrl", "+cmd+shift", "-all"
746        #[track_caller]
747        pub fn send_modifiers(&mut self, modifiers: &str) -> &mut Self {
748            self.expect_is_recording(true);
749            let new_modifiers = if modifiers == "-all" {
750                Modifiers::default()
751            } else {
752                self.parse_modifier_change(modifiers)
753            };
754
755            let event = ModifiersChangedEvent {
756                modifiers: new_modifiers,
757                capslock: gpui::Capslock::default(),
758            };
759
760            self.update_input(|input, window, cx| {
761                window.set_modifiers(new_modifiers);
762                input.on_modifiers_changed(&event, window, cx);
763            });
764
765            self.current_modifiers = new_modifiers;
766            self
767        }
768
769        /// Sends multiple events in sequence
770        /// Each event string is either a keystroke or modifier change
771        #[track_caller]
772        pub fn send_events(&mut self, events: &[&str]) -> &mut Self {
773            self.expect_is_recording(true);
774            for event in events {
775                if event.starts_with('+') || event.starts_with('-') {
776                    self.send_modifiers(event);
777                } else {
778                    self.send_keystroke(event);
779                }
780            }
781            self
782        }
783
784        #[track_caller]
785        fn expect_keystrokes_equal(actual: &[Keystroke], expected: &[&str]) {
786            let expected_keystrokes: Result<Vec<Keystroke>, _> = expected
787                .iter()
788                .map(|s| {
789                    let keystroke_str = if s.ends_with('-') {
790                        format!("{}_", s)
791                    } else {
792                        s.to_string()
793                    };
794
795                    let mut keystroke = Keystroke::parse(&keystroke_str)?;
796
797                    // Remove the dummy key if we added it for modifier-only keystrokes
798                    if s.ends_with('-') && keystroke_str.ends_with("_") {
799                        keystroke.key = "".to_string();
800                    }
801
802                    Ok(keystroke)
803                })
804                .collect();
805
806            let expected_keystrokes = expected_keystrokes
807                .unwrap_or_else(|e: anyhow::Error| panic!("Invalid expected keystroke: {}", e));
808
809            assert_eq!(
810                actual.len(),
811                expected_keystrokes.len(),
812                "Keystroke count mismatch. Expected: {:?}, Actual: {:?}",
813                expected_keystrokes
814                    .iter()
815                    .map(|k| k.unparse())
816                    .collect::<Vec<_>>(),
817                actual.iter().map(|k| k.unparse()).collect::<Vec<_>>()
818            );
819
820            for (i, (actual, expected)) in actual.iter().zip(expected_keystrokes.iter()).enumerate()
821            {
822                assert_eq!(
823                    actual.unparse(),
824                    expected.unparse(),
825                    "Keystroke {} mismatch. Expected: '{}', Actual: '{}'",
826                    i,
827                    expected.unparse(),
828                    actual.unparse()
829                );
830            }
831        }
832
833        /// Verifies that the keystrokes match the expected strings
834        #[track_caller]
835        pub fn expect_keystrokes(&mut self, expected: &[&str]) -> &mut Self {
836            let actual: Vec<Keystroke> = self.input.read_with(&self.cx, |input, _| {
837                input
838                    .keystrokes
839                    .iter()
840                    .map(|keystroke| keystroke.inner().clone())
841                    .collect()
842            });
843            Self::expect_keystrokes_equal(&actual, expected);
844            self
845        }
846
847        #[track_caller]
848        pub fn expect_close_keystrokes(&mut self, expected: &[&str]) -> &mut Self {
849            let actual = self
850                .input
851                .read_with(&self.cx, |input, _| input.close_keystrokes.clone())
852                .unwrap_or_default();
853            Self::expect_keystrokes_equal(&actual, expected);
854            self
855        }
856
857        /// Verifies that there are no keystrokes
858        #[track_caller]
859        pub fn expect_empty(&mut self) -> &mut Self {
860            self.expect_keystrokes(&[])
861        }
862
863        /// Starts recording keystrokes
864        #[track_caller]
865        pub fn start_recording(&mut self) -> &mut Self {
866            self.expect_is_recording(false);
867            self.input.update_in(&mut self.cx, |input, window, cx| {
868                input.start_recording(&StartRecording, window, cx);
869            });
870            self
871        }
872
873        /// Stops recording keystrokes
874        pub fn stop_recording(&mut self) -> &mut Self {
875            self.expect_is_recording(true);
876            self.input.update_in(&mut self.cx, |input, window, cx| {
877                input.stop_recording(&StopRecording, window, cx);
878            });
879            self
880        }
881
882        /// Clears all keystrokes
883        #[track_caller]
884        pub fn clear_keystrokes(&mut self) -> &mut Self {
885            let change_tracker = KeystrokeUpdateTracker::new(self.input.clone(), &mut self.cx);
886            self.input.update_in(&mut self.cx, |input, window, cx| {
887                input.clear_keystrokes(&ClearKeystrokes, window, cx);
888            });
889            KeystrokeUpdateTracker::finish(change_tracker, &self.cx);
890            self.current_modifiers = Default::default();
891            self
892        }
893
894        /// Verifies the recording state
895        #[track_caller]
896        pub fn expect_is_recording(&mut self, expected: bool) -> &mut Self {
897            let actual = self
898                .input
899                .update_in(&mut self.cx, |input, window, _| input.is_recording(window));
900            assert_eq!(
901                actual, expected,
902                "Recording state mismatch. Expected: {}, Actual: {}",
903                expected, actual
904            );
905            self
906        }
907
908        pub async fn wait_for_close_keystroke_capture_end(&mut self) -> &mut Self {
909            let task = self.input.update_in(&mut self.cx, |input, _, _| {
910                input.clear_close_keystrokes_timer.take()
911            });
912            let task = task.expect("No close keystroke capture end timer task");
913            self.cx
914                .executor()
915                .advance_clock(CLOSE_KEYSTROKE_CAPTURE_END_TIMEOUT);
916            task.await;
917            self
918        }
919
920        /// Parses modifier change strings like "+ctrl", "-shift", "+cmd+alt"
921        #[track_caller]
922        fn parse_modifier_change(&self, modifiers_str: &str) -> Modifiers {
923            let mut modifiers = self.current_modifiers;
924
925            assert!(!modifiers_str.is_empty(), "Empty modifier string");
926
927            let value;
928            let split_char;
929            let remaining;
930            if let Some(to_add) = modifiers_str.strip_prefix('+') {
931                value = true;
932                split_char = '+';
933                remaining = to_add;
934            } else {
935                let to_remove = modifiers_str
936                    .strip_prefix('-')
937                    .expect("Modifier string must start with '+' or '-'");
938                value = false;
939                split_char = '-';
940                remaining = to_remove;
941            }
942
943            for modifier in remaining.split(split_char) {
944                match modifier {
945                    "ctrl" | "control" => modifiers.control = value,
946                    "alt" | "option" => modifiers.alt = value,
947                    "shift" => modifiers.shift = value,
948                    "cmd" | "command" | "platform" => modifiers.platform = value,
949                    "fn" | "function" => modifiers.function = value,
950                    _ => panic!("Unknown modifier: {}", modifier),
951                }
952            }
953
954            modifiers
955        }
956
957        #[track_caller]
958        fn update_input<R>(
959            &mut self,
960            cb: impl FnOnce(&mut KeystrokeInput, &mut Window, &mut Context<KeystrokeInput>) -> R,
961        ) -> R {
962            let change_tracker = KeystrokeUpdateTracker::new(self.input.clone(), &mut self.cx);
963            let result = self.input.update_in(&mut self.cx, cb);
964            KeystrokeUpdateTracker::finish(change_tracker, &self.cx);
965            result
966        }
967    }
968
969    /// For GPUI, when you press `ctrl-shift-2`, it produces `ctrl-@` without the shift modifier.
970    fn to_gpui_keystroke(mut keystroke: Keystroke) -> Keystroke {
971        if keystroke.modifiers.shift {
972            match keystroke.key.as_str() {
973                "`" => {
974                    keystroke.key = "~".into();
975                    keystroke.modifiers.shift = false;
976                }
977                "1" => {
978                    keystroke.key = "!".into();
979                    keystroke.modifiers.shift = false;
980                }
981                "2" => {
982                    keystroke.key = "@".into();
983                    keystroke.modifiers.shift = false;
984                }
985                "3" => {
986                    keystroke.key = "#".into();
987                    keystroke.modifiers.shift = false;
988                }
989                "4" => {
990                    keystroke.key = "$".into();
991                    keystroke.modifiers.shift = false;
992                }
993                "5" => {
994                    keystroke.key = "%".into();
995                    keystroke.modifiers.shift = false;
996                }
997                "6" => {
998                    keystroke.key = "^".into();
999                    keystroke.modifiers.shift = false;
1000                }
1001                "7" => {
1002                    keystroke.key = "&".into();
1003                    keystroke.modifiers.shift = false;
1004                }
1005                "8" => {
1006                    keystroke.key = "*".into();
1007                    keystroke.modifiers.shift = false;
1008                }
1009                "9" => {
1010                    keystroke.key = "(".into();
1011                    keystroke.modifiers.shift = false;
1012                }
1013                "0" => {
1014                    keystroke.key = ")".into();
1015                    keystroke.modifiers.shift = false;
1016                }
1017                "-" => {
1018                    keystroke.key = "_".into();
1019                    keystroke.modifiers.shift = false;
1020                }
1021                "=" => {
1022                    keystroke.key = "+".into();
1023                    keystroke.modifiers.shift = false;
1024                }
1025                "[" => {
1026                    keystroke.key = "{".into();
1027                    keystroke.modifiers.shift = false;
1028                }
1029                "]" => {
1030                    keystroke.key = "}".into();
1031                    keystroke.modifiers.shift = false;
1032                }
1033                "\\" => {
1034                    keystroke.key = "|".into();
1035                    keystroke.modifiers.shift = false;
1036                }
1037                ";" => {
1038                    keystroke.key = ":".into();
1039                    keystroke.modifiers.shift = false;
1040                }
1041                "'" => {
1042                    keystroke.key = "\"".into();
1043                    keystroke.modifiers.shift = false;
1044                }
1045                "," => {
1046                    keystroke.key = "<".into();
1047                    keystroke.modifiers.shift = false;
1048                }
1049                "." => {
1050                    keystroke.key = ">".into();
1051                    keystroke.modifiers.shift = false;
1052                }
1053                "/" => {
1054                    keystroke.key = "?".into();
1055                    keystroke.modifiers.shift = false;
1056                }
1057                _ => {}
1058            }
1059        }
1060        keystroke
1061    }
1062
1063    struct KeystrokeUpdateTracker {
1064        initial_keystrokes: Vec<KeybindingKeystroke>,
1065        _subscription: Subscription,
1066        input: Entity<KeystrokeInput>,
1067        received_keystrokes_updated: bool,
1068    }
1069
1070    impl KeystrokeUpdateTracker {
1071        fn new(input: Entity<KeystrokeInput>, cx: &mut VisualTestContext) -> Entity<Self> {
1072            cx.new(|cx| Self {
1073                initial_keystrokes: input.read_with(cx, |input, _| input.keystrokes.clone()),
1074                _subscription: cx.subscribe(&input, |this: &mut Self, _, _, _| {
1075                    this.received_keystrokes_updated = true;
1076                }),
1077                input,
1078                received_keystrokes_updated: false,
1079            })
1080        }
1081        #[track_caller]
1082        fn finish(this: Entity<Self>, cx: &VisualTestContext) {
1083            let (received_keystrokes_updated, initial_keystrokes_str, updated_keystrokes_str) =
1084                this.read_with(cx, |this, cx| {
1085                    let updated_keystrokes = this
1086                        .input
1087                        .read_with(cx, |input, _| input.keystrokes.clone());
1088                    let initial_keystrokes_str = keystrokes_str(&this.initial_keystrokes);
1089                    let updated_keystrokes_str = keystrokes_str(&updated_keystrokes);
1090                    (
1091                        this.received_keystrokes_updated,
1092                        initial_keystrokes_str,
1093                        updated_keystrokes_str,
1094                    )
1095                });
1096            if received_keystrokes_updated {
1097                assert_ne!(
1098                    initial_keystrokes_str, updated_keystrokes_str,
1099                    "Received keystrokes_updated event, expected different keystrokes"
1100                );
1101            } else {
1102                assert_eq!(
1103                    initial_keystrokes_str, updated_keystrokes_str,
1104                    "Received no keystrokes_updated event, expected same keystrokes"
1105                );
1106            }
1107
1108            fn keystrokes_str(ks: &[KeybindingKeystroke]) -> String {
1109                ks.iter().map(|ks| ks.inner().unparse()).join(" ")
1110            }
1111        }
1112    }
1113
1114    async fn init_test(cx: &mut TestAppContext) -> KeystrokeInputTestHelper {
1115        cx.update(|cx| {
1116            let settings_store = SettingsStore::test(cx);
1117            cx.set_global(settings_store);
1118            theme_settings::init(theme::LoadThemes::JustBase, cx);
1119        });
1120
1121        let fs = FakeFs::new(cx.executor());
1122        let project = Project::test(fs, [], cx).await;
1123        let window_handle =
1124            cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
1125        let cx = VisualTestContext::from_window(window_handle.into(), cx);
1126        KeystrokeInputTestHelper::new(cx)
1127    }
1128
1129    #[gpui::test]
1130    async fn test_basic_keystroke_input(cx: &mut TestAppContext) {
1131        init_test(cx)
1132            .await
1133            .send_keystroke("a")
1134            .clear_keystrokes()
1135            .expect_empty();
1136    }
1137
1138    #[gpui::test]
1139    async fn test_modifier_handling(cx: &mut TestAppContext) {
1140        init_test(cx)
1141            .await
1142            .with_search_mode(true)
1143            .send_events(&["+ctrl", "a", "-ctrl"])
1144            .expect_keystrokes(&["ctrl-a"]);
1145    }
1146
1147    #[gpui::test]
1148    async fn test_multiple_modifiers(cx: &mut TestAppContext) {
1149        init_test(cx)
1150            .await
1151            .send_keystroke("cmd-shift-z")
1152            .expect_keystrokes(&["cmd-shift-z", "cmd-shift-"]);
1153    }
1154
1155    #[gpui::test]
1156    async fn test_search_mode_behavior(cx: &mut TestAppContext) {
1157        init_test(cx)
1158            .await
1159            .with_search_mode(true)
1160            .send_events(&["+cmd", "shift-f", "-cmd"])
1161            // In search mode, when completing a modifier-only keystroke with a key,
1162            // only the original modifiers are preserved, not the keystroke's modifiers
1163            //
1164            // Update:
1165            // This behavior was changed to preserve all modifiers in search mode, this is now reflected in the expected keystrokes.
1166            // Specifically, considering the sequence: `+cmd +shift -shift 2`, we expect it to produce the same result as `+cmd +shift 2`
1167            // which is `cmd-@`. But in the case of `+cmd +shift -shift 2`, the keystroke we receive is `cmd-2`, which means that
1168            // we need to dynamically map the key from `2` to `@` when the shift modifier is not present, which is not possible.
1169            // Therefore, we now preserve all modifiers in search mode to ensure consistent behavior.
1170            // And also, VSCode seems to preserve all modifiers in search mode as well.
1171            .expect_keystrokes(&["cmd-shift-f"]);
1172    }
1173
1174    #[gpui::test]
1175    async fn test_keystroke_limit(cx: &mut TestAppContext) {
1176        init_test(cx)
1177            .await
1178            .send_keystroke("a")
1179            .send_keystroke("b")
1180            .send_keystroke("c")
1181            .expect_keystrokes(&["a", "b", "c"]) // At max limit
1182            .send_keystroke("d")
1183            .expect_empty(); // Should clear when exceeding limit
1184    }
1185
1186    #[gpui::test]
1187    async fn test_modifier_release_all(cx: &mut TestAppContext) {
1188        init_test(cx)
1189            .await
1190            .with_search_mode(true)
1191            .send_events(&["+ctrl+shift", "a", "-all"])
1192            .expect_keystrokes(&["ctrl-shift-a"]);
1193    }
1194
1195    #[gpui::test]
1196    async fn test_search_new_modifiers_not_added_until_all_released(cx: &mut TestAppContext) {
1197        init_test(cx)
1198            .await
1199            .with_search_mode(true)
1200            .send_events(&["+ctrl+shift", "a", "-ctrl"])
1201            .expect_keystrokes(&["ctrl-shift-a"])
1202            .send_events(&["+ctrl"])
1203            .expect_keystrokes(&["ctrl-shift-a", "ctrl-shift-"]);
1204    }
1205
1206    #[gpui::test]
1207    async fn test_previous_modifiers_no_effect_when_not_search(cx: &mut TestAppContext) {
1208        init_test(cx)
1209            .await
1210            .with_search_mode(false)
1211            .send_events(&["+ctrl+shift", "a", "-all"])
1212            .expect_keystrokes(&["ctrl-shift-a"]);
1213    }
1214
1215    #[gpui::test]
1216    async fn test_keystroke_limit_overflow_non_search_mode(cx: &mut TestAppContext) {
1217        init_test(cx)
1218            .await
1219            .with_search_mode(false)
1220            .send_events(&["a", "b", "c", "d"]) // 4 keystrokes, exceeds limit of 3
1221            .expect_empty(); // Should clear when exceeding limit
1222    }
1223
1224    #[gpui::test]
1225    async fn test_complex_modifier_sequences(cx: &mut TestAppContext) {
1226        init_test(cx)
1227            .await
1228            .with_search_mode(true)
1229            .send_events(&["+ctrl", "+shift", "+alt", "a", "-ctrl", "-shift", "-alt"])
1230            .expect_keystrokes(&["ctrl-shift-alt-a"]);
1231    }
1232
1233    #[gpui::test]
1234    async fn test_modifier_only_keystrokes_search_mode(cx: &mut TestAppContext) {
1235        init_test(cx)
1236            .await
1237            .with_search_mode(true)
1238            .send_events(&["+ctrl", "+shift", "-ctrl", "-shift"])
1239            .expect_keystrokes(&["ctrl-shift-"]); // Modifier-only sequences create modifier-only keystrokes
1240    }
1241
1242    #[gpui::test]
1243    async fn test_modifier_only_keystrokes_non_search_mode(cx: &mut TestAppContext) {
1244        init_test(cx)
1245            .await
1246            .with_search_mode(false)
1247            .send_events(&["+ctrl", "+shift", "-ctrl", "-shift"])
1248            .expect_empty(); // Modifier-only sequences get filtered in non-search mode
1249    }
1250
1251    #[gpui::test]
1252    async fn test_rapid_modifier_changes(cx: &mut TestAppContext) {
1253        init_test(cx)
1254            .await
1255            .with_search_mode(true)
1256            .send_events(&["+ctrl", "-ctrl", "+shift", "-shift", "+alt", "a", "-alt"])
1257            .expect_keystrokes(&["ctrl-", "shift-", "alt-a"]);
1258    }
1259
1260    #[gpui::test]
1261    async fn test_clear_keystrokes_search_mode(cx: &mut TestAppContext) {
1262        init_test(cx)
1263            .await
1264            .with_search_mode(true)
1265            .send_events(&["+ctrl", "a", "-ctrl", "b"])
1266            .expect_keystrokes(&["ctrl-a", "b"])
1267            .clear_keystrokes()
1268            .expect_empty();
1269    }
1270
1271    #[gpui::test]
1272    async fn test_non_search_mode_modifier_key_sequence(cx: &mut TestAppContext) {
1273        init_test(cx)
1274            .await
1275            .with_search_mode(false)
1276            .send_events(&["+ctrl", "a"])
1277            .expect_keystrokes(&["ctrl-a", "ctrl-"])
1278            .send_events(&["-ctrl"])
1279            .expect_keystrokes(&["ctrl-a"]); // Non-search mode filters trailing empty keystrokes
1280    }
1281
1282    #[gpui::test]
1283    async fn test_all_modifiers_at_once(cx: &mut TestAppContext) {
1284        init_test(cx)
1285            .await
1286            .with_search_mode(true)
1287            .send_events(&["+ctrl+shift+alt+cmd", "a", "-all"])
1288            .expect_keystrokes(&["ctrl-shift-alt-cmd-a"]);
1289    }
1290
1291    #[gpui::test]
1292    async fn test_keystrokes_at_exact_limit(cx: &mut TestAppContext) {
1293        init_test(cx)
1294            .await
1295            .with_search_mode(true)
1296            .send_events(&["a", "b", "c"]) // exactly 3 keystrokes (at limit)
1297            .expect_keystrokes(&["a", "b", "c"])
1298            .send_events(&["d"]) // should clear when exceeding
1299            .expect_empty();
1300    }
1301
1302    #[gpui::test]
1303    async fn test_function_modifier_key(cx: &mut TestAppContext) {
1304        init_test(cx)
1305            .await
1306            .with_search_mode(true)
1307            .send_events(&["+fn", "f1", "-fn"])
1308            .expect_keystrokes(&["fn-f1"]);
1309    }
1310
1311    #[gpui::test]
1312    async fn test_start_stop_recording(cx: &mut TestAppContext) {
1313        init_test(cx)
1314            .await
1315            .send_events(&["a", "b"])
1316            .expect_keystrokes(&["a", "b"]) // start_recording clears existing keystrokes
1317            .stop_recording()
1318            .expect_is_recording(false)
1319            .start_recording()
1320            .send_events(&["c"])
1321            .expect_keystrokes(&["c"]);
1322    }
1323
1324    #[gpui::test]
1325    async fn test_modifier_sequence_with_interruption(cx: &mut TestAppContext) {
1326        init_test(cx)
1327            .await
1328            .with_search_mode(true)
1329            .send_events(&["+ctrl", "+shift", "a", "-shift", "b", "-ctrl"])
1330            .expect_keystrokes(&["ctrl-shift-a", "ctrl-b"]);
1331    }
1332
1333    #[gpui::test]
1334    async fn test_empty_key_sequence_search_mode(cx: &mut TestAppContext) {
1335        init_test(cx)
1336            .await
1337            .with_search_mode(true)
1338            .send_events(&[]) // No events at all
1339            .expect_empty();
1340    }
1341
1342    #[gpui::test]
1343    async fn test_modifier_sequence_completion_search_mode(cx: &mut TestAppContext) {
1344        init_test(cx)
1345            .await
1346            .with_search_mode(true)
1347            .send_events(&["+ctrl", "+shift", "-shift", "a", "-ctrl"])
1348            .expect_keystrokes(&["ctrl-a"]);
1349    }
1350
1351    #[gpui::test]
1352    async fn test_triple_escape_stops_recording_search_mode(cx: &mut TestAppContext) {
1353        init_test(cx)
1354            .await
1355            .with_search_mode(true)
1356            .send_events(&["a", "escape", "escape", "escape"])
1357            .expect_keystrokes(&["a"]) // Triple escape removes final escape, stops recording
1358            .expect_is_recording(false);
1359    }
1360
1361    #[gpui::test]
1362    async fn test_triple_escape_stops_recording_non_search_mode(cx: &mut TestAppContext) {
1363        init_test(cx)
1364            .await
1365            .with_search_mode(false)
1366            .send_events(&["a", "escape", "escape", "escape"])
1367            .expect_keystrokes(&["a"]); // Triple escape stops recording but only removes final escape
1368    }
1369
1370    #[gpui::test]
1371    async fn test_triple_escape_at_keystroke_limit(cx: &mut TestAppContext) {
1372        init_test(cx)
1373            .await
1374            .with_search_mode(true)
1375            .send_events(&["a", "b", "c", "escape", "escape", "escape"]) // 6 keystrokes total, exceeds limit
1376            .expect_keystrokes(&["a", "b", "c"]); // Triple escape stops recording and removes escapes, leaves original keystrokes
1377    }
1378
1379    #[gpui::test]
1380    async fn test_interrupted_escape_sequence(cx: &mut TestAppContext) {
1381        init_test(cx)
1382            .await
1383            .with_search_mode(true)
1384            .send_events(&["escape", "escape", "a", "escape"]) // Partial escape sequence interrupted by 'a'
1385            .expect_keystrokes(&["escape", "escape", "a"]); // Escape sequence interrupted by 'a', no close triggered
1386    }
1387
1388    #[gpui::test]
1389    async fn test_interrupted_escape_sequence_within_limit(cx: &mut TestAppContext) {
1390        init_test(cx)
1391            .await
1392            .with_search_mode(true)
1393            .send_events(&["escape", "escape", "a"]) // Partial escape sequence interrupted by 'a' (3 keystrokes, at limit)
1394            .expect_keystrokes(&["escape", "escape", "a"]); // Should not trigger close, interruption resets escape detection
1395    }
1396
1397    #[gpui::test]
1398    async fn test_partial_escape_sequence_no_close(cx: &mut TestAppContext) {
1399        init_test(cx)
1400            .await
1401            .with_search_mode(true)
1402            .send_events(&["escape", "escape"]) // Only 2 escapes, not enough to close
1403            .expect_keystrokes(&["escape", "escape"])
1404            .expect_is_recording(true); // Should remain in keystrokes, no close triggered
1405    }
1406
1407    #[gpui::test]
1408    async fn test_recording_state_after_triple_escape(cx: &mut TestAppContext) {
1409        init_test(cx)
1410            .await
1411            .with_search_mode(true)
1412            .send_events(&["a", "escape", "escape", "escape"])
1413            .expect_keystrokes(&["a"]) // Triple escape stops recording, removes final escape
1414            .expect_is_recording(false);
1415    }
1416
1417    #[gpui::test]
1418    async fn test_triple_escape_mixed_with_other_keystrokes(cx: &mut TestAppContext) {
1419        init_test(cx)
1420            .await
1421            .with_search_mode(true)
1422            .send_events(&["a", "escape", "b", "escape", "escape"]) // Mixed sequence, should not trigger close
1423            .expect_keystrokes(&["a", "escape", "b"]); // No complete triple escape sequence, stays at limit
1424    }
1425
1426    #[gpui::test]
1427    async fn test_triple_escape_only(cx: &mut TestAppContext) {
1428        init_test(cx)
1429            .await
1430            .with_search_mode(true)
1431            .send_events(&["escape", "escape", "escape"]) // Pure triple escape sequence
1432            .expect_empty();
1433    }
1434
1435    #[gpui::test]
1436    async fn test_end_close_keystroke_capture(cx: &mut TestAppContext) {
1437        init_test(cx)
1438            .await
1439            .send_events(&["+ctrl", "g", "-ctrl", "escape"])
1440            .expect_keystrokes(&["ctrl-g", "escape"])
1441            .wait_for_close_keystroke_capture_end()
1442            .await
1443            .send_events(&["escape", "escape"])
1444            .expect_keystrokes(&["ctrl-g", "escape", "escape"])
1445            .expect_close_keystrokes(&["escape", "escape"])
1446            .send_keystroke("escape")
1447            .expect_keystrokes(&["ctrl-g", "escape"]);
1448    }
1449
1450    #[gpui::test]
1451    async fn test_search_previous_modifiers_are_sticky(cx: &mut TestAppContext) {
1452        init_test(cx)
1453            .await
1454            .with_search_mode(true)
1455            .send_events(&["+ctrl+alt", "-ctrl", "j"])
1456            .expect_keystrokes(&["alt-j"]);
1457    }
1458
1459    #[gpui::test]
1460    async fn test_previous_modifiers_can_be_entered_separately(cx: &mut TestAppContext) {
1461        init_test(cx)
1462            .await
1463            .with_search_mode(true)
1464            .send_events(&["+ctrl", "-ctrl"])
1465            .expect_keystrokes(&["ctrl-"])
1466            .send_events(&["+alt", "-alt"])
1467            .expect_keystrokes(&["ctrl-", "alt-"]);
1468    }
1469
1470    #[gpui::test]
1471    async fn test_previous_modifiers_reset_on_key(cx: &mut TestAppContext) {
1472        init_test(cx)
1473            .await
1474            .with_search_mode(true)
1475            .send_events(&["+ctrl+alt", "-ctrl", "+shift"])
1476            .expect_keystrokes(&["ctrl-shift-alt-"])
1477            .send_keystroke("j")
1478            .expect_keystrokes(&["shift-alt-j"])
1479            .send_keystroke("i")
1480            .expect_keystrokes(&["shift-alt-j", "shift-alt-i"])
1481            .send_events(&["-shift-alt", "+cmd"])
1482            .expect_keystrokes(&["shift-alt-j", "shift-alt-i", "cmd-"]);
1483    }
1484
1485    #[gpui::test]
1486    async fn test_previous_modifiers_reset_on_release_all(cx: &mut TestAppContext) {
1487        init_test(cx)
1488            .await
1489            .with_search_mode(true)
1490            .send_events(&["+ctrl+alt", "-ctrl", "+shift"])
1491            .expect_keystrokes(&["ctrl-shift-alt-"])
1492            .send_events(&["-all", "j"])
1493            .expect_keystrokes(&["ctrl-shift-alt-", "j"]);
1494    }
1495
1496    #[gpui::test]
1497    async fn test_search_repeat_modifiers(cx: &mut TestAppContext) {
1498        init_test(cx)
1499            .await
1500            .with_search_mode(true)
1501            .send_events(&["+ctrl", "-ctrl", "+alt", "-alt", "+shift", "-shift"])
1502            .expect_keystrokes(&["ctrl-", "alt-", "shift-"])
1503            .send_events(&["+cmd"])
1504            .expect_empty();
1505    }
1506
1507    #[gpui::test]
1508    async fn test_not_search_repeat_modifiers(cx: &mut TestAppContext) {
1509        init_test(cx)
1510            .await
1511            .with_search_mode(false)
1512            .send_events(&["+ctrl", "-ctrl", "+alt", "-alt", "+shift", "-shift"])
1513            .expect_empty();
1514    }
1515
1516    #[gpui::test]
1517    async fn test_not_search_shifted_keys(cx: &mut TestAppContext) {
1518        init_test(cx)
1519            .await
1520            .with_search_mode(false)
1521            .send_events(&["+ctrl", "+shift", "4", "-all"])
1522            .expect_keystrokes(&["ctrl-$"]);
1523    }
1524}
1525
Served at tenant.openagents/omega Member data and write actions are omitted.