Repository: ipython/ipython

Issue:
Improve autocomplete UX
Hi,

IPython recently introduced hinted suggestions as input is being typed. The user can accept the suggestion by pressing END or RIGHT. Alternatively, the user can scroll through other prefix matches in the history by pressing UP (and DOWN).  However, all these modes of navigation move the cursor to the end of the line. It is impossible to accept a prefix of a hinted suggestion.
 
We would like to propose keeping the cursor in place and naturally supporting the cursor movement with the logic of “accept anything that is on the LHS of the cursor”. This would allow reusing long prefixes without the need to accept the entire line and make edits from its end.
 
For example, suppose the history includes
very.long.module.foo.func()
very.long.module.bar.Baz()
The user types the prefix “very.”. The hinted suggestion would be “long.module.bar.Baz()”. The user can now press:
1.	RIGHT/CTRL-RIGHT to accept letters/words from the hinted completion. This would make it easier to type “very.long.module.bar.Zap()”.
2.	END to accept the entire suggestion.
3.	UP to replace the hinted suggestion with “long.module.foo.func()” (cursor stays in place).
4.	BACKSPACE to delete last character and resume hinting from the new prefix (currently, it aborts hinting).
5.	LEFT to accept the hint and move the cursor to the left.
6.	New key binding to accept the suggestion but keep cursor in place.
 
Thoughts?


Discussion:

Patch:
diff --git a/IPython/terminal/interactiveshell.py b/IPython/terminal/interactiveshell.py
index b5fc148ab..41a3321b0 100644
--- a/IPython/terminal/interactiveshell.py
+++ b/IPython/terminal/interactiveshell.py
@@ -16,6 +16,7 @@
     Unicode,
     Dict,
     Integer,
+    List,
     observe,
     Instance,
     Type,
@@ -29,7 +30,7 @@
 
 from prompt_toolkit.auto_suggest import AutoSuggestFromHistory
 from prompt_toolkit.enums import DEFAULT_BUFFER, EditingMode
-from prompt_toolkit.filters import (HasFocus, Condition, IsDone)
+from prompt_toolkit.filters import HasFocus, Condition, IsDone
 from prompt_toolkit.formatted_text import PygmentsTokens
 from prompt_toolkit.history import History
 from prompt_toolkit.layout.processors import ConditionalProcessor, HighlightMatchingBracketProcessor
@@ -49,7 +50,13 @@
 from .pt_inputhooks import get_inputhook_name_and_func
 from .prompts import Prompts, ClassicPrompts, RichPromptDisplayHook
 from .ptutils import IPythonPTCompleter, IPythonPTLexer
-from .shortcuts import create_ipython_shortcuts
+from .shortcuts import (
+    create_ipython_shortcuts,
+    create_identifier,
+    RuntimeBinding,
+    add_binding,
+)
+from .shortcuts.filters import KEYBINDING_FILTERS, filter_from_string
 from .shortcuts.auto_suggest import (
     NavigableAutoSuggestFromHistory,
     AppendAutoSuggestionInAnyLine,
@@ -415,6 +422,165 @@ def _autosuggestions_provider_changed(self, change):
         provider = change.new
         self._set_autosuggestions(provider)
 
+    shortcuts = List(
+        trait=Dict(
+            key_trait=Enum(
+                [
+                    "command",
+                    "match_keys",
+                    "match_filter",
+                    "new_keys",
+                    "new_filter",
+                    "create",
+                ]
+            ),
+            per_key_traits={
+                "command": Unicode(),
+                "match_keys": List(Unicode()),
+                "match_filter": Unicode(),
+                "new_keys": List(Unicode()),
+                "new_filter": Unicode(),
+                "create": Bool(False),
+            },
+        ),
+        help="""Add, disable or modifying shortcuts.
+
+        Each entry on the list should be a dictionary with ``command`` key
+        identifying the target function executed by the shortcut and at least
+        one of the following:
+
+        - ``match_keys``: list of keys used to match an existing shortcut,
+        - ``match_filter``: shortcut filter used to match an existing shortcut,
+        - ``new_keys``: list of keys to set,
+        - ``new_filter``: a new shortcut filter to set
+
+        The filters have to be composed of pre-defined verbs and joined by one
+        of the following conjunctions: ``&`` (and), ``|`` (or), ``~`` (not).
+        The pre-defined verbs are:
+
+        {}
+
+
+        To disable a shortcut set ``new_keys`` to an empty list.
+        To add a shortcut add key ``create`` with value ``True``.
+
+        When modifying/disabling shortcuts, ``match_keys``/``match_filter`` can
+        be omitted if the provided specification uniquely identifies a shortcut
+        to be modified/disabled. When modifying a shortcut ``new_filter`` or
+        ``new_keys`` can be omitted which will result in reuse of the existing
+        filter/keys.
+
+        Only shortcuts defined in IPython (and not default prompt-toolkit
+        shortcuts) can be modified or disabled. The full list of shortcuts,
+        command identifiers and filters is available under
+        :ref:`terminal-shortcuts-list`.
+        """.format(
+            "\n        ".join([f"- `{k}`" for k in KEYBINDING_FILTERS])
+        ),
+    ).tag(config=True)
+
+    @observe("shortcuts")
+    def _shortcuts_changed(self, change):
+        if self.pt_app:
+            self.pt_app.key_bindings = self._merge_shortcuts(user_shortcuts=change.new)
+
+    def _merge_shortcuts(self, user_shortcuts):
+        # rebuild the bindings list from scratch
+        key_bindings = create_ipython_shortcuts(self)
+
+        # for now we only allow adding shortcuts for commands which are already
+        # registered; this is a security precaution.
+        known_commands = {
+            create_identifier(binding.handler): binding.handler
+            for binding in key_bindings.bindings
+        }
+        shortcuts_to_skip = []
+        shortcuts_to_add = []
+
+        for shortcut in user_shortcuts:
+            command_id = shortcut["command"]
+            if command_id not in known_commands:
+                allowed_commands = "\n - ".join(known_commands)
+                raise ValueError(
+                    f"{command_id} is not a known shortcut command."
+                    f" Allowed commands are: \n - {allowed_commands}"
+                )
+            old_keys = shortcut.get("match_keys", None)
+            old_filter = (
+                filter_from_string(shortcut["match_filter"])
+                if "match_filter" in shortcut
+                else None
+            )
+            matching = [
+                binding
+                for binding in key_bindings.bindings
+                if (
+                    (old_filter is None or binding.filter == old_filter)
+                    and (old_keys is None or [k for k in binding.keys] == old_keys)
+                    and create_identifier(binding.handler) == command_id
+                )
+            ]
+
+            new_keys = shortcut.get("new_keys", None)
+            new_filter = shortcut.get("new_filter", None)
+
+            command = known_commands[command_id]
+
+            creating_new = shortcut.get("create", False)
+            modifying_existing = not creating_new and (
+                new_keys is not None or new_filter
+            )
+
+            if creating_new and new_keys == []:
+                raise ValueError("Cannot add a shortcut without keys")
+
+            if modifying_existing:
+                specification = {
+                    key: shortcut[key]
+                    for key in ["command", "filter"]
+                    if key in shortcut
+                }
+                if len(matching) == 0:
+                    raise ValueError(f"No shortcuts matching {specification} found")
+                elif len(matching) > 1:
+                    raise ValueError(
+                        f"Multiple shortcuts matching {specification} found,"
+                        f" please add keys/filter to select one of: {matching}"
+                    )
+
+                matched = matching[0]
+                old_filter = matched.filter
+                old_keys = list(matched.keys)
+                shortcuts_to_skip.append(
+                    RuntimeBinding(
+                        command,
+                        keys=old_keys,
+                        filter=old_filter,
+                    )
+                )
+
+            if new_keys != []:
+                shortcuts_to_add.append(
+                    RuntimeBinding(
+                        command,
+                        keys=new_keys or old_keys,
+                        filter=filter_from_string(new_filter)
+                        if new_filter is not None
+                        else (
+                            old_filter
+                            if old_filter is not None
+                            else filter_from_string("always")
+                        ),
+                    )
+                )
+
+        # rebuild the bindings list from scratch
+        key_bindings = create_ipython_shortcuts(self, skip=shortcuts_to_skip)
+        for binding in shortcuts_to_add:
+            add_binding(key_bindings, binding)
+
+        return key_bindings
+
     prompt_includes_vi_mode = Bool(True,
         help="Display the current vi mode (when using vi editing mode)."
     ).tag(config=True)
@@ -452,8 +618,7 @@ def prompt():
             return
 
         # Set up keyboard shortcuts
-        key_bindings = create_ipython_shortcuts(self)
-
+        key_bindings = self._merge_shortcuts(user_shortcuts=self.shortcuts)
 
         # Pre-populate history from IPython's history database
         history = PtkHistoryAdapter(self)
@@ -477,7 +642,7 @@ def prompt():
             enable_open_in_editor=self.extra_open_editor_shortcuts,
             color_depth=self.color_depth,
             tempfile_suffix=".py",
-            **self._extra_prompt_options()
+            **self._extra_prompt_options(),
         )
         if isinstance(self.auto_suggest, NavigableAutoSuggestFromHistory):
             self.auto_suggest.connect(self.pt_app)
diff --git a/IPython/terminal/shortcuts/__init__.py b/IPython/terminal/shortcuts/__init__.py
index 7ec9a2871..9711c81ef 100644
--- a/IPython/terminal/shortcuts/__init__.py
+++ b/IPython/terminal/shortcuts/__init__.py
@@ -7,414 +7,269 @@
 # Distributed under the terms of the Modified BSD License.
 
 import os
-import re
 import signal
 import sys
 import warnings
-from typing import Callable, Dict, Union
+from dataclasses import dataclass
+from typing import Callable, Any, Optional, List
 
 from prompt_toolkit.application.current import get_app
-from prompt_toolkit.enums import DEFAULT_BUFFER, SEARCH_BUFFER
-from prompt_toolkit.filters import Condition, emacs_insert_mode, has_completions
-from prompt_toolkit.filters import has_focus as has_focus_impl
-from prompt_toolkit.filters import (
-    has_selection,
-    has_suggestion,
-    vi_insert_mode,
-    vi_mode,
-)
 from prompt_toolkit.key_binding import KeyBindings
+from prompt_toolkit.key_binding.key_processor import KeyPressEvent
 from prompt_toolkit.key_binding.bindings import named_commands as nc
 from prompt_toolkit.key_binding.bindings.completion import (
     display_completions_like_readline,
 )
 from prompt_toolkit.key_binding.vi_state import InputMode, ViState
-from prompt_toolkit.layout.layout import FocusableElement
+from prompt_toolkit.filters import Condition
 
+from IPython.core.getipython import get_ipython
 from IPython.terminal.shortcuts import auto_match as match
 from IPython.terminal.shortcuts import auto_suggest
+from IPython.terminal.shortcuts.filters import filter_from_string
 from IPython.utils.decorators import undoc
 
 __all__ = ["create_ipython_shortcuts"]
 
 
-@undoc
-@Condition
-def cursor_in_leading_ws():
-    before = get_app().current_buffer.document.current_line_before_cursor
-    return (not before) or before.isspace()
-
-
-def has_focus(value: FocusableElement):
-    """Wrapper around has_focus adding a nice `__name__` to tester function"""
-    tester = has_focus_impl(value).func
-    tester.__name__ = f"is_focused({value})"
-    return Condition(tester)
-
-
-@undoc
-@Condition
-def has_line_below() -> bool:
-    document = get_app().current_buffer.document
-    return document.cursor_position_row < len(document.lines) - 1
-
-
-@undoc
-@Condition
-def has_line_above() -> bool:
-    document = get_app().current_buffer.document
-    return document.cursor_position_row != 0
-
-
-def create_ipython_shortcuts(shell, for_all_platforms: bool = False) -> KeyBindings:
-    """Set up the prompt_toolkit keyboard shortcuts for IPython.
-
-    Parameters
-    ----------
-    shell: InteractiveShell
-        The current IPython shell Instance
-    for_all_platforms: bool (default false)
-        This parameter is mostly used in generating the documentation
-        to create the shortcut binding for all the platforms, and export
-        them.
-
-    Returns
-    -------
-    KeyBindings
-        the keybinding instance for prompt toolkit.
+@dataclass
+class BaseBinding:
+    command: Callable[[KeyPressEvent], Any]
+    keys: List[str]
 
-    """
-    # Warning: if possible, do NOT define handler functions in the locals
-    # scope of this function, instead define functions in the global
-    # scope, or a separate module, and include a user-friendly docstring
-    # describing the action.
 
-    kb = KeyBindings()
-    insert_mode = vi_insert_mode | emacs_insert_mode
+@dataclass
+class RuntimeBinding(BaseBinding):
+    filter: Condition
 
-    if getattr(shell, "handle_return", None):
-        return_handler = shell.handle_return(shell)
-    else:
-        return_handler = newline_or_execute_outer(shell)
 
-    kb.add("enter", filter=(has_focus(DEFAULT_BUFFER) & ~has_selection & insert_mode))(
-        return_handler
-    )
-
-    @Condition
-    def ebivim():
-        return shell.emacs_bindings_in_vi_insert_mode
-
-    @kb.add(
-        "escape",
-        "enter",
-        filter=(has_focus(DEFAULT_BUFFER) & ~has_selection & insert_mode & ebivim),
-    )
-    def reformat_and_execute(event):
-        """Reformat code and execute it"""
-        reformat_text_before_cursor(
-            event.current_buffer, event.current_buffer.document, shell
-        )
-        event.current_buffer.validate_and_handle()
-
-    kb.add("c-\\")(quit)
-
-    kb.add("c-p", filter=(vi_insert_mode & has_focus(DEFAULT_BUFFER)))(
-        previous_history_or_previous_completion
-    )
-
-    kb.add("c-n", filter=(vi_insert_mode & has_focus(DEFAULT_BUFFER)))(
-        next_history_or_next_completion
-    )
-
-    kb.add("c-g", filter=(has_focus(DEFAULT_BUFFER) & has_completions))(
-        dismiss_completion
-    )
-
-    kb.add("c-c", filter=has_focus(DEFAULT_BUFFER))(reset_buffer)
-
-    kb.add("c-c", filter=has_focus(SEARCH_BUFFER))(reset_search_buffer)
-
-    supports_suspend = Condition(lambda: hasattr(signal, "SIGTSTP"))
-    kb.add("c-z", filter=supports_suspend)(suspend_to_bg)
-
-    # Ctrl+I == Tab
-    kb.add(
-        "tab",
-        filter=(
-            has_focus(DEFAULT_BUFFER)
-            & ~has_selection
-            & insert_mode
-            & cursor_in_leading_ws
-        ),
-    )(indent_buffer)
-    kb.add("c-o", filter=(has_focus(DEFAULT_BUFFER) & emacs_insert_mode))(
-        newline_autoindent_outer(shell.input_transformer_manager)
-    )
-
-    kb.add("f2", filter=has_focus(DEFAULT_BUFFER))(open_input_in_editor)
-
-    @Condition
-    def auto_match():
-        return shell.auto_match
-
-    def all_quotes_paired(quote, buf):
-        paired = True
-        i = 0
-        while i < len(buf):
-            c = buf[i]
-            if c == quote:
-                paired = not paired
-            elif c == "\\":
-                i += 1
-            i += 1
-        return paired
-
-    focused_insert = (vi_insert_mode | emacs_insert_mode) & has_focus(DEFAULT_BUFFER)
-    _preceding_text_cache: Dict[Union[str, Callable], Condition] = {}
-    _following_text_cache: Dict[Union[str, Callable], Condition] = {}
-
-    def preceding_text(pattern: Union[str, Callable]):
-        if pattern in _preceding_text_cache:
-            return _preceding_text_cache[pattern]
-
-        if callable(pattern):
-
-            def _preceding_text():
-                app = get_app()
-                before_cursor = app.current_buffer.document.current_line_before_cursor
-                # mypy can't infer if(callable): https://github.com/python/mypy/issues/3603
-                return bool(pattern(before_cursor))  # type: ignore[operator]
+@dataclass
+class Binding(BaseBinding):
+    # while filter could be created by referencing variables directly (rather
+    # than created from strings), by using strings we ensure that users will
+    # be able to create filters in configuration (e.g. JSON) files too, which
+    # also benefits the documentation by enforcing human-readable filter names.
+    condition: Optional[str] = None
 
+    def __post_init__(self):
+        if self.condition:
+            self.filter = filter_from_string(self.condition)
         else:
-            m = re.compile(pattern)
-
-            def _preceding_text():
-                app = get_app()
-                before_cursor = app.current_buffer.document.current_line_before_cursor
-                return bool(m.match(before_cursor))
-
-            _preceding_text.__name__ = f"preceding_text({pattern!r})"
-
-        condition = Condition(_preceding_text)
-        _preceding_text_cache[pattern] = condition
-        return condition
-
-    def following_text(pattern):
-        try:
-            return _following_text_cache[pattern]
-        except KeyError:
-            pass
-        m = re.compile(pattern)
-
-        def _following_text():
-            app = get_app()
-            return bool(m.match(app.current_buffer.document.current_line_after_cursor))
-
-        _following_text.__name__ = f"following_text({pattern!r})"
+            self.filter = None
 
-        condition = Condition(_following_text)
-        _following_text_cache[pattern] = condition
-        return condition
 
-    @Condition
-    def not_inside_unclosed_string():
-        app = get_app()
-        s = app.current_buffer.document.text_before_cursor
-        # remove escaped quotes
-        s = s.replace('\\"', "").replace("\\'", "")
-        # remove triple-quoted string literals
-        s = re.sub(r"(?:\"\"\"[\s\S]*\"\"\"|'''[\s\S]*''')", "", s)
-        # remove single-quoted string literals
-        s = re.sub(r"""(?:"[^"]*["\n]|'[^']*['\n])""", "", s)
-        return not ('"' in s or "'" in s)
-
-    # auto match
-    for key, cmd in match.auto_match_parens.items():
-        kb.add(key, filter=focused_insert & auto_match & following_text(r"[,)}\]]|$"))(
-            cmd
-        )
+def create_identifier(handler: Callable):
+    parts = handler.__module__.split(".")
+    name = handler.__name__
+    package = parts[0]
+    if len(parts) > 1:
+        final_module = parts[-1]
+        return f"{package}:{final_module}.{name}"
+    else:
+        return f"{package}:{name}"
 
-    # raw string
-    for key, cmd in match.auto_match_parens_raw_string.items():
-        kb.add(
-            key,
-            filter=focused_insert & auto_match & preceding_text(r".*(r|R)[\"'](-*)$"),
-        )(cmd)
-
-    kb.add(
-        '"',
-        filter=focused_insert
-        & auto_match
-        & not_inside_unclosed_string
-        & preceding_text(lambda line: all_quotes_paired('"', line))
-        & following_text(r"[,)}\]]|$"),
-    )(match.double_quote)
-
-    kb.add(
-        "'",
-        filter=focused_insert
-        & auto_match
-        & not_inside_unclosed_string
-        & preceding_text(lambda line: all_quotes_paired("'", line))
-        & following_text(r"[,)}\]]|$"),
-    )(match.single_quote)
-
-    kb.add(
-        '"',
-        filter=focused_insert
-        & auto_match
-        & not_inside_unclosed_string
-        & preceding_text(r'^.*""$'),
-    )(match.docstring_double_quotes)
-
-    kb.add(
-        "'",
-        filter=focused_insert
-        & auto_match
-        & not_inside_unclosed_string
-        & preceding_text(r"^.*''$"),
-    )(match.docstring_single_quotes)
-
-    # just move cursor
-    kb.add(")", filter=focused_insert & auto_match & following_text(r"^\)"))(
-        match.skip_over
-    )
-    kb.add("]", filter=focused_insert & auto_match & following_text(r"^\]"))(
-        match.skip_over
-    )
-    kb.add("}", filter=focused_insert & auto_match & following_text(r"^\}"))(
-        match.skip_over
-    )
-    kb.add('"', filter=focused_insert & auto_match & following_text('^"'))(
-        match.skip_over
-    )
-    kb.add("'", filter=focused_insert & auto_match & following_text("^'"))(
-        match.skip_over
-    )
 
-    kb.add(
-        "backspace",
-        filter=focused_insert
-        & preceding_text(r".*\($")
-        & auto_match
-        & following_text(r"^\)"),
-    )(match.delete_pair)
-    kb.add(
-        "backspace",
-        filter=focused_insert
-        & preceding_text(r".*\[$")
-        & auto_match
-        & following_text(r"^\]"),
-    )(match.delete_pair)
-    kb.add(
-        "backspace",
-        filter=focused_insert
-        & preceding_text(r".*\{$")
-        & auto_match
-        & following_text(r"^\}"),
-    )(match.delete_pair)
-    kb.add(
-        "backspace",
-        filter=focused_insert
-        & preceding_text('.*"$')
-        & auto_match
-        & following_text('^"'),
-    )(match.delete_pair)
-    kb.add(
-        "backspace",
-        filter=focused_insert
-        & preceding_text(r".*'$")
-        & auto_match
-        & following_text(r"^'"),
-    )(match.delete_pair)
-
-    if shell.display_completions == "readlinelike":
-        kb.add(
-            "c-i",
-            filter=(
-                has_focus(DEFAULT_BUFFER)
-                & ~has_selection
-                & insert_mode
-                & ~cursor_in_leading_ws
-            ),
-        )(display_completions_like_readline)
-
-    if sys.platform == "win32" or for_all_platforms:
-        kb.add("c-v", filter=(has_focus(DEFAULT_BUFFER) & ~vi_mode))(win_paste)
-
-    focused_insert_vi = has_focus(DEFAULT_BUFFER) & vi_insert_mode
-
-    # autosuggestions
-    @Condition
-    def navigable_suggestions():
-        return isinstance(
-            shell.auto_suggest, auto_suggest.NavigableAutoSuggestFromHistory
+AUTO_MATCH_BINDINGS = [
+    *[
+        Binding(
+            cmd, [key], "focused_insert & auto_match & followed_by_closing_paren_or_end"
         )
-
-    kb.add("end", filter=has_focus(DEFAULT_BUFFER) & (ebivim | ~vi_insert_mode))(
-        auto_suggest.accept_in_vi_insert_mode
-    )
-    kb.add("c-e", filter=focused_insert_vi & ebivim)(
-        auto_suggest.accept_in_vi_insert_mode
-    )
-    kb.add("c-f", filter=focused_insert_vi)(auto_suggest.accept)
-    kb.add("escape", "f", filter=focused_insert_vi & ebivim)(auto_suggest.accept_word)
-    kb.add("c-right", filter=has_suggestion & has_focus(DEFAULT_BUFFER))(
-        auto_suggest.accept_token
-    )
-    kb.add(
-        "escape", filter=has_suggestion & has_focus(DEFAULT_BUFFER) & emacs_insert_mode
-    )(auto_suggest.discard)
-    kb.add(
-        "up",
-        filter=navigable_suggestions
-        & ~has_line_above
-        & has_suggestion
-        & has_focus(DEFAULT_BUFFER),
-    )(auto_suggest.swap_autosuggestion_up(shell.auto_suggest))
-    kb.add(
-        "down",
-        filter=navigable_suggestions
-        & ~has_line_below
-        & has_suggestion
-        & has_focus(DEFAULT_BUFFER),
-    )(auto_suggest.swap_autosuggestion_down(shell.auto_suggest))
-    kb.add(
-        "up", filter=has_line_above & navigable_suggestions & has_focus(DEFAULT_BUFFER)
-    )(auto_suggest.up_and_update_hint)
-    kb.add(
-        "down",
-        filter=has_line_below & navigable_suggestions & has_focus(DEFAULT_BUFFER),
-    )(auto_suggest.down_and_update_hint)
-    kb.add("right", filter=has_suggestion & has_focus(DEFAULT_BUFFER))(
-        auto_suggest.accept_character
-    )
-    kb.add("c-left", filter=has_suggestion & has_focus(DEFAULT_BUFFER))(
-        auto_suggest.accept_and_move_cursor_left
-    )
-    kb.add("c-down", filter=has_suggestion & has_focus(DEFAULT_BUFFER))(
-        auto_suggest.accept_and_keep_cursor
-    )
-    kb.add("backspace", filter=has_suggestion & has_focus(DEFAULT_BUFFER))(
-        auto_suggest.backspace_and_resume_hint
-    )
-
-    # Simple Control keybindings
-    key_cmd_dict = {
+        for key, cmd in match.auto_match_parens.items()
+    ],
+    *[
+        # raw string
+        Binding(cmd, [key], "focused_insert & auto_match & preceded_by_raw_str_prefix")
+        for key, cmd in match.auto_match_parens_raw_string.items()
+    ],
+    Binding(
+        match.double_quote,
+        ['"'],
+        "focused_insert"
+        " & auto_match"
+        " & not_inside_unclosed_string"
+        " & preceded_by_paired_double_quotes"
+        " & followed_by_closing_paren_or_end",
+    ),
+    Binding(
+        match.single_quote,
+        ["'"],
+        "focused_insert"
+        " & auto_match"
+        " & not_inside_unclosed_string"
+        " & preceded_by_paired_single_quotes"
+        " & followed_by_closing_paren_or_end",
+    ),
+    Binding(
+        match.docstring_double_quotes,
+        ['"'],
+        "focused_insert"
+        " & auto_match"
+        " & not_inside_unclosed_string"
+        " & preceded_by_two_double_quotes",
+    ),
+    Binding(
+        match.docstring_single_quotes,
+        ["'"],
+        "focused_insert"
+        " & auto_match"
+        " & not_inside_unclosed_string"
+        " & preceded_by_two_single_quotes",
+    ),
+    Binding(
+        match.skip_over,
+        [")"],
+        "focused_insert & auto_match & followed_by_closing_round_paren",
+    ),
+    Binding(
+        match.skip_over,
+        ["]"],
+        "focused_insert & auto_match & followed_by_closing_bracket",
+    ),
+    Binding(
+        match.skip_over,
+        ["}"],
+        "focused_insert & auto_match & followed_by_closing_brace",
+    ),
+    Binding(
+        match.skip_over, ['"'], "focused_insert & auto_match & followed_by_double_quote"
+    ),
+    Binding(
+        match.skip_over, ["'"], "focused_insert & auto_match & followed_by_single_quote"
+    ),
+    Binding(
+        match.delete_pair,
+        ["backspace"],
+        "focused_insert"
+        " & preceded_by_opening_round_paren"
+        " & auto_match"
+        " & followed_by_closing_round_paren",
+    ),
+    Binding(
+        match.delete_pair,
+        ["backspace"],
+        "focused_insert"
+        " & preceded_by_opening_bracket"
+        " & auto_match"
+        " & followed_by_closing_bracket",
+    ),
+    Binding(
+        match.delete_pair,
+        ["backspace"],
+        "focused_insert"
+        " & preceded_by_opening_brace"
+        " & auto_match"
+        " & followed_by_closing_brace",
+    ),
+    Binding(
+        match.delete_pair,
+        ["backspace"],
+        "focused_insert"
+        " & preceded_by_double_quote"
+        " & auto_match"
+        " & followed_by_double_quote",
+    ),
+    Binding(
+        match.delete_pair,
+        ["backspace"],
+        "focused_insert"
+        " & preceded_by_single_quote"
+        " & auto_match"
+        " & followed_by_single_quote",
+    ),
+]
+
+AUTO_SUGGEST_BINDINGS = [
+    Binding(
+        auto_suggest.accept_in_vi_insert_mode,
+        ["end"],
+        "default_buffer_focused & (ebivim | ~vi_insert_mode)",
+    ),
+    Binding(
+        auto_suggest.accept_in_vi_insert_mode,
+        ["c-e"],
+        "vi_insert_mode & default_buffer_focused & ebivim",
+    ),
+    Binding(auto_suggest.accept, ["c-f"], "vi_insert_mode & default_buffer_focused"),
+    Binding(
+        auto_suggest.accept_word,
+        ["escape", "f"],
+        "vi_insert_mode & default_buffer_focused & ebivim",
+    ),
+    Binding(
+        auto_suggest.accept_token,
+        ["c-right"],
+        "has_suggestion & default_buffer_focused",
+    ),
+    Binding(
+        auto_suggest.discard,
+        ["escape"],
+        "has_suggestion & default_buffer_focused & emacs_insert_mode",
+    ),
+    Binding(
+        auto_suggest.swap_autosuggestion_up,
+        ["up"],
+        "navigable_suggestions"
+        " & ~has_line_above"
+        " & has_suggestion"
+        " & default_buffer_focused",
+    ),
+    Binding(
+        auto_suggest.swap_autosuggestion_down,
+        ["down"],
+        "navigable_suggestions"
+        " & ~has_line_below"
+        " & has_suggestion"
+        " & default_buffer_focused",
+    ),
+    Binding(
+        auto_suggest.up_and_update_hint,
+        ["up"],
+        "has_line_above & navigable_suggestions & default_buffer_focused",
+    ),
+    Binding(
+        auto_suggest.down_and_update_hint,
+        ["down"],
+        "has_line_below & navigable_suggestions & default_buffer_focused",
+    ),
+    Binding(
+        auto_suggest.accept_character,
+        ["escape", "right"],
+        "has_suggestion & default_buffer_focused",
+    ),
+    Binding(
+        auto_suggest.accept_and_move_cursor_left,
+        ["c-left"],
+        "has_suggestion & default_buffer_focused",
+    ),
+    Binding(
+        auto_suggest.accept_and_keep_cursor,
+        ["c-down"],
+        "has_suggestion & default_buffer_focused",
+    ),
+    Binding(
+        auto_suggest.backspace_and_resume_hint,
+        ["backspace"],
+        "has_suggestion & default_buffer_focused",
+    ),
+]
+
+
+SIMPLE_CONTROL_BINDINGS = [
+    Binding(cmd, [key], "vi_insert_mode & default_buffer_focused & ebivim")
+    for key, cmd in {
         "c-a": nc.beginning_of_line,
         "c-b": nc.backward_char,
         "c-k": nc.kill_line,
         "c-w": nc.backward_kill_word,
         "c-y": nc.yank,
         "c-_": nc.undo,
-    }
+    }.items()
+]
 
-    for key, cmd in key_cmd_dict.items():
-        kb.add(key, filter=focused_insert_vi & ebivim)(cmd)
 
-    # Alt and Combo Control keybindings
-    keys_cmd_dict = {
+ALT_AND_COMOBO_CONTROL_BINDINGS = [
+    Binding(cmd, list(keys), "vi_insert_mode & default_buffer_focused & ebivim")
+    for keys, cmd in {
         # Control Combos
         ("c-x", "c-e"): nc.edit_and_execute,
         ("c-x", "e"): nc.edit_and_execute,
@@ -427,10 +282,48 @@ def navigable_suggestions():
         ("escape", "u"): nc.uppercase_word,
         ("escape", "y"): nc.yank_pop,
         ("escape", "."): nc.yank_last_arg,
-    }
+    }.items()
+]
+
+
+def add_binding(bindings: KeyBindings, binding: Binding):
+    bindings.add(
+        *binding.keys,
+        **({"filter": binding.filter} if binding.filter is not None else {}),
+    )(binding.command)
+
+
+def create_ipython_shortcuts(shell, skip=None) -> KeyBindings:
+    """Set up the prompt_toolkit keyboard shortcuts for IPython.
 
-    for keys, cmd in keys_cmd_dict.items():
-        kb.add(*keys, filter=focused_insert_vi & ebivim)(cmd)
+    Parameters
+    ----------
+    shell: InteractiveShell
+        The current IPython shell Instance
+    skip: List[Binding]
+        Bindings to skip.
+
+    Returns
+    -------
+    KeyBindings
+        the keybinding instance for prompt toolkit.
+
+    """
+    kb = KeyBindings()
+    skip = skip or []
+    for binding in KEY_BINDINGS:
+        skip_this_one = False
+        for to_skip in skip:
+            if (
+                to_skip.command == binding.command
+                and to_skip.filter == binding.filter
+                and to_skip.keys == binding.keys
+            ):
+                skip_this_one = True
+                break
+        if skip_this_one:
+            continue
+        add_binding(kb, binding)
 
     def get_input_mode(self):
         app = get_app()
@@ -451,9 +344,19 @@ def set_input_mode(self, mode):
     if shell.editing_mode == "vi" and shell.modal_cursor:
         ViState._input_mode = InputMode.INSERT  # type: ignore
         ViState.input_mode = property(get_input_mode, set_input_mode)  # type: ignore
+
     return kb
 
 
+def reformat_and_execute(event):
+    """Reformat code and execute it"""
+    shell = get_ipython()
+    reformat_text_before_cursor(
+        event.current_buffer, event.current_buffer.document, shell
+    )
+    event.current_buffer.validate_and_handle()
+
+
 def reformat_text_before_cursor(buffer, document, shell):
     text = buffer.delete_before_cursor(len(document.text[: document.cursor_position]))
     try:
@@ -463,6 +366,14 @@ def reformat_text_before_cursor(buffer, document, shell):
         buffer.insert_text(text)
 
 
+def handle_return_or_newline_or_execute(event):
+    shell = get_ipython()
+    if getattr(shell, "handle_return", None):
+        return shell.handle_return(shell)(event)
+    else:
+        return newline_or_execute_outer(shell)(event)
+
+
 def newline_or_execute_outer(shell):
     def newline_or_execute(event):
         """When the user presses return, insert a newline or execute the code."""
@@ -512,8 +423,6 @@ def newline_or_execute(event):
             else:
                 b.insert_text("\n")
 
-    newline_or_execute.__qualname__ = "newline_or_execute"
-
     return newline_or_execute
 
 
@@ -610,30 +519,24 @@ def newline_with_copy_margin(event):
         b.cursor_right(count=pos_diff)
 
 
-def newline_autoindent_outer(inputsplitter) -> Callable[..., None]:
-    """
-    Return a function suitable for inserting a indented newline after the cursor.
+def newline_autoindent(event):
+    """Insert a newline after the cursor indented appropriately.
 
     Fancier version of deprecated ``newline_with_copy_margin`` which should
     compute the correct indentation of the inserted line. That is to say, indent
     by 4 extra space after a function definition, class definition, context
     manager... And dedent by 4 space after ``pass``, ``return``, ``raise ...``.
     """
+    shell = get_ipython()
+    inputsplitter = shell.input_transformer_manager
+    b = event.current_buffer
+    d = b.document
 
-    def newline_autoindent(event):
-        """Insert a newline after the cursor indented appropriately."""
-        b = event.current_buffer
-        d = b.document
-
-        if b.complete_state:
-            b.cancel_completion()
-        text = d.text[: d.cursor_position] + "\n"
-        _, indent = inputsplitter.check_complete(text)
-        b.insert_text("\n" + (" " * (indent or 0)), move_cursor=False)
-
-    newline_autoindent.__qualname__ = "newline_autoindent"
-
-    return newline_autoindent
+    if b.complete_state:
+        b.cancel_completion()
+    text = d.text[: d.cursor_position] + "\n"
+    _, indent = inputsplitter.check_complete(text)
+    b.insert_text("\n" + (" " * (indent or 0)), move_cursor=False)
 
 
 def open_input_in_editor(event):
@@ -666,5 +569,58 @@ def win_paste(event):
 
     @undoc
     def win_paste(event):
-        """Stub used when auto-generating shortcuts for documentation"""
+        """Stub used on other platforms"""
         pass
+
+
+KEY_BINDINGS = [
+    Binding(
+        handle_return_or_newline_or_execute,
+        ["enter"],
+        "default_buffer_focused & ~has_selection & insert_mode",
+    ),
+    Binding(
+        reformat_and_execute,
+        ["escape", "enter"],
+        "default_buffer_focused & ~has_selection & insert_mode & ebivim",
+    ),
+    Binding(quit, ["c-\\"]),
+    Binding(
+        previous_history_or_previous_completion,
+        ["c-p"],
+        "vi_insert_mode & default_buffer_focused",
+    ),
+    Binding(
+        next_history_or_next_completion,
+        ["c-n"],
+        "vi_insert_mode & default_buffer_focused",
+    ),
+    Binding(dismiss_completion, ["c-g"], "default_buffer_focused & has_completions"),
+    Binding(reset_buffer, ["c-c"], "default_buffer_focused"),
+    Binding(reset_search_buffer, ["c-c"], "search_buffer_focused"),
+    Binding(suspend_to_bg, ["c-z"], "supports_suspend"),
+    Binding(
+        indent_buffer,
+        ["tab"],  # Ctrl+I == Tab
+        "default_buffer_focused"
+        " & ~has_selection"
+        " & insert_mode"
+        " & cursor_in_leading_ws",
+    ),
+    Binding(newline_autoindent, ["c-o"], "default_buffer_focused & emacs_insert_mode"),
+    Binding(open_input_in_editor, ["f2"], "default_buffer_focused"),
+    *AUTO_MATCH_BINDINGS,
+    *AUTO_SUGGEST_BINDINGS,
+    Binding(
+        display_completions_like_readline,
+        ["c-i"],
+        "readline_like_completions"
+        " & default_buffer_focused"
+        " & ~has_selection"
+        " & insert_mode"
+        " & ~cursor_in_leading_ws",
+    ),
+    Binding(win_paste, ["c-v"], "default_buffer_focused & ~vi_mode & is_windows_os"),
+    *SIMPLE_CONTROL_BINDINGS,
+    *ALT_AND_COMOBO_CONTROL_BINDINGS,
+]
diff --git a/IPython/terminal/shortcuts/auto_match.py b/IPython/terminal/shortcuts/auto_match.py
index 46cb1bd87..6c2b1ef70 100644
--- a/IPython/terminal/shortcuts/auto_match.py
+++ b/IPython/terminal/shortcuts/auto_match.py
@@ -1,7 +1,7 @@
 """
-Utilities function for keybinding with prompt toolkit. 
+Utilities function for keybinding with prompt toolkit.
 
-This will be bound to specific key press and filter modes, 
+This will be bound to specific key press and filter modes,
 like whether we are in edit mode, and whether the completer is open.
 """
 import re
@@ -84,9 +84,9 @@ def raw_string_braces(event: KeyPressEvent):
 
 
 def skip_over(event: KeyPressEvent):
-    """Skip over automatically added parenthesis.
+    """Skip over automatically added parenthesis/quote.
 
-    (rather than adding another parenthesis)"""
+    (rather than adding another parenthesis/quote)"""
     event.current_buffer.cursor_right()
 
 
diff --git a/IPython/terminal/shortcuts/auto_suggest.py b/IPython/terminal/shortcuts/auto_suggest.py
index 3bfd6d54b..0c193d9ce 100644
--- a/IPython/terminal/shortcuts/auto_suggest.py
+++ b/IPython/terminal/shortcuts/auto_suggest.py
@@ -1,7 +1,7 @@
 import re
 import tokenize
 from io import StringIO
-from typing import Callable, List, Optional, Union, Generator, Tuple, Sequence
+from typing import Callable, List, Optional, Union, Generator, Tuple
 
 from prompt_toolkit.buffer import Buffer
 from prompt_toolkit.key_binding import KeyPressEvent
@@ -16,6 +16,7 @@
     TransformationInput,
 )
 
+from IPython.core.getipython import get_ipython
 from IPython.utils.tokenutil import generate_tokens
 
 
@@ -346,33 +347,29 @@ def _swap_autosuggestion(
     buffer.suggestion = new_suggestion
 
 
-def swap_autosuggestion_up(provider: Provider):
-    def swap_autosuggestion_up(event: KeyPressEvent):
-        """Get next autosuggestion from history."""
-        if not isinstance(provider, NavigableAutoSuggestFromHistory):
-            return
+def swap_autosuggestion_up(event: KeyPressEvent):
+    """Get next autosuggestion from history."""
+    shell = get_ipython()
+    provider = shell.auto_suggest
 
-        return _swap_autosuggestion(
-            buffer=event.current_buffer, provider=provider, direction_method=provider.up
-        )
+    if not isinstance(provider, NavigableAutoSuggestFromHistory):
+        return
 
-    swap_autosuggestion_up.__name__ = "swap_autosuggestion_up"
-    return swap_autosuggestion_up
+    return _swap_autosuggestion(
+        buffer=event.current_buffer, provider=provider, direction_method=provider.up
+    )
 
 
-def swap_autosuggestion_down(
-    provider: Union[AutoSuggestFromHistory, NavigableAutoSuggestFromHistory, None]
-):
-    def swap_autosuggestion_down(event: KeyPressEvent):
-        """Get previous autosuggestion from history."""
-        if not isinstance(provider, NavigableAutoSuggestFromHistory):
-            return
+def swap_autosuggestion_down(event: KeyPressEvent):
+    """Get previous autosuggestion from history."""
+    shell = get_ipython()
+    provider = shell.auto_suggest
 
-        return _swap_autosuggestion(
-            buffer=event.current_buffer,
-            provider=provider,
-            direction_method=provider.down,
-        )
+    if not isinstance(provider, NavigableAutoSuggestFromHistory):
+        return
 
-    swap_autosuggestion_down.__name__ = "swap_autosuggestion_down"
-    return swap_autosuggestion_down
+    return _swap_autosuggestion(
+        buffer=event.current_buffer,
+        provider=provider,
+        direction_method=provider.down,
+    )
diff --git a/IPython/terminal/shortcuts/filters.py b/IPython/terminal/shortcuts/filters.py
new file mode 100644
index 000000000..a4a621307
--- /dev/null
+++ b/IPython/terminal/shortcuts/filters.py
@@ -0,0 +1,256 @@
+"""
+Filters restricting scope of IPython Terminal shortcuts.
+"""
+
+# Copyright (c) IPython Development Team.
+# Distributed under the terms of the Modified BSD License.
+
+import ast
+import re
+import signal
+import sys
+from typing import Callable, Dict, Union
+
+from prompt_toolkit.application.current import get_app
+from prompt_toolkit.enums import DEFAULT_BUFFER, SEARCH_BUFFER
+from prompt_toolkit.filters import Condition, emacs_insert_mode, has_completions
+from prompt_toolkit.filters import has_focus as has_focus_impl
+from prompt_toolkit.filters import (
+    Always,
+    has_selection,
+    has_suggestion,
+    vi_insert_mode,
+    vi_mode,
+)
+from prompt_toolkit.layout.layout import FocusableElement
+
+from IPython.core.getipython import get_ipython
+from IPython.core.guarded_eval import _find_dunder, BINARY_OP_DUNDERS, UNARY_OP_DUNDERS
+from IPython.terminal.shortcuts import auto_suggest
+from IPython.utils.decorators import undoc
+
+
+@undoc
+@Condition
+def cursor_in_leading_ws():
+    before = get_app().current_buffer.document.current_line_before_cursor
+    return (not before) or before.isspace()
+
+
+def has_focus(value: FocusableElement):
+    """Wrapper around has_focus adding a nice `__name__` to tester function"""
+    tester = has_focus_impl(value).func
+    tester.__name__ = f"is_focused({value})"
+    return Condition(tester)
+
+
+@undoc
+@Condition
+def has_line_below() -> bool:
+    document = get_app().current_buffer.document
+    return document.cursor_position_row < len(document.lines) - 1
+
+
+@undoc
+@Condition
+def has_line_above() -> bool:
+    document = get_app().current_buffer.document
+    return document.cursor_position_row != 0
+
+
+@Condition
+def ebivim():
+    shell = get_ipython()
+    return shell.emacs_bindings_in_vi_insert_mode
+
+
+@Condition
+def supports_suspend():
+    return hasattr(signal, "SIGTSTP")
+
+
+@Condition
+def auto_match():
+    shell = get_ipython()
+    return shell.auto_match
+
+
+def all_quotes_paired(quote, buf):
+    paired = True
+    i = 0
+    while i < len(buf):
+        c = buf[i]
+        if c == quote:
+            paired = not paired
+        elif c == "\\":
+            i += 1
+        i += 1
+    return paired
+
+
+_preceding_text_cache: Dict[Union[str, Callable], Condition] = {}
+_following_text_cache: Dict[Union[str, Callable], Condition] = {}
+
+
+def preceding_text(pattern: Union[str, Callable]):
+    if pattern in _preceding_text_cache:
+        return _preceding_text_cache[pattern]
+
+    if callable(pattern):
+
+        def _preceding_text():
+            app = get_app()
+            before_cursor = app.current_buffer.document.current_line_before_cursor
+            # mypy can't infer if(callable): https://github.com/python/mypy/issues/3603
+            return bool(pattern(before_cursor))  # type: ignore[operator]
+
+    else:
+        m = re.compile(pattern)
+
+        def _preceding_text():
+            app = get_app()
+            before_cursor = app.current_buffer.document.current_line_before_cursor
+            return bool(m.match(before_cursor))
+
+        _preceding_text.__name__ = f"preceding_text({pattern!r})"
+
+    condition = Condition(_preceding_text)
+    _preceding_text_cache[pattern] = condition
+    return condition
+
+
+def following_text(pattern):
+    try:
+        return _following_text_cache[pattern]
+    except KeyError:
+        pass
+    m = re.compile(pattern)
+
+    def _following_text():
+        app = get_app()
+        return bool(m.match(app.current_buffer.document.current_line_after_cursor))
+
+    _following_text.__name__ = f"following_text({pattern!r})"
+
+    condition = Condition(_following_text)
+    _following_text_cache[pattern] = condition
+    return condition
+
+
+@Condition
+def not_inside_unclosed_string():
+    app = get_app()
+    s = app.current_buffer.document.text_before_cursor
+    # remove escaped quotes
+    s = s.replace('\\"', "").replace("\\'", "")
+    # remove triple-quoted string literals
+    s = re.sub(r"(?:\"\"\"[\s\S]*\"\"\"|'''[\s\S]*''')", "", s)
+    # remove single-quoted string literals
+    s = re.sub(r"""(?:"[^"]*["\n]|'[^']*['\n])""", "", s)
+    return not ('"' in s or "'" in s)
+
+
+@Condition
+def navigable_suggestions():
+    shell = get_ipython()
+    return isinstance(shell.auto_suggest, auto_suggest.NavigableAutoSuggestFromHistory)
+
+
+@Condition
+def readline_like_completions():
+    shell = get_ipython()
+    return shell.display_completions == "readlinelike"
+
+
+@Condition
+def is_windows_os():
+    return sys.platform == "win32"
+
+
+# these one is callable and re-used multiple times hence needs to be
+# only defined once beforhand so that transforming back to human-readable
+# names works well in the documentation.
+default_buffer_focused = has_focus(DEFAULT_BUFFER)
+
+KEYBINDING_FILTERS = {
+    "always": Always(),
+    "has_line_below": has_line_below,
+    "has_line_above": has_line_above,
+    "has_selection": has_selection,
+    "has_suggestion": has_suggestion,
+    "vi_mode": vi_mode,
+    "vi_insert_mode": vi_insert_mode,
+    "emacs_insert_mode": emacs_insert_mode,
+    "has_completions": has_completions,
+    "insert_mode": vi_insert_mode | emacs_insert_mode,
+    "default_buffer_focused": default_buffer_focused,
+    "search_buffer_focused": has_focus(SEARCH_BUFFER),
+    "ebivim": ebivim,
+    "supports_suspend": supports_suspend,
+    "is_windows_os": is_windows_os,
+    "auto_match": auto_match,
+    "focused_insert": (vi_insert_mode | emacs_insert_mode) & default_buffer_focused,
+    "not_inside_unclosed_string": not_inside_unclosed_string,
+    "readline_like_completions": readline_like_completions,
+    "preceded_by_paired_double_quotes": preceding_text(
+        lambda line: all_quotes_paired('"', line)
+    ),
+    "preceded_by_paired_single_quotes": preceding_text(
+        lambda line: all_quotes_paired("'", line)
+    ),
+    "preceded_by_raw_str_prefix": preceding_text(r".*(r|R)[\"'](-*)$"),
+    "preceded_by_two_double_quotes": preceding_text(r'^.*""$'),
+    "preceded_by_two_single_quotes": preceding_text(r"^.*''$"),
+    "followed_by_closing_paren_or_end": following_text(r"[,)}\]]|$"),
+    "preceded_by_opening_round_paren": preceding_text(r".*\($"),
+    "preceded_by_opening_bracket": preceding_text(r".*\[$"),
+    "preceded_by_opening_brace": preceding_text(r".*\{$"),
+    "preceded_by_double_quote": preceding_text('.*"$'),
+    "preceded_by_single_quote": preceding_text(r".*'$"),
+    "followed_by_closing_round_paren": following_text(r"^\)"),
+    "followed_by_closing_bracket": following_text(r"^\]"),
+    "followed_by_closing_brace": following_text(r"^\}"),
+    "followed_by_double_quote": following_text('^"'),
+    "followed_by_single_quote": following_text("^'"),
+    "navigable_suggestions": navigable_suggestions,
+    "cursor_in_leading_ws": cursor_in_leading_ws,
+}
+
+
+def eval_node(node: Union[ast.AST, None]):
+    if node is None:
+        return None
+    if isinstance(node, ast.Expression):
+        return eval_node(node.body)
+    if isinstance(node, ast.BinOp):
+        left = eval_node(node.left)
+        right = eval_node(node.right)
+        dunders = _find_dunder(node.op, BINARY_OP_DUNDERS)
+        if dunders:
+            return getattr(left, dunders[0])(right)
+        raise ValueError(f"Unknown binary operation: {node.op}")
+    if isinstance(node, ast.UnaryOp):
+        value = eval_node(node.operand)
+        dunders = _find_dunder(node.op, UNARY_OP_DUNDERS)
+        if dunders:
+            return getattr(value, dunders[0])()
+        raise ValueError(f"Unknown unary operation: {node.op}")
+    if isinstance(node, ast.Name):
+        if node.id in KEYBINDING_FILTERS:
+            return KEYBINDING_FILTERS[node.id]
+        else:
+            sep = "\n  - "
+            known_filters = sep.join(sorted(KEYBINDING_FILTERS))
+            raise NameError(
+                f"{node.id} is not a known shortcut filter."
+                f" Known filters are: {sep}{known_filters}."
+            )
+    raise ValueError("Unhandled node", ast.dump(node))
+
+
+def filter_from_string(code: str):
+    expression = ast.parse(code, mode="eval")
+    return eval_node(expression)
+
+
+__all__ = ["KEYBINDING_FILTERS", "filter_from_string"]
diff --git a/docs/autogen_shortcuts.py b/docs/autogen_shortcuts.py
index f8fd17bb5..15bb0eed6 100755
--- a/docs/autogen_shortcuts.py
+++ b/docs/autogen_shortcuts.py
@@ -1,7 +1,7 @@
 from dataclasses import dataclass
 from inspect import getsource
 from pathlib import Path
-from typing import cast, Callable, List, Union
+from typing import cast, List, Union
 from html import escape as html_escape
 import re
 
@@ -10,7 +10,8 @@
 from prompt_toolkit.filters import Filter, Condition
 from prompt_toolkit.shortcuts import PromptSession
 
-from IPython.terminal.shortcuts import create_ipython_shortcuts
+from IPython.terminal.shortcuts import create_ipython_shortcuts, create_identifier
+from IPython.terminal.shortcuts.filters import KEYBINDING_FILTERS
 
 
 @dataclass
@@ -44,11 +45,16 @@ class _Invert(Filter):
     filter: Filter
 
 
-conjunctions_labels = {"_AndList": "and", "_OrList": "or"}
+conjunctions_labels = {"_AndList": "&", "_OrList": "|"}
 
 ATOMIC_CLASSES = {"Never", "Always", "Condition"}
 
 
+HUMAN_NAMES_FOR_FILTERS = {
+    filter_: name for name, filter_ in KEYBINDING_FILTERS.items()
+}
+
+
 def format_filter(
     filter_: Union[Filter, _NestedFilter, Condition, _Invert],
     is_top_level=True,
@@ -58,6 +64,8 @@ def format_filter(
     s = filter_.__class__.__name__
     if s == "Condition":
         func = cast(Condition, filter_).func
+        if filter_ in HUMAN_NAMES_FOR_FILTERS:
+            return HUMAN_NAMES_FOR_FILTERS[filter_]
         name = func.__name__
         if name == "<lambda>":
             source = getsource(func)
@@ -66,10 +74,12 @@ def format_filter(
     elif s == "_Invert":
         operand = cast(_Invert, filter_).filter
         if operand.__class__.__name__ in ATOMIC_CLASSES:
-            return f"not {format_filter(operand, is_top_level=False)}"
-        return f"not ({format_filter(operand, is_top_level=False)})"
+            return f"~{format_filter(operand, is_top_level=False)}"
+        return f"~({format_filter(operand, is_top_level=False)})"
     elif s in conjunctions_labels:
         filters = cast(_NestedFilter, filter_).filters
+        if filter_ in HUMAN_NAMES_FOR_FILTERS:
+            return HUMAN_NAMES_FOR_FILTERS[filter_]
         conjunction = conjunctions_labels[s]
         glue = f" {conjunction} "
         result = glue.join(format_filter(x, is_top_level=False) for x in filters)
@@ -104,17 +114,6 @@ class _DummyTerminal:
     auto_suggest = None
 
 
-def create_identifier(handler: Callable):
-    parts = handler.__module__.split(".")
-    name = handler.__name__
-    package = parts[0]
-    if len(parts) > 1:
-        final_module = parts[-1]
-        return f"{package}:{final_module}.{name}"
-    else:
-        return f"{package}:{name}"
-
-
 def bindings_from_prompt_toolkit(prompt_bindings: KeyBindingsBase) -> List[Binding]:
     """Collect bindings to a simple format that does not depend on prompt-toolkit internals"""
     bindings: List[Binding] = []
@@ -178,11 +177,12 @@ def to_rst(key):
 
     return result
 
+
 if __name__ == '__main__':
     here = Path(__file__).parent
     dest = here / "source" / "config" / "shortcuts"
 
-    ipy_bindings = create_ipython_shortcuts(_DummyTerminal(), for_all_platforms=True)
+    ipy_bindings = create_ipython_shortcuts(_DummyTerminal())
 
     session = PromptSession(key_bindings=ipy_bindings)
     prompt_bindings = session.app.key_bindings
diff --git a/docs/environment.yml b/docs/environment.yml
index 996125313..9fe7d4a9c 100644
--- a/docs/environment.yml
+++ b/docs/environment.yml
@@ -16,3 +16,4 @@ dependencies:
     - prompt_toolkit
     - ipykernel
     - stack_data
+    - -e ..
diff --git a/docs/source/config/details.rst b/docs/source/config/details.rst
index 3cc310a4b..c002d9a92 100644
--- a/docs/source/config/details.rst
+++ b/docs/source/config/details.rst
@@ -200,10 +200,20 @@ With (X)EMacs >= 24, You can enable IPython in python-mode with:
 Keyboard Shortcuts
 ==================
 
+.. versionadded:: 8.11
+
+You can modify, disable or modify keyboard shortcuts for IPython Terminal using
+:std:configtrait:`TerminalInteractiveShell.shortcuts` traitlet.
+
+The list of shortcuts is available in the Configuring IPython :ref:`terminal-shortcuts-list` section.
+
+Advanced configuration
+----------------------
+
 .. versionchanged:: 5.0
 
-You can customise keyboard shortcuts for terminal IPython. Put code like this in
-a :ref:`startup file <startup_files>`::
+Creating custom commands requires adding custom code to a
+:ref:`startup file <startup_files>`::
 
     from IPython import get_ipython
     from prompt_toolkit.enums import DEFAULT_BUFFER
diff --git a/docs/source/config/shortcuts/index.rst b/docs/source/config/shortcuts/index.rst
index e361ec26c..42fd6acfd 100755
--- a/docs/source/config/shortcuts/index.rst
+++ b/docs/source/config/shortcuts/index.rst
@@ -1,8 +1,10 @@
+.. _terminal-shortcuts-list:
+
 =================
 IPython shortcuts
 =================
 
-Available shortcuts in an IPython terminal.
+Shortcuts available in an IPython terminal.
 
 .. note::
 
@@ -12,7 +14,10 @@ Available shortcuts in an IPython terminal.
 
 * Comma-separated keys, e.g. :kbd:`Esc`, :kbd:`f`, indicate a sequence which can be activated by pressing the listed keys in succession.
 * Plus-separated keys, e.g. :kbd:`Esc` + :kbd:`f` indicate a combination which requires pressing all keys simultaneously.
-* Hover over the ⓘ icon in the filter column to see when the shortcut is active.g
+* Hover over the ⓘ icon in the filter column to see when the shortcut is active.
+
+You can use :std:configtrait:`TerminalInteractiveShell.shortcuts` configuration
+to modify, disable or add shortcuts.
 
 .. role:: raw-html(raw)
    :format: html
diff --git a/docs/source/whatsnew/pr/shortcuts-config-traitlet.rst b/docs/source/whatsnew/pr/shortcuts-config-traitlet.rst
new file mode 100644
index 000000000..8d3ce11d8
--- /dev/null
+++ b/docs/source/whatsnew/pr/shortcuts-config-traitlet.rst
@@ -0,0 +1,21 @@
+Terminal shortcuts customization
+================================
+
+Previously modifying shortcuts was only possible by hooking into startup files
+and practically limited to adding new shortcuts or removing all shortcuts bound
+to a specific key. This release enables users to override existing terminal
+shortcuts, disable them or add new keybindings.
+
+For example, to set the :kbd:`right` to accept a single character of auto-suggestion
+you could use::
+
+    my_shortcuts = [
+        {
+            "command": "IPython:auto_suggest.accept_character",
+            "new_keys": ["right"]
+        }
+    ]
+    %config TerminalInteractiveShell.shortcuts = my_shortcuts
+
+You can learn more in :std:configtrait:`TerminalInteractiveShell.shortcuts`
+configuration reference.
\ No newline at end of file


Test patch:
diff --git a/IPython/terminal/tests/test_interactivshell.py b/IPython/terminal/tests/test_interactivshell.py
index 01008d783..ae7da217f 100644
--- a/IPython/terminal/tests/test_interactivshell.py
+++ b/IPython/terminal/tests/test_interactivshell.py
@@ -9,9 +9,8 @@
 
 from prompt_toolkit.auto_suggest import AutoSuggestFromHistory
 
-from IPython.core.inputtransformer import InputTransformer
+
 from IPython.testing import tools as tt
-from IPython.utils.capture import capture_output
 
 from IPython.terminal.ptutils import _elide, _adjust_completion_text_based_on_context
 from IPython.terminal.shortcuts.auto_suggest import NavigableAutoSuggestFromHistory
diff --git a/IPython/terminal/tests/test_shortcuts.py b/IPython/terminal/tests/test_shortcuts.py
index 309205d4f..18c9daba9 100644
--- a/IPython/terminal/tests/test_shortcuts.py
+++ b/IPython/terminal/tests/test_shortcuts.py
@@ -11,6 +11,8 @@
     swap_autosuggestion_up,
     swap_autosuggestion_down,
 )
+from IPython.terminal.shortcuts.auto_match import skip_over
+from IPython.terminal.shortcuts import create_ipython_shortcuts
 
 from prompt_toolkit.history import InMemoryHistory
 from prompt_toolkit.buffer import Buffer
@@ -192,18 +194,20 @@ def test_autosuggest_token_empty():
 def test_other_providers():
     """Ensure that swapping autosuggestions does not break with other providers"""
     provider = AutoSuggestFromHistory()
-    up = swap_autosuggestion_up(provider)
-    down = swap_autosuggestion_down(provider)
+    ip = get_ipython()
+    ip.auto_suggest = provider
     event = Mock()
     event.current_buffer = Buffer()
-    assert up(event) is None
-    assert down(event) is None
+    assert swap_autosuggestion_up(event) is None
+    assert swap_autosuggestion_down(event) is None
 
 
 async def test_navigable_provider():
     provider = NavigableAutoSuggestFromHistory()
     history = InMemoryHistory(history_strings=["very_a", "very", "very_b", "very_c"])
     buffer = Buffer(history=history)
+    ip = get_ipython()
+    ip.auto_suggest = provider
 
     async for _ in history.load():
         pass
@@ -211,8 +215,8 @@ async def test_navigable_provider():
     buffer.cursor_position = 5
     buffer.text = "very"
 
-    up = swap_autosuggestion_up(provider)
-    down = swap_autosuggestion_down(provider)
+    up = swap_autosuggestion_up
+    down = swap_autosuggestion_down
 
     event = Mock()
     event.current_buffer = buffer
@@ -254,14 +258,16 @@ async def test_navigable_provider_multiline_entries():
     provider = NavigableAutoSuggestFromHistory()
     history = InMemoryHistory(history_strings=["very_a\nvery_b", "very_c"])
     buffer = Buffer(history=history)
+    ip = get_ipython()
+    ip.auto_suggest = provider
 
     async for _ in history.load():
         pass
 
     buffer.cursor_position = 5
     buffer.text = "very"
-    up = swap_autosuggestion_up(provider)
-    down = swap_autosuggestion_down(provider)
+    up = swap_autosuggestion_up
+    down = swap_autosuggestion_down
 
     event = Mock()
     event.current_buffer = buffer
@@ -316,3 +322,140 @@ def test_navigable_provider_connection():
     session_1.default_buffer.on_text_insert.fire()
     session_2.default_buffer.on_text_insert.fire()
     assert provider.skip_lines == 3
+
+
+@pytest.fixture
+def ipython_with_prompt():
+    ip = get_ipython()
+    ip.pt_app = Mock()
+    ip.pt_app.key_bindings = create_ipython_shortcuts(ip)
+    try:
+        yield ip
+    finally:
+        ip.pt_app = None
+
+
+def find_bindings_by_command(command):
+    ip = get_ipython()
+    return [
+        binding
+        for binding in ip.pt_app.key_bindings.bindings
+        if binding.handler == command
+    ]
+
+
+def test_modify_unique_shortcut(ipython_with_prompt):
+    original = find_bindings_by_command(accept_token)
+    assert len(original) == 1
+
+    ipython_with_prompt.shortcuts = [
+        {"command": "IPython:auto_suggest.accept_token", "new_keys": ["a", "b", "c"]}
+    ]
+    matched = find_bindings_by_command(accept_token)
+    assert len(matched) == 1
+    assert list(matched[0].keys) == ["a", "b", "c"]
+    assert list(matched[0].keys) != list(original[0].keys)
+    assert matched[0].filter == original[0].filter
+
+    ipython_with_prompt.shortcuts = [
+        {"command": "IPython:auto_suggest.accept_token", "new_filter": "always"}
+    ]
+    matched = find_bindings_by_command(accept_token)
+    assert len(matched) == 1
+    assert list(matched[0].keys) != ["a", "b", "c"]
+    assert list(matched[0].keys) == list(original[0].keys)
+    assert matched[0].filter != original[0].filter
+
+
+def test_disable_shortcut(ipython_with_prompt):
+    matched = find_bindings_by_command(accept_token)
+    assert len(matched) == 1
+
+    ipython_with_prompt.shortcuts = [
+        {"command": "IPython:auto_suggest.accept_token", "new_keys": []}
+    ]
+    matched = find_bindings_by_command(accept_token)
+    assert len(matched) == 0
+
+    ipython_with_prompt.shortcuts = []
+    matched = find_bindings_by_command(accept_token)
+    assert len(matched) == 1
+
+
+def test_modify_shortcut_with_filters(ipython_with_prompt):
+    matched = find_bindings_by_command(skip_over)
+    matched_keys = {m.keys[0] for m in matched}
+    assert matched_keys == {")", "]", "}", "'", '"'}
+
+    with pytest.raises(ValueError, match="Multiple shortcuts matching"):
+        ipython_with_prompt.shortcuts = [
+            {"command": "IPython:auto_match.skip_over", "new_keys": ["x"]}
+        ]
+
+    ipython_with_prompt.shortcuts = [
+        {
+            "command": "IPython:auto_match.skip_over",
+            "new_keys": ["x"],
+            "match_filter": "focused_insert & auto_match & followed_by_single_quote",
+        }
+    ]
+    matched = find_bindings_by_command(skip_over)
+    matched_keys = {m.keys[0] for m in matched}
+    assert matched_keys == {")", "]", "}", "x", '"'}
+
+
+def example_command():
+    pass
+
+
+def test_add_shortcut_for_new_command(ipython_with_prompt):
+    matched = find_bindings_by_command(example_command)
+    assert len(matched) == 0
+
+    with pytest.raises(ValueError, match="example_command is not a known"):
+        ipython_with_prompt.shortcuts = [
+            {"command": "example_command", "new_keys": ["x"]}
+        ]
+    matched = find_bindings_by_command(example_command)
+    assert len(matched) == 0
+
+
+def test_modify_shortcut_failure(ipython_with_prompt):
+    with pytest.raises(ValueError, match="No shortcuts matching"):
+        ipython_with_prompt.shortcuts = [
+            {
+                "command": "IPython:auto_match.skip_over",
+                "match_keys": ["x"],
+                "new_keys": ["y"],
+            }
+        ]
+
+
+def test_add_shortcut_for_existing_command(ipython_with_prompt):
+    matched = find_bindings_by_command(skip_over)
+    assert len(matched) == 5
+
+    with pytest.raises(ValueError, match="Cannot add a shortcut without keys"):
+        ipython_with_prompt.shortcuts = [
+            {"command": "IPython:auto_match.skip_over", "new_keys": [], "create": True}
+        ]
+
+    ipython_with_prompt.shortcuts = [
+        {"command": "IPython:auto_match.skip_over", "new_keys": ["x"], "create": True}
+    ]
+    matched = find_bindings_by_command(skip_over)
+    assert len(matched) == 6
+
+    ipython_with_prompt.shortcuts = []
+    matched = find_bindings_by_command(skip_over)
+    assert len(matched) == 5
+
+
+def test_setting_shortcuts_before_pt_app_init():
+    ipython = get_ipython()
+    assert ipython.pt_app is None
+    shortcuts = [
+        {"command": "IPython:auto_match.skip_over", "new_keys": ["x"], "create": True}
+    ]
+    ipython.shortcuts = shortcuts
+    assert ipython.shortcuts == shortcuts