Repository: tmbo/questionary

Issue:
Add prefix filter search in select control
As featured here (when selecting the AWS region)
https://asciinema.org/a/AyujEPdjcDmSPoOK26pTozCiO

@tmbo thanks for pointing me here from PyInquirer. Looks like the more modern/maintained alternatives (latest lib, tests, ...)

I'll try to use questionary soon and if it's all good, I'll contribute the filter that I have built that I find very practical on long lists like the one I'm dealing with.

Discussion:
@tmbo: The filter looks really cool, would love to integrate that :rocket:@gbataille: Hey @tmbo 
so I migrated my code to `questionary`. https://github.com/gbataille/password-organizer/pull/1

What I like
- you created a `Choice` class (like I had done) 👍 
- I could remove all my custom code (at least the part that was emulating PyInquirer)

What I'm missing
- I think `Choice` should be a `Generic` type, parameterised by the type `T` of it's return value. Actually I would personally tend to remove the python-style "I accept multiple type in input" and just deal with `List` of `Choice` and not accept `str` and `dict`
- You swallow `KeyboardInterrupt` errors to return `None`. I had made the choice to not do that. Not sure I like it, but at least we should document it.
- The current implementation does not allow custom key bindings. That's something I'll have to work on for the **prefix search** feature and see how to not make it collide with the shortcut feature and the vim-based navigation in the menu (maybe one of the other, or a chord to launch the search)
- It would be nice to but the **Back** feature that I have (https://github.com/gbataille/password-organizer/blob/master/password_organizer/menu.py#L49) in the lib
- I'm missing the highlighting of the currently selected answer, but I guess that's just a default config/style I have to change somewhere
...

I'll try and do individual issues/PR in your repo for all those points, unless you feel otherwise

Cheers@gbataille: pending work here
https://github.com/gbataille/questionary/tree/prefix_filter_search

Solution patch:
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 0971e85..5146e51 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -1,6 +1,6 @@
 repos:
-  - repo: https://github.com/humitos/mirrors-autoflake
-    rev: v1.1
+  - repo: https://github.com/PyCQA/autoflake
+    rev: v2.3.1
     hooks:
       - id: autoflake
         args: ["-i", "--remove-all-unused-imports"]
@@ -26,7 +26,7 @@ repos:
           - "--profile=black"
 
   - repo: https://github.com/pycqa/flake8
-    rev: 6.0.0
+    rev: 7.1.0
     hooks:
       - id: flake8
         args: ["--max-line-length", "120", "--max-doc-length", "140"]
diff --git a/examples/checkbox_search.py b/examples/checkbox_search.py
new file mode 100644
index 0000000..24cf7e3
--- /dev/null
+++ b/examples/checkbox_search.py
@@ -0,0 +1,75 @@
+import questionary
+from examples import custom_style_dope
+
+zoo_animals = [
+    "Lion",
+    "Tiger",
+    "Elephant",
+    "Giraffe",
+    "Zebra",
+    "Panda",
+    "Kangaroo",
+    "Gorilla",
+    "Chimpanzee",
+    "Orangutan",
+    "Hippopotamus",
+    "Rhinoceros",
+    "Leopard",
+    "Cheetah",
+    "Polar Bear",
+    "Grizzly Bear",
+    "Penguin",
+    "Flamingo",
+    "Peacock",
+    "Ostrich",
+    "Emu",
+    "Koala",
+    "Sloth",
+    "Armadillo",
+    "Meerkat",
+    "Lemur",
+    "Red Panda",
+    "Wolf",
+    "Fox",
+    "Otter",
+    "Sea Lion",
+    "Walrus",
+    "Seal",
+    "Crocodile",
+    "Alligator",
+    "Python",
+    "Boa Constrictor",
+    "Iguana",
+    "Komodo Dragon",
+    "Tortoise",
+    "Turtle",
+    "Parrot",
+    "Toucan",
+    "Macaw",
+    "Hyena",
+    "Jaguar",
+    "Anteater",
+    "Capybara",
+    "Bison",
+    "Moose",
+]
+
+
+if __name__ == "__main__":
+    toppings = (
+        questionary.checkbox(
+            "Select animals for your zoo",
+            choices=zoo_animals,
+            validate=lambda a: (
+                True if len(a) > 0 else "You must select at least one zoo animal"
+            ),
+            style=custom_style_dope,
+            use_jk_keys=False,
+            use_search_filter=True,
+        ).ask()
+        or []
+    )
+
+    print(
+        f"Alright let's create our zoo with following animals: {', '.join(toppings)}."
+    )
diff --git a/examples/select_search.py b/examples/select_search.py
new file mode 100644
index 0000000..ac45363
--- /dev/null
+++ b/examples/select_search.py
@@ -0,0 +1,62 @@
+# -*- coding: utf-8 -*-
+"""Example for a select question type with search enabled.
+
+Run example by typing `python -m examples.select_search` in your console."""
+from pprint import pprint
+
+import questionary
+from examples import custom_style_dope
+from questionary import Choice
+from questionary import Separator
+from questionary import prompt
+
+
+def ask_pystyle(**kwargs):
+    # create the question object
+    question = questionary.select(
+        "What do you want to do?",
+        qmark="😃",
+        choices=[
+            "Order a pizza",
+            "Make a reservation",
+            "Cancel a reservation",
+            "Modify your order",
+            Separator(),
+            "Ask for opening hours",
+            Choice("Contact support", disabled="Unavailable at this time"),
+            "Talk to the receptionist",
+        ],
+        style=custom_style_dope,
+        use_jk_keys=False,
+        use_search_filter=True,
+        **kwargs,
+    )
+
+    # prompt the user for an answer
+    return question.ask()
+
+
+def ask_dictstyle(**kwargs):
+    questions = [
+        {
+            "type": "select",
+            "name": "theme",
+            "message": "What do you want to do?",
+            "choices": [
+                "Order a pizza",
+                "Make a reservation",
+                "Cancel a reservation",
+                "Modify your order",
+                Separator(),
+                "Ask for opening hours",
+                {"name": "Contact support", "disabled": "Unavailable at this time"},
+                "Talk to the receptionist",
+            ],
+        }
+    ]
+
+    return prompt(questions, style=custom_style_dope, **kwargs)
+
+
+if __name__ == "__main__":
+    pprint(ask_pystyle())
diff --git a/pyproject.toml b/pyproject.toml
index 26118ab..fada9db 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -43,7 +43,7 @@ Sphinx = ">=4.1,<8.0"
 sphinx-rtd-theme = ">=0.5,<2.1"
 sphinx-autobuild = ">=2020.9.1,<2022.0.0"
 sphinx-copybutton = ">=0.3.1,<0.6.0"
-sphinx-autodoc-typehints = "^1.11.1"
+sphinx-autodoc-typehints = ">=1.11.1,<3.0.0"
 
 [tool.poetry.group.dev.dependencies]
 pytest-cov = ">=5.0.0,<6"
diff --git a/questionary/constants.py b/questionary/constants.py
index 94c5ff4..845dd78 100644
--- a/questionary/constants.py
+++ b/questionary/constants.py
@@ -39,6 +39,14 @@ DEFAULT_STYLE = Style(
         ("qmark", "fg:#5f819d"),  # token in front of the question
         ("question", "bold"),  # question text
         ("answer", "fg:#FF9D00 bold"),  # submitted answer text behind the question
+        (
+            "search_success",
+            "noinherit fg:#00FF00 bold",
+        ),  # submitted answer text behind the question
+        (
+            "search_none",
+            "noinherit fg:#FF0000 bold",
+        ),  # submitted answer text behind the question
         ("pointer", ""),  # pointer used in select and checkbox prompts
         ("selected", ""),  # style for a selected item of a checkbox
         ("separator", ""),  # separator in lists
diff --git a/questionary/prompts/checkbox.py b/questionary/prompts/checkbox.py
index 25161e4..465892b 100644
--- a/questionary/prompts/checkbox.py
+++ b/questionary/prompts/checkbox.py
@@ -1,3 +1,4 @@
+import string
 from typing import Any
 from typing import Callable
 from typing import Dict
@@ -37,6 +38,7 @@ def checkbox(
     use_arrow_keys: bool = True,
     use_jk_keys: bool = True,
     use_emacs_keys: bool = True,
+    use_search_filter: Union[str, bool, None] = False,
     instruction: Optional[str] = None,
     show_description: bool = True,
     **kwargs: Any,
@@ -105,6 +107,14 @@ def checkbox(
 
         use_emacs_keys: Allow the user to select items from the list using
                         `Ctrl+N` (down) and `Ctrl+P` (up) keys.
+
+        use_search_filter: Flag to enable search filtering. Typing some string will
+                           filter the choices to keep only the ones that contain the
+                           search string.
+                           Note that activating this option disables "vi-like"
+                           navigation as "j" and "k" can be part of a prefix and
+                           therefore cannot be used for navigation
+
         instruction: A message describing how to navigate the menu.
 
         show_description: Display description of current selection if available.
@@ -119,6 +129,11 @@ def checkbox(
             "Emacs keys."
         )
 
+    if use_jk_keys and use_search_filter:
+        raise ValueError(
+            "Cannot use j/k keys with prefix filter search, since j/k can be part of the prefix."
+        )
+
     merged_style = merge_styles_default(
         [
             # Disable the default inverted colours bottom-toolbar behaviour (for
@@ -179,8 +194,9 @@ def checkbox(
                         "class:instruction",
                         "(Use arrow keys to move, "
                         "<space> to select, "
-                        "<a> to toggle, "
-                        "<i> to invert)",
+                        f"<{'ctrl-a' if use_search_filter else 'a'}> to toggle, "
+                        f"<{'ctrl-a' if use_search_filter else 'i'}> to invert"
+                        f"{', type to filter' if use_search_filter else ''})",
                     )
                 )
         return tokens
@@ -225,7 +241,7 @@ def checkbox(
 
         perform_validation(get_selected_values())
 
-    @bindings.add("i", eager=True)
+    @bindings.add(Keys.ControlI if use_search_filter else "i", eager=True)
     def invert(_event):
         inverted_selection = [
             c.value
@@ -238,7 +254,7 @@ def checkbox(
 
         perform_validation(get_selected_values())
 
-    @bindings.add("a", eager=True)
+    @bindings.add(Keys.ControlA if use_search_filter else "a", eager=True)
     def all(_event):
         all_selected = True  # all choices have been selected
         for c in ic.choices:
@@ -265,6 +281,17 @@ def checkbox(
         while not ic.is_selection_valid():
             ic.select_previous()
 
+    if use_search_filter:
+
+        def search_filter(event):
+            ic.add_search_character(event.key_sequence[0].key)
+
+        for character in string.printable:
+            if character in string.whitespace:
+                continue
+            bindings.add(character, eager=True)(search_filter)
+        bindings.add(Keys.Backspace, eager=True)(search_filter)
+
     if use_arrow_keys:
         bindings.add(Keys.Down, eager=True)(move_cursor_down)
         bindings.add(Keys.Up, eager=True)(move_cursor_up)
diff --git a/questionary/prompts/common.py b/questionary/prompts/common.py
index 827629d..d07d0e6 100644
--- a/questionary/prompts/common.py
+++ b/questionary/prompts/common.py
@@ -12,11 +12,13 @@ from prompt_toolkit import PromptSession
 from prompt_toolkit.filters import Always
 from prompt_toolkit.filters import Condition
 from prompt_toolkit.filters import IsDone
+from prompt_toolkit.keys import Keys
 from prompt_toolkit.layout import ConditionalContainer
 from prompt_toolkit.layout import FormattedTextControl
 from prompt_toolkit.layout import HSplit
 from prompt_toolkit.layout import Layout
 from prompt_toolkit.layout import Window
+from prompt_toolkit.layout.dimension import LayoutDimension
 from prompt_toolkit.styles import Style
 from prompt_toolkit.validation import ValidationError
 from prompt_toolkit.validation import Validator
@@ -69,8 +71,7 @@ class Choice:
     checked: Optional[bool]
     """Whether the choice is initially selected"""
 
-    shortcut_key: Optional[str]
-    """A shortcut key for the choice"""
+    __shortcut_key: Optional[Union[str, bool]]
 
     description: Optional[str]
     """Choice description"""
@@ -86,6 +87,8 @@ class Choice:
     ) -> None:
         self.disabled = disabled
         self.title = title
+        self.shortcut_key = shortcut_key
+        # self.auto_shortcut is set by the self.shortcut_key setter
         self.checked = checked if checked is not None else False
         self.description = description
 
@@ -96,17 +99,6 @@ class Choice:
         else:
             self.value = title
 
-        if shortcut_key is not None:
-            if isinstance(shortcut_key, bool):
-                self.auto_shortcut = shortcut_key
-                self.shortcut_key = None
-            else:
-                self.shortcut_key = str(shortcut_key)
-                self.auto_shortcut = False
-        else:
-            self.shortcut_key = None
-            self.auto_shortcut = True
-
     @staticmethod
     def build(c: Union[str, "Choice", Dict[str, Any]]) -> "Choice":
         """Create a choice object from different representations.
@@ -134,12 +126,54 @@ class Choice:
                 c.get("description", None),
             )
 
+    @property
+    def shortcut_key(self) -> Optional[Union[str, bool]]:
+        """A shortcut key for the choice"""
+        return self.__shortcut_key
+
+    @shortcut_key.setter
+    def shortcut_key(self, key: Optional[Union[str, bool]]):
+        if key is not None:
+            if isinstance(key, bool):
+                self.__auto_shortcut = key
+                self.__shortcut_key = None
+            else:
+                self.__shortcut_key = str(key)
+                self.__auto_shortcut = False
+        else:
+            self.__shortcut_key = None
+            self.__auto_shortcut = True
+
+    @shortcut_key.deleter
+    def shortcut_key(self):
+        self.__shortcut_key = None
+        self.__auto_shortcut = True
+
     def get_shortcut_title(self):
         if self.shortcut_key is None:
             return "-) "
         else:
             return "{}) ".format(self.shortcut_key)
 
+    @property
+    def auto_shortcut(self) -> bool:
+        """Whether to assign a shortcut key to the choice
+
+        Keys are assigned starting with numbers and proceeding
+        through the ASCII alphabet.
+        """
+        return self.__auto_shortcut
+
+    @auto_shortcut.setter
+    def auto_shortcut(self, should_assign: bool):
+        self.__auto_shortcut = should_assign
+        if self.__auto_shortcut:
+            self.__shortcut_key = None
+
+    @auto_shortcut.deleter
+    def auto_shortcut(self):
+        self.__auto_shortcut = False
+
 
 class Separator(Choice):
     """Used to space/separate choices group."""
@@ -204,6 +238,7 @@ class InquirerControl(FormattedTextControl):
     choices: List[Choice]
     default: Optional[Union[str, Choice, Dict[str, Any]]]
     selected_options: List[Any]
+    search_filter: Union[str, None] = None
     use_indicator: bool
     use_shortcuts: bool
     use_arrow_keys: bool
@@ -275,6 +310,7 @@ class InquirerControl(FormattedTextControl):
         self.submission_attempted = False
         self.error_message = None
         self.selected_options = []
+        self.found_in_search = False
 
         self._init_choices(choices, pointed_at)
         self._assign_shortcut_keys()
@@ -343,9 +379,19 @@ class InquirerControl(FormattedTextControl):
 
             self.choices.append(choice)
 
+    @property
+    def filtered_choices(self):
+        if not self.search_filter:
+            return self.choices
+        filtered = [
+            c for c in self.choices if self.search_filter.lower() in c.title.lower()
+        ]
+        self.found_in_search = len(filtered) > 0
+        return filtered if self.found_in_search else self.choices
+
     @property
     def choice_count(self) -> int:
-        return len(self.choices)
+        return len(self.filtered_choices)
 
     def _get_choice_tokens(self):
         tokens = []
@@ -425,7 +471,7 @@ class InquirerControl(FormattedTextControl):
             tokens.append(("", "\n"))
 
         # prepare the select choices
-        for i, c in enumerate(self.choices):
+        for i, c in enumerate(self.filtered_choices):
             append(i, c)
 
         current = self.get_pointed_at()
@@ -467,7 +513,7 @@ class InquirerControl(FormattedTextControl):
         self.pointed_at = (self.pointed_at + 1) % self.choice_count
 
     def get_pointed_at(self) -> Choice:
-        return self.choices[self.pointed_at]
+        return self.filtered_choices[self.pointed_at]
 
     def get_selected_values(self) -> List[Choice]:
         # get values not labels
@@ -477,6 +523,39 @@ class InquirerControl(FormattedTextControl):
             if (not isinstance(c, Separator) and c.value in self.selected_options)
         ]
 
+    def add_search_character(self, char: Keys) -> None:
+        """Adds a character to the search filter"""
+        if char == Keys.Backspace:
+            self.remove_search_character()
+        else:
+            if self.search_filter is None:
+                self.search_filter = str(char)
+            else:
+                self.search_filter += str(char)
+
+        # Make sure that the selection is in the bounds of the filtered list
+        self.pointed_at = 0
+
+    def remove_search_character(self) -> None:
+        if self.search_filter and len(self.search_filter) > 1:
+            self.search_filter = self.search_filter[:-1]
+        else:
+            self.search_filter = None
+
+    def get_search_string_tokens(self):
+        if self.search_filter is None:
+            return None
+
+        return [
+            ("", "\n"),
+            ("class:question-mark", "/ "),
+            (
+                "class:search_success" if self.found_in_search else "class:search_none",
+                self.search_filter,
+            ),
+            ("class:question-mark", "..."),
+        ]
+
 
 def build_validator(validate: Any) -> Optional[Validator]:
     if validate:
@@ -531,6 +610,10 @@ def create_inquirer_layout(
     )
     _fix_unecessary_blank_lines(ps)
 
+    @Condition
+    def has_search_string():
+        return ic.get_search_string_tokens() is not None
+
     validation_prompt: PromptSession = PromptSession(
         bottom_toolbar=lambda: ic.error_message, **kwargs
     )
@@ -540,6 +623,13 @@ def create_inquirer_layout(
             [
                 ps.layout.container,
                 ConditionalContainer(Window(ic), filter=~IsDone()),
+                ConditionalContainer(
+                    Window(
+                        height=LayoutDimension.exact(2),
+                        content=FormattedTextControl(ic.get_search_string_tokens),
+                    ),
+                    filter=has_search_string & ~IsDone(),
+                ),
                 ConditionalContainer(
                     validation_prompt.layout.container,
                     filter=Condition(lambda: ic.error_message is not None),
diff --git a/questionary/prompts/select.py b/questionary/prompts/select.py
index 41121bd..e41dbe5 100644
--- a/questionary/prompts/select.py
+++ b/questionary/prompts/select.py
@@ -1,5 +1,6 @@
 # -*- coding: utf-8 -*-
 
+import string
 from typing import Any
 from typing import Dict
 from typing import Optional
@@ -34,6 +35,7 @@ def select(
     use_indicator: bool = False,
     use_jk_keys: bool = True,
     use_emacs_keys: bool = True,
+    use_search_filter: bool = False,
     show_selected: bool = False,
     show_description: bool = True,
     instruction: Optional[str] = None,
@@ -109,6 +111,13 @@ def select(
                         `Ctrl+N` (down) and `Ctrl+P` (up) keys. Arrow keys, j/k keys,
                         emacs keys and shortcuts are not mutually exclusive.
 
+        use_search_filter: Flag to enable search filtering. Typing some string will
+                           filter the choices to keep only the ones that contain the
+                           search string.
+                           Note that activating this option disables "vi-like"
+                           navigation as "j" and "k" can be part of a prefix and
+                           therefore cannot be used for navigation
+
         show_selected: Display current selection choice at the bottom of list.
 
         show_description: Display description of current selection if available.
@@ -124,6 +133,11 @@ def select(
             )
         )
 
+    if use_jk_keys and use_search_filter:
+        raise ValueError(
+            "Cannot use j/k keys with prefix filter search, since j/k can be part of the prefix."
+        )
+
     if use_shortcuts and use_jk_keys:
         if any(getattr(c, "shortcut_key", "") in ["j", "k"] for c in choices):
             raise ValueError(
@@ -179,11 +193,11 @@ def select(
                 tokens.append(("class:instruction", instruction))
             else:
                 if use_shortcuts and use_arrow_keys:
-                    instruction_msg = "(Use shortcuts or arrow keys)"
+                    instruction_msg = f"(Use shortcuts or arrow keys{', type to filter' if use_search_filter else ''})"
                 elif use_shortcuts and not use_arrow_keys:
-                    instruction_msg = "(Use shortcuts)"
+                    instruction_msg = f"(Use shortcuts{', type to filter' if use_search_filter else ''})"
                 else:
-                    instruction_msg = "(Use arrow keys)"
+                    instruction_msg = f"(Use arrow keys{', type to filter' if use_search_filter else ''})"
                 tokens.append(("class:instruction", instruction_msg))
 
         return tokens
@@ -229,6 +243,15 @@ def select(
         while not ic.is_selection_valid():
             ic.select_previous()
 
+    if use_search_filter:
+
+        def search_filter(event):
+            ic.add_search_character(event.key_sequence[0].key)
+
+        for character in string.printable:
+            bindings.add(character, eager=True)(search_filter)
+        bindings.add(Keys.Backspace, eager=True)(search_filter)
+
     if use_arrow_keys:
         bindings.add(Keys.Down, eager=True)(move_cursor_down)
         bindings.add(Keys.Up, eager=True)(move_cursor_up)


Test patch:
diff --git a/tests/prompts/test_checkbox.py b/tests/prompts/test_checkbox.py
index 4b7c840..50da9bd 100644
--- a/tests/prompts/test_checkbox.py
+++ b/tests/prompts/test_checkbox.py
@@ -350,3 +350,42 @@ def test_fail_on_no_method_to_move_selection():
 
     with pytest.raises(ValueError):
         feed_cli_with_input("checkbox", message, text, **kwargs)
+
+
+def test_select_filter_first_choice():
+    message = "Foo message"
+    kwargs = {"choices": ["foo", "bar", "bazz"]}
+    text = KeyInputs.SPACE + KeyInputs.ENTER + "\r"
+
+    result, cli = feed_cli_with_input(
+        "checkbox",
+        message,
+        text,
+        use_search_filter=True,
+        use_jk_keys=False,
+        **kwargs,
+    )
+    assert result == ["foo"]
+
+
+def test_select_filter_multiple_after_search():
+    message = "Foo message"
+    kwargs = {"choices": ["foo", "bar", "bazz", "buzz"]}
+    text = (
+        KeyInputs.SPACE
+        + "bu"
+        + KeyInputs.SPACE
+        + KeyInputs.BACK
+        + KeyInputs.BACK
+        + "\r"
+    )
+
+    result, cli = feed_cli_with_input(
+        "checkbox",
+        message,
+        text,
+        use_search_filter=True,
+        use_jk_keys=False,
+        **kwargs,
+    )
+    assert result == ["foo", "buzz"]
diff --git a/tests/prompts/test_select.py b/tests/prompts/test_select.py
index 1d3eaa5..9aa3759 100644
--- a/tests/prompts/test_select.py
+++ b/tests/prompts/test_select.py
@@ -1,4 +1,6 @@
 # -*- coding: utf-8 -*-
+from copy import copy
+
 import pytest
 
 from questionary import Choice
@@ -199,6 +201,29 @@ def test_allow_shortcut_key_with_True():
     assert result == "bazz"
 
 
+def test_auto_shortcut_key_stable_in_loop():
+    message = "Foo message"
+    choices = [
+        Choice("foo"),
+        Choice("bar"),
+    ]
+    kwargs = {
+        "choices": choices,
+        "use_shortcuts": True,
+    }
+    text = "\r"
+
+    result, cli = feed_cli_with_input("select", message, text, **kwargs)
+    assert result == "foo"
+    result_shortcut_keys = [copy(c.shortcut_key) for c in choices]
+    result2, cli = feed_cli_with_input("select", message, text, **kwargs)
+    assert result2 == "foo"
+    result2_shortcut_keys = [copy(c.shortcut_key) for c in choices]
+    assert (
+        result_shortcut_keys == result2_shortcut_keys
+    ), "Shortcut keys changed across two runs of 'select'"
+
+
 def test_select_initial_choice_with_value():
     message = "Foo message"
     choice = Choice(title="bazz", value="bar")
@@ -384,3 +409,102 @@ def test_select_default_has_arrow_keys():
 
     result, cli = feed_cli_with_input("select", message, text, **kwargs)
     assert result == "bazz"
+
+
+def test_select_filter_with_jk_movement_exception():
+    message = "Foo message"
+    kwargs = {
+        "choices": ["foo", "bazz"],
+        "use_arrow_keys": True,
+        "use_shortcuts": False,
+    }
+    text = "2" + KeyInputs.ENTER + "\r"
+    with pytest.raises(ValueError):
+        feed_cli_with_input("select", message, text, use_search_filter=True, **kwargs)
+
+
+def test_filter_prefix_one_letter():
+    message = "Foo message"
+    kwargs = {"choices": ["abc", "def", "ghi", "jkl"]}
+    text = "g" + KeyInputs.ENTER + "\r"
+
+    result, cli = feed_cli_with_input(
+        "select",
+        message,
+        text,
+        use_search_filter=True,
+        use_jk_keys=False,
+        **kwargs,
+    )
+    assert result == "ghi"
+
+
+def test_filter_prefix_multiple_letters():
+    message = "Foo message"
+    kwargs = {"choices": ["abc", "def", "ghi", "jkl", "jag", "jja"]}
+    text = "j" + "j" + KeyInputs.ENTER + "\r"
+
+    result, cli = feed_cli_with_input(
+        "select",
+        message,
+        text,
+        use_search_filter=True,
+        use_jk_keys=False,
+        **kwargs,
+    )
+    assert result == "jja"
+
+
+def test_select_filter_handle_backspace():
+    message = "Foo message"
+    kwargs = {"choices": ["abc", "def", "ghi", "jkl", "jag", "jja"]}
+    text = "j" + "j" + KeyInputs.BACK + KeyInputs.ENTER + "\r"
+
+    result, cli = feed_cli_with_input(
+        "select",
+        message,
+        text,
+        use_search_filter=True,
+        use_jk_keys=False,
+        **kwargs,
+    )
+    assert result == "jkl"
+
+    message = "Foo message"
+    kwargs = {"choices": ["abc", "def", "ghi", "jkl", "jag", "jja"]}
+    text = (
+        "j"
+        + "j"
+        + KeyInputs.BACK
+        + KeyInputs.BACK
+        + KeyInputs.BACK
+        + KeyInputs.BACK
+        + KeyInputs.ENTER
+        + "\r"
+    )
+
+    result, cli = feed_cli_with_input(
+        "select",
+        message,
+        text,
+        use_search_filter=True,
+        use_jk_keys=False,
+        **kwargs,
+    )
+    assert result == "abc"
+
+
+def test_select_goes_back_to_top_after_filtering():
+    message = "Foo message"
+    kwargs = {"choices": ["abc", "def", "ghi", "jkl", "jag", "jja"]}
+    text = KeyInputs.DOWN + KeyInputs.DOWN + "j" + KeyInputs.ENTER + "\r"
+
+    result, cli = feed_cli_with_input(
+        "select",
+        message,
+        text,
+        use_search_filter=True,
+        use_jk_keys=False,
+        **kwargs,
+    )
+    assert result == "jkl"