diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml
new file mode 100644
index 00000000..d53bfcc1
--- /dev/null
+++ b/.github/workflows/test.yaml
@@ -0,0 +1,41 @@
+name: test
+
+on:
+  push: # any branch
+  pull_request:
+    branches: [master]
+
+jobs:
+  test-ubuntu:
+    runs-on: ubuntu-latest
+    strategy:
+      matrix:
+        python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13"]
+
+    steps:
+      - uses: actions/checkout@v4
+      - uses: astral-sh/setup-uv@v5
+        with:
+          python-version: ${{ matrix.python-version }}
+      - name: Type Checking
+        run: |
+          uvx --with . mypy src/ptpython/
+          uvx --with . mypy examples/
+      - name: Code formatting
+        if: ${{ matrix.python-version == '3.13' }}
+        run: |
+          uvx ruff check .
+          uvx ruff format --check .
+      - name: Typos
+        if: ${{ matrix.python-version == '3.13' }}
+        run: |
+          uvx typos .
+      - name: Unit test
+        run: |
+          uvx --with . pytest tests/
+      - name: Validate README.md
+        if: ${{ matrix.python-version == '3.13' }}
+        # Ensure that the README renders correctly (required for uploading to PyPI).
+        run: |
+          uv pip install readme_renderer
+          python -m readme_renderer README.rst > /dev/null
diff --git a/.travis.yml b/.travis.yml
deleted file mode 100644
index 02853d77..00000000
--- a/.travis.yml
+++ /dev/null
@@ -1,12 +0,0 @@
-language: python
-env:
-  - TOXENV=py26
-  - TOXENV=py27
-  - TOXENV=py33
-  - TOXENV=py34
-  - TOXENV=pypy
-  - TOXENV=pypy3
-install:
-  - travis_retry pip install tox
-script:
-  - tox
diff --git a/CHANGELOG b/CHANGELOG
index fdc3b838..7706260d 100644
--- a/CHANGELOG
+++ b/CHANGELOG
@@ -1,60 +1,407 @@
 CHANGELOG
 =========
 
-0.41: 2017-07-27
-----------------
+3.0.30: 2025-04-15
+------------------
+
+New features:
+- Show exception cause/context when printing chained exceptions.
+- Reworked project layout and use pyproject.toml instead of setup.py.
+
+Breaking changes:
+- Drop Python 3.7 support.
+
+
+3.0.29: 2024-07-22
+------------------
 
 Fixes:
-- Catch Jedi SystemError.
+- Further improve performance of dictionary completions.
 
 
-0.40: 2017-07-27
-----------------
+3.0.28: 2024-07-22
+------------------
+
+New features:
+- Custom 'exit' function to return from REPL that
+  * doesn't terminate `sys.stdin` when `exit` is called (important for
+    `embed()`).
+  * doesn't require to be called with parentheses.
 
 Fixes:
-- Fix in setup.py: `extras_require` instead of `extra_require`.
-- Catch AssertionError in Jedi completion.
-- Use __main__.__dict__ for globals and locals.
+- Clean up signatures on control-c.
+
+
+3.0.27: 2024-05-27
+------------------
+
+- Limit number of completions to 5k (for performance).
+- Several typing fixes.
+
+
+3.0.26: 2024-02-06
+------------------
+
+Fixes:
+- Handle `GeneratorExit` exception when leaving the paginator.
+
+
+3.0.25: 2023-12-14
+------------------
+
+Fixes:
+- Fix handling of 'config file does not exist' when embedding ptpython.
+
+
+3.0.24: 2023-12-13
+------------------
+
+Fixes:
+- Don't show "Impossible to read config file" warnings when no config file was
+  passed to `run_config()`.
+- IPython integration fixes:
+  * Fix top-level await in IPython.
+  * Fix IPython `DeprecationWarning`.
+- Output printing fixes:
+  * Paginate exceptions if pagination is enabled.
+  * Handle big outputs without running out of memory.
+- Asyncio REPL improvements:
+  * From now on, passing `--asyncio` is required to activate the asyncio-REPL.
+    This will ensure that an event loop is created at the start in which we can
+    run top-level await statements.
+  * Use `get_running_loop()` instead of `get_event_loop()`.
+  * Better handling of `SystemExit` and control-c in the async REPL.
+
+
+3.0.23: 2023-02-22
+------------------
+
+Fixes:
+- Don't print exception messages twice for unhandled exceptions.
+- Added cursor shape support.
+
+Breaking changes:
+- Drop Python 3.6 support.
+
+
+3.0.22: 2022-12-06
+------------------
 
 New features:
-- Support `user_ns` arg for compatibility with start_ipython.
+- Improve rendering performance when there are many completions.
 
 
-0.39: 2017-03-28
-----------------
+3.0.21: 2022-11-25
+------------------
+
+New features:
+- Make ptipython respect more config changes.
+  (See: https://github.com/prompt-toolkit/ptpython/pull/110 )
+- Improved performance of `DictionaryCompleter` for slow mappings.
 
 Fixes:
-- Fixed bug in run_ptipython. (It could fail to start if the config directory
-  already existed.)
+- Call `super()` in `PythonInputFilter`. This will prevent potentially breakage
+  with an upcoming prompt_toolkit change.
+  (See: https://github.com/prompt-toolkit/python-prompt-toolkit/pull/1690 )
+- Improved type annotations.
+- Added `py.typed` to the `package_data`.
 
 
-0.38: 2017-03-26
-----------------
+3.0.20: 2021-09-14
+------------------
+
+New features:
+- For `DictionaryCompleter`: show parentheses after methods.
 
 Fixes:
-- Fixed syntax error in run_ptipython script.
+- Don't crash when trying to complete broken mappings in `DictionaryCompleter`.
+- Don't crash when an older version of `black` is installed that is not
+  compatible.
 
 
-0.37: 2017-03-26
-----------------
+3.0.19: 2021-07-08
+------------------
+
+Fixes:
+- Fix handling of `SystemExit` (fixes "ValueError: I/O operation on closed
+  file").
+- Allow usage of `await` in assignment expressions or for-loops.
+
+
+3.0.18: 2021-06-26
+------------------
+
+Fixes:
+- Made "black" an optional dependency.
+
+
+3.0.17: 2021-03-22
+------------------
 
 Fixes:
-- Display 'VISUAL BLOCK' and 'INSERT' when we're in these modes.
-- Handle ValueError in PythonValidator. Python2 raises ValueError if the input
-  contains an invalid escape sequence.
-- Use load_key_bindings instead of KeyBindingsManager. (For the latest
-  prompt_toolkit.)
-- Set 'reverse_vi_search_direction'. (Search backwards when '/' was pressed in
-  Vi mode.)
-- Check for symlink when creating config dir.
-- Make default config dir filepath OS independent.
-- Remove minor python version in entry point.
-- Fixed .ptpython directory creation in run_ptpython.
+- Fix leaking file descriptors due to not closing the asyncio event loop after
+  reading input in a thread.
+- Fix race condition during retrieval of signatures.
+
+
+3.0.16: 2021-02-11
+------------------
+
+(Commit 7f619e was missing in previous release.)
+
+Fixes:
+- Several fixes to the completion code:
+  * Give dictionary completions priority over path completions.
+  * Always call non-fuzzy completer after fuzzy completer to prevent that some
+    completions were missed out if the fuzzy completer doesn't find them.
+
+
+3.0.15: 2021-02-11
+------------------
 
 New features:
-- Also accept Ctrl-D for quitting the 'exit confirmation' dialog.
+- When pressing control-w, only delete characters until a punctuation.
+
+Fixes:
+- Fix `AttributeError` during retrieval of signatures with type annotations.
+
+
+3.0.14: 2021-02-10
+------------------
+
+New features:
+- Display of signature and completion drop down together.
+- If `DictionaryCompleter` is enabled, also retrieve signatures when Jedi
+  fails, using the same logic.
+- List function parameters first and private attributes at the end in the
+  completion menu.
+- Cleanup of the completion code.
+
+Fixes:
+- Handle exceptions raised when `repr()` is called.
+- Fix leakage of `exc_info` from eval to exec call.
+- Fix handling of `KeyboardInterrupt` in REPL during evaluation of `__repr__`.
+- Fix style for signature toolbar.
+- Hide signature when sidebar is visible.
+
+
+3.0.13: 2021-01-26
+------------------
+
+New features:
+- Added 'print all' option to pager.
+- Improve handling of indented code:
+  * Allow multiline input to be indented as a whole (we will unindent before
+    executing).
+  * Correctly visualize tabs (instead of ^I, which happens when pasted in
+    bracketed paste).
+
+Fixes:
+- Fix line ending bug in pager.
+
+
+3.0.12: 2021-01-24
+------------------
+
+New features:
+- Expose a `get_ptpython` function in the global namespace, to get programmatic
+  access to the REPL.
+- Expose `embed()` at the top level of the package. Make it possible to do
+  `from ptpython import embed`.
+
+Fixes:
+- Properly handle exceptions when trying to access `__pt_repr__`.
+- Properly handle `SystemExit`.
+
+
+3.0.11: 2021-01-20
+------------------
+
+New features:
+- Add support for top-level await.
+- Refactoring of event loop usage:
+
+  * The ptpython input UI will now run in a separate thread. This makes it
+    possible to properly embed ptpython in an asyncio application, without
+    having to deal with nested event loops (which asyncio does not support).
+
+  * The "eval" part doesn't anymore take place within a ptpython coroutine, so
+    it can spawn its own loop if needed. This also fixes `asyncio.run()` usage
+    in the REPL, which was broken before.
+
+- Added syntax highlighting and autocompletion for !-style system commands.
+
+Fixes:
+- Remove unexpected additional line after output.
+- Fix system prompt. Accept !-style inputs again.
+- Don't execute PYTHONSTARTUP when -i flag was given.
+
+
+3.0.10: 2021-01-13
+------------------
+
+Fixes:
+- Do dictionary completion on Sequence and Mapping objects (from
+  collections.abc). Note that dictionary completion is still turned off by
+  default.
+
+
+3.0.9: 2021-01-10
+-----------------
+
+New features:
+- Allow replacing `PythonInput.completer` at runtime (useful for tools build on
+  top of ptpython).
+- Show REPL title in pager.
+
+
+3.0.8: 2021-01-05
+-----------------
+
+New features:
+- Optional output formatting using Black.
+- Optional pager for displaying outputs that don't fit on the screen.
+- Added --light-bg and --dark-bg flags to automatically optimize the brightness
+  of the colors according to the terminal background.
+- Add `PTPYTHON_CONFIG_HOME` for explicitly setting the config directory.
+- Show completion suffixes (like '(' for functions).
+
+Fixes:
+- Fix dictionary completion on Pandas objects.
+- Stop using deprecated Jedi functions.
+
+
+3.0.7: 2020-09-25
+-----------------
+
+New features:
+- Option to show/hide private attributes during a completion
+- Added `insert_blank_line_after_input` option similar to
+  `insert_blank_line_after_output`.
+
+Fixes:
+- Fixed some formatting issues of `__pt_repr__`.
+- Abbreviate completion meta information for dictionary completer if needed.
+
+
+3.0.6: 2020-09-23
+-----------------
+
+New features:
+- (Experimental) support for `__pt_repr__` methods. If objects implement this
+  method, this will be used to print the result in the REPL instead of the
+  normal `__repr__`.
+- Added py.typed file, to enable type checking for applications that are
+  embedding ptpython.
+
+
+3.0.5: 2020-08-10
+-----------------
+
+Fixes:
+- Handle bug in dictionary completion when numeric keys are used.
+
+
+3.0.4: 2020-08-10
+-----------------
+
+New features:
+- Allow leading whitespace before single line expressions.
+- Show full syntax error in validator.
+- Added `vi_start_in_navigation_mode` and `vi_keep_last_used_mode` options.
+
+Fixes:
+- Improved dictionary completion: handle keys that contain spaces and don't
+  recognize numbers as variable names.
+- Fix in exit confirmation.
+
+
+3.0.3: 2020-07-10
+-----------------
+
+Fixes:
+- Sort attribute names for `DictionaryCompleter` and move underscored
+  attributes to the end.
+- Handle unhandled exceptions in `get_compiler_flags`.
+- Improved `run_async` code.
+- Fix --version parameter.
+
+
+3.0.2: 2020-04-14
+-----------------
+
+New features:
+- Improved custom dictionary completion:
+    * Also complete list indexes.
+    * Also complete attributes after doing a dictionary lookup.
+    * Also complete iterators in a for-loop.
+- Added a 'title' option, so that applications embedding ptpython can set a
+  title in the status bar.
+
+
+3.0.1: 2020-02-24
+-----------------
+
+- Fix backwards-compatibility of the `run_config` function. (used by
+  django-extensions).
+- Fix input mode in status bar for block selection.
+
+
+3.0.0: 2020-01-29
+-----------------
+
+Upgrade to prompt_toolkit 3.0.
+Requires at least Python 3.6.
+
+New features:
+- Uses XDG base directory specification.
+
+
+2.0.5: 2019-10-09
+-----------------
+
+New features:
+- Added dictionary completer (off by default).
+- Added fuzzy completion (off by default).
+- Highlight keywords in completion dropdown menu.
+- Enable universal wheels.
+
+Fixes:
+- Fixed embedding repl as asyncio coroutine.
+- Fixed patching stdout in embedded repl.
+- Fixed ResourceWarning in setup.py.
+
+
+2.0.4: 2018-10-30
+-----------------
+
+- Fixed ptipython.
+- Fixed config: setting of color depth.
+- Fixed auto-suggest key bindings.
+- Fixed Control-D key binding for exiting REPL when (confirm_exit=False).
+- Correctly focus/unfocus sidebar.
+- Fixed open_in_editor and suspend key bindings.
+
+
+2.0.3: 2018-10-12
+-----------------
+
+- Allow changing the min/max brightness.
+- Some changes for compatibility with the latest prompt_toolkit.
+
+2.0.2: 2018-09-30
+-----------------
+
+Fixes:
+- Don't crash the history browser when there was no history.
+- Set last exception in the sys module, when an exception was raised.
+- Require prompt_toolkit 2.0.5.
+
+
+2.0.1: 2018-09-30
+-----------------
 
-Requires prompt_toolkit 1.0.14.
+Upgrade to prompt_toolkit 2.0.x.
 
 
 0.36: 2016-10-16
diff --git a/LICENSE b/LICENSE
index 910b80a7..89a51144 100644
--- a/LICENSE
+++ b/LICENSE
@@ -1,4 +1,4 @@
-Copyright (c) 2015, Jonathan Slenders
+Copyright (c) 2015-2023, Jonathan Slenders
 All rights reserved.
 
 Redistribution and use in source and binary forms, with or without modification,
diff --git a/README.rst b/README.rst
index 9650490b..06c1e02b 100644
--- a/README.rst
+++ b/README.rst
@@ -1,6 +1,8 @@
 ptpython
 ========
 
+|Build Status|  |PyPI|  |License|
+
 *A better Python REPL*
 
 ::
@@ -9,12 +11,13 @@ ptpython
 
 .. image :: https://github.com/jonathanslenders/ptpython/raw/master/docs/images/example1.png
 
-|Build Status|
-
 Ptpython is an advanced Python REPL. It should work on all
-Python versions from 2.6 up to 3.5 and work cross platform (Linux,
+Python versions from 2.6 up to 3.11 and work cross platform (Linux,
 BSD, OS X and Windows).
 
+Note: this version of ptpython requires at least Python 3.6. Install ptpython
+2.0.5 for older Python versions.
+
 
 Installation
 ************
@@ -39,7 +42,7 @@ Features
 - Support for `bracketed paste <https://cirw.in/blog/bracketed-paste>`_ [2].
 - Both Vi and Emacs key bindings.
 - Support for double width (Chinese) characters.
-- ... and many other stuff.
+- ... and many other things.
 
 
 [1] Disabled by default. (Enable in the menu.)
@@ -47,6 +50,59 @@ Features
 [2] If the terminal supports it (most terminals do), this allows pasting
 without going into paste mode. It will keep the indentation.
 
+Command Line Options
+********************
+
+The help menu shows basic command-line options.
+
+::
+
+    $ ptpython --help
+    usage: ptpython [-h] [--vi] [-i] [--light-bg] [--dark-bg] [--config-file CONFIG_FILE]
+                    [--history-file HISTORY_FILE] [-V]
+                    [args ...]
+
+    ptpython: Interactive Python shell.
+
+    positional arguments:
+      args                  Script and arguments
+
+    optional arguments:
+      -h, --help            show this help message and exit
+      --vi                  Enable Vi key bindings
+      -i, --interactive     Start interactive shell after executing this file.
+      --asyncio             Run an asyncio event loop to support top-level "await".
+      --light-bg            Run on a light background (use dark colors for text).
+      --dark-bg             Run on a dark background (use light colors for text).
+      --config-file CONFIG_FILE
+                            Location of configuration file.
+      --history-file HISTORY_FILE
+                            Location of history file.
+      -V, --version         show program's version number and exit
+
+    environment variables:
+      PTPYTHON_CONFIG_HOME: a configuration directory to use
+      PYTHONSTARTUP: file executed on interactive startup (no default)
+
+
+__pt_repr__: A nicer repr with colors
+*************************************
+
+When classes implement a ``__pt_repr__`` method, this will be used instead of
+``__repr__`` for printing. Any `prompt_toolkit "formatted text"
+<https://python-prompt-toolkit.readthedocs.io/en/master/pages/printing_text.html>`_
+can be returned from here. In order to avoid writing a ``__repr__`` as well,
+the ``ptpython.utils.ptrepr_to_repr`` decorator can be applied. For instance:
+
+.. code:: python
+
+    from ptpython.utils import ptrepr_to_repr
+    from prompt_toolkit.formatted_text import HTML
+
+    @ptrepr_to_repr
+    class MyClass:
+        def __pt_repr__(self):
+            return HTML('<yellow>Hello world!</yellow>')
 
 More screenshots
 ****************
@@ -74,6 +130,22 @@ Embedding the REPL in any Python application is easy:
     from ptpython.repl import embed
     embed(globals(), locals())
 
+You can make ptpython your default Python REPL by creating a `PYTHONSTARTUP file
+<https://docs.python.org/3/tutorial/appendix.html#the-interactive-startup-file>`_ containing code
+like this:
+
+.. code:: python
+
+   import sys
+   try:
+       from ptpython.repl import embed
+   except ImportError:
+       print("ptpython is not available: falling back to standard prompt")
+   else:
+       sys.exit(embed(globals(), locals()))
+
+Note config file support currently only works when invoking `ptpython` directly.
+That it, the config file will be ignored when embedding ptpython in an application.
 
 Multiline editing
 *****************
@@ -100,6 +172,20 @@ error.
 .. image :: https://github.com/jonathanslenders/ptpython/raw/master/docs/images/validation.png
 
 
+Asyncio REPL and top level await
+********************************
+
+In order to get top-level ``await`` support, start ptpython as follows:
+
+.. code::
+
+   ptpython --asyncio
+
+This will spawn an asyncio event loop and embed the async REPL in the event
+loop. After this, top-level await will work and statements like ``await
+asyncio.sleep(10)`` will execute.
+
+
 Additional features
 *******************
 
@@ -107,18 +193,26 @@ Running system commands: Press ``Meta-!`` in Emacs mode or just ``!`` in Vi
 navigation mode to see the "Shell command" prompt. There you can enter system
 commands without leaving the REPL.
 
-Selecting text: Press ``Control+Space`` in Emacs mode on ``V`` (major V) in Vi
+Selecting text: Press ``Control+Space`` in Emacs mode or ``V`` (major V) in Vi
 navigation mode.
 
 
 Configuration
 *************
 
-It is possible to create a ``~/.ptpython/config.py`` file to customize the configuration.
+It is possible to create a ``config.py`` file to customize configuration.
+ptpython will look in an appropriate platform-specific directory via `appdirs
+<https://pypi.org/project/appdirs/>`. See the ``appdirs`` documentation for the
+precise location for your platform. A ``PTPYTHON_CONFIG_HOME`` environment
+variable, if set, can also be used to explicitly override where configuration
+is looked for.
 
 Have a look at this example to see what is possible:
 `config.py <https://github.com/jonathanslenders/ptpython/blob/master/examples/ptpython_config/config.py>`_
 
+Note config file support currently only works when invoking `ptpython` directly.
+That it, the config file will be ignored when embedding ptpython in an application.
+
 
 IPython support
 ***************
@@ -130,6 +224,13 @@ ipython``)
 
 .. image :: https://github.com/jonathanslenders/ptpython/raw/master/docs/images/ipython.png
 
+This is also available for embedding:
+
+.. code:: python
+
+    from ptpython.ipython import embed
+    embed(globals(), locals())
+
 
 Django support
 **************
@@ -154,6 +255,22 @@ Windows. Some things might not work, but it is usable:
 
 .. image :: https://github.com/jonathanslenders/ptpython/raw/master/docs/images/windows.png
 
+Windows terminal integration
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+If you are using the `Windows Terminal <https://aka.ms/terminal>`_ and want to 
+integrate ``ptpython`` as a profile, go to *Settings -> Open JSON file* and add the
+following profile under *profiles.list*:
+
+.. code-block:: JSON
+
+    {
+        "commandline": "%SystemRoot%\\System32\\cmd.exe /k ptpython",
+        "guid": "{f91d49a3-741b-409c-8a15-c4360649121f}",
+        "hidden": false,
+        "icon": "https://upload.wikimedia.org/wikipedia/commons/e/e6/Python_Windows_interpreter_icon_2006%E2%80%932016_Tiny.png",
+        "name": "ptpython@cmd"
+    }
 
 FAQ
 ***
@@ -164,7 +281,7 @@ FAQ
 
 **Q**: The ``Meta``-key doesn't work.
 
-**A**: For some terminals you have to enable the Alt-key to act as meta key, but you 
+**A**: For some terminals you have to enable the Alt-key to act as meta key, but you
 can also type ``Escape`` before any key instead.
 
 
@@ -183,14 +300,16 @@ Special thanks to
 *****************
 
 - `Pygments <http://pygments.org/>`_: Syntax highlighter.
-- `Jedi <http://jedi.readthedocs.io/en/latest/>`_: Autocompletion library.
-- `Docopt <http://docopt.org/>`_: Command-line interface description language.
+- `Jedi <http://jedi.jedidjah.ch/en/latest/>`_: Autocompletion library.
 - `wcwidth <https://github.com/jquast/wcwidth>`_: Determine columns needed for a wide characters.
 - `prompt_toolkit <http://github.com/jonathanslenders/python-prompt-toolkit>`_ for the interface.
 
-.. |Build Status| image:: https://api.travis-ci.org/jonathanslenders/ptpython.svg?branch=master
-    :target: https://travis-ci.org/jonathanslenders/ptpython#
+.. |Build Status| image:: https://github.com/prompt-toolkit/ptpython/actions/workflows/test.yaml/badge.svg
+    :target: https://github.com/prompt-toolkit/ptpython/actions/workflows/test.yaml
+
+.. |License| image:: https://img.shields.io/github/license/prompt-toolkit/ptpython.svg
+    :target: https://github.com/prompt-toolkit/ptpython/blob/master/LICENSE
 
-.. |PyPI| image:: https://pypip.in/version/prompt-toolkit/badge.svg
-    :target: https://pypi.python.org/pypi/prompt-toolkit/
+.. |PyPI| image:: https://img.shields.io/pypi/v/ptpython.svg
+    :target: https://pypi.org/project/ptpython/
     :alt: Latest Version
diff --git a/docs/concurrency-challenges.rst b/docs/concurrency-challenges.rst
new file mode 100644
index 00000000..0ff9c6c3
--- /dev/null
+++ b/docs/concurrency-challenges.rst
@@ -0,0 +1,91 @@
+
+Concurrency-related challenges regarding embedding of ptpython in asyncio code
+==============================================================================
+
+Things we want to be possible
+-----------------------------
+
+- Embed blocking ptpython in non-asyncio code (the normal use case).
+- Embed blocking ptpython in asyncio code (the event loop will block).
+- Embed awaitable ptpython in asyncio code (the loop will continue).
+- React to resize events (SIGWINCH).
+- Support top-level await.
+- Be able to patch_stdout, so that logging messages from another thread will be
+  printed above the prompt.
+- It should be possible to handle `KeyboardInterrupt` during evaluation of an
+  expression.
+- The "eval" should happen in the same thread from where embed() was called.
+
+
+Limitations of asyncio/python
+-----------------------------
+
+- We can only listen to SIGWINCH signal (resize) events in the main thread.
+
+- Usage of Control-C for triggering a `KeyboardInterrupt` only works for code
+  running in the main thread. (And only if the terminal was not set in raw
+  input mode).
+
+- Spawning a new event loop from within a coroutine, that's being executed in
+  an existing event loop is not allowed in asyncio. We can however spawn any
+  event loop in a separate thread, and wait for that thread to finish.
+
+- For patch_stdout to work correctly, we have to know what prompt_toolkit
+  application is running on the terminal, then tell that application to print
+  the output and redraw itself.
+
+
+Additional challenges for IPython
+---------------------------------
+
+IPython supports integration of 3rd party event loops (for various GUI
+toolkits). These event loops are supposed to continue running while we are
+prompting for input. In an asyncio environment, it means that there are
+situations where we have to juggle three event loops:
+
+- The asyncio loop in which the code was embedded.
+- The asyncio loop from the prompt.
+- The 3rd party GUI loop. 
+
+Approach taken in ptpython 3.0.11
+---------------------------------
+
+For ptpython, the most reliable solution is to to run the prompt_toolkit input
+prompt in a separate background thread. This way it can use its own asyncio
+event loop without ever having to interfere with whatever runs in the main
+thread.
+
+Then, depending on how we embed, we do the following:
+When a normal blocking embed is used:
+    * We start the UI thread for the input, and do a blocking wait on
+      `thread.join()` here.
+    * The "eval" happens in the main thread.
+    * The "print" happens also in the main thread. Unless a pager is shown,
+      which is also a prompt_toolkit application, then the pager itself is runs
+      also in another thread, similar to the way we do the input.
+
+When an awaitable embed is used, for embedding in a coroutine, but having the
+event loop continue:
+    * We run the input method from the blocking embed in an asyncio executor
+      and do an `await loop.run_in_executor(...)`.
+    * The "eval" happens again in the main thread.
+    * "print" is also similar, except that the pager code (if used) runs in an
+      executor too.
+
+This means that the prompt_toolkit application code will always run in a
+different thread. It means it won't be able to respond to SIGWINCH (window
+resize events), but prompt_toolkit's 3.0.11 has now terminal size polling which
+solves this.
+
+Control-C key presses won't interrupt the main thread while we wait for input,
+because the prompt_toolkit application turns the terminal in raw mode, while
+it's reading, which means that it will receive control-c key presses as raw
+data in its own thread.
+
+Top-level await works in most situations as expected.
+- If a blocking embed is used. We execute ``loop.run_until_complete(code)``.
+  This assumes that the blocking embed is not used in a coroutine of a running
+  event loop, otherwise, this will attempt to start a nested event loop, which
+  asyncio does not support. In that case we will get an exception.
+- If an awaitable embed is used. We literally execute ``await code``. This will
+  integrate nicely in the current event loop.
diff --git a/examples/asyncio-python-embed.py b/examples/asyncio-python-embed.py
index eadad2d8..cb909731 100755
--- a/examples/asyncio-python-embed.py
+++ b/examples/asyncio-python-embed.py
@@ -11,46 +11,44 @@
 to stdout, it won't break the input line, but instead writes nicely above the
 prompt.
 """
-from __future__ import unicode_literals
-from ptpython.repl import embed
 
 import asyncio
 
+from ptpython.repl import embed
+
 loop = asyncio.get_event_loop()
 counter = [0]
 
 
-@asyncio.coroutine
-def print_counter():
+async def print_counter() -> None:
     """
     Coroutine that prints counters and saves it in a global variable.
     """
     while True:
-        print('Counter: %i' % counter[0])
+        print(f"Counter: {counter[0]}")
         counter[0] += 1
-        yield from asyncio.sleep(3)
+        await asyncio.sleep(3)
 
 
-@asyncio.coroutine
-def interactive_shell():
+async def interactive_shell() -> None:
     """
     Coroutine that starts a Python REPL from which we can access the global
     counter variable.
     """
-    print('You should be able to read and update the "counter[0]" variable from this shell.')
-    yield from embed(globals=globals(), return_asyncio_coroutine=True, patch_stdout=True)
-
-    # Stop the loop when quitting the repl. (Ctrl-D press.)
-    loop.stop()
-
+    print(
+        'You should be able to read and update the "counter[0]" variable from this shell.'
+    )
+    try:
+        await embed(globals=globals(), return_asyncio_coroutine=True, patch_stdout=True)
+    except EOFError:
+        # Stop the loop when quitting the repl. (Ctrl-D press.)
+        loop.stop()
 
-def main():
-    asyncio.async(print_counter())
-    asyncio.async(interactive_shell())
 
-    loop.run_forever()
-    loop.close()
+async def main() -> None:
+    asyncio.create_task(print_counter())
+    await interactive_shell()
 
 
-if __name__ == '__main__':
-    main()
+if __name__ == "__main__":
+    asyncio.run(main())
diff --git a/examples/asyncio-ssh-python-embed.py b/examples/asyncio-ssh-python-embed.py
index cbd07003..bf79df78 100755
--- a/examples/asyncio-ssh-python-embed.py
+++ b/examples/asyncio-ssh-python-embed.py
@@ -5,10 +5,12 @@
 
 Run this example and then SSH to localhost, port 8222.
 """
+
 import asyncio
-import asyncssh
 import logging
 
+import asyncssh
+
 from ptpython.contrib.asyncssh_repl import ReplSSHServerSession
 
 logging.basicConfig()
@@ -19,6 +21,7 @@ class MySSHServer(asyncssh.SSHServer):
     """
     Server without authentication, running `ReplSSHServerSession`.
     """
+
     def __init__(self, get_namespace):
         self.get_namespace = get_namespace
 
@@ -30,29 +33,25 @@ def session_requested(self):
         return ReplSSHServerSession(self.get_namespace)
 
 
-def main(port=8222):
+async def main(port: int = 8222) -> None:
     """
     Example that starts the REPL through an SSH server.
     """
-    loop = asyncio.get_event_loop()
-
     # Namespace exposed in the REPL.
-    environ = {'hello': 'world'}
+    environ = {"hello": "world"}
 
     # Start SSH server.
-    def create_server():
+    def create_server() -> MySSHServer:
         return MySSHServer(lambda: environ)
 
-    print('Listening on :%i' % port)
-    print('To connect, do "ssh localhost -p %i"' % port)
-
-    loop.run_until_complete(
-        asyncssh.create_server(create_server, '', port,
-                               server_host_keys=['/etc/ssh/ssh_host_dsa_key']))
+    print(f"Listening on: {port}")
+    print(f'To connect, do "ssh localhost -p {port}"')
 
-    # Run eventloop.
-    loop.run_forever()
+    await asyncssh.create_server(
+        create_server, "", port, server_host_keys=["/etc/ssh/ssh_host_dsa_key"]
+    )
+    await asyncio.Future()  # Wait forever.
 
 
-if __name__ == '__main__':
-    main()
+if __name__ == "__main__":
+    asyncio.run(main())
diff --git a/examples/ptpython_config/config.py b/examples/ptpython_config/config.py
index 0d8c8eb3..bfd3914e 100644
--- a/examples/ptpython_config/config.py
+++ b/examples/ptpython_config/config.py
@@ -1,19 +1,19 @@
 """
 Configuration example for ``ptpython``.
 
-Copy this file to ~/.ptpython/config.py
+Copy this file to $XDG_CONFIG_HOME/ptpython/config.py
+On Linux, this is: ~/.config/ptpython/config.py
+On macOS, this is: ~/Library/Application Support/ptpython/config.py
 """
-from __future__ import unicode_literals
+
 from prompt_toolkit.filters import ViInsertMode
-from prompt_toolkit.key_binding.input_processor import KeyPress
+from prompt_toolkit.key_binding.key_processor import KeyPress
 from prompt_toolkit.keys import Keys
-from pygments.token import Token
+from prompt_toolkit.styles import Style
 
 from ptpython.layout import CompletionVisualisation
 
-__all__ = (
-    'configure',
-)
+__all__ = ["configure"]
 
 
 def configure(repl):
@@ -40,7 +40,7 @@ def configure(repl):
     repl.completion_menu_scroll_offset = 0
 
     # Show line numbers (when the input contains multiple lines.)
-    repl.show_line_numbers = True
+    repl.show_line_numbers = False
 
     # Show status bar.
     repl.show_status_bar = True
@@ -48,7 +48,10 @@ def configure(repl):
     # When the sidebar is visible, also show the help text.
     repl.show_sidebar_help = True
 
-    # Highlight matching parethesis.
+    # Swap light/dark colors on or off
+    repl.swap_light_and_dark = False
+
+    # Highlight matching parentheses.
     repl.highlight_matching_parenthesis = True
 
     # Line wrapping. (Instead of horizontal scrolling.)
@@ -61,14 +64,21 @@ def configure(repl):
     # completion menu is shown.)
     repl.complete_while_typing = True
 
+    # Fuzzy and dictionary completion.
+    repl.enable_fuzzy_completion = False
+    repl.enable_dictionary_completion = False
+
     # Vi mode.
     repl.vi_mode = False
 
+    # Enable the modal cursor (when using Vi mode). Other options are 'Block', 'Underline',  'Beam',  'Blink under', 'Blink block', and 'Blink beam'
+    repl.cursor_shape_config = "Modal (vi)"
+
     # Paste mode. (When True, don't insert whitespace after new line.)
     repl.paste_mode = False
 
     # Use the classic prompt. (Display '>>>' instead of 'In [1]'.)
-    repl.prompt_style = 'classic'  # 'classic' or 'ipython'
+    repl.prompt_style = "classic"  # 'classic' or 'ipython'
 
     # Don't insert a blank line after the output.
     repl.insert_blank_line_after_output = False
@@ -85,7 +95,7 @@ def configure(repl):
     # based on the history.)
     repl.enable_auto_suggest = False
 
-    # Enable open-in-editor. Pressing C-X C-E in emacs mode or 'v' in
+    # Enable open-in-editor. Pressing C-x C-e in emacs mode or 'v' in
     # Vi navigation mode will open the input in the current editor.
     repl.enable_open_in_editor = True
 
@@ -101,50 +111,75 @@ def configure(repl):
     repl.enable_input_validation = True
 
     # Use this colorscheme for the code.
-    repl.use_code_colorscheme('pastie')
+    # Ptpython uses Pygments for code styling, so you can choose from Pygments'
+    # color schemes. See:
+    # https://pygments.org/docs/styles/
+    # https://pygments.org/demo/
+    repl.use_code_colorscheme("default")
+    # A colorscheme that looks good on dark backgrounds is 'native':
+    # repl.use_code_colorscheme("native")
+
+    # Set color depth (keep in mind that not all terminals support true color).
+
+    # repl.color_depth = "DEPTH_1_BIT"  # Monochrome.
+    # repl.color_depth = "DEPTH_4_BIT"  # ANSI colors only.
+    repl.color_depth = "DEPTH_8_BIT"  # The default, 256 colors.
+    # repl.color_depth = "DEPTH_24_BIT"  # True color.
 
-    # Enable 24bit True color. (Not all terminals support this. -- maybe check
-    # $TERM before changing.)
-    repl.true_color = False
+    # Min/max brightness
+    repl.min_brightness = 0.0  # Increase for dark terminal backgrounds.
+    repl.max_brightness = 1.0  # Decrease for light terminal backgrounds.
+
+    # Syntax.
+    repl.enable_syntax_highlighting = True
+
+    # Get into Vi navigation mode at startup
+    repl.vi_start_in_navigation_mode = False
+
+    # Preserve last used Vi input mode between main loop iterations
+    repl.vi_keep_last_used_mode = False
 
     # Install custom colorscheme named 'my-colorscheme' and use it.
     """
-    repl.install_ui_colorscheme('my-colorscheme', _custom_ui_colorscheme)
-    repl.use_ui_colorscheme('my-colorscheme')
+    repl.install_ui_colorscheme("my-colorscheme", Style.from_dict(_custom_ui_colorscheme))
+    repl.use_ui_colorscheme("my-colorscheme")
     """
 
     # Add custom key binding for PDB.
-    @repl.add_key_binding(Keys.ControlB)
+    """
+    @repl.add_key_binding("c-b")
     def _(event):
-        ' Pressing Control-B will insert "pdb.set_trace()" '
-        event.cli.current_buffer.insert_text('\nimport pdb; pdb.set_trace()\n')
+        " Pressing Control-B will insert "pdb.set_trace()" "
+        event.cli.current_buffer.insert_text("\nimport pdb; pdb.set_trace()\n")
+    """
 
     # Typing ControlE twice should also execute the current command.
     # (Alternative for Meta-Enter.)
-    @repl.add_key_binding(Keys.ControlE, Keys.ControlE)
+    """
+    @repl.add_key_binding("c-e", "c-e")
     def _(event):
-        b = event.current_buffer
-        if b.accept_action.is_returnable:
-            b.accept_action.validate_and_handle(event.cli, b)
-
+        event.current_buffer.validate_and_handle()
+    """
 
     # Typing 'jj' in Vi Insert mode, should send escape. (Go back to navigation
     # mode.)
-    @repl.add_key_binding('j', 'j', filter=ViInsertMode())
+    """
+    @repl.add_key_binding("j", "j", filter=ViInsertMode())
     def _(event):
         " Map 'jj' to Escape. "
-        event.cli.input_processor.feed(KeyPress(Keys.Escape))
-
+        event.cli.key_processor.feed(KeyPress(Keys("escape")))
     """
+
     # Custom key binding for some simple autocorrection while typing.
+    """
     corrections = {
-        'impotr': 'import',
-        'pritn': 'print',
+        "impotr": "import",
+        "pritn": "print",
     }
 
-    @repl.add_key_binding(' ')
+    @repl.add_key_binding(" ")
     def _(event):
-        ' When a space is pressed. Check & correct word before cursor. '
+        " When a space is pressed. Check & correct word before cursor. "
         b = event.cli.current_buffer
         w = b.document.get_word_before_cursor()
 
@@ -153,7 +188,13 @@ def _(event):
                 b.delete_before_cursor(count=len(w))
                 b.insert_text(corrections[w])
 
-        b.insert_text(' ')
+        b.insert_text(" ")
+    """
+
+    # Add a custom title to the status bar. This is useful when ptpython is
+    # embedded in other applications.
+    """
+    repl.title = "My custom prompt."
     """
 
 
@@ -161,8 +202,7 @@ def _(event):
 # `ptpython/style.py` for all possible tokens.
 _custom_ui_colorscheme = {
     # Blue prompt.
-    Token.Layout.Prompt:                          'bg:#eeeeff #000000 bold',
-
+    "prompt": "bg:#eeeeff #000000 bold",
     # Make the status toolbar red.
-    Token.Toolbar.Status:                         'bg:#ff0000 #000000',
+    "status-toolbar": "bg:#ff0000 #000000",
 }
diff --git a/examples/python-embed-with-custom-prompt.py b/examples/python-embed-with-custom-prompt.py
index fe889fad..5e8c7079 100755
--- a/examples/python-embed-with-custom-prompt.py
+++ b/examples/python-embed-with-custom-prompt.py
@@ -2,50 +2,38 @@
 """
 Example of embedding a Python REPL, and setting a custom prompt.
 """
-from __future__ import unicode_literals
 
-from ptpython.repl import embed
-from ptpython.prompt_style import PromptStyle
-from pygments.token import Token
+from prompt_toolkit.formatted_text import HTML, AnyFormattedText
 
+from ptpython.prompt_style import PromptStyle
+from ptpython.repl import embed
 
-def configure(repl):
-    # There are several ways to override the prompt.
 
-    # 1. Probably, the best is to add a new PromptStyle to `all_prompt_styles`
-    #    and activate it. This way, the other styles are still selectable from
-    #    the menu.
+def configure(repl) -> None:
+    # Probably, the best is to add a new PromptStyle to `all_prompt_styles` and
+    # activate it. This way, the other styles are still selectable from the
+    # menu.
     class CustomPrompt(PromptStyle):
-        def in_tokens(self, cli):
-            return [
-                (Token.In, 'Input['),
-                (Token.In.Number, '%s' % repl.current_statement_index),
-                (Token.In, '] >>: '),
-            ]
-
-        def out_tokens(self, cli):
-            return [
-                (Token.Out, 'Result['),
-                (Token.Out.Number, '%s' % repl.current_statement_index),
-                (Token.Out, ']: '),
-            ]
-
-    repl.all_prompt_styles['custom'] = CustomPrompt()
-    repl.prompt_style = 'custom'
+        def in_prompt(self) -> AnyFormattedText:
+            return HTML("<ansigreen>Input[%s]</ansigreen>: ") % (
+                repl.current_statement_index,
+            )
 
-    # 2. Assign a new callable to `get_input_prompt_tokens`. This will always take effect.
-    ## repl.get_input_prompt_tokens = lambda cli: [(Token.In, '[hello] >>> ')]
+        def in2_prompt(self, width: int) -> AnyFormattedText:
+            return "...: ".rjust(width)
 
-    # 3. Also replace `get_input_prompt_tokens`, but still call the original. This inserts
-    #    a prefix.
+        def out_prompt(self) -> AnyFormattedText:
+            return HTML("<ansired>Result[%s]</ansired>: ") % (
+                repl.current_statement_index,
+            )
 
-    ## original = repl.get_input_prompt_tokens
-    ## repl.get_input_prompt_tokens = lambda cli: [(Token.In, '[prefix]')] + original(cli)
+    repl.all_prompt_styles["custom"] = CustomPrompt()
+    repl.prompt_style = "custom"
 
 
-def main():
+def main() -> None:
     embed(globals(), locals(), configure=configure)
 
 
-if __name__ == '__main__':
+if __name__ == "__main__":
     main()
diff --git a/examples/python-embed.py b/examples/python-embed.py
index 72c1c101..a7481011 100755
--- a/examples/python-embed.py
+++ b/examples/python-embed.py
@@ -1,14 +1,12 @@
 #!/usr/bin/env python
-"""
-"""
-from __future__ import unicode_literals
+""" """
 
 from ptpython.repl import embed
 
 
-def main():
+def main() -> None:
     embed(globals(), locals(), vi_mode=False)
 
 
-if __name__ == '__main__':
+if __name__ == "__main__":
     main()
diff --git a/examples/python-input.py b/examples/python-input.py
index 9acb8d9e..d586d0f5 100755
--- a/examples/python-input.py
+++ b/examples/python-input.py
@@ -1,22 +1,15 @@
 #!/usr/bin/env python
-"""
-"""
-from __future__ import unicode_literals
+""" """
 
-from prompt_toolkit.shortcuts import create_eventloop
-from ptpython.python_input import PythonCommandLineInterface
+from ptpython.python_input import PythonInput
 
 
 def main():
-    eventloop = create_eventloop()
-    try:
-        cli = PythonCommandLineInterface(eventloop)
+    prompt = PythonInput()
 
-        code_obj = cli.run()
-        print('You said: ' + code_obj.text)
-    finally:
-        eventloop.close()
+    text = prompt.app.run()
+    print("You said: " + text)
 
 
-if __name__ == '__main__':
+if __name__ == "__main__":
     main()
diff --git a/examples/ssh-and-telnet-embed.py b/examples/ssh-and-telnet-embed.py
new file mode 100755
index 00000000..2b293e6f
--- /dev/null
+++ b/examples/ssh-and-telnet-embed.py
@@ -0,0 +1,54 @@
+#!/usr/bin/env python
+"""
+Serve a ptpython console using both telnet and ssh.
+
+Thanks to Vincent Michel for this!
+https://gist.github.com/vxgmichel/7685685b3e5ead04ada4a3ba75a48eef
+"""
+
+from __future__ import annotations
+
+import asyncio
+import pathlib
+
+import asyncssh
+from prompt_toolkit import print_formatted_text
+from prompt_toolkit.contrib.ssh.server import (
+    PromptToolkitSSHServer,
+    PromptToolkitSSHSession,
+)
+from prompt_toolkit.contrib.telnet.server import TelnetConnection, TelnetServer
+
+from ptpython.repl import embed
+
+
+def ensure_key(filename: str = "ssh_host_key") -> str:
+    path = pathlib.Path(filename)
+    if not path.exists():
+        rsa_key = asyncssh.generate_private_key("ssh-rsa")
+        path.write_bytes(rsa_key.export_private_key())
+    return str(path)
+
+
+async def interact(connection: PromptToolkitSSHSession | TelnetConnection) -> None:
+    global_dict = {**globals(), "print": print_formatted_text}
+    await embed(return_asyncio_coroutine=True, globals=global_dict)
+
+
+async def main(ssh_port: int = 8022, telnet_port: int = 8023) -> None:
+    ssh_server = PromptToolkitSSHServer(interact=interact)
+    await asyncssh.create_server(
+        lambda: ssh_server, "", ssh_port, server_host_keys=[ensure_key()]
+    )
+    print(f"Running ssh server on port {ssh_port}...")
+
+    telnet_server = TelnetServer(interact=interact, port=telnet_port)
+    telnet_server.start()
+    print(f"Running telnet server on port {telnet_port}...")
+
+    while True:
+        await asyncio.sleep(60)
+
+
+if __name__ == "__main__":
+    asyncio.run(main())
diff --git a/examples/test-cases/ptpython-in-other-thread.py b/examples/test-cases/ptpython-in-other-thread.py
new file mode 100644
index 00000000..bfe14109
--- /dev/null
+++ b/examples/test-cases/ptpython-in-other-thread.py
@@ -0,0 +1,25 @@
+#!/usr/bin/env python
+"""
+Example of running ptpython in another thread.
+
+(For testing whether it's working fine if it's not embedded in the main
+thread.)
+"""
+
+import threading
+
+from ptpython.repl import embed
+
+
+def in_thread():
+    embed(globals(), locals(), vi_mode=False)
+
+
+def main():
+    th = threading.Thread(target=in_thread)
+    th.start()
+    th.join()
+
+
+if __name__ == "__main__":
+    main()
diff --git a/ptpython/completer.py b/ptpython/completer.py
deleted file mode 100644
index bba9aec6..00000000
--- a/ptpython/completer.py
+++ /dev/null
@@ -1,161 +0,0 @@
-from __future__ import unicode_literals
-
-from prompt_toolkit.completion import Completer, Completion
-from prompt_toolkit.contrib.completers import PathCompleter
-from prompt_toolkit.contrib.regular_languages.compiler import compile as compile_grammar
-from prompt_toolkit.contrib.regular_languages.completion import GrammarCompleter
-
-from ptpython.utils import get_jedi_script_from_document
-
-import re
-
-__all__ = (
-    'PythonCompleter',
-)
-
-
-class PythonCompleter(Completer):
-    """
-    Completer for Python code.
-    """
-    def __init__(self, get_globals, get_locals):
-        super(PythonCompleter, self).__init__()
-
-        self.get_globals = get_globals
-        self.get_locals = get_locals
-
-        self._path_completer_cache = None
-        self._path_completer_grammar_cache = None
-
-    @property
-    def _path_completer(self):
-        if self._path_completer_cache is None:
-            self._path_completer_cache = GrammarCompleter(
-                self._path_completer_grammar, {
-                    'var1': PathCompleter(expanduser=True),
-                    'var2': PathCompleter(expanduser=True),
-                })
-        return self._path_completer_cache
-
-    @property
-    def _path_completer_grammar(self):
-        """
-        Return the grammar for matching paths inside strings inside Python
-        code.
-        """
-        # We make this lazy, because it delays startup time a little bit.
-        # This way, the grammar is build during the first completion.
-        if self._path_completer_grammar_cache is None:
-            self._path_completer_grammar_cache = self._create_path_completer_grammar()
-        return self._path_completer_grammar_cache
-
-    def _create_path_completer_grammar(self):
-        def unwrapper(text):
-            return re.sub(r'\\(.)', r'\1', text)
-
-        def single_quoted_wrapper(text):
-            return text.replace('\\', '\\\\').replace("'", "\\'")
-
-        def double_quoted_wrapper(text):
-            return text.replace('\\', '\\\\').replace('"', '\\"')
-
-        grammar = r"""
-                # Text before the current string.
-                (
-                    [^'"#]                                  |  # Not quoted characters.
-                    '''  ([^'\\]|'(?!')|''(?!')|\\.])*  ''' |  # Inside single quoted triple strings
-                    "" " ([^"\\]|"(?!")|""(?!^)|\\.])* "" " |  # Inside double quoted triple strings
-
-                    \#[^\n]*(\n|$)           |  # Comment.
-                    "(?!"") ([^"\\]|\\.)*"   |  # Inside double quoted strings.
-                    '(?!'') ([^'\\]|\\.)*'      # Inside single quoted strings.
-
-                        # Warning: The negative lookahead in the above two
-                        #          statements is important. If we drop that,
-                        #          then the regex will try to interpret every
-                        #          triple quoted string also as a single quoted
-                        #          string, making this exponentially expensive to
-                        #          execute!
-                )*
-                # The current string that we're completing.
-                (
-                    ' (?P<var1>([^\n'\\]|\\.)*) |  # Inside a single quoted string.
-                    " (?P<var2>([^\n"\\]|\\.)*)    # Inside a double quoted string.
-                )
-        """
-
-        return compile_grammar(
-            grammar,
-            escape_funcs={
-                'var1': single_quoted_wrapper,
-                'var2': double_quoted_wrapper,
-            },
-            unescape_funcs={
-                'var1': unwrapper,
-                'var2': unwrapper,
-            })
-
-    def _complete_path_while_typing(self, document):
-        char_before_cursor = document.char_before_cursor
-        return document.text and (
-            char_before_cursor.isalnum() or char_before_cursor in '/.~')
-
-    def _complete_python_while_typing(self, document):
-        char_before_cursor = document.char_before_cursor
-        return document.text and (
-            char_before_cursor.isalnum() or char_before_cursor in '_.')
-
-    def get_completions(self, document, complete_event):
-        """
-        Get Python completions.
-        """
-        # Do Path completions
-        if complete_event.completion_requested or self._complete_path_while_typing(document):
-            for c in self._path_completer.get_completions(document, complete_event):
-                yield c
-
-        # If we are inside a string, Don't do Jedi completion.
-        if self._path_completer_grammar.match(document.text_before_cursor):
-            return
-
-        # Do Jedi Python completions.
-        if complete_event.completion_requested or self._complete_python_while_typing(document):
-            script = get_jedi_script_from_document(document, self.get_locals(), self.get_globals())
-
-            if script:
-                try:
-                    completions = script.completions()
-                except TypeError:
-                    # Issue #9: bad syntax causes completions() to fail in jedi.
-                    # https://github.com/jonathanslenders/python-prompt-toolkit/issues/9
-                    pass
-                except UnicodeDecodeError:
-                    # Issue #43: UnicodeDecodeError on OpenBSD
-                    # https://github.com/jonathanslenders/python-prompt-toolkit/issues/43
-                    pass
-                except AttributeError:
-                    # Jedi issue #513: https://github.com/davidhalter/jedi/issues/513
-                    pass
-                except ValueError:
-                    # Jedi issue: "ValueError: invalid \x escape"
-                    pass
-                except KeyError:
-                    # Jedi issue: "KeyError: u'a_lambda'."
-                    # https://github.com/jonathanslenders/ptpython/issues/89
-                    pass
-                except IOError:
-                    # Jedi issue: "IOError: No such file or directory."
-                    # https://github.com/jonathanslenders/ptpython/issues/71
-                    pass
-                except AssertionError:
-                    # In jedi.parser.__init__.py: 227, in remove_last_newline,
-                    # the assertion "newline.value.endswith('\n')" can fail.
-                    pass
-                except SystemError:
-                    # File "jedi/api/helpers.py", line 140, in get_stack_at_position
-                    # raise SystemError("This really shouldn't happen. There's a bug in Jedi.")
-                    pass
-                else:
-                    for c in completions:
-                        yield Completion(c.name_with_symbols, len(c.complete) - len(c.name_with_symbols),
-                                         display=c.name_with_symbols)
diff --git a/ptpython/entry_points/run_ptipython.py b/ptpython/entry_points/run_ptipython.py
deleted file mode 100644
index ac53a1f1..00000000
--- a/ptpython/entry_points/run_ptipython.py
+++ /dev/null
@@ -1,96 +0,0 @@
-#!/usr/bin/env python
-"""
-ptipython: IPython interactive shell with the `prompt_toolkit` front-end.
-Usage:
-    ptpython [ --vi ]
-             [ --config-dir=<directory> ] [ --interactive=<filename> ]
-             [--] [ <arg>... ]
-    ptpython -h | --help
-
-Options:
-    --vi                     : Use Vi keybindings instead of Emacs bindings.
-    --config-dir=<directory> : Pass config directory. By default '~/.ptpython/'.
-    -i, --interactive=<filename> : Start interactive shell after executing this file.
-"""
-from __future__ import absolute_import, unicode_literals
-
-import docopt
-import os
-import six
-import sys
-
-
-def run(user_ns=None):
-    a = docopt.docopt(__doc__)
-
-    vi_mode = bool(a['--vi'])
-    config_dir = os.path.expanduser(a['--config-dir'] or os.path.join('~', '.ptpython'))
-
-    # Create config directory.
-    if not os.path.isdir(config_dir) and not os.path.islink(config_dir):
-        os.mkdir(config_dir)
-
-    # If IPython is not available, show message and exit here with error status
-    # code.
-    try:
-        import IPython
-    except ImportError:
-        print('IPython not found. Please install IPython (pip install ipython).')
-        sys.exit(1)
-    else:
-        from ptpython.ipython import embed
-        from ptpython.repl import run_config, enable_deprecation_warnings
-
-    # Add the current directory to `sys.path`.
-    if sys.path[0] != '':
-        sys.path.insert(0, '')
-
-    # When a file has been given, run that, otherwise start the shell.
-    if a['<arg>'] and not a['--interactive']:
-        sys.argv = a['<arg>']
-        six.exec_(compile(open(a['<arg>'][0], "rb").read(), a['<arg>'][0], 'exec'))
-    else:
-        enable_deprecation_warnings()
-
-        # Create an empty namespace for this interactive shell. (If we don't do
-        # that, all the variables from this function will become available in
-        # the IPython shell.)
-        if user_ns is None:
-            user_ns = {}
-
-        # Startup path
-        startup_paths = []
-        if 'PYTHONSTARTUP' in os.environ:
-            startup_paths.append(os.environ['PYTHONSTARTUP'])
-
-        # --interactive
-        if a['--interactive']:
-            startup_paths.append(a['--interactive'])
-            sys.argv = [a['--interactive']] + a['<arg>']
-
-        # exec scripts from startup paths
-        for path in startup_paths:
-            if os.path.exists(path):
-                with open(path, 'r') as f:
-                    code = compile(f.read(), path, 'exec')
-                    six.exec_(code, user_ns, user_ns)
-            else:
-                print('File not found: {}\n\n'.format(path))
-                sys.exit(1)
-
-        # Apply config file
-        def configure(repl):
-            path = os.path.join(config_dir, 'config.py')
-            if os.path.exists(path):
-                run_config(repl, path)
-
-        # Run interactive shell.
-        embed(vi_mode=vi_mode,
-              history_filename=os.path.join(config_dir, 'history'),
-              configure=configure,
-              user_ns=user_ns,
-              title='IPython REPL (ptipython)')
-
-
-if __name__ == '__main__':
-    run()
diff --git a/ptpython/entry_points/run_ptpython.py b/ptpython/entry_points/run_ptpython.py
deleted file mode 100644
index c1ec22d1..00000000
--- a/ptpython/entry_points/run_ptpython.py
+++ /dev/null
@@ -1,77 +0,0 @@
-#!/usr/bin/env python
-"""
-ptpython: Interactive Python shell.
-Usage:
-    ptpython [ --vi ]
-             [ --config-dir=<directory> ] [ --interactive=<filename> ]
-             [--] [ <arg>... ]
-    ptpython -h | --help
-
-Options:
-    --vi                         : Use Vi keybindings instead of Emacs bindings.
-    --config-dir=<directory>     : Pass config directory. By default '~/.ptpython/'.
-    -i, --interactive=<filename> : Start interactive shell after executing this file.
-
-Other environment variables:
-PYTHONSTARTUP: file executed on interactive startup (no default)
-"""
-from __future__ import absolute_import, unicode_literals
-
-import docopt
-import os
-import six
-import sys
-
-from ptpython.repl import embed, enable_deprecation_warnings, run_config
-
-
-def run():
-    a = docopt.docopt(__doc__)
-
-    vi_mode = bool(a['--vi'])
-    config_dir = os.path.expanduser(a['--config-dir'] or os.path.join('~', '.ptpython'))
-
-    # Create config directory.
-    if not os.path.isdir(config_dir) and not os.path.islink(config_dir):
-        os.mkdir(config_dir)
-
-    # Startup path
-    startup_paths = []
-    if 'PYTHONSTARTUP' in os.environ:
-        startup_paths.append(os.environ['PYTHONSTARTUP'])
-
-    # --interactive
-    if a['--interactive']:
-        startup_paths.append(a['--interactive'])
-        sys.argv = [a['--interactive']] + a['<arg>']
-
-    # Add the current directory to `sys.path`.
-    if sys.path[0] != '':
-        sys.path.insert(0, '')
-
-    # When a file has been given, run that, otherwise start the shell.
-    if a['<arg>'] and not a['--interactive']:
-        sys.argv = a['<arg>']
-        six.exec_(compile(open(a['<arg>'][0], "rb").read(), a['<arg>'][0], 'exec'))
-
-    # Run interactive shell.
-    else:
-        enable_deprecation_warnings()
-
-        # Apply config file
-        def configure(repl):
-            path = os.path.join(config_dir, 'config.py')
-            if os.path.exists(path):
-                run_config(repl, path)
-
-        import __main__
-        embed(vi_mode=vi_mode,
-              history_filename=os.path.join(config_dir, 'history'),
-              configure=configure,
-              locals=__main__.__dict__,
-              globals=__main__.__dict__,
-              startup_paths=startup_paths,
-              title='Python REPL (ptpython)')
-
-if __name__ == '__main__':
-    run()
diff --git a/ptpython/history_browser.py b/ptpython/history_browser.py
deleted file mode 100644
index b6fb8f07..00000000
--- a/ptpython/history_browser.py
+++ /dev/null
@@ -1,596 +0,0 @@
-"""
-Utility to easily select lines from the history and execute them again.
-
-`create_history_application` creates an `Application` instance that runs will
-run as a sub application of the Repl/PythonInput.
-"""
-from __future__ import unicode_literals
-
-from prompt_toolkit.application import Application
-from prompt_toolkit.buffer import Buffer, AcceptAction
-from prompt_toolkit.buffer_mapping import BufferMapping
-from prompt_toolkit.document import Document
-from prompt_toolkit.enums import DEFAULT_BUFFER
-from prompt_toolkit.filters import Condition, HasFocus, InFocusStack
-from prompt_toolkit.key_binding.defaults import load_key_bindings
-from prompt_toolkit.keys import Keys
-from prompt_toolkit.layout.containers import HSplit, VSplit, Window, FloatContainer, Float, ConditionalContainer, Container, ScrollOffsets
-from prompt_toolkit.layout.controls import BufferControl, FillControl
-from prompt_toolkit.layout.dimension import LayoutDimension as D
-from prompt_toolkit.layout.lexers import PygmentsLexer
-from prompt_toolkit.layout.margins import Margin, ScrollbarMargin
-from prompt_toolkit.layout.processors import Processor, Transformation, HighlightSearchProcessor, HighlightSelectionProcessor
-from prompt_toolkit.layout.screen import Char
-from prompt_toolkit.layout.toolbars import ArgToolbar, SearchToolbar
-from prompt_toolkit.layout.toolbars import TokenListToolbar
-from prompt_toolkit.layout.utils import token_list_to_text
-from pygments.lexers import RstLexer
-from pygments.token import Token
-
-from .utils import if_mousedown
-
-from ptpython.layout import get_inputmode_tokens
-from functools import partial
-import six
-
-if six.PY2:
-    from pygments.lexers import PythonLexer
-else:
-    from pygments.lexers import Python3Lexer as PythonLexer
-
-
-HISTORY_BUFFER = 'HISTORY_BUFFER'
-HELP_BUFFER = 'HELP_BUFFER'
-
-HISTORY_COUNT = 2000
-
-__all__ = (
-    'create_history_application',
-)
-
-HELP_TEXT = """
-This interface is meant to select multiple lines from the
-history and execute them together.
-
-Typical usage
--------------
-
-1. Move the ``cursor up`` in the history pane, until the
-   cursor is on the first desired line.
-2. Hold down the ``space bar``, or press it multiple
-   times. Each time it will select one line and move to
-   the next one. Each selected line will appear on the
-   right side.
-3. When all the required lines are displayed on the right
-   side, press ``Enter``. This will go back to the Python
-   REPL and show these lines as the current input. They
-   can still be edited from there.
-
-Key bindings
-------------
-
-Many Emacs and Vi navigation key bindings should work.
-Press ``F4`` to switch between Emacs and Vi mode.
-
-Additional bindings:
-
-- ``Space``: Select or delect a line.
-- ``Tab``: Move the focus between the history and input
-  pane. (Alternative: ``Ctrl-W``)
-- ``Ctrl-C``: Cancel. Ignore the result and go back to
-  the REPL. (Alternatives: ``q`` and ``Control-G``.)
-- ``Enter``: Accept the result and go back to the REPL.
-- ``F1``: Show/hide help. Press ``Enter`` to quit this
-  help message.
-
-Further, remember that searching works like in Emacs
-(using ``Ctrl-R``) or Vi (using ``/``).
-"""
-
-
-class BORDER:
-    " Box drawing characters. "
-    HORIZONTAL = '\u2501'
-    VERTICAL = '\u2503'
-    TOP_LEFT = '\u250f'
-    TOP_RIGHT = '\u2513'
-    BOTTOM_LEFT = '\u2517'
-    BOTTOM_RIGHT = '\u251b'
-    LIGHT_VERTICAL = '\u2502'
-
-
-def create_popup_window(title, body):
-    """
-    Return the layout for a pop-up window. It consists of a title bar showing
-    the `title` text, and a body layout. The window is surrounded by borders.
-    """
-    assert isinstance(title, six.text_type)
-    assert isinstance(body, Container)
-
-    return HSplit([
-        VSplit([
-            Window(width=D.exact(1), height=D.exact(1),
-                   content=FillControl(BORDER.TOP_LEFT, token=Token.Window.Border)),
-            TokenListToolbar(
-                get_tokens=lambda cli: [(Token.Window.Title, ' %s ' % title)],
-                align_center=True,
-                default_char=Char(BORDER.HORIZONTAL, Token.Window.Border)),
-            Window(width=D.exact(1), height=D.exact(1),
-                   content=FillControl(BORDER.TOP_RIGHT, token=Token.Window.Border)),
-        ]),
-        VSplit([
-            Window(width=D.exact(1),
-                   content=FillControl(BORDER.VERTICAL, token=Token.Window.Border)),
-            body,
-            Window(width=D.exact(1),
-                   content=FillControl(BORDER.VERTICAL, token=Token.Window.Border)),
-        ]),
-        VSplit([
-            Window(width=D.exact(1), height=D.exact(1),
-                   content=FillControl(BORDER.BOTTOM_LEFT, token=Token.Window.Border)),
-            Window(height=D.exact(1),
-                   content=FillControl(BORDER.HORIZONTAL, token=Token.Window.Border)),
-            Window(width=D.exact(1), height=D.exact(1),
-                   content=FillControl(BORDER.BOTTOM_RIGHT, token=Token.Window.Border)),
-        ]),
-    ])
-
-
-def create_layout(python_input, history_mapping):
-    """
-    Create and return a `Container` instance for the history
-    application.
-    """
-    processors = [
-        HighlightSearchProcessor(preview_search=True),
-        HighlightSelectionProcessor()]
-
-    help_window = create_popup_window(
-        title='History Help',
-        body=Window(
-            content=BufferControl(
-                buffer_name=HELP_BUFFER,
-                default_char=Char(token=Token),
-                lexer=PygmentsLexer(RstLexer),
-                input_processors=processors),
-            right_margins=[ScrollbarMargin()],
-            scroll_offsets=ScrollOffsets(top=2, bottom=2)))
-
-    return HSplit([
-        #  Top title bar.
-        TokenListToolbar(
-            get_tokens=_get_top_toolbar_tokens,
-            align_center=True,
-            default_char=Char(' ', Token.Toolbar.Status)),
-        FloatContainer(
-            content=VSplit([
-                # Left side: history.
-                Window(
-                    content=BufferControl(
-                        buffer_name=HISTORY_BUFFER,
-                        lexer=PygmentsLexer(PythonLexer),
-                        input_processors=processors),
-                    wrap_lines=False,
-                    left_margins=[HistoryMargin(history_mapping)],
-                    scroll_offsets=ScrollOffsets(top=2, bottom=2)),
-                # Separator.
-                Window(width=D.exact(1),
-                       content=FillControl(BORDER.LIGHT_VERTICAL, token=Token.Separator)),
-                # Right side: result.
-                Window(
-                    content=BufferControl(
-                        buffer_name=DEFAULT_BUFFER,
-                        input_processors=processors + [GrayExistingText(history_mapping)],
-                        lexer=PygmentsLexer(PythonLexer)),
-                    wrap_lines=False,
-                    left_margins=[ResultMargin(history_mapping)],
-                    scroll_offsets=ScrollOffsets(top=2, bottom=2)),
-            ]),
-            floats=[
-                # Help text as a float.
-                Float(width=60, top=3, bottom=2,
-                      content=ConditionalContainer(
-                          # (We use InFocusStack, because it's possible to search
-                          # through the help text as well, and at that point the search
-                          # buffer has the focus.)
-                          content=help_window, filter=InFocusStack(HELP_BUFFER))),
-            ]
-        ),
-        # Bottom toolbars.
-        ArgToolbar(),
-        SearchToolbar(),
-        TokenListToolbar(
-            get_tokens=partial(_get_bottom_toolbar_tokens, python_input=python_input),
-            default_char=Char(' ', Token.Toolbar.Status)),
-    ])
-
-
-def _get_top_toolbar_tokens(cli):
-    return [(Token.Toolbar.Status.Title, 'History browser - Insert from history')]
-
-
-def _get_bottom_toolbar_tokens(cli, python_input):
-    @if_mousedown
-    def f1(cli, mouse_event):
-        _toggle_help(cli)
-
-    @if_mousedown
-    def tab(cli, mouse_event):
-        _select_other_window(cli)
-
-    return [
-        (Token.Toolbar.Status, ' ')
-    ] + get_inputmode_tokens(cli, python_input) + [
-        (Token.Toolbar.Status, ' '),
-        (Token.Toolbar.Status.Key, '[Space]'),
-        (Token.Toolbar.Status, ' Toggle '),
-        (Token.Toolbar.Status.Key, '[Tab]', tab),
-        (Token.Toolbar.Status, ' Focus ', tab),
-        (Token.Toolbar.Status.Key, '[Enter]'),
-        (Token.Toolbar.Status, ' Accept '),
-        (Token.Toolbar.Status.Key, '[F1]', f1),
-        (Token.Toolbar.Status, ' Help ', f1),
-    ]
-
-
-class HistoryMargin(Margin):
-    """
-    Margin for the history buffer.
-    This displays a green bar for the selected entries.
-    """
-    def __init__(self, history_mapping):
-        self.history_mapping = history_mapping
-
-    def get_width(self, cli, ui_content):
-        return 2
-
-    def create_margin(self, cli, window_render_info, width, height):
-        document = cli.buffers[HISTORY_BUFFER].document
-
-        lines_starting_new_entries = self.history_mapping.lines_starting_new_entries
-        selected_lines = self.history_mapping.selected_lines
-
-        current_lineno = document.cursor_position_row
-
-        visible_line_to_input_line = window_render_info.visible_line_to_input_line
-        result = []
-
-        for y in range(height):
-            line_number = visible_line_to_input_line.get(y)
-
-            # Show stars at the start of each entry.
-            # (Visualises multiline entries.)
-            if line_number in lines_starting_new_entries:
-                char = '*'
-            else:
-                char = ' '
-
-            if line_number in selected_lines:
-                t = Token.History.Line.Selected
-            else:
-                t = Token.History.Line
-
-            if line_number == current_lineno:
-                t = t.Current
-
-            result.append((t, char))
-            result.append((Token, '\n'))
-
-        return result
-
-
-class ResultMargin(Margin):
-    """
-    The margin to be shown in the result pane.
-    """
-    def __init__(self, history_mapping):
-        self.history_mapping = history_mapping
-
-    def get_width(self, cli, ui_content):
-        return 2
-
-    def create_margin(self, cli, window_render_info, width, height):
-        document = cli.buffers[DEFAULT_BUFFER].document
-
-        current_lineno = document.cursor_position_row
-        offset = self.history_mapping.result_line_offset #original_document.cursor_position_row
-
-        visible_line_to_input_line = window_render_info.visible_line_to_input_line
-
-        result = []
-
-        for y in range(height):
-            line_number = visible_line_to_input_line.get(y)
-
-            if (line_number is None or line_number < offset or
-                    line_number >= offset + len(self.history_mapping.selected_lines)):
-                t = Token
-            elif line_number == current_lineno:
-                t = Token.History.Line.Selected.Current
-            else:
-                t = Token.History.Line.Selected
-
-            result.append((t, ' '))
-            result.append((Token, '\n'))
-
-        return result
-
-    def invalidation_hash(self, cli, document):
-        return document.cursor_position_row
-
-
-class GrayExistingText(Processor):
-    """
-    Turn the existing input, before and after the inserted code gray.
-    """
-    def __init__(self, history_mapping):
-        self.history_mapping = history_mapping
-        self._lines_before = len(history_mapping.original_document.text_before_cursor.splitlines())
-
-    def apply_transformation(self, cli, document, lineno, source_to_display, tokens):
-        if (lineno < self._lines_before or
-                lineno >= self._lines_before + len(self.history_mapping.selected_lines)):
-            text = token_list_to_text(tokens)
-            return Transformation(tokens=[(Token.History.ExistingInput, text)])
-        else:
-            return Transformation(tokens=tokens)
-
-
-class HistoryMapping(object):
-    """
-    Keep a list of all the lines from the history and the selected lines.
-    """
-    def __init__(self, python_history, original_document):
-        self.python_history = python_history
-        self.original_document = original_document
-
-        self.lines_starting_new_entries = set()
-        self.selected_lines = set()
-
-        # Process history.
-        history_lines = []
-
-        for entry_nr, entry in list(enumerate(python_history))[-HISTORY_COUNT:]:
-            self.lines_starting_new_entries.add(len(history_lines))
-
-            for line in entry.splitlines():
-                history_lines.append(line)
-
-        if len(python_history) > HISTORY_COUNT:
-            history_lines[0] = '# *** History has been truncated to %s lines ***' % HISTORY_COUNT
-
-        self.history_lines = history_lines
-        self.concatenated_history = '\n'.join(history_lines)
-
-        # Line offset.
-        if self.original_document.text_before_cursor:
-            self.result_line_offset = self.original_document.cursor_position_row + 1
-        else:
-            self.result_line_offset = 0
-
-    def get_new_document(self, cursor_pos=None):
-        """
-        Create a `Document` instance that contains the resulting text.
-        """
-        lines = []
-
-        # Original text, before cursor.
-        if self.original_document.text_before_cursor:
-            lines.append(self.original_document.text_before_cursor)
-
-        # Selected entries from the history.
-        for line_no in sorted(self.selected_lines):
-            lines.append(self.history_lines[line_no])
-
-        # Original text, after cursor.
-        if self.original_document.text_after_cursor:
-            lines.append(self.original_document.text_after_cursor)
-
-        # Create `Document` with cursor at the right position.
-        text = '\n'.join(lines)
-        if cursor_pos is not None and cursor_pos > len(text):
-            cursor_pos = len(text)
-        return Document(text, cursor_pos)
-
-    def update_default_buffer(self, cli):
-        b = cli.buffers[DEFAULT_BUFFER]
-
-        b.set_document(
-            self.get_new_document(b.cursor_position), bypass_readonly=True)
-
-
-def _toggle_help(cli):
-    " Display/hide help. "
-    if cli.current_buffer_name == HELP_BUFFER:
-        cli.pop_focus()
-    else:
-        cli.push_focus(HELP_BUFFER)
-
-
-def _select_other_window(cli):
-    " Toggle focus between left/right window. "
-    if cli.current_buffer_name == HISTORY_BUFFER:
-        cli.focus(DEFAULT_BUFFER)
-
-    elif cli.current_buffer_name == DEFAULT_BUFFER:
-        cli.focus(HISTORY_BUFFER)
-
-
-def create_key_bindings(python_input, history_mapping):
-    """
-    Key bindings.
-    """
-    registry = load_key_bindings(
-        enable_search=True,
-        enable_extra_page_navigation=True)
-    handle = registry.add_binding
-
-    @handle(' ', filter=HasFocus(HISTORY_BUFFER))
-    def _(event):
-        """
-        Space: select/deselect line from history pane.
-        """
-        b = event.current_buffer
-        line_no = b.document.cursor_position_row
-
-        if line_no in history_mapping.selected_lines:
-            # Remove line.
-            history_mapping.selected_lines.remove(line_no)
-            history_mapping.update_default_buffer(event.cli)
-        else:
-            # Add line.
-            history_mapping.selected_lines.add(line_no)
-            history_mapping.update_default_buffer(event.cli)
-
-            # Update cursor position
-            default_buffer = event.cli.buffers[DEFAULT_BUFFER]
-            default_lineno = sorted(history_mapping.selected_lines).index(line_no) + \
-                history_mapping.result_line_offset
-            default_buffer.cursor_position = \
-                default_buffer.document.translate_row_col_to_index(default_lineno, 0)
-
-        # Also move the cursor to the next line. (This way they can hold
-        # space to select a region.)
-        b.cursor_position = b.document.translate_row_col_to_index(line_no + 1, 0)
-
-    @handle(' ', filter=HasFocus(DEFAULT_BUFFER))
-    @handle(Keys.Delete, filter=HasFocus(DEFAULT_BUFFER))
-    @handle(Keys.ControlH, filter=HasFocus(DEFAULT_BUFFER))
-    def _(event):
-        """
-        Space: remove line from default pane.
-        """
-        b = event.current_buffer
-        line_no = b.document.cursor_position_row - history_mapping.result_line_offset
-
-        if line_no >= 0:
-            try:
-                history_lineno = sorted(history_mapping.selected_lines)[line_no]
-            except IndexError:
-                pass  # When `selected_lines` is an empty set.
-            else:
-                history_mapping.selected_lines.remove(history_lineno)
-
-            history_mapping.update_default_buffer(event.cli)
-
-    help_focussed = HasFocus(HELP_BUFFER)
-    main_buffer_focussed = HasFocus(HISTORY_BUFFER) | HasFocus(DEFAULT_BUFFER)
-
-    @handle(Keys.Tab, filter=main_buffer_focussed)
-    @handle(Keys.ControlX, filter=main_buffer_focussed, eager=True)
-        # Eager: ignore the Emacs [Ctrl-X Ctrl-X] binding.
-    @handle(Keys.ControlW, filter=main_buffer_focussed)
-    def _(event):
-        " Select other window. "
-        _select_other_window(event.cli)
-
-    @handle(Keys.F4)
-    def _(event):
-        " Switch between Emacs/Vi mode. "
-        python_input.vi_mode = not python_input.vi_mode
-
-    @handle(Keys.F1)
-    def _(event):
-        " Display/hide help. "
-        _toggle_help(event.cli)
-
-    @handle(Keys.ControlJ, filter=help_focussed)
-    @handle(Keys.ControlC, filter=help_focussed)
-    @handle(Keys.ControlG, filter=help_focussed)
-    @handle(Keys.Escape, filter=help_focussed)
-    def _(event):
-        " Leave help. "
-        event.cli.pop_focus()
-
-    @handle('q', filter=main_buffer_focussed)
-    @handle(Keys.F3, filter=main_buffer_focussed)
-    @handle(Keys.ControlC, filter=main_buffer_focussed)
-    @handle(Keys.ControlG, filter=main_buffer_focussed)
-    def _(event):
-        " Cancel and go back. "
-        event.cli.set_return_value(None)
-
-    enable_system_bindings = Condition(lambda cli: python_input.enable_system_bindings)
-
-    @handle(Keys.ControlZ, filter=enable_system_bindings)
-    def _(event):
-        " Suspend to background. "
-        event.cli.suspend_to_background()
-
-    return registry
-
-
-def create_history_application(python_input, original_document):
-    """
-    Create an `Application` for the history screen.
-    This has to be run as a sub application of `python_input`.
-
-    When this application runs and returns, it retuns the selected lines.
-    """
-    history_mapping = HistoryMapping(python_input.history, original_document)
-
-    def default_buffer_pos_changed(_):
-        """ When the cursor changes in the default buffer. Synchronize with
-        history buffer. """
-        # Only when this buffer has the focus.
-        if buffer_mapping.focus_stack[-1] == DEFAULT_BUFFER:
-            try:
-                line_no = default_buffer.document.cursor_position_row - \
-                    history_mapping.result_line_offset
-
-                if line_no < 0:  # When the cursor is above the inserted region.
-                    raise IndexError
-
-                history_lineno = sorted(history_mapping.selected_lines)[line_no]
-            except IndexError:
-                pass
-            else:
-                history_buffer.cursor_position = \
-                    history_buffer.document.translate_row_col_to_index(history_lineno, 0)
-
-    def history_buffer_pos_changed(_):
-        """ When the cursor changes in the history buffer. Synchronize. """
-        # Only when this buffer has the focus.
-        if buffer_mapping.focus_stack[-1] == HISTORY_BUFFER:
-            line_no = history_buffer.document.cursor_position_row
-
-            if line_no in history_mapping.selected_lines:
-                default_lineno = sorted(history_mapping.selected_lines).index(line_no) + \
-                    history_mapping.result_line_offset
-
-                default_buffer.cursor_position = \
-                    default_buffer.document.translate_row_col_to_index(default_lineno, 0)
-
-    history_buffer = Buffer(
-        initial_document=Document(history_mapping.concatenated_history),
-        on_cursor_position_changed=history_buffer_pos_changed,
-        accept_action=AcceptAction(
-            lambda cli, buffer: cli.set_return_value(default_buffer.document)),
-        read_only=True)
-
-    default_buffer = Buffer(
-        initial_document=history_mapping.get_new_document(),
-        on_cursor_position_changed=default_buffer_pos_changed,
-        read_only=True)
-
-    help_buffer = Buffer(
-        initial_document=Document(HELP_TEXT, 0),
-        accept_action=AcceptAction.IGNORE,
-        read_only=True
-    )
-
-    buffer_mapping = BufferMapping({
-        HISTORY_BUFFER: history_buffer,
-        DEFAULT_BUFFER: default_buffer,
-        HELP_BUFFER: help_buffer,
-    }, initial=HISTORY_BUFFER)
-
-    application = Application(
-        layout=create_layout(python_input, history_mapping),
-        use_alternate_screen=True,
-        buffers=buffer_mapping,
-        style=python_input._current_style,
-        mouse_support=Condition(lambda cli: python_input.enable_mouse_support),
-        key_bindings_registry=create_key_bindings(python_input, history_mapping)
-    )
-    return application
diff --git a/ptpython/ipython.py b/ptpython/ipython.py
deleted file mode 100644
index d66d09b2..00000000
--- a/ptpython/ipython.py
+++ /dev/null
@@ -1,302 +0,0 @@
-"""
-
-Adaptor for using the input system of `prompt_toolkit` with the IPython
-backend.
-
-This gives a powerful interactive shell that has a nice user interface, but
-also the power of for instance all the %-magic functions that IPython has to
-offer.
-
-"""
-from __future__ import unicode_literals, print_function
-
-from prompt_toolkit.completion import Completion, Completer
-from prompt_toolkit.contrib.completers import PathCompleter, WordCompleter, SystemCompleter
-from prompt_toolkit.contrib.regular_languages.compiler import compile
-from prompt_toolkit.contrib.regular_languages.completion import GrammarCompleter
-from prompt_toolkit.contrib.regular_languages.lexer import GrammarLexer
-from prompt_toolkit.document import Document
-from prompt_toolkit.interface import CommandLineInterface
-from prompt_toolkit.layout.lexers import PygmentsLexer, SimpleLexer
-
-from .python_input import PythonInput, PythonValidator, PythonCompleter
-from .eventloop import create_eventloop
-from .style import default_ui_style
-
-from IPython.terminal.embed import InteractiveShellEmbed as _InteractiveShellEmbed
-from IPython.terminal.ipapp import load_default_config
-from IPython import utils as ipy_utils
-from IPython.core.inputsplitter import IPythonInputSplitter
-
-from pygments.lexers import PythonLexer, BashLexer
-from pygments.token import Token
-from ptpython.prompt_style import PromptStyle
-
-__all__ = (
-    'embed',
-)
-
-
-class IPythonPrompt(PromptStyle):
-    """
-    PromptStyle that uses the templates, as set by IPython.
-    Usually, something like "In [1]:".
-    """
-    def __init__(self, prompt_manager):
-        self.prompt_manager = prompt_manager
-
-    def in_tokens(self, cli):
-        text = self.prompt_manager.render('in', color=False, just=False)
-        return [(Token.In, text)]
-
-    def in2_tokens(self, cli, width):
-        text = self.prompt_manager.render('in2', color=False, just=False)
-        return [(Token.In, text.rjust(width))]
-
-    def out_tokens(self, cli):
-        # This function is currently not used by IPython. But for completeness,
-        # it would look like this.
-        text = self.prompt_manager.render('out', color=False, just=False)
-        return [(Token.Out, text)]
-
-
-class IPython5Prompt(PromptStyle):
-    """
-    Style for IPython >5.0, use the prompt_toolkit tokens directly.
-    """
-    def __init__(self, prompts):
-        self.prompts = prompts
-
-    def in_tokens(self, cli):
-        return self.prompts.in_prompt_tokens(cli)
-
-    def in2_tokens(self, cli, width):
-        return self.prompts.continuation_prompt_tokens(cli)
-
-    def out_tokens(self, cli):
-        return []
-
-
-class IPythonValidator(PythonValidator):
-    def __init__(self, *args, **kwargs):
-        super(IPythonValidator, self).__init__(*args, **kwargs)
-        self.isp = IPythonInputSplitter()
-
-    def validate(self, document):
-        document = Document(text=self.isp.transform_cell(document.text))
-        super(IPythonValidator, self).validate(document)
-
-
-def create_ipython_grammar():
-    """
-    Return compiled IPython grammar.
-    """
-    return compile(r"""
-        \s*
-        (
-            (?P<percent>%)(
-                (?P<magic>pycat|run|loadpy|load)  \s+ (?P<py_filename>[^\s]+)  |
-                (?P<magic>cat)                    \s+ (?P<filename>[^\s]+)     |
-                (?P<magic>pushd|cd|ls)            \s+ (?P<directory>[^\s]+)    |
-                (?P<magic>pdb)                    \s+ (?P<pdb_arg>[^\s]+)      |
-                (?P<magic>autocall)               \s+ (?P<autocall_arg>[^\s]+) |
-                (?P<magic>time|timeit|prun)       \s+ (?P<python>.+)           |
-                (?P<magic>psource|pfile|pinfo|pinfo2) \s+ (?P<python>.+)       |
-                (?P<magic>system)                 \s+ (?P<system>.+)           |
-                (?P<magic>unalias)                \s+ (?P<alias_name>.+)       |
-                (?P<magic>[^\s]+)   .* |
-            ) .*            |
-            !(?P<system>.+) |
-            (?![%!]) (?P<python>.+)
-        )
-        \s*
-    """)
-
-
-def create_completer(get_globals, get_locals, magics_manager, alias_manager):
-    g = create_ipython_grammar()
-
-    return GrammarCompleter(g, {
-        'python': PythonCompleter(get_globals, get_locals),
-        'magic': MagicsCompleter(magics_manager),
-        'alias_name': AliasCompleter(alias_manager),
-        'pdb_arg': WordCompleter(['on', 'off'], ignore_case=True),
-        'autocall_arg': WordCompleter(['0', '1', '2'], ignore_case=True),
-        'py_filename': PathCompleter(only_directories=False, file_filter=lambda name: name.endswith('.py')),
-        'filename': PathCompleter(only_directories=False),
-        'directory': PathCompleter(only_directories=True),
-        'system': SystemCompleter(),
-    })
-
-
-def create_lexer():
-    g = create_ipython_grammar()
-
-    return GrammarLexer(
-        g,
-        lexers={
-            'percent': SimpleLexer(Token.Operator),
-            'magic': SimpleLexer(Token.Keyword),
-            'filename': SimpleLexer(Token.Name),
-            'python': PygmentsLexer(PythonLexer),
-            'system': PygmentsLexer(BashLexer),
-        })
-
-
-class MagicsCompleter(Completer):
-    def __init__(self, magics_manager):
-        self.magics_manager = magics_manager
-
-    def get_completions(self, document, complete_event):
-        text = document.text_before_cursor.lstrip()
-
-        for m in sorted(self.magics_manager.magics['line']):
-            if m.startswith(text):
-                yield Completion('%s' % m, -len(text))
-
-
-class AliasCompleter(Completer):
-    def __init__(self, alias_manager):
-        self.alias_manager = alias_manager
-
-    def get_completions(self, document, complete_event):
-        text = document.text_before_cursor.lstrip()
-        #aliases = [a for a, _ in self.alias_manager.aliases]
-        aliases = self.alias_manager.aliases
-
-        for a, cmd in sorted(aliases, key=lambda a: a[0]):
-            if a.startswith(text):
-                yield Completion('%s' % a, -len(text),
-                                 display_meta=cmd)
-
-
-class IPythonInput(PythonInput):
-    """
-    Override our `PythonCommandLineInterface` to add IPython specific stuff.
-    """
-    def __init__(self, ipython_shell, *a, **kw):
-        kw['_completer'] = create_completer(kw['get_globals'], kw['get_globals'],
-                                            ipython_shell.magics_manager,
-                                            ipython_shell.alias_manager)
-        kw['_lexer'] = create_lexer()
-        kw['_validator'] = IPythonValidator(
-            get_compiler_flags=self.get_compiler_flags)
-
-        super(IPythonInput, self).__init__(*a, **kw)
-        self.ipython_shell = ipython_shell
-
-        # Prompt for IPython < 5.0
-        if hasattr(ipython_shell, 'prompt_manager'):
-            self.all_prompt_styles['ipython'] = IPythonPrompt(ipython_shell.prompt_manager)
-            self.prompt_style = 'ipython'
-
-        # Prompt for IPython >=5.0:
-        if hasattr(ipython_shell, 'prompts'):
-            self.all_prompt_styles['ipython'] = IPython5Prompt(ipython_shell.prompts)
-            self.prompt_style = 'ipython'
-
-
-        # UI style for IPython. Add tokens that are used by IPython>5.0
-        style_dict = {}
-        style_dict.update(default_ui_style)
-        style_dict.update({
-            Token.Prompt:        '#009900',
-            Token.PromptNum:     '#00ff00 bold',
-            Token.OutPrompt:     '#990000',
-            Token.OutPromptNum:  '#ff0000 bold',
-        })
-
-        self.ui_styles = {
-            'default': style_dict,
-        }
-        self.use_ui_colorscheme('default')
-
-
-class InteractiveShellEmbed(_InteractiveShellEmbed):
-    """
-    Override the `InteractiveShellEmbed` from IPython, to replace the front-end
-    with our input shell.
-
-    :param configure: Callable for configuring the repl.
-    """
-    def __init__(self, *a, **kw):
-        vi_mode = kw.pop('vi_mode', False)
-        history_filename = kw.pop('history_filename', None)
-        configure = kw.pop('configure', None)
-        title = kw.pop('title', None)
-
-        super(InteractiveShellEmbed, self).__init__(*a, **kw)
-
-        def get_globals():
-            return self.user_ns
-
-        self._eventloop = create_eventloop()
-        ipython_input = IPythonInput(
-            self,
-            get_globals=get_globals, vi_mode=vi_mode,
-            history_filename=history_filename)
-
-        if title:
-            ipython_input.terminal_title = title
-
-        if configure:
-            configure(ipython_input)
-            ipython_input.prompt_style = 'ipython'  # Don't take from config.
-
-        self._cli = CommandLineInterface(
-                application=ipython_input.create_application(),
-                eventloop=self._eventloop)
-
-    def prompt_for_code(self):
-        # IPython 5.0 calls `prompt_for_code` instead of `raw_input`.
-        return self.raw_input(self)
-
-    def raw_input(self, prompt=''):
-        print('')
-        try:
-            string = self._cli.run(reset_current_buffer=True).text
-
-            # In case of multiline input, make sure to append a newline to the input,
-            # otherwise, IPython will ask again for more input in some cases.
-            if '\n' in string:
-                return string + '\n\n'
-            else:
-                return string
-        except EOFError:
-            self.ask_exit()
-            return ''
-
-
-def initialize_extensions(shell, extensions):
-    """
-    Partial copy of `InteractiveShellApp.init_extensions` from IPython.
-    """
-    try:
-        iter(extensions)
-    except TypeError:
-        pass  # no extensions found
-    else:
-        for ext in extensions:
-            try:
-                shell.extension_manager.load_extension(ext)
-            except:
-                ipy_utils.warn.warn(
-                    "Error in loading extension: %s" % ext +
-                    "\nCheck your config files in %s" % ipy_utils.path.get_ipython_dir())
-                shell.showtraceback()
-
-
-def embed(**kwargs):
-    """
-    Copied from `IPython/terminal/embed.py`, but using our `InteractiveShellEmbed` instead.
-    """
-    config = kwargs.get('config')
-    header = kwargs.pop('header', u'')
-    compile_flags = kwargs.pop('compile_flags', None)
-    if config is None:
-        config = load_default_config()
-        config.InteractiveShellEmbed = config.TerminalInteractiveShell
-        kwargs['config'] = config
-    shell = InteractiveShellEmbed.instance(**kwargs)
-    initialize_extensions(shell, config['InteractiveShellApp']['extensions'])
-    shell(header=header, stack_depth=2, compile_flags=compile_flags)
diff --git a/ptpython/key_bindings.py b/ptpython/key_bindings.py
deleted file mode 100644
index d0200f38..00000000
--- a/ptpython/key_bindings.py
+++ /dev/null
@@ -1,247 +0,0 @@
-from __future__ import unicode_literals
-
-from prompt_toolkit.document import Document
-from prompt_toolkit.enums import DEFAULT_BUFFER
-from prompt_toolkit.filters import HasSelection, IsMultiline, Filter, HasFocus, Condition, ViInsertMode, EmacsInsertMode
-from prompt_toolkit.key_binding.vi_state import InputMode
-from prompt_toolkit.key_binding.registry import Registry
-from prompt_toolkit.keys import Keys
-
-__all__ = (
-    'load_python_bindings',
-    'load_sidebar_bindings',
-    'load_confirm_exit_bindings',
-)
-
-
-class TabShouldInsertWhitespaceFilter(Filter):
-    """
-    When the 'tab' key is pressed with only whitespace character before the
-    cursor, do autocompletion. Otherwise, insert indentation.
-
-    Except for the first character at the first line. Then always do a
-    completion. It doesn't make sense to start the first line with
-    indentation.
-    """
-    def __call__(self, cli):
-        b = cli.current_buffer
-        before_cursor = b.document.current_line_before_cursor
-
-        return bool(b.text and (not before_cursor or before_cursor.isspace()))
-
-
-def load_python_bindings(python_input):
-    """
-    Custom key bindings.
-    """
-    registry = Registry()
-
-    sidebar_visible = Condition(lambda cli: python_input.show_sidebar)
-    handle = registry.add_binding
-    has_selection = HasSelection()
-    vi_mode_enabled = Condition(lambda cli: python_input.vi_mode)
-
-    @handle(Keys.ControlL)
-    def _(event):
-        """
-        Clear whole screen and render again -- also when the sidebar is visible.
-        """
-        event.cli.renderer.clear()
-
-    @handle(Keys.F2)
-    def _(event):
-        """
-        Show/hide sidebar.
-        """
-        python_input.show_sidebar = not python_input.show_sidebar
-
-    @handle(Keys.F3)
-    def _(event):
-        """
-        Select from the history.
-        """
-        python_input.enter_history(event.cli)
-
-    @handle(Keys.F4)
-    def _(event):
-        """
-        Toggle between Vi and Emacs mode.
-        """
-        python_input.vi_mode = not python_input.vi_mode
-
-    @handle(Keys.F6)
-    def _(event):
-        """
-        Enable/Disable paste mode.
-        """
-        python_input.paste_mode = not python_input.paste_mode
-
-    @handle(Keys.Tab, filter= ~sidebar_visible & ~has_selection & TabShouldInsertWhitespaceFilter())
-    def _(event):
-        """
-        When tab should insert whitespace, do that instead of completion.
-        """
-        event.cli.current_buffer.insert_text('    ')
-
-    @handle(Keys.ControlJ, filter= ~sidebar_visible & ~has_selection &
-            (ViInsertMode() | EmacsInsertMode()) &
-            HasFocus(DEFAULT_BUFFER) & IsMultiline())
-    def _(event):
-        """
-        Behaviour of the Enter key.
-
-        Auto indent after newline/Enter.
-        (When not in Vi navigaton mode, and when multiline is enabled.)
-        """
-        b = event.current_buffer
-        empty_lines_required = python_input.accept_input_on_enter or 10000
-
-        def at_the_end(b):
-            """ we consider the cursor at the end when there is no text after
-            the cursor, or only whitespace. """
-            text = b.document.text_after_cursor
-            return text == '' or (text.isspace() and not '\n' in text)
-
-        if python_input.paste_mode:
-            # In paste mode, always insert text.
-            b.insert_text('\n')
-
-        elif at_the_end(b) and b.document.text.replace(' ', '').endswith(
-                    '\n' * (empty_lines_required - 1)):
-            if b.validate():
-                # When the cursor is at the end, and we have an empty line:
-                # drop the empty lines, but return the value.
-                b.document = Document(
-                    text=b.text.rstrip(),
-                    cursor_position=len(b.text.rstrip()))
-
-                b.accept_action.validate_and_handle(event.cli, b)
-        else:
-            auto_newline(b)
-
-    @handle(Keys.ControlD, filter=~sidebar_visible & Condition(lambda cli:
-            # Only when the `confirm_exit` flag is set.
-            python_input.confirm_exit and
-            # And the current buffer is empty.
-            cli.current_buffer_name == DEFAULT_BUFFER and
-            not cli.current_buffer.text))
-    def _(event):
-        """
-        Override Control-D exit, to ask for confirmation.
-        """
-        python_input.show_exit_confirmation = True
-
-    return registry
-
-
-def load_sidebar_bindings(python_input):
-    """
-    Load bindings for the navigation in the sidebar.
-    """
-    registry = Registry()
-
-    handle = registry.add_binding
-    sidebar_visible = Condition(lambda cli: python_input.show_sidebar)
-
-    @handle(Keys.Up, filter=sidebar_visible)
-    @handle(Keys.ControlP, filter=sidebar_visible)
-    @handle('k', filter=sidebar_visible)
-    def _(event):
-        " Go to previous option. "
-        python_input.selected_option_index = (
-            (python_input.selected_option_index - 1) % python_input.option_count)
-
-    @handle(Keys.Down, filter=sidebar_visible)
-    @handle(Keys.ControlN, filter=sidebar_visible)
-    @handle('j', filter=sidebar_visible)
-    def _(event):
-        " Go to next option. "
-        python_input.selected_option_index = (
-            (python_input.selected_option_index + 1) % python_input.option_count)
-
-    @handle(Keys.Right, filter=sidebar_visible)
-    @handle('l', filter=sidebar_visible)
-    @handle(' ', filter=sidebar_visible)
-    def _(event):
-        " Select next value for current option. "
-        option = python_input.selected_option
-        option.activate_next()
-
-    @handle(Keys.Left, filter=sidebar_visible)
-    @handle('h', filter=sidebar_visible)
-    def _(event):
-        " Select previous value for current option. "
-        option = python_input.selected_option
-        option.activate_previous()
-
-    @handle(Keys.ControlC, filter=sidebar_visible)
-    @handle(Keys.ControlG, filter=sidebar_visible)
-    @handle(Keys.ControlD, filter=sidebar_visible)
-    @handle(Keys.ControlJ, filter=sidebar_visible)
-    @handle(Keys.Escape, filter=sidebar_visible)
-    def _(event):
-        " Hide sidebar. "
-        python_input.show_sidebar = False
-
-    return registry
-
-
-def load_confirm_exit_bindings(python_input):
-    """
-    Handle yes/no key presses when the exit confirmation is shown.
-    """
-    registry = Registry()
-
-    handle = registry.add_binding
-    confirmation_visible = Condition(lambda cli: python_input.show_exit_confirmation)
-
-    @handle('y', filter=confirmation_visible)
-    @handle('Y', filter=confirmation_visible)
-    @handle(Keys.ControlJ, filter=confirmation_visible)
-    @handle(Keys.ControlD, filter=confirmation_visible)
-    def _(event):
-        """
-        Really quit.
-        """
-        event.cli.exit()
-
-    @handle(Keys.Any, filter=confirmation_visible)
-    def _(event):
-        """
-        Cancel exit.
-        """
-        python_input.show_exit_confirmation = False
-
-    return registry
-
-
-def auto_newline(buffer):
-    r"""
-    Insert \n at the cursor position. Also add necessary padding.
-    """
-    insert_text = buffer.insert_text
-
-    if buffer.document.current_line_after_cursor:
-        # When we are in the middle of a line. Always insert a newline.
-        insert_text('\n')
-    else:
-        # Go to new line, but also add indentation.
-        current_line = buffer.document.current_line_before_cursor.rstrip()
-        insert_text('\n')
-
-        # Unident if the last line ends with 'pass', remove four spaces.
-        unindent = current_line.rstrip().endswith(' pass')
-
-        # Copy whitespace from current line
-        current_line2 = current_line[4:] if unindent else current_line
-
-        for c in current_line2:
-            if c.isspace():
-                insert_text(c)
-            else:
-                break
-
-        # If the last line ends with a colon, add four extra spaces.
-        if current_line[-1:] == ':':
-            for x in range(4):
-                insert_text(' ')
diff --git a/ptpython/layout.py b/ptpython/layout.py
deleted file mode 100644
index 2a403b96..00000000
--- a/ptpython/layout.py
+++ /dev/null
@@ -1,598 +0,0 @@
-"""
-Creation of the `Layout` instance for the Python input/REPL.
-"""
-from __future__ import unicode_literals
-
-from prompt_toolkit.enums import DEFAULT_BUFFER, SEARCH_BUFFER
-from prompt_toolkit.filters import IsDone, HasCompletions, RendererHeightIsKnown, HasFocus, Condition
-from prompt_toolkit.key_binding.vi_state import InputMode
-from prompt_toolkit.layout.containers import Window, HSplit, VSplit, FloatContainer, Float, ConditionalContainer, ScrollOffsets
-from prompt_toolkit.layout.controls import BufferControl, TokenListControl, FillControl
-from prompt_toolkit.layout.dimension import LayoutDimension
-from prompt_toolkit.layout.lexers import SimpleLexer
-from prompt_toolkit.layout.margins import PromptMargin
-from prompt_toolkit.layout.menus import CompletionsMenu, MultiColumnCompletionsMenu
-from prompt_toolkit.layout.processors import ConditionalProcessor, AppendAutoSuggestion, HighlightSearchProcessor, HighlightSelectionProcessor, HighlightMatchingBracketProcessor, Processor, Transformation
-from prompt_toolkit.layout.screen import Char
-from prompt_toolkit.layout.toolbars import CompletionsToolbar, ArgToolbar, SearchToolbar, ValidationToolbar, SystemToolbar, TokenListToolbar
-from prompt_toolkit.layout.utils import token_list_width
-from prompt_toolkit.reactive import Integer
-from prompt_toolkit.selection import SelectionType
-
-from .filters import HasSignature, ShowSidebar, ShowSignature, ShowDocstring
-from .utils import if_mousedown
-
-from pygments.lexers import PythonLexer
-from pygments.token import Token
-
-import platform
-import sys
-
-__all__ = (
-    'create_layout',
-    'CompletionVisualisation',
-)
-
-
-# DisplayMultipleCursors: Only for prompt_toolkit>=1.0.8
-try:
-    from prompt_toolkit.layout.processors import DisplayMultipleCursors
-except ImportError:
-    class DisplayMultipleCursors(Processor):
-        " Dummy. "
-        def __init__(self, *a):
-            pass
-
-        def apply_transformation(self, cli, document, lineno,
-                                 source_to_display, tokens):
-            return Transformation(tokens)
-
-
-class CompletionVisualisation:
-    " Visualisation method for the completions. "
-    NONE = 'none'
-    POP_UP = 'pop-up'
-    MULTI_COLUMN = 'multi-column'
-    TOOLBAR = 'toolbar'
-
-
-def show_completions_toolbar(python_input):
-    return Condition(lambda cli: python_input.completion_visualisation == CompletionVisualisation.TOOLBAR)
-
-
-def show_completions_menu(python_input):
-    return Condition(lambda cli: python_input.completion_visualisation == CompletionVisualisation.POP_UP)
-
-
-def show_multi_column_completions_menu(python_input):
-    return Condition(lambda cli: python_input.completion_visualisation == CompletionVisualisation.MULTI_COLUMN)
-
-
-def python_sidebar(python_input):
-    """
-    Create the `Layout` for the sidebar with the configurable options.
-    """
-    def get_tokens(cli):
-        tokens = []
-        T = Token.Sidebar
-
-        def append_category(category):
-            tokens.extend([
-                (T, '  '),
-                (T.Title, '   %-36s' % category.title),
-                (T, '\n'),
-            ])
-
-        def append(index, label, status):
-            selected = index == python_input.selected_option_index
-
-            @if_mousedown
-            def select_item(cli, mouse_event):
-                python_input.selected_option_index = index
-
-            @if_mousedown
-            def goto_next(cli, mouse_event):
-                " Select item and go to next value. "
-                python_input.selected_option_index = index
-                option = python_input.selected_option
-                option.activate_next()
-
-            token = T.Selected if selected else T
-
-            tokens.append((T, ' >' if selected else '  '))
-            tokens.append((token.Label, '%-24s' % label, select_item))
-            tokens.append((token.Status, ' ', select_item))
-            tokens.append((token.Status, '%s' % status, goto_next))
-
-            if selected:
-                tokens.append((Token.SetCursorPosition, ''))
-
-            tokens.append((token.Status, ' ' * (13 - len(status)), goto_next))
-            tokens.append((T, '<' if selected else ''))
-            tokens.append((T, '\n'))
-
-        i = 0
-        for category in python_input.options:
-            append_category(category)
-
-            for option in category.options:
-                append(i, option.title, '%s' % option.get_current_value())
-                i += 1
-
-        tokens.pop()  # Remove last newline.
-
-        return tokens
-
-    class Control(TokenListControl):
-        def move_cursor_down(self, cli):
-            python_input.selected_option_index += 1
-
-        def move_cursor_up(self, cli):
-            python_input.selected_option_index -= 1
-
-    return ConditionalContainer(
-        content=Window(
-            Control(get_tokens, Char(token=Token.Sidebar),
-                has_focus=ShowSidebar(python_input) & ~IsDone()),
-            width=LayoutDimension.exact(43),
-            height=LayoutDimension(min=3),
-            scroll_offsets=ScrollOffsets(top=1, bottom=1)),
-        filter=ShowSidebar(python_input) & ~IsDone())
-
-
-def python_sidebar_navigation(python_input):
-    """
-    Create the `Layout` showing the navigation information for the sidebar.
-    """
-    def get_tokens(cli):
-        tokens = []
-        T = Token.Sidebar
-
-        # Show navigation info.
-        tokens.extend([
-            (T.Separator, ' ' * 43 + '\n'),
-            (T, '    '),
-            (T.Key, '[Arrows]'),
-            (T, ' '),
-            (T.Key.Description, 'Navigate'),
-            (T, ' '),
-            (T.Key, '[Enter]'),
-            (T, ' '),
-            (T.Key.Description, 'Hide menu'),
-        ])
-
-        return tokens
-
-    return ConditionalContainer(
-        content=Window(
-            TokenListControl(get_tokens, Char(token=Token.Sidebar)),
-            width=LayoutDimension.exact(43),
-            height=LayoutDimension.exact(2)),
-        filter=ShowSidebar(python_input) & ~IsDone())
-
-
-def python_sidebar_help(python_input):
-    """
-    Create the `Layout` for the help text for the current item in the sidebar.
-    """
-    token = Token.Sidebar.HelpText
-
-    def get_current_description():
-        """
-        Return the description of the selected option.
-        """
-        i = 0
-        for category in python_input.options:
-            for option in category.options:
-                if i == python_input.selected_option_index:
-                    return option.description
-                i += 1
-        return ''
-
-    def get_tokens(cli):
-        return [(token, get_current_description())]
-
-    return ConditionalContainer(
-        content=Window(
-            TokenListControl(get_tokens, Char(token=token)),
-            height=LayoutDimension(min=3)),
-        filter=ShowSidebar(python_input) &
-               Condition(lambda cli: python_input.show_sidebar_help) & ~IsDone())
-
-
-def signature_toolbar(python_input):
-    """
-    Return the `Layout` for the signature.
-    """
-    def get_tokens(cli):
-        result = []
-        append = result.append
-        Signature = Token.Toolbar.Signature
-
-        if python_input.signatures:
-            sig = python_input.signatures[0]  # Always take the first one.
-
-            append((Signature, ' '))
-            try:
-                append((Signature, sig.full_name))
-            except IndexError:
-                # Workaround for #37: https://github.com/jonathanslenders/python-prompt-toolkit/issues/37
-                # See also: https://github.com/davidhalter/jedi/issues/490
-                return []
-
-            append((Signature.Operator, '('))
-
-            try:
-                enumerated_params = enumerate(sig.params)
-            except AttributeError:
-                # Workaround for #136: https://github.com/jonathanslenders/ptpython/issues/136
-                # AttributeError: 'Lambda' object has no attribute 'get_subscope_by_name'
-                return []
-
-            for i, p in enumerated_params:
-                # Workaround for #47: 'p' is None when we hit the '*' in the signature.
-                #                     and sig has no 'index' attribute.
-                # See: https://github.com/jonathanslenders/ptpython/issues/47
-                #      https://github.com/davidhalter/jedi/issues/598
-                description = (p.description if p else '*') #or '*'
-                sig_index = getattr(sig, 'index', 0)
-
-                if i == sig_index:
-                    # Note: we use `_Param.description` instead of
-                    #       `_Param.name`, that way we also get the '*' before args.
-                    append((Signature.CurrentName, str(description)))
-                else:
-                    append((Signature, str(description)))
-                append((Signature.Operator, ', '))
-
-            if sig.params:
-                # Pop last comma
-                result.pop()
-
-            append((Signature.Operator, ')'))
-            append((Signature, ' '))
-        return result
-
-    return ConditionalContainer(
-        content=Window(
-            TokenListControl(get_tokens),
-            height=LayoutDimension.exact(1)),
-        filter=
-            # Show only when there is a signature
-            HasSignature(python_input) &
-            # And there are no completions to be shown. (would cover signature pop-up.)
-            ~(HasCompletions() & (show_completions_menu(python_input) |
-                                   show_multi_column_completions_menu(python_input)))
-            # Signature needs to be shown.
-            & ShowSignature(python_input) &
-            # Not done yet.
-            ~IsDone())
-
-
-class PythonPromptMargin(PromptMargin):
-    """
-    Create margin that displays the prompt.
-    It shows something like "In [1]:".
-    """
-    def __init__(self, python_input):
-        self.python_input = python_input
-
-        def get_prompt_style():
-            return python_input.all_prompt_styles[python_input.prompt_style]
-
-        def get_prompt(cli):
-            return get_prompt_style().in_tokens(cli)
-
-        def get_continuation_prompt(cli, width):
-            return get_prompt_style().in2_tokens(cli, width)
-
-        super(PythonPromptMargin, self).__init__(get_prompt, get_continuation_prompt,
-                show_numbers=Condition(lambda cli: python_input.show_line_numbers))
-
-
-def status_bar(python_input):
-    """
-    Create the `Layout` for the status bar.
-    """
-    TB = Token.Toolbar.Status
-
-    @if_mousedown
-    def toggle_paste_mode(cli, mouse_event):
-        python_input.paste_mode = not python_input.paste_mode
-
-    @if_mousedown
-    def enter_history(cli, mouse_event):
-        python_input.enter_history(cli)
-
-    def get_tokens(cli):
-        python_buffer = cli.buffers[DEFAULT_BUFFER]
-
-        result = []
-        append = result.append
-
-        append((TB, ' '))
-        result.extend(get_inputmode_tokens(cli, python_input))
-        append((TB, ' '))
-
-        # Position in history.
-        append((TB, '%i/%i ' % (python_buffer.working_index + 1,
-                                len(python_buffer._working_lines))))
-
-        # Shortcuts.
-        if not python_input.vi_mode and cli.current_buffer_name == SEARCH_BUFFER:
-            append((TB, '[Ctrl-G] Cancel search [Enter] Go to this position.'))
-        elif bool(cli.current_buffer.selection_state) and not python_input.vi_mode:
-            # Emacs cut/copy keys.
-            append((TB, '[Ctrl-W] Cut [Meta-W] Copy [Ctrl-Y] Paste [Ctrl-G] Cancel'))
-        else:
-            result.extend([
-                (TB.Key, '[F3]', enter_history),
-                (TB, ' History ', enter_history),
-                (TB.Key, '[F6]', toggle_paste_mode),
-                (TB, ' ', toggle_paste_mode),
-            ])
-
-            if python_input.paste_mode:
-                append((TB.PasteModeOn, 'Paste mode (on)', toggle_paste_mode))
-            else:
-                append((TB, 'Paste mode', toggle_paste_mode))
-
-        return result
-
-    return TokenListToolbar(
-        get_tokens,
-        default_char=Char(token=TB),
-        filter=~IsDone() & RendererHeightIsKnown() &
-            Condition(lambda cli: python_input.show_status_bar and
-                                  not python_input.show_exit_confirmation))
-
-
-def get_inputmode_tokens(cli, python_input):
-    """
-    Return current input mode as a list of (token, text) tuples for use in a
-    toolbar.
-
-    :param cli: `CommandLineInterface` instance.
-    """
-    @if_mousedown
-    def toggle_vi_mode(cli, mouse_event):
-        python_input.vi_mode = not python_input.vi_mode
-
-    token = Token.Toolbar.Status
-
-    mode = cli.vi_state.input_mode
-    result = []
-    append = result.append
-
-    append((token.InputMode, '[F4] ', toggle_vi_mode))
-
-    # InputMode
-    if python_input.vi_mode:
-        if bool(cli.current_buffer.selection_state):
-            if cli.current_buffer.selection_state.type == SelectionType.LINES:
-                append((token.InputMode, 'Vi (VISUAL LINE)', toggle_vi_mode))
-            elif cli.current_buffer.selection_state.type == SelectionType.CHARACTERS:
-                append((token.InputMode, 'Vi (VISUAL)', toggle_vi_mode))
-                append((token, ' '))
-            elif cli.current_buffer.selection_state.type == 'BLOCK':
-                append((token.InputMode, 'Vi (VISUAL BLOCK)', toggle_vi_mode))
-                append((token, ' '))
-        elif mode in (InputMode.INSERT, 'vi-insert-multiple'):
-            append((token.InputMode, 'Vi (INSERT)', toggle_vi_mode))
-            append((token, '  '))
-        elif mode == InputMode.NAVIGATION:
-            append((token.InputMode, 'Vi (NAV)', toggle_vi_mode))
-            append((token, '     '))
-        elif mode == InputMode.REPLACE:
-            append((token.InputMode, 'Vi (REPLACE)', toggle_vi_mode))
-            append((token, ' '))
-    else:
-        append((token.InputMode, 'Emacs', toggle_vi_mode))
-        append((token, ' '))
-
-    return result
-
-
-def show_sidebar_button_info(python_input):
-    """
-    Create `Layout` for the information in the right-bottom corner.
-    (The right part of the status bar.)
-    """
-    @if_mousedown
-    def toggle_sidebar(cli, mouse_event):
-        " Click handler for the menu. "
-        python_input.show_sidebar = not python_input.show_sidebar
-
-    token = Token.Toolbar.Status
-
-    version = sys.version_info
-    tokens = [
-        (token.Key, '[F2]', toggle_sidebar),
-        (token, ' Menu', toggle_sidebar),
-        (token, ' - '),
-        (token.PythonVersion, '%s %i.%i.%i' % (platform.python_implementation(),
-                                               version[0], version[1], version[2])),
-        (token, ' '),
-    ]
-    width = token_list_width(tokens)
-
-    def get_tokens(cli):
-        # Python version
-        return tokens
-
-    return ConditionalContainer(
-        content=Window(
-            TokenListControl(get_tokens, default_char=Char(token=token)),
-            height=LayoutDimension.exact(1),
-            width=LayoutDimension.exact(width)),
-        filter=~IsDone() & RendererHeightIsKnown() &
-            Condition(lambda cli: python_input.show_status_bar and
-                                  not python_input.show_exit_confirmation))
-
-
-def exit_confirmation(python_input, token=Token.ExitConfirmation):
-    """
-    Create `Layout` for the exit message.
-    """
-    def get_tokens(cli):
-        # Show "Do you really want to exit?"
-        return [
-            (token, '\n %s ([y]/n)' % python_input.exit_message),
-            (Token.SetCursorPosition, ''),
-            (token, '  \n'),
-        ]
-
-    visible = ~IsDone() & Condition(lambda cli: python_input.show_exit_confirmation)
-
-    return ConditionalContainer(
-        content=Window(TokenListControl(
-            get_tokens, default_char=Char(token=token), has_focus=visible)),
-        filter=visible)
-
-
-def meta_enter_message(python_input):
-    """
-    Create the `Layout` for the 'Meta+Enter` message.
-    """
-    def get_tokens(cli):
-        return [(Token.AcceptMessage, ' [Meta+Enter] Execute ')]
-
-    def extra_condition(cli):
-        " Only show when... "
-        b = cli.buffers[DEFAULT_BUFFER]
-
-        return (
-            python_input.show_meta_enter_message and
-            (not b.document.is_cursor_at_the_end or
-                python_input.accept_input_on_enter is None) and
-            b.is_multiline())
-
-    visible = ~IsDone() & HasFocus(DEFAULT_BUFFER) & Condition(extra_condition)
-
-    return ConditionalContainer(
-        content=Window(TokenListControl(get_tokens)),
-        filter=visible)
-
-
-def create_layout(python_input,
-                  lexer=PythonLexer,
-                  extra_body=None, extra_toolbars=None,
-                  extra_buffer_processors=None, input_buffer_height=None):
-    D = LayoutDimension
-    extra_body = [extra_body] if extra_body else []
-    extra_toolbars = extra_toolbars or []
-    extra_buffer_processors = extra_buffer_processors or []
-    input_buffer_height = input_buffer_height or D(min=6)
-
-    def create_python_input_window():
-        def menu_position(cli):
-            """
-            When there is no autocompletion menu to be shown, and we have a signature,
-            set the pop-up position at `bracket_start`.
-            """
-            b = cli.buffers[DEFAULT_BUFFER]
-
-            if b.complete_state is None and python_input.signatures:
-                row, col = python_input.signatures[0].bracket_start
-                index = b.document.translate_row_col_to_index(row - 1, col)
-                return index
-
-        return Window(
-            BufferControl(
-                buffer_name=DEFAULT_BUFFER,
-                lexer=lexer,
-                input_processors=[
-                    ConditionalProcessor(
-                        processor=HighlightSearchProcessor(preview_search=True),
-                        filter=HasFocus(SEARCH_BUFFER),
-                    ),
-                    HighlightSelectionProcessor(),
-                    DisplayMultipleCursors(DEFAULT_BUFFER),
-                    # Show matching parentheses, but only while editing.
-                    ConditionalProcessor(
-                        processor=HighlightMatchingBracketProcessor(chars='[](){}'),
-                        filter=HasFocus(DEFAULT_BUFFER) & ~IsDone() &
-                            Condition(lambda cli: python_input.highlight_matching_parenthesis)),
-                    ConditionalProcessor(
-                        processor=AppendAutoSuggestion(),
-                        filter=~IsDone())
-                ] + extra_buffer_processors,
-                menu_position=menu_position,
-
-                # Make sure that we always see the result of an reverse-i-search:
-                preview_search=True,
-            ),
-            left_margins=[PythonPromptMargin(python_input)],
-            # Scroll offsets. The 1 at the bottom is important to make sure the
-            # cursor is never below the "Press [Meta+Enter]" message which is a float.
-            scroll_offsets=ScrollOffsets(bottom=1, left=4, right=4),
-            # As long as we're editing, prefer a minimal height of 6.
-            get_height=(lambda cli: (
-                None if cli.is_done or python_input.show_exit_confirmation
-                        else input_buffer_height)),
-            wrap_lines=Condition(lambda cli: python_input.wrap_lines),
-        )
-
-    return HSplit([
-        VSplit([
-            HSplit([
-                FloatContainer(
-                    content=HSplit(
-                        [create_python_input_window()] + extra_body
-                    ),
-                    floats=[
-                        Float(xcursor=True,
-                              ycursor=True,
-                              content=CompletionsMenu(
-                                  scroll_offset=Integer.from_callable(
-                                      lambda: python_input.completion_menu_scroll_offset),
-                                  max_height=12,
-                                  extra_filter=show_completions_menu(python_input))),
-                        Float(xcursor=True,
-                              ycursor=True,
-                              content=MultiColumnCompletionsMenu(
-                                  extra_filter=show_multi_column_completions_menu(python_input))),
-                        Float(xcursor=True,
-                              ycursor=True,
-                              content=signature_toolbar(python_input)),
-                        Float(left=2,
-                              bottom=1,
-                              content=exit_confirmation(python_input)),
-                        Float(bottom=0, right=0, height=1,
-                              content=meta_enter_message(python_input),
-                              hide_when_covering_content=True),
-                        Float(bottom=1, left=1, right=0, content=python_sidebar_help(python_input)),
-                    ]),
-                ArgToolbar(),
-                SearchToolbar(),
-                SystemToolbar(),
-                ValidationToolbar(),
-                CompletionsToolbar(extra_filter=show_completions_toolbar(python_input)),
-
-                # Docstring region.
-                ConditionalContainer(
-                    content=Window(height=D.exact(1),
-                                   content=FillControl('\u2500', token=Token.Separator)),
-                    filter=HasSignature(python_input) & ShowDocstring(python_input) & ~IsDone()),
-                ConditionalContainer(
-                    content=Window(
-                        BufferControl(
-                            buffer_name='docstring',
-                            lexer=SimpleLexer(default_token=Token.Docstring),
-                            #lexer=PythonLexer,
-                        ),
-                        height=D(max=12)),
-                    filter=HasSignature(python_input) & ShowDocstring(python_input) & ~IsDone(),
-                ),
-            ]),
-            HSplit([
-                python_sidebar(python_input),
-                python_sidebar_navigation(python_input),
-            ])
-        ]),
-    ] + extra_toolbars + [
-        VSplit([
-            status_bar(python_input),
-            show_sidebar_button_info(python_input),
-        ])
-    ])
diff --git a/ptpython/prompt_style.py b/ptpython/prompt_style.py
deleted file mode 100644
index 5a78d19c..00000000
--- a/ptpython/prompt_style.py
+++ /dev/null
@@ -1,77 +0,0 @@
-from __future__ import unicode_literals
-from abc import ABCMeta, abstractmethod
-from six import with_metaclass
-from pygments.token import Token
-
-__all__ = (
-    'PromptStyle',
-    'IPythonPrompt',
-    'ClassicPrompt',
-)
-
-
-class PromptStyle(with_metaclass(ABCMeta, object)):
-    """
-    Base class for all prompts.
-    """
-    @abstractmethod
-    def in_tokens(self, cli):
-        " Return the input tokens. "
-        return []
-
-    @abstractmethod
-    def in2_tokens(self, cli, width):
-        """
-        Tokens for every following input line.
-
-        :param width: The available width. This is coming from the width taken
-                      by `in_tokens`.
-        """
-        return []
-
-    @abstractmethod
-    def out_tokens(self, cli):
-        " Return the output tokens. "
-        return []
-
-
-class IPythonPrompt(PromptStyle):
-    """
-    A prompt resembling the IPython prompt.
-    """
-    def __init__(self, python_input):
-        self.python_input = python_input
-
-    def in_tokens(self, cli):
-        return [
-            (Token.In, 'In ['),
-            (Token.In.Number, '%s' % self.python_input.current_statement_index),
-            (Token.In, ']: '),
-        ]
-
-    def in2_tokens(self, cli, width):
-        return [
-            (Token.In, '...: '.rjust(width)),
-        ]
-
-    def out_tokens(self, cli):
-        return [
-            (Token.Out, 'Out['),
-            (Token.Out.Number, '%s' % self.python_input.current_statement_index),
-            (Token.Out, ']:'),
-            (Token, ' '),
-        ]
-
-
-class ClassicPrompt(PromptStyle):
-    """
-    The classic Python prompt.
-    """
-    def in_tokens(self, cli):
-        return [(Token.Prompt, '>>> ')]
-
-    def in2_tokens(self, cli, width):
-        return [(Token.Prompt.Dots, '...')]
-
-    def out_tokens(self, cli):
-        return []
diff --git a/ptpython/python_input.py b/ptpython/python_input.py
deleted file mode 100644
index 748c52d1..00000000
--- a/ptpython/python_input.py
+++ /dev/null
@@ -1,671 +0,0 @@
-"""
-CommandLineInterface for reading Python input.
-This can be used for creation of Python REPLs.
-
-::
-
-    cli = PythonCommandLineInterface()
-    cli.run()
-"""
-from __future__ import unicode_literals
-
-from prompt_toolkit import AbortAction
-from prompt_toolkit.auto_suggest import AutoSuggestFromHistory, ConditionalAutoSuggest
-from prompt_toolkit.buffer import Buffer
-from prompt_toolkit.document import Document
-from prompt_toolkit.enums import DEFAULT_BUFFER, EditingMode
-from prompt_toolkit.filters import Condition, Always
-from prompt_toolkit.history import FileHistory, InMemoryHistory
-from prompt_toolkit.interface import CommandLineInterface, Application, AcceptAction
-from prompt_toolkit.key_binding.defaults import load_key_bindings_for_prompt, load_mouse_bindings
-from prompt_toolkit.key_binding.vi_state import InputMode
-from prompt_toolkit.key_binding.registry import MergedRegistry, ConditionalRegistry
-from prompt_toolkit.layout.lexers import PygmentsLexer
-from prompt_toolkit.shortcuts import create_output
-from prompt_toolkit.styles import DynamicStyle
-from prompt_toolkit.utils import is_windows
-from prompt_toolkit.validation import ConditionalValidator
-
-from .completer import PythonCompleter
-from .history_browser import create_history_application
-from .key_bindings import load_python_bindings, load_sidebar_bindings, load_confirm_exit_bindings
-from .layout import create_layout, CompletionVisualisation
-from .prompt_style import IPythonPrompt, ClassicPrompt
-from .style import get_all_code_styles, get_all_ui_styles, generate_style
-from .utils import get_jedi_script_from_document, document_is_multiline_python
-from .validator import PythonValidator
-
-from functools import partial
-
-import six
-import __future__
-
-if six.PY2:
-    from pygments.lexers import PythonLexer
-else:
-    from pygments.lexers import Python3Lexer as PythonLexer
-
-__all__ = (
-    'PythonInput',
-    'PythonCommandLineInterface',
-)
-
-
-class OptionCategory(object):
-    def __init__(self, title, options):
-        assert isinstance(title, six.text_type)
-        assert isinstance(options, list)
-
-        self.title = title
-        self.options = options
-
-
-class Option(object):
-    """
-    Ptpython configuration option that can be shown and modified from the
-    sidebar.
-
-    :param title: Text.
-    :param description: Text.
-    :param get_values: Callable that returns a dictionary mapping the
-            possible values to callbacks that activate these value.
-    :param get_current_value: Callable that returns the current, active value.
-    """
-    def __init__(self, title, description, get_current_value, get_values):
-        assert isinstance(title, six.text_type)
-        assert isinstance(description, six.text_type)
-        assert callable(get_current_value)
-        assert callable(get_values)
-
-        self.title = title
-        self.description = description
-        self.get_current_value = get_current_value
-        self.get_values = get_values
-
-    @property
-    def values(self):
-        return self.get_values()
-
-    def activate_next(self, _previous=False):
-        """
-        Activate next value.
-        """
-        current = self.get_current_value()
-        options = sorted(self.values.keys())
-
-        # Get current index.
-        try:
-            index = options.index(current)
-        except ValueError:
-            index = 0
-
-        # Go to previous/next index.
-        if _previous:
-            index -= 1
-        else:
-            index += 1
-
-        # Call handler for this option.
-        next_option = options[index % len(options)]
-        self.values[next_option]()
-
-    def activate_previous(self):
-        """
-        Activate previous value.
-        """
-        self.activate_next(_previous=True)
-
-
-class PythonInput(object):
-    """
-    Prompt for reading Python input.
-
-    ::
-
-        python_input = PythonInput(...)
-        application = python_input.create_application()
-        cli = PythonCommandLineInterface(application=application)
-        python_code = cli.run()
-    """
-    def __init__(self,
-                 get_globals=None, get_locals=None, history_filename=None,
-                 vi_mode=False,
-
-                 # For internal use.
-                 _completer=None, _validator=None,
-                 _lexer=None, _extra_buffers=None, _extra_buffer_processors=None,
-                 _on_start=None,
-                 _extra_layout_body=None, _extra_toolbars=None,
-                 _input_buffer_height=None,
-                 _accept_action=AcceptAction.RETURN_DOCUMENT,
-                 _on_exit=AbortAction.RAISE_EXCEPTION):
-
-        self.get_globals = get_globals or (lambda: {})
-        self.get_locals = get_locals or self.get_globals
-
-        self._completer = _completer or PythonCompleter(self.get_globals, self.get_locals)
-        self._validator = _validator or PythonValidator(self.get_compiler_flags)
-        self.history = FileHistory(history_filename) if history_filename else InMemoryHistory()
-        self._lexer = _lexer or PygmentsLexer(PythonLexer)
-        self._extra_buffers = _extra_buffers
-        self._accept_action = _accept_action
-        self._on_exit = _on_exit
-        self._on_start = _on_start
-
-        self._input_buffer_height = _input_buffer_height
-        self._extra_layout_body = _extra_layout_body or []
-        self._extra_toolbars = _extra_toolbars or []
-        self._extra_buffer_processors = _extra_buffer_processors or []
-
-        # Settings.
-        self.show_signature = False
-        self.show_docstring = False
-        self.show_meta_enter_message = True
-        self.completion_visualisation = CompletionVisualisation.MULTI_COLUMN
-        self.completion_menu_scroll_offset = 1
-
-        self.show_line_numbers = False
-        self.show_status_bar = True
-        self.wrap_lines = True
-        self.complete_while_typing = True
-        self.vi_mode = vi_mode
-        self.paste_mode = False  # When True, don't insert whitespace after newline.
-        self.confirm_exit = True  # Ask for confirmation when Control-D is pressed.
-        self.accept_input_on_enter = 2  # Accept when pressing Enter 'n' times.
-                                        # 'None' means that meta-enter is always required.
-        self.enable_open_in_editor = True
-        self.enable_system_bindings = True
-        self.enable_input_validation = True
-        self.enable_auto_suggest = False
-        self.enable_mouse_support = False
-        self.enable_history_search = False  # When True, like readline, going
-                                            # back in history will filter the
-                                            # history on the records starting
-                                            # with the current input.
-
-        self.highlight_matching_parenthesis = False
-        self.show_sidebar = False  # Currently show the sidebar.
-        self.show_sidebar_help = True # When the sidebar is visible, also show the help text.
-        self.show_exit_confirmation = False  # Currently show 'Do you really want to exit?'
-        self.terminal_title = None  # The title to be displayed in the terminal. (None or string.)
-        self.exit_message = 'Do you really want to exit?'
-        self.insert_blank_line_after_output = True  # (For the REPL.)
-
-        # Tokens to be shown at the prompt.
-        self.prompt_style = 'classic'  # The currently active style.
-
-        self.all_prompt_styles = {  # Styles selectable from the menu.
-            'ipython': IPythonPrompt(self),
-            'classic': ClassicPrompt(),
-        }
-
-        self.get_input_prompt_tokens = lambda cli: \
-            self.all_prompt_styles[self.prompt_style].in_tokens(cli)
-
-        self.get_output_prompt_tokens = lambda cli: \
-            self.all_prompt_styles[self.prompt_style].out_tokens(cli)
-
-        #: Load styles.
-        self.code_styles = get_all_code_styles()
-        self.ui_styles = get_all_ui_styles()
-        self._current_code_style_name = 'default'
-        self._current_ui_style_name = 'default'
-
-        if is_windows():
-            self._current_code_style_name = 'win32'
-
-        self._current_style = self._generate_style()
-        self.true_color = False
-
-        # Options to be configurable from the sidebar.
-        self.options = self._create_options()
-        self.selected_option_index = 0
-
-        #: Incremeting integer counting the current statement.
-        self.current_statement_index = 1
-
-        # Code signatures. (This is set asynchronously after a timeout.)
-        self.signatures = []
-
-        # Create a Registry for the key bindings.
-        self.key_bindings_registry = MergedRegistry([
-            ConditionalRegistry(
-                registry=load_key_bindings_for_prompt(
-                    enable_abort_and_exit_bindings=True,
-                    enable_search=True,
-                    enable_open_in_editor=Condition(lambda cli: self.enable_open_in_editor),
-                    enable_system_bindings=Condition(lambda cli: self.enable_system_bindings),
-                    enable_auto_suggest_bindings=Condition(lambda cli: self.enable_auto_suggest)),
-
-                # Disable all default key bindings when the sidebar or the exit confirmation
-                # are shown.
-                filter=Condition(lambda cli: not (self.show_sidebar or self.show_exit_confirmation))
-            ),
-            load_mouse_bindings(),
-            load_python_bindings(self),
-            load_sidebar_bindings(self),
-            load_confirm_exit_bindings(self),
-        ])
-
-        # Boolean indicating whether we have a signatures thread running.
-        # (Never run more than one at the same time.)
-        self._get_signatures_thread_running = False
-
-    @property
-    def option_count(self):
-        " Return the total amount of options. (In all categories together.) "
-        return sum(len(category.options) for category in self.options)
-
-    @property
-    def selected_option(self):
-        " Return the currently selected option. "
-        i = 0
-        for category in self.options:
-            for o in category.options:
-                if i == self.selected_option_index:
-                    return o
-                else:
-                    i += 1
-
-    def get_compiler_flags(self):
-        """
-        Give the current compiler flags by looking for _Feature instances
-        in the globals.
-        """
-        flags = 0
-
-        for value in self.get_globals().values():
-            if isinstance(value, __future__._Feature):
-                flags |= value.compiler_flag
-
-        return flags
-
-    @property
-    def add_key_binding(self):
-        """
-        Shortcut for adding new key bindings.
-        (Mostly useful for a .ptpython/config.py file, that receives
-        a PythonInput/Repl instance as input.)
-
-        ::
-
-            @python_input.add_key_binding(Keys.ControlX, filter=...)
-            def handler(event):
-                ...
-        """
-        # Extra key bindings should not be active when the sidebar is visible.
-        sidebar_visible = Condition(lambda cli: self.show_sidebar)
-
-        def add_binding_decorator(*keys, **kw):
-            # Pop default filter keyword argument.
-            filter = kw.pop('filter', Always())
-            assert not kw
-
-            return self.key_bindings_registry.add_binding(*keys, filter=filter & ~sidebar_visible)
-        return add_binding_decorator
-
-    def install_code_colorscheme(self, name, style_dict):
-        """
-        Install a new code color scheme.
-        """
-        assert isinstance(name, six.text_type)
-        assert isinstance(style_dict, dict)
-
-        self.code_styles[name] = style_dict
-
-    def use_code_colorscheme(self, name):
-        """
-        Apply new colorscheme. (By name.)
-        """
-        assert name in self.code_styles
-
-        self._current_code_style_name = name
-        self._current_style = self._generate_style()
-
-    def install_ui_colorscheme(self, name, style_dict):
-        """
-        Install a new UI color scheme.
-        """
-        assert isinstance(name, six.text_type)
-        assert isinstance(style_dict, dict)
-
-        self.ui_styles[name] = style_dict
-
-    def use_ui_colorscheme(self, name):
-        """
-        Apply new colorscheme. (By name.)
-        """
-        assert name in self.ui_styles
-
-        self._current_ui_style_name = name
-        self._current_style = self._generate_style()
-
-    def _generate_style(self):
-        """
-        Create new Style instance.
-        (We don't want to do this on every key press, because each time the
-        renderer receives a new style class, he will redraw everything.)
-        """
-        return generate_style(self.code_styles[self._current_code_style_name],
-                              self.ui_styles[self._current_ui_style_name])
-
-    def _create_options(self):
-        """
-        Create a list of `Option` instances for the options sidebar.
-        """
-        def enable(attribute, value=True):
-            setattr(self, attribute, value)
-
-            # Return `True`, to be able to chain this in the lambdas below.
-            return True
-
-        def disable(attribute):
-            setattr(self, attribute, False)
-            return True
-
-        def simple_option(title, description, field_name, values=None):
-            " Create Simple on/of option. "
-            values = values or ['off', 'on']
-
-            def get_current_value():
-                return values[bool(getattr(self, field_name))]
-
-            def get_values():
-                return {
-                    values[1]: lambda: enable(field_name),
-                    values[0]: lambda: disable(field_name),
-                }
-
-            return Option(title=title, description=description,
-                          get_values=get_values,
-                          get_current_value=get_current_value)
-
-        return [
-            OptionCategory('Input', [
-                simple_option(title='Input mode',
-                              description='Vi or emacs key bindings.',
-                              field_name='vi_mode',
-                              values=['emacs', 'vi']),
-                simple_option(title='Paste mode',
-                              description="When enabled, don't indent automatically.",
-                              field_name='paste_mode'),
-                Option(title='Complete while typing',
-                       description="Generate autocompletions automatically while typing. "
-                                   'Don\'t require pressing TAB. (Not compatible with "History search".)',
-                       get_current_value=lambda: ['off', 'on'][self.complete_while_typing],
-                       get_values=lambda: {
-                           'on': lambda: enable('complete_while_typing') and disable('enable_history_search'),
-                           'off': lambda: disable('complete_while_typing'),
-                       }),
-                Option(title='History search',
-                       description='When pressing the up-arrow, filter the history on input starting '
-                                   'with the current text. (Not compatible with "Complete while typing".)',
-                       get_current_value=lambda: ['off', 'on'][self.enable_history_search],
-                       get_values=lambda: {
-                           'on': lambda: enable('enable_history_search') and disable('complete_while_typing'),
-                           'off': lambda: disable('enable_history_search'),
-                       }),
-                simple_option(title='Mouse support',
-                              description='Respond to mouse clicks and scrolling for positioning the cursor, '
-                                          'selecting text and scrolling through windows.',
-                              field_name='enable_mouse_support'),
-                simple_option(title='Confirm on exit',
-                              description='Require confirmation when exiting.',
-                              field_name='confirm_exit'),
-                simple_option(title='Input validation',
-                              description='In case of syntax errors, move the cursor to the error '
-                                          'instead of showing a traceback of a SyntaxError.',
-                              field_name='enable_input_validation'),
-                simple_option(title='Auto suggestion',
-                              description='Auto suggest inputs by looking at the history. '
-                                          'Pressing right arrow or Ctrl-E will complete the entry.',
-                              field_name='enable_auto_suggest'),
-                Option(title='Accept input on enter',
-                       description='Amount of ENTER presses required to execute input when the cursor '
-                                   'is at the end of the input. (Note that META+ENTER will always execute.)',
-                       get_current_value=lambda: str(self.accept_input_on_enter or 'meta-enter'),
-                       get_values=lambda: {
-                           '2': lambda: enable('accept_input_on_enter', 2),
-                           '3': lambda: enable('accept_input_on_enter', 3),
-                           '4': lambda: enable('accept_input_on_enter', 4),
-                           'meta-enter': lambda: enable('accept_input_on_enter', None),
-                       }),
-            ]),
-            OptionCategory('Display', [
-                Option(title='Completions',
-                       description='Visualisation to use for displaying the completions. (Multiple columns, one column, a toolbar or nothing.)',
-                       get_current_value=lambda: self.completion_visualisation,
-                       get_values=lambda: {
-                           CompletionVisualisation.NONE: lambda: enable('completion_visualisation', CompletionVisualisation.NONE),
-                           CompletionVisualisation.POP_UP: lambda: enable('completion_visualisation', CompletionVisualisation.POP_UP),
-                           CompletionVisualisation.MULTI_COLUMN: lambda: enable('completion_visualisation', CompletionVisualisation.MULTI_COLUMN),
-                           CompletionVisualisation.TOOLBAR: lambda: enable('completion_visualisation', CompletionVisualisation.TOOLBAR),
-                       }),
-                Option(title='Prompt',
-                       description="Visualisation of the prompt. ('>>>' or 'In [1]:')",
-                       get_current_value=lambda: self.prompt_style,
-                       get_values=lambda: dict((s, partial(enable, 'prompt_style', s)) for s in self.all_prompt_styles)),
-                simple_option(title='Blank line after output',
-                              description='Insert a blank line after the output.',
-                              field_name='insert_blank_line_after_output'),
-                simple_option(title='Show signature',
-                              description='Display function signatures.',
-                              field_name='show_signature'),
-                simple_option(title='Show docstring',
-                              description='Display function docstrings.',
-                              field_name='show_docstring'),
-                simple_option(title='Show line numbers',
-                              description='Show line numbers when the input consists of multiple lines.',
-                              field_name='show_line_numbers'),
-                simple_option(title='Show Meta+Enter message',
-                              description='Show the [Meta+Enter] message when this key combination is required to execute commands. ' +
-                                  '(This is the case when a simple [Enter] key press will insert a newline.',
-                              field_name='show_meta_enter_message'),
-                simple_option(title='Wrap lines',
-                              description='Wrap lines instead of scrolling horizontally.',
-                              field_name='wrap_lines'),
-                simple_option(title='Show status bar',
-                              description='Show the status bar at the bottom of the terminal.',
-                              field_name='show_status_bar'),
-                simple_option(title='Show sidebar help',
-                              description='When the sidebar is visible, also show this help text.',
-                              field_name='show_sidebar_help'),
-                simple_option(title='Highlight parenthesis',
-                              description='Highlight matching parenthesis, when the cursor is on or right after one.',
-                              field_name='highlight_matching_parenthesis'),
-            ]),
-            OptionCategory('Colors', [
-                Option(title='Code',
-                       description='Color scheme to use for the Python code.',
-                       get_current_value=lambda: self._current_code_style_name,
-                       get_values=lambda: dict(
-                            (name, partial(self.use_code_colorscheme, name)) for name in self.code_styles)
-                       ),
-                Option(title='User interface',
-                       description='Color scheme to use for the user interface.',
-                       get_current_value=lambda: self._current_ui_style_name,
-                       get_values=lambda: dict(
-                            (name, partial(self.use_ui_colorscheme, name)) for name in self.ui_styles)
-                       ),
-                simple_option(title='True color (24 bit)',
-                              description='Use 24 bit colors instead of 265 colors',
-                              field_name='true_color'),
-            ]),
-        ]
-
-    def create_application(self):
-        """
-        Create an `Application` instance for use in a `CommandLineInterface`.
-        """
-        buffers = {
-            'docstring': Buffer(read_only=True),
-        }
-        buffers.update(self._extra_buffers or {})
-
-        return Application(
-            layout=create_layout(
-                self,
-                lexer=self._lexer,
-                input_buffer_height=self._input_buffer_height,
-                extra_buffer_processors=self._extra_buffer_processors,
-                extra_body=self._extra_layout_body,
-                extra_toolbars=self._extra_toolbars),
-            buffer=self._create_buffer(),
-            buffers=buffers,
-            key_bindings_registry=self.key_bindings_registry,
-            paste_mode=Condition(lambda cli: self.paste_mode),
-            mouse_support=Condition(lambda cli: self.enable_mouse_support),
-            on_abort=AbortAction.RETRY,
-            on_exit=self._on_exit,
-            style=DynamicStyle(lambda: self._current_style),
-            get_title=lambda: self.terminal_title,
-            reverse_vi_search_direction=True,
-            on_initialize=self._on_cli_initialize,
-            on_start=self._on_start,
-            on_input_timeout=self._on_input_timeout)
-
-    def _create_buffer(self):
-        """
-        Create the `Buffer` for the Python input.
-        """
-        def is_buffer_multiline():
-            return (self.paste_mode or
-                    self.accept_input_on_enter is None or
-                    document_is_multiline_python(python_buffer.document))
-
-        python_buffer = Buffer(
-            is_multiline=Condition(is_buffer_multiline),
-            complete_while_typing=Condition(lambda: self.complete_while_typing),
-            enable_history_search=Condition(lambda: self.enable_history_search),
-            tempfile_suffix='.py',
-            history=self.history,
-            completer=self._completer,
-            validator=ConditionalValidator(
-                self._validator,
-                Condition(lambda: self.enable_input_validation)),
-            auto_suggest=ConditionalAutoSuggest(
-                AutoSuggestFromHistory(),
-                Condition(lambda cli: self.enable_auto_suggest)),
-            accept_action=self._accept_action)
-
-        return python_buffer
-
-    def _on_cli_initialize(self, cli):
-        """
-        Called when a CommandLineInterface has been created.
-        """
-        # Synchronize PythonInput state with the CommandLineInterface.
-        def synchronize(_=None):
-            if self.vi_mode:
-                cli.editing_mode = EditingMode.VI
-            else:
-                cli.editing_mode = EditingMode.EMACS
-
-        cli.input_processor.beforeKeyPress += synchronize
-        cli.input_processor.afterKeyPress += synchronize
-        synchronize()
-
-    def _on_input_timeout(self, cli):
-        """
-        When there is no input activity,
-        in another thread, get the signature of the current code.
-        """
-        if cli.current_buffer_name != DEFAULT_BUFFER:
-            return
-
-        # Never run multiple get-signature threads.
-        if self._get_signatures_thread_running:
-            return
-        self._get_signatures_thread_running = True
-
-        buffer = cli.current_buffer
-        document = buffer.document
-
-        def run():
-            script = get_jedi_script_from_document(document, self.get_locals(), self.get_globals())
-
-            # Show signatures in help text.
-            if script:
-                try:
-                    signatures = script.call_signatures()
-                except ValueError:
-                    # e.g. in case of an invalid \\x escape.
-                    signatures = []
-                except Exception:
-                    # Sometimes we still get an exception (TypeError), because
-                    # of probably bugs in jedi. We can silence them.
-                    # See: https://github.com/davidhalter/jedi/issues/492
-                    signatures = []
-                else:
-                    # Try to access the params attribute just once. For Jedi
-                    # signatures containing the keyword-only argument star,
-                    # this will crash when retrieving it the first time with
-                    # AttributeError. Every following time it works.
-                    # See: https://github.com/jonathanslenders/ptpython/issues/47
-                    #      https://github.com/davidhalter/jedi/issues/598
-                    try:
-                        if signatures:
-                            signatures[0].params
-                    except AttributeError:
-                        pass
-            else:
-                signatures = []
-
-            self._get_signatures_thread_running = False
-
-            # Set signatures and redraw if the text didn't change in the
-            # meantime. Otherwise request new signatures.
-            if buffer.text == document.text:
-                self.signatures = signatures
-
-                # Set docstring in docstring buffer.
-                if signatures:
-                    string = signatures[0].docstring()
-                    if not isinstance(string, six.text_type):
-                        string = string.decode('utf-8')
-                    cli.buffers['docstring'].reset(
-                        initial_document=Document(string, cursor_position=0))
-                else:
-                    cli.buffers['docstring'].reset()
-
-                cli.request_redraw()
-            else:
-                self._on_input_timeout(cli)
-
-        cli.eventloop.run_in_executor(run)
-
-    def on_reset(self, cli):
-        self.signatures = []
-
-    def enter_history(self, cli):
-        """
-        Display the history.
-        """
-        cli.vi_state.input_mode = InputMode.NAVIGATION
-
-        def done(result):
-            if result is not None:
-                cli.buffers[DEFAULT_BUFFER].document = result
-
-            cli.vi_state.input_mode = InputMode.INSERT
-
-        cli.run_sub_application(create_history_application(
-            self, cli.buffers[DEFAULT_BUFFER].document), done)
-
-
-class PythonCommandLineInterface(CommandLineInterface):
-    def __init__(self, eventloop=None, python_input=None, input=None, output=None):
-        assert python_input is None or isinstance(python_input, PythonInput)
-
-        python_input = python_input or PythonInput()
-
-        # Make sure that the prompt_toolkit 'renderer' knows about the
-        # 'true_color' property of PythonInput.
-        if output is None:
-            output=create_output(true_color=Condition(lambda: python_input.true_color))
-
-        super(PythonCommandLineInterface, self).__init__(
-            application=python_input.create_application(),
-            eventloop=eventloop,
-            input=input,
-            output=output)
diff --git a/ptpython/repl.py b/ptpython/repl.py
deleted file mode 100644
index 7c1230fb..00000000
--- a/ptpython/repl.py
+++ /dev/null
@@ -1,320 +0,0 @@
-"""
-Utility for creating a Python repl.
-
-::
-
-    from ptpython.repl import embed
-    embed(globals(), locals(), vi_mode=False)
-
-"""
-from __future__ import unicode_literals
-
-from pygments.lexers import PythonTracebackLexer, PythonLexer
-from pygments.styles.default import DefaultStyle
-
-from prompt_toolkit.application import AbortAction
-from prompt_toolkit.enums import DEFAULT_BUFFER
-from prompt_toolkit.interface import AcceptAction
-from prompt_toolkit.key_binding.vi_state import InputMode
-from prompt_toolkit.layout.utils import token_list_width
-from prompt_toolkit.shortcuts import create_asyncio_eventloop
-from prompt_toolkit.styles import style_from_pygments
-from prompt_toolkit.utils import DummyContext
-
-from .python_input import PythonInput, PythonCommandLineInterface
-from .eventloop import create_eventloop
-
-import os
-import six
-import sys
-import traceback
-import warnings
-
-__all__ = (
-    'PythonRepl',
-    'enable_deprecation_warnings',
-    'run_config',
-    'embed',
-)
-
-
-class PythonRepl(PythonInput):
-    def __init__(self, *a, **kw):
-        self._startup_paths = kw.pop('startup_paths', None)
-
-        kw.update({
-            '_accept_action': AcceptAction.run_in_terminal(
-                handler=self._process_document, render_cli_done=True),
-            '_on_start': self._on_start,
-            '_on_exit': AbortAction.RETURN_NONE,
-        })
-
-        super(PythonRepl, self).__init__(*a, **kw)
-
-    def _on_start(self, cli):
-        """
-        Start the Read-Eval-Print Loop.
-        """
-        if self._startup_paths:
-            for path in self._startup_paths:
-                if os.path.exists(path):
-                    with open(path, 'r') as f:
-                        code = compile(f.read(), path, 'exec')
-                        six.exec_(code, self.get_globals(), self.get_locals())
-                else:
-                    output = cli.output
-                    output.write('WARNING | File not found: {}\n\n'.format(path))
-
-    def _process_document(self, cli, buffer):
-        line = buffer.text
-
-        if line and not line.isspace():
-            try:
-                # Eval and print.
-                self._execute(cli, line)
-            except KeyboardInterrupt as e:  # KeyboardInterrupt doesn't inherit from Exception.
-                self._handle_keyboard_interrupt(cli, e)
-            except Exception as e:
-                self._handle_exception(cli, e)
-
-            if self.insert_blank_line_after_output:
-                cli.output.write('\n')
-
-            self.current_statement_index += 1
-            self.signatures = []
-
-            # Append to history and reset.
-            cli.search_state.text = ''
-            cli.buffers[DEFAULT_BUFFER].reset(append_to_history=True)
-
-        # Make sure that we end up in insert mode.
-        # (Not exactly the right place to check this.)
-        if cli.vi_state.input_mode == InputMode.NAVIGATION:
-            cli.vi_state.input_mode = InputMode.INSERT
-
-    def _execute(self, cli, line):
-        """
-        Evaluate the line and print the result.
-        """
-        output = cli.output
-
-        def compile_with_flags(code, mode):
-            " Compile code with the right compiler flags. "
-            return compile(code, '<stdin>', mode,
-                           flags=self.get_compiler_flags(),
-                           dont_inherit=True)
-
-        if line.lstrip().startswith('\x1a'):
-            # When the input starts with Ctrl-Z, quit the REPL.
-            cli.exit()
-
-        elif line.lstrip().startswith('!'):
-            # Run as shell command
-            os.system(line[1:])
-        else:
-            # Try eval first
-            try:
-                code = compile_with_flags(line, 'eval')
-                result = eval(code, self.get_globals(), self.get_locals())
-
-                locals = self.get_locals()
-                locals['_'] = locals['_%i' % self.current_statement_index] = result
-
-                if result is not None:
-                    out_tokens = self.get_output_prompt_tokens(cli)
-
-                    try:
-                        result_str = '%r\n' % (result, )
-                    except UnicodeDecodeError:
-                        # In Python 2: `__repr__` should return a bytestring,
-                        # so to put it in a unicode context could raise an
-                        # exception that the 'ascii' codec can't decode certain
-                        # characters. Decode as utf-8 in that case.
-                        result_str = '%s\n' % repr(result).decode('utf-8')
-
-                    # Align every line to the first one.
-                    line_sep = '\n' + ' ' * token_list_width(out_tokens)
-                    result_str = line_sep.join(result_str.splitlines()) + '\n'
-
-                    # Write output tokens.
-                    out_tokens.extend(_lex_python_result(result_str))
-                    cli.print_tokens(out_tokens)
-            # If not a valid `eval` expression, run using `exec` instead.
-            except SyntaxError:
-                code = compile_with_flags(line, 'exec')
-                six.exec_(code, self.get_globals(), self.get_locals())
-
-            output.flush()
-
-    @classmethod
-    def _handle_exception(cls, cli, e):
-        output = cli.output
-
-        # Instead of just calling ``traceback.format_exc``, we take the
-        # traceback and skip the bottom calls of this framework.
-        t, v, tb = sys.exc_info()
-        tblist = traceback.extract_tb(tb)
-
-        for line_nr, tb_tuple in enumerate(tblist):
-            if tb_tuple[0] == '<stdin>':
-                tblist = tblist[line_nr:]
-                break
-
-        l = traceback.format_list(tblist)
-        if l:
-            l.insert(0, "Traceback (most recent call last):\n")
-        l.extend(traceback.format_exception_only(t, v))
-
-        # For Python2: `format_list` and `format_exception_only` return
-        # non-unicode strings. Ensure that everything is unicode.
-        if six.PY2:
-            l = [i.decode('utf-8') if isinstance(i, six.binary_type) else i for i in l]
-
-        tb = ''.join(l)
-
-        # Format exception and write to output.
-        # (We use the default style. Most other styles result
-        # in unreadable colors for the traceback.)
-        tokens = _lex_python_traceback(tb)
-        cli.print_tokens(tokens, style=style_from_pygments(DefaultStyle))
-
-        output.write('%s\n' % e)
-        output.flush()
-
-    @classmethod
-    def _handle_keyboard_interrupt(cls, cli, e):
-        output = cli.output
-
-        output.write('\rKeyboardInterrupt\n\n')
-        output.flush()
-
-
-def _lex_python_traceback(tb):
-    " Return token list for traceback string. "
-    lexer = PythonTracebackLexer()
-    return lexer.get_tokens(tb)
-
-
-def _lex_python_result(tb):
-    " Return token list for Python string. "
-    lexer = PythonLexer()
-    return lexer.get_tokens(tb)
-
-
-def enable_deprecation_warnings():
-    """
-    Show deprecation warnings, when they are triggered directly by actions in
-    the REPL. This is recommended to call, before calling `embed`.
-
-    e.g. This will show an error message when the user imports the 'sha'
-         library on Python 2.7.
-    """
-    warnings.filterwarnings('default', category=DeprecationWarning,
-                            module='__main__')
-
-
-def run_config(repl, config_file='~/.ptpython/config.py'):
-    """
-    Execute REPL config file.
-
-    :param repl: `PythonInput` instance.
-    :param config_file: Path of the configuration file.
-    """
-    assert isinstance(repl, PythonInput)
-    assert isinstance(config_file, six.text_type)
-
-    # Expand tildes.
-    config_file = os.path.expanduser(config_file)
-
-    def enter_to_continue():
-         six.moves.input('\nPress ENTER to continue...')
-
-    # Check whether this file exists.
-    if not os.path.exists(config_file):
-        print('Impossible to read %r' % config_file)
-        enter_to_continue()
-        return
-
-    # Run the config file in an empty namespace.
-    try:
-        namespace = {}
-
-        with open(config_file, 'r') as f:
-            code = compile(f.read(), config_file, 'exec')
-            six.exec_(code, namespace, namespace)
-
-        # Now we should have a 'configure' method in this namespace. We call this
-        # method with the repl as an argument.
-        if 'configure' in namespace:
-            namespace['configure'](repl)
-
-    except Exception:
-         traceback.print_exc()
-         enter_to_continue()
-
-
-def embed(globals=None, locals=None, configure=None,
-          vi_mode=False, history_filename=None, title=None,
-          startup_paths=None, patch_stdout=False, return_asyncio_coroutine=False):
-    """
-    Call this to embed  Python shell at the current point in your program.
-    It's similar to `IPython.embed` and `bpython.embed`. ::
-
-        from prompt_toolkit.contrib.repl import embed
-        embed(globals(), locals())
-
-    :param vi_mode: Boolean. Use Vi instead of Emacs key bindings.
-    :param configure: Callable that will be called with the `PythonRepl` as a first
-                      argument, to trigger configuration.
-    :param title: Title to be displayed in the terminal titlebar. (None or string.)
-    """
-    assert configure is None or callable(configure)
-
-    # Default globals/locals
-    if globals is None:
-        globals = {
-            '__name__': '__main__',
-            '__package__': None,
-            '__doc__': None,
-            '__builtins__': six.moves.builtins,
-        }
-
-    locals = locals or globals
-
-    def get_globals():
-        return globals
-
-    def get_locals():
-        return locals
-
-    # Create eventloop.
-    if return_asyncio_coroutine:
-        eventloop = create_asyncio_eventloop()
-    else:
-        eventloop = create_eventloop()
-
-    # Create REPL.
-    repl = PythonRepl(get_globals, get_locals, vi_mode=vi_mode,
-                      history_filename=history_filename,
-                      startup_paths=startup_paths)
-
-    if title:
-        repl.terminal_title = title
-
-    if configure:
-        configure(repl)
-
-    cli = PythonCommandLineInterface(python_input=repl, eventloop=eventloop)
-
-    # Start repl.
-    patch_context = cli.patch_stdout_context() if patch_stdout else DummyContext()
-
-    if return_asyncio_coroutine:
-        def coroutine():
-            with patch_context:
-                for future in cli.run_async():
-                    yield future
-        return coroutine()
-    else:
-        with patch_context:
-            cli.run()
diff --git a/ptpython/style.py b/ptpython/style.py
deleted file mode 100644
index f80754df..00000000
--- a/ptpython/style.py
+++ /dev/null
@@ -1,195 +0,0 @@
-from __future__ import unicode_literals
-
-from pygments.token import Token, Keyword, Name, Comment, String, Operator, Number
-from pygments.styles import get_style_by_name, get_all_styles
-from prompt_toolkit.styles import DEFAULT_STYLE_EXTENSIONS, style_from_dict
-from prompt_toolkit.utils import is_windows, is_conemu_ansi
-
-__all__ = (
-    'get_all_code_styles',
-    'get_all_ui_styles',
-    'generate_style',
-)
-
-
-def get_all_code_styles():
-    """
-    Return a mapping from style names to their classes.
-    """
-    result = dict((name, get_style_by_name(name).styles) for name in get_all_styles())
-    result['win32'] = win32_code_style
-    return result
-
-
-def get_all_ui_styles():
-    """
-    Return a dict mapping {ui_style_name -> style_dict}.
-    """
-    return {
-        'default': default_ui_style,
-        'blue': blue_ui_style,
-    }
-
-
-def generate_style(python_style, ui_style):
-    """
-    Generate Pygments Style class from two dictionaries
-    containing style rules.
-    """
-    assert isinstance(python_style, dict)
-    assert isinstance(ui_style, dict)
-
-    styles = {}
-    styles.update(DEFAULT_STYLE_EXTENSIONS)
-    styles.update(python_style)
-    styles.update(ui_style)
-
-    return style_from_dict(styles)
-
-
-# Code style for Windows consoles. They support only 16 colors,
-# so we choose a combination that displays nicely.
-win32_code_style = {
-    Comment:                   "#00ff00",
-    Keyword:                   '#44ff44',
-    Number:                    '',
-    Operator:                  '',
-    String:                    '#ff44ff',
-
-    Name:                      '',
-    Name.Decorator:            '#ff4444',
-    Name.Class:                '#ff4444',
-    Name.Function:             '#ff4444',
-    Name.Builtin:              '#ff4444',
-
-    Name.Attribute:            '',
-    Name.Constant:             '',
-    Name.Entity:               '',
-    Name.Exception:            '',
-    Name.Label:                '',
-    Name.Namespace:            '',
-    Name.Tag:                  '',
-    Name.Variable:             '',
-}
-
-
-default_ui_style = {
-        # Classic prompt.
-        Token.Prompt:                                 'bold',
-        Token.Prompt.Dots:                            'noinherit',
-
-        # (IPython <5.0) Prompt: "In [1]:"
-        Token.In:                                     'bold #008800',
-        Token.In.Number:                              '',
-
-        # Return value.
-        Token.Out:                                    '#ff0000',
-        Token.Out.Number:                             '#ff0000',
-
-        # Separator between windows. (Used above docstring.)
-        Token.Separator:                              '#bbbbbb',
-
-        # Search toolbar.
-        Token.Toolbar.Search:                         '#22aaaa noinherit',
-        Token.Toolbar.Search.Text:                    'noinherit',
-
-        # System toolbar
-        Token.Toolbar.System:                         '#22aaaa noinherit',
-
-        # "arg" toolbar.
-        Token.Toolbar.Arg:                            '#22aaaa noinherit',
-        Token.Toolbar.Arg.Text:                       'noinherit',
-
-        # Signature toolbar.
-        Token.Toolbar.Signature:                      'bg:#44bbbb #000000',
-        Token.Toolbar.Signature.CurrentName:          'bg:#008888 #ffffff bold',
-        Token.Toolbar.Signature.Operator:             '#000000 bold',
-
-        Token.Docstring:                              '#888888',
-
-        # Validation toolbar.
-        Token.Toolbar.Validation:                     'bg:#440000 #aaaaaa',
-
-        # Status toolbar.
-        Token.Toolbar.Status:                         'bg:#222222 #aaaaaa',
-        Token.Toolbar.Status.Title:                   'underline',
-        Token.Toolbar.Status.InputMode:               'bg:#222222 #ffffaa',
-        Token.Toolbar.Status.Key:                     'bg:#000000 #888888',
-        Token.Toolbar.Status.PasteModeOn:             'bg:#aa4444 #ffffff',
-        Token.Toolbar.Status.PythonVersion:           'bg:#222222 #ffffff bold',
-
-        # When Control-C has been pressed. Grayed.
-        Token.Aborted:                                '#888888',
-
-        # The options sidebar.
-        Token.Sidebar:                                'bg:#bbbbbb #000000',
-        Token.Sidebar.Title:                          'bg:#668866 #ffffff',
-        Token.Sidebar.Label:                          'bg:#bbbbbb #222222',
-        Token.Sidebar.Status:                         'bg:#dddddd #000011',
-        Token.Sidebar.Selected.Label:                 'bg:#222222 #eeeeee',
-        Token.Sidebar.Selected.Status:                'bg:#444444 #ffffff bold',
-
-        Token.Sidebar.Separator:                      'bg:#bbbbbb #ffffff underline',
-        Token.Sidebar.Key:                            'bg:#bbddbb #000000 bold',
-        Token.Sidebar.Key.Description:                'bg:#bbbbbb #000000',
-        Token.Sidebar.HelpText:                       'bg:#fdf6e3 #000011',
-
-        # Styling for the history layout.
-        Token.History.Line:                          '',
-        Token.History.Line.Selected:                 'bg:#008800  #000000',
-        Token.History.Line.Current:                  'bg:#ffffff #000000',
-        Token.History.Line.Selected.Current:         'bg:#88ff88 #000000',
-        Token.History.ExistingInput:                  '#888888',
-
-        # Help Window.
-        Token.Window.Border:                          '#bbbbbb',
-        Token.Window.Title:                           'bg:#bbbbbb #000000',
-
-        # Meta-enter message.
-        Token.AcceptMessage:                          'bg:#ffff88 #444444',
-
-        # Exit confirmation.
-        Token.ExitConfirmation:                       'bg:#884444 #ffffff',
-}
-
-
-# Some changes to get a bit more contrast on Windows consoles.
-# (They only support 16 colors.)
-if is_windows() and not is_conemu_ansi():
-    default_ui_style.update({
-        Token.Sidebar.Title:                          'bg:#00ff00 #ffffff',
-        Token.ExitConfirmation:                       'bg:#ff4444 #ffffff',
-        Token.Toolbar.Validation:                     'bg:#ff4444 #ffffff',
-
-        Token.Menu.Completions.Completion:            'bg:#ffffff #000000',
-        Token.Menu.Completions.Completion.Current:    'bg:#aaaaaa #000000',
-    })
-
-
-blue_ui_style = {}
-blue_ui_style.update(default_ui_style)
-blue_ui_style.update({
-        # Line numbers.
-        Token.LineNumber:                             '#aa6666',
-
-        # Highlighting of search matches in document.
-        Token.SearchMatch:                            '#ffffff bg:#4444aa',
-        Token.SearchMatch.Current:                    '#ffffff bg:#44aa44',
-
-        # Highlighting of select text in document.
-        Token.SelectedText:                           '#ffffff bg:#6666aa',
-
-        # Completer toolbar.
-        Token.Toolbar.Completions:                    'bg:#44bbbb #000000',
-        Token.Toolbar.Completions.Arrow:              'bg:#44bbbb #000000 bold',
-        Token.Toolbar.Completions.Completion:         'bg:#44bbbb #000000',
-        Token.Toolbar.Completions.Completion.Current: 'bg:#008888 #ffffff',
-
-        # Completer menu.
-        Token.Menu.Completions.Completion:            'bg:#44bbbb #000000',
-        Token.Menu.Completions.Completion.Current:    'bg:#008888 #ffffff',
-        Token.Menu.Completions.Meta:                  'bg:#449999 #000000',
-        Token.Menu.Completions.Meta.Current:          'bg:#00aaaa #000000',
-        Token.Menu.Completions.ProgressBar:           'bg:#aaaaaa',
-        Token.Menu.Completions.ProgressButton:        'bg:#000000',
-})
diff --git a/ptpython/utils.py b/ptpython/utils.py
deleted file mode 100644
index f6f914d0..00000000
--- a/ptpython/utils.py
+++ /dev/null
@@ -1,127 +0,0 @@
-"""
-For internal use only.
-"""
-from __future__ import unicode_literals
-
-from prompt_toolkit.mouse_events import MouseEventTypes
-import re
-
-__all__ = (
-    'has_unclosed_brackets',
-    'get_jedi_script_from_document',
-    'document_is_multiline_python',
-)
-
-
-def has_unclosed_brackets(text):
-    """
-    Starting at the end of the string. If we find an opening bracket
-    for which we didn't had a closing one yet, return True.
-    """
-    stack = []
-
-    # Ignore braces inside strings
-    text = re.sub(r'''('[^']*'|"[^"]*")''', '', text)  # XXX: handle escaped quotes.!
-
-    for c in reversed(text):
-        if c in '])}':
-            stack.append(c)
-
-        elif c in '[({':
-            if stack:
-                if ((c == '[' and stack[-1] == ']') or
-                        (c == '{' and stack[-1] == '}') or
-                        (c == '(' and stack[-1] == ')')):
-                    stack.pop()
-            else:
-                # Opening bracket for which we didn't had a closing one.
-                return True
-
-    return False
-
-
-def get_jedi_script_from_document(document, locals, globals):
-    import jedi  # We keep this import in-line, to improve start-up time.
-                 # Importing Jedi is 'slow'.
-
-    try:
-        return jedi.Interpreter(
-            document.text,
-            column=document.cursor_position_col,
-            line=document.cursor_position_row + 1,
-            path='input-text',
-            namespaces=[locals, globals])
-    except ValueError:
-        # Invalid cursor position.
-        # ValueError('`column` parameter is not in a valid range.')
-        return None
-    except AttributeError:
-        # Workaround for #65: https://github.com/jonathanslenders/python-prompt-toolkit/issues/65
-        # See also: https://github.com/davidhalter/jedi/issues/508
-        return None
-    except IndexError:
-        # Workaround Jedi issue #514: for https://github.com/davidhalter/jedi/issues/514
-        return None
-    except KeyError:
-        # Workaroud for a crash when the input is "u'", the start of a unicode string.
-        return None
-    except Exception:
-        # Workaround for: https://github.com/jonathanslenders/ptpython/issues/91
-        return None
-
-
-_multiline_string_delims = re.compile('''[']{3}|["]{3}''')
-
-
-def document_is_multiline_python(document):
-    """
-    Determine whether this is a multiline Python document.
-    """
-    def ends_in_multiline_string():
-        """
-        ``True`` if we're inside a multiline string at the end of the text.
-        """
-        delims = _multiline_string_delims.findall(document.text)
-        opening = None
-        for delim in delims:
-            if opening is None:
-                opening = delim
-            elif delim == opening:
-                opening = None
-        return bool(opening)
-
-    if '\n' in document.text or ends_in_multiline_string():
-        return True
-
-    def line_ends_with_colon():
-        return document.current_line.rstrip()[-1:] == ':'
-
-    # If we just typed a colon, or still have open brackets, always insert a real newline.
-    if line_ends_with_colon() or \
-            (document.is_cursor_at_the_end and
-             has_unclosed_brackets(document.text_before_cursor)) or \
-            document.text.startswith('@'):
-        return True
-
-    # If the character before the cursor is a backslash (line continuation
-    # char), insert a new line.
-    elif document.text_before_cursor[-1:] == '\\':
-        return True
-
-    return False
-
-
-def if_mousedown(handler):
-    """
-    Decorator for mouse handlers.
-    Only handle event when the user pressed mouse down.
-
-    (When applied to a token list. Scroll events will bubble up and are handled
-    by the Window.)
-    """
-    def handle_if_mouse_down(cli, mouse_event):
-        if mouse_event.event_type == MouseEventTypes.MOUSE_DOWN:
-            return handler(cli, mouse_event)
-        else:
-            return NotImplemented
-    return handle_if_mouse_down
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 00000000..00e2d5f8
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,95 @@
+[project]
+name = "ptpython"
+version = "3.0.30"
+description = "Python REPL build on top of prompt_toolkit"
+readme = "README.rst"
+authors = [{ name = "Jonathan Slenders" }]
+classifiers = [
+    "License :: OSI Approved :: BSD License",
+    "Programming Language :: Python :: 3",
+    "Programming Language :: Python :: 3.8",
+    "Programming Language :: Python :: 3.9",
+    "Programming Language :: Python :: 3.10",
+    "Programming Language :: Python :: 3.11",
+    "Programming Language :: Python :: 3.12",
+    "Programming Language :: Python :: 3.13",
+    "Programming Language :: Python :: 3 :: Only",
+    "Programming Language :: Python",
+]
+requires-python = ">=3.8"
+dependencies = [
+    "appdirs",
+    "jedi>=0.16.0",
+    # Use prompt_toolkit 3.0.43, because of `OneStyleAndTextTuple` import.
+    "prompt_toolkit>=3.0.43,<3.1.0",
+    "pygments",
+]
+
+
+[project.urls]
+Homepage = "https://github.com/prompt-toolkit/ptpython"
+Changelog = "https://github.com/prompt-toolkit/ptpython/blob/master/CHANGELOG"
+"Bug Tracker" = "https://github.com/prompt-toolkit/ptpython/issues"
+"Source Code" = "https://github.com/prompt-toolkit/ptpython"
+
+
+[project.scripts]
+ptpython = "ptpython.entry_points.run_ptpython:run"
+ptipython = "ptpython.entry_points.run_ptipython:run"
+
+
+[project.optional-dependencies]
+ptipython = ["ipython"] # For ptipython, we need to have IPython
+
+
+[tool.mypy]
+ignore_missing_imports = true
+no_implicit_optional = true
+platform = "win32"
+strict_equality = true
+strict_optional = true
+
+
+[tool.ruff]
+target-version = "py37"
+lint.select = [
+    "E",  # pycodestyle errors
+    "W",  # pycodestyle warnings
+    "F",  # pyflakes
+    "C",  # flake8-comprehensions
+    "T",  # Print.
+    "I",  # isort
+    # "B",  # flake8-bugbear
+    "UP",  # pyupgrade
+    "RUF100",  # unused-noqa
+    "Q", # quotes
+]
+lint.ignore = [
+    "E501",  # Line too long, handled by black
+    "C901",  # Too complex
+    "E722",  # bare except.
+]
+
+
+[tool.ruff.lint.per-file-ignores]
+"examples/*" = ["T201"]  # Print allowed in examples.
+"examples/ptpython_config/config.py" = ["F401"]  # Unused imports in config.
+"src/ptpython/entry_points/run_ptipython.py" = ["T201", "F401"] # Print, import usage.
+"src/ptpython/entry_points/run_ptpython.py" = ["T201"]  # Print usage.
+"src/ptpython/ipython.py" = ["T100"]  # Import usage.
+"src/ptpython/repl.py" = ["T201"]  # Print usage.
+"src/ptpython/printer.py" = ["T201"]  # Print usage.
+
+
+[tool.ruff.lint.isort]
+known-first-party = ["ptpython"]
+known-third-party = ["prompt_toolkit", "pygments", "asyncssh"]
+
+[tool.typos.default]
+extend-ignore-re = [
+    "impotr" # Intentional typo in: ./examples/ptpython_config/config.py
+]
+
+[build-system]
+requires = ["setuptools>=68"]
+build-backend = "setuptools.build_meta"
diff --git a/setup.py b/setup.py
deleted file mode 100644
index 351e473c..00000000
--- a/setup.py
+++ /dev/null
@@ -1,39 +0,0 @@
-#!/usr/bin/env python
-import os
-import sys
-from setuptools import setup, find_packages
-
-long_description = open(
-    os.path.join(
-        os.path.dirname(__file__),
-        'README.rst'
-    )
-).read()
-
-
-setup(
-    name='ptpython',
-    author='Jonathan Slenders',
-    version='0.41',
-    url='https://github.com/jonathanslenders/ptpython',
-    description='Python REPL build on top of prompt_toolkit',
-    long_description=long_description,
-    packages=find_packages('.'),
-    install_requires = [
-        'docopt',
-        'jedi>=0.9.0',
-        'prompt_toolkit>=1.0.14,<2.0.0',
-        'pygments',
-    ],
-    entry_points={
-        'console_scripts': [
-            'ptpython = ptpython.entry_points.run_ptpython:run',
-            'ptipython = ptpython.entry_points.run_ptipython:run',
-            'ptpython%s = ptpython.entry_points.run_ptpython:run' % sys.version_info[0],
-            'ptipython%s = ptpython.entry_points.run_ptipython:run' % sys.version_info[0],
-        ]
-    },
-    extras_require={
-        'ptipython':  ['ipython'] # For ptipython, we need to have IPython
-    }
-)
diff --git a/src/ptpython/__init__.py b/src/ptpython/__init__.py
new file mode 100644
index 00000000..63c6233d
--- /dev/null
+++ b/src/ptpython/__init__.py
@@ -0,0 +1,5 @@
+from __future__ import annotations
+
+from .repl import embed
+
+__all__ = ["embed"]
diff --git a/ptpython/__main__.py b/src/ptpython/__main__.py
similarity index 74%
rename from ptpython/__main__.py
rename to src/ptpython/__main__.py
index 7e4cbabe..3a2f7ddf 100644
--- a/ptpython/__main__.py
+++ b/src/ptpython/__main__.py
@@ -1,7 +1,9 @@
 """
 Make `python -m ptpython` an alias for running `./ptpython`.
 """
-from __future__ import unicode_literals
+
+from __future__ import annotations
+
 from .entry_points.run_ptpython import run
 
 run()
diff --git a/src/ptpython/completer.py b/src/ptpython/completer.py
new file mode 100644
index 00000000..e8bab285
--- /dev/null
+++ b/src/ptpython/completer.py
@@ -0,0 +1,689 @@
+from __future__ import annotations
+
+import ast
+import collections.abc as collections_abc
+import inspect
+import keyword
+import re
+from enum import Enum
+from itertools import islice
+from typing import TYPE_CHECKING, Any, Callable, Iterable
+
+from prompt_toolkit.completion import (
+    CompleteEvent,
+    Completer,
+    Completion,
+    PathCompleter,
+)
+from prompt_toolkit.contrib.completers.system import SystemCompleter
+from prompt_toolkit.contrib.regular_languages.compiler import compile as compile_grammar
+from prompt_toolkit.contrib.regular_languages.completion import GrammarCompleter
+from prompt_toolkit.document import Document
+from prompt_toolkit.formatted_text import fragment_list_to_text, to_formatted_text
+
+from ptpython.utils import get_jedi_script_from_document
+
+if TYPE_CHECKING:
+    import jedi.api.classes
+    from prompt_toolkit.contrib.regular_languages.compiler import _CompiledGrammar
+
+__all__ = ["PythonCompleter", "CompletePrivateAttributes", "HidePrivateCompleter"]
+
+
+class CompletePrivateAttributes(Enum):
+    """
+    Should we display private attributes in the completion pop-up?
+    """
+
+    NEVER = "NEVER"
+    IF_NO_PUBLIC = "IF_NO_PUBLIC"
+    ALWAYS = "ALWAYS"
+
+
+class PythonCompleter(Completer):
+    """
+    Completer for Python code.
+    """
+
+    def __init__(
+        self,
+        get_globals: Callable[[], dict[str, Any]],
+        get_locals: Callable[[], dict[str, Any]],
+        enable_dictionary_completion: Callable[[], bool],
+    ) -> None:
+        super().__init__()
+
+        self.get_globals = get_globals
+        self.get_locals = get_locals
+        self.enable_dictionary_completion = enable_dictionary_completion
+
+        self._system_completer = SystemCompleter()
+        self._jedi_completer = JediCompleter(get_globals, get_locals)
+        self._dictionary_completer = DictionaryCompleter(get_globals, get_locals)
+
+        self._path_completer_cache: GrammarCompleter | None = None
+        self._path_completer_grammar_cache: _CompiledGrammar | None = None
+
+    @property
+    def _path_completer(self) -> GrammarCompleter:
+        if self._path_completer_cache is None:
+            self._path_completer_cache = GrammarCompleter(
+                self._path_completer_grammar,
+                {
+                    "var1": PathCompleter(expanduser=True),
+                    "var2": PathCompleter(expanduser=True),
+                },
+            )
+        return self._path_completer_cache
+
+    @property
+    def _path_completer_grammar(self) -> _CompiledGrammar:
+        """
+        Return the grammar for matching paths inside strings inside Python
+        code.
+        """
+        # We make this lazy, because it delays startup time a little bit.
+        # This way, the grammar is build during the first completion.
+        if self._path_completer_grammar_cache is None:
+            self._path_completer_grammar_cache = self._create_path_completer_grammar()
+        return self._path_completer_grammar_cache
+
+    def _create_path_completer_grammar(self) -> _CompiledGrammar:
+        def unwrapper(text: str) -> str:
+            return re.sub(r"\\(.)", r"\1", text)
+
+        def single_quoted_wrapper(text: str) -> str:
+            return text.replace("\\", "\\\\").replace("'", "\\'")
+
+        def double_quoted_wrapper(text: str) -> str:
+            return text.replace("\\", "\\\\").replace('"', '\\"')
+
+        grammar = r"""
+                # Text before the current string.
+                (
+                    [^'"#]                                  |  # Not quoted characters.
+                    '''  ([^'\\]|'(?!')|''(?!')|\\.])*  ''' |  # Inside single quoted triple strings
+                    "" " ([^"\\]|"(?!")|""(?!^)|\\.])* "" " |  # Inside double quoted triple strings
+
+                    \#[^\n]*(\n|$)           |  # Comment.
+                    "(?!"") ([^"\\]|\\.)*"   |  # Inside double quoted strings.
+                    '(?!'') ([^'\\]|\\.)*'      # Inside single quoted strings.
+
+                        # Warning: The negative lookahead in the above two
+                        #          statements is important. If we drop that,
+                        #          then the regex will try to interpret every
+                        #          triple quoted string also as a single quoted
+                        #          string, making this exponentially expensive to
+                        #          execute!
+                )*
+                # The current string that we're completing.
+                (
+                    ' (?P<var1>([^\n'\\]|\\.)*) |  # Inside a single quoted string.
+                    " (?P<var2>([^\n"\\]|\\.)*)    # Inside a double quoted string.
+                )
+        """
+
+        return compile_grammar(
+            grammar,
+            escape_funcs={"var1": single_quoted_wrapper, "var2": double_quoted_wrapper},
+            unescape_funcs={"var1": unwrapper, "var2": unwrapper},
+        )
+
+    def _complete_path_while_typing(self, document: Document) -> bool:
+        char_before_cursor = document.char_before_cursor
+        return bool(
+            document.text
+            and (char_before_cursor.isalnum() or char_before_cursor in "/.~")
+        )
+
+    def _complete_python_while_typing(self, document: Document) -> bool:
+        """
+        When `complete_while_typing` is set, only return completions when this
+        returns `True`.
+        """
+        text = document.text_before_cursor  # .rstrip()
+        char_before_cursor = text[-1:]
+        return bool(
+            text and (char_before_cursor.isalnum() or char_before_cursor in "_.([,")
+        )
+
+    def get_completions(
+        self, document: Document, complete_event: CompleteEvent
+    ) -> Iterable[Completion]:
+        """
+        Get Python completions.
+        """
+        # If the input starts with an exclamation mark. Use the system completer.
+        if document.text.lstrip().startswith("!"):
+            yield from self._system_completer.get_completions(
+                Document(
+                    text=document.text[1:], cursor_position=document.cursor_position - 1
+                ),
+                complete_event,
+            )
+            return
+
+        # Do dictionary key completions.
+        if complete_event.completion_requested or self._complete_python_while_typing(
+            document
+        ):
+            if self.enable_dictionary_completion():
+                has_dict_completions = False
+                for c in self._dictionary_completer.get_completions(
+                    document, complete_event
+                ):
+                    if c.text not in "[.":
+                        # If we get the [ or . completion, still include the other
+                        # completions.
+                        has_dict_completions = True
+                    yield c
+                if has_dict_completions:
+                    return
+
+        # Do Path completions (if there were no dictionary completions).
+        if complete_event.completion_requested or self._complete_path_while_typing(
+            document
+        ):
+            yield from self._path_completer.get_completions(document, complete_event)
+
+        # Do Jedi completions.
+        if complete_event.completion_requested or self._complete_python_while_typing(
+            document
+        ):
+            # If we are inside a string, Don't do Jedi completion.
+            if not self._path_completer_grammar.match(document.text_before_cursor):
+                # Do Jedi Python completions.
+                yield from self._jedi_completer.get_completions(
+                    document, complete_event
+                )
+
+
+class JediCompleter(Completer):
+    """
+    Autocompleter that uses the Jedi library.
+    """
+
+    def __init__(
+        self,
+        get_globals: Callable[[], dict[str, Any]],
+        get_locals: Callable[[], dict[str, Any]],
+    ) -> None:
+        super().__init__()
+
+        self.get_globals = get_globals
+        self.get_locals = get_locals
+
+    def get_completions(
+        self, document: Document, complete_event: CompleteEvent
+    ) -> Iterable[Completion]:
+        script = get_jedi_script_from_document(
+            document, self.get_locals(), self.get_globals()
+        )
+
+        if script:
+            try:
+                jedi_completions = script.complete(
+                    column=document.cursor_position_col,
+                    line=document.cursor_position_row + 1,
+                )
+            except TypeError:
+                # Issue #9: bad syntax causes completions() to fail in jedi.
+                # https://github.com/jonathanslenders/python-prompt-toolkit/issues/9
+                pass
+            except UnicodeDecodeError:
+                # Issue #43: UnicodeDecodeError on OpenBSD
+                # https://github.com/jonathanslenders/python-prompt-toolkit/issues/43
+                pass
+            except AttributeError:
+                # Jedi issue #513: https://github.com/davidhalter/jedi/issues/513
+                pass
+            except ValueError:
+                # Jedi issue: "ValueError: invalid \x escape"
+                pass
+            except KeyError:
+                # Jedi issue: "KeyError: u'a_lambda'."
+                # https://github.com/jonathanslenders/ptpython/issues/89
+                pass
+            except OSError:
+                # Jedi issue: "IOError: No such file or directory."
+                # https://github.com/jonathanslenders/ptpython/issues/71
+                pass
+            except AssertionError:
+                # In jedi.parser.__init__.py: 227, in remove_last_newline,
+                # the assertion "newline.value.endswith('\n')" can fail.
+                pass
+            except SystemError:
+                # In jedi.api.helpers.py: 144, in get_stack_at_position
+                # raise SystemError("This really shouldn't happen. There's a bug in Jedi.")
+                pass
+            except NotImplementedError:
+                # See: https://github.com/jonathanslenders/ptpython/issues/223
+                pass
+            except Exception:
+                # Suppress all other Jedi exceptions.
+                pass
+            else:
+                # Move function parameters to the top.
+                jedi_completions = sorted(
+                    jedi_completions,
+                    key=lambda jc: (
+                        # Params first.
+                        jc.type != "param",
+                        # Private at the end.
+                        jc.name.startswith("_"),
+                        # Then sort by name.
+                        jc.name_with_symbols.lower(),
+                    ),
+                )
+
+                for jc in jedi_completions:
+                    if jc.type == "function":
+                        suffix = "()"
+                    else:
+                        suffix = ""
+
+                    if jc.type == "param":
+                        suffix = "..."
+
+                    yield Completion(
+                        jc.name_with_symbols,
+                        len(jc.complete) - len(jc.name_with_symbols),
+                        display=jc.name_with_symbols + suffix,
+                        display_meta=jc.type,
+                        style=_get_style_for_jedi_completion(jc),
+                    )
+
+
+class DictionaryCompleter(Completer):
+    """
+    Experimental completer for Python dictionary keys.
+
+    Warning: This does an `eval` and `repr` on some Python expressions before
+             the cursor, which is potentially dangerous. It doesn't match on
+             function calls, so it only triggers attribute access.
+    """
+
+    def __init__(
+        self,
+        get_globals: Callable[[], dict[str, Any]],
+        get_locals: Callable[[], dict[str, Any]],
+    ) -> None:
+        super().__init__()
+
+        self.get_globals = get_globals
+        self.get_locals = get_locals
+
+        # Pattern for expressions that are "safe" to eval for auto-completion.
+        # These are expressions that contain only attribute and index lookups.
+        varname = r"[a-zA-Z_][a-zA-Z0-9_]*"
+
+        expression = rf"""
+            # Any expression safe enough to eval while typing.
+            # No operators, except dot, and only other dict lookups.
+            # Technically, this can be unsafe of course, if bad code runs
+            # in `__getattr__` or ``__getitem__``.
+            (
+                # Variable name
+                {varname}
+
+                \s*
+
+                (?:
+                    # Attribute access.
+                    \s* \. \s* {varname} \s*
+
+                    |
+
+                    # Item lookup.
+                    # (We match the square brackets. The key can be anything.
+                    # We don't care about matching quotes here in the regex.
+                    # Nested square brackets are not supported.)
+                    \s* \[ [^\[\]]+ \] \s*
+                )*
+            )
+        """
+
+        # Pattern for recognizing for-loops, so that we can provide
+        # autocompletion on the iterator of the for-loop. (According to the
+        # first item of the collection we're iterating over.)
+        self.for_loop_pattern = re.compile(
+            rf"""
+                for \s+ ([a-zA-Z0-9_]+) \s+ in \s+ {expression} \s* :
+            """,
+            re.VERBOSE,
+        )
+
+        # Pattern for matching a simple expression (for completing [ or .
+        # operators).
+        self.expression_pattern = re.compile(
+            rf"""
+                {expression}
+                $
+            """,
+            re.VERBOSE,
+        )
+
+        # Pattern for matching item lookups.
+        self.item_lookup_pattern = re.compile(
+            rf"""
+                {expression}
+
+                # Dict lookup to complete (square bracket open + start of
+                # string).
+                \[
+                \s* ([^\[\]]*)$
+            """,
+            re.VERBOSE,
+        )
+
+        # Pattern for matching attribute lookups.
+        self.attribute_lookup_pattern = re.compile(
+            rf"""
+                {expression}
+
+                # Attribute lookup to complete (dot + varname).
+                \.
+                \s* ([a-zA-Z0-9_]*)$
+            """,
+            re.VERBOSE,
+        )
+
+    def _lookup(self, expression: str, temp_locals: dict[str, Any]) -> object:
+        """
+        Do lookup of `object_var` in the context.
+        `temp_locals` is a dictionary, used for the locals.
+        """
+        try:
+            return eval(expression.strip(), self.get_globals(), temp_locals)
+        except BaseException:
+            return None  # Many exception, like NameError can be thrown here.
+
+    def get_completions(
+        self, document: Document, complete_event: CompleteEvent
+    ) -> Iterable[Completion]:
+        # First, find all for-loops, and assign the first item of the
+        # collections they're iterating to the iterator variable, so that we
+        # can provide code completion on the iterators.
+        temp_locals = self.get_locals().copy()
+
+        for match in self.for_loop_pattern.finditer(document.text_before_cursor):
+            varname, expression = match.groups()
+            expression_val = self._lookup(expression, temp_locals)
+
+            # We do this only for lists and tuples. Calling `next()` on any
+            # collection would create undesired side effects.
+            if isinstance(expression_val, (list, tuple)) and expression_val:
+                temp_locals[varname] = expression_val[0]
+
+        # Get all completions.
+        yield from self._get_expression_completions(
+            document, complete_event, temp_locals
+        )
+        yield from self._get_item_lookup_completions(
+            document, complete_event, temp_locals
+        )
+        yield from self._get_attribute_completions(
+            document, complete_event, temp_locals
+        )
+
+    def _do_repr(self, obj: object) -> str:
+        try:
+            return str(repr(obj))
+        except BaseException:
+            raise ReprFailedError
+
+    def eval_expression(self, document: Document, locals: dict[str, Any]) -> object:
+        """
+        Evaluate
+        """
+        match = self.expression_pattern.search(document.text_before_cursor)
+        if match is not None:
+            object_var = match.groups()[0]
+            return self._lookup(object_var, locals)
+
+        return None
+
+    def _get_expression_completions(
+        self,
+        document: Document,
+        complete_event: CompleteEvent,
+        temp_locals: dict[str, Any],
+    ) -> Iterable[Completion]:
+        """
+        Complete the [ or . operator after an object.
+        """
+        result = self.eval_expression(document, temp_locals)
+
+        if result is not None:
+            if isinstance(
+                result,
+                (list, tuple, dict, collections_abc.Mapping, collections_abc.Sequence),
+            ):
+                yield Completion("[", 0)
+
+            else:
+                # Note: Don't call `if result` here. That can fail for types
+                #       that have custom truthness checks.
+                yield Completion(".", 0)
+
+    def _get_item_lookup_completions(
+        self,
+        document: Document,
+        complete_event: CompleteEvent,
+        temp_locals: dict[str, Any],
+    ) -> Iterable[Completion]:
+        """
+        Complete dictionary keys.
+        """
+
+        def meta_repr(obj: object, key: object) -> Callable[[], str]:
+            "Abbreviate meta text, make sure it fits on one line."
+            cached_result: str | None = None
+
+            # We return a function, so that it gets computed when it's needed.
+            # When there are many completions, that improves the performance
+            # quite a bit (for the multi-column completion menu, we only need
+            # to display one meta text).
+            # Note that we also do the lookup itself in here (`obj[key]`),
+            # because this part can also be slow for some mapping
+            # implementations.
+            def get_value_repr() -> str:
+                nonlocal cached_result
+                if cached_result is not None:
+                    return cached_result
+
+                try:
+                    value = obj[key]  # type: ignore
+
+                    text = self._do_repr(value)
+                except BaseException:
+                    return "-"
+
+                # Take first line, if multiple lines.
+                if "\n" in text:
+                    text = text.split("\n", 1)[0] + "..."
+
+                cached_result = text
+                return text
+
+            return get_value_repr
+
+        match = self.item_lookup_pattern.search(document.text_before_cursor)
+        if match is not None:
+            object_var, key = match.groups()
+
+            # Do lookup of `object_var` in the context.
+            result = self._lookup(object_var, temp_locals)
+
+            # If this object is a dictionary, complete the keys.
+            if isinstance(result, (dict, collections_abc.Mapping)):
+                # Try to evaluate the key.
+                key_obj_str = str(key)
+                for k in [key, key + '"', key + "'"]:
+                    try:
+                        key_obj_str = str(ast.literal_eval(k))
+                    except (SyntaxError, ValueError):
+                        continue
+                    else:
+                        break
+
+                for k in result:
+                    if str(k).startswith(key_obj_str):
+                        try:
+                            k_repr = self._do_repr(k)
+                            yield Completion(
+                                k_repr + "]",
+                                -len(key),
+                                display=f"[{k_repr}]",
+                                display_meta=meta_repr(result, k),
+                            )
+                        except ReprFailedError:
+                            pass
+
+            # Complete list/tuple index keys.
+            elif isinstance(result, (list, tuple, collections_abc.Sequence)):
+                if not key or key.isdigit():
+                    for k in range(min(len(result), 1000)):
+                        if str(k).startswith(key):
+                            try:
+                                k_repr = self._do_repr(k)
+                                yield Completion(
+                                    k_repr + "]",
+                                    -len(key),
+                                    display=f"[{k_repr}]",
+                                    display_meta=meta_repr(result, k),
+                                )
+                            except KeyError:
+                                # `result[k]` lookup failed. Trying to complete
+                                # broken object.
+                                pass
+                            except ReprFailedError:
+                                pass
+
+    def _get_attribute_completions(
+        self,
+        document: Document,
+        complete_event: CompleteEvent,
+        temp_locals: dict[str, Any],
+    ) -> Iterable[Completion]:
+        """
+        Complete attribute names.
+        """
+        match = self.attribute_lookup_pattern.search(document.text_before_cursor)
+        if match is not None:
+            object_var, attr_name = match.groups()
+
+            # Do lookup of `object_var` in the context.
+            result = self._lookup(object_var, temp_locals)
+
+            names = self._sort_attribute_names(dir(result))
+
+            def get_suffix(name: str) -> str:
+                try:
+                    obj = getattr(result, name, None)
+                    if inspect.isfunction(obj) or inspect.ismethod(obj):
+                        return "()"
+                    if isinstance(obj, collections_abc.Mapping):
+                        return "{}"
+                    if isinstance(obj, collections_abc.Sequence):
+                        return "[]"
+                except:
+                    pass
+                return ""
+
+            for name in names:
+                if name.startswith(attr_name):
+                    suffix = get_suffix(name)
+                    yield Completion(name, -len(attr_name), display=name + suffix)
+
+    def _sort_attribute_names(self, names: list[str]) -> list[str]:
+        """
+        Sort attribute names alphabetically, but move the double underscore and
+        underscore names to the end.
+        """
+
+        def sort_key(name: str) -> tuple[int, str]:
+            if name.startswith("__"):
+                return (2, name)  # Double underscore comes latest.
+            if name.startswith("_"):
+                return (1, name)  # Single underscore before that.
+            return (0, name)  # Other names first.
+
+        return sorted(names, key=sort_key)
+
+
+class HidePrivateCompleter(Completer):
+    """
+    Wrapper around completer that hides private fields, depending on whether or
+    not public fields are shown.
+
+    (The reason this is implemented as a `Completer` wrapper is because this
+    way it works also with `FuzzyCompleter`.)
+    """
+
+    def __init__(
+        self,
+        completer: Completer,
+        complete_private_attributes: Callable[[], CompletePrivateAttributes],
+    ) -> None:
+        self.completer = completer
+        self.complete_private_attributes = complete_private_attributes
+
+    def get_completions(
+        self, document: Document, complete_event: CompleteEvent
+    ) -> Iterable[Completion]:
+        completions = list(
+            # Limit at 5k completions for performance.
+            islice(self.completer.get_completions(document, complete_event), 0, 5000)
+        )
+        complete_private_attributes = self.complete_private_attributes()
+        hide_private = False
+
+        def is_private(completion: Completion) -> bool:
+            text = fragment_list_to_text(to_formatted_text(completion.display))
+            return text.startswith("_")
+
+        if complete_private_attributes == CompletePrivateAttributes.NEVER:
+            hide_private = True
+
+        elif complete_private_attributes == CompletePrivateAttributes.IF_NO_PUBLIC:
+            hide_private = any(not is_private(completion) for completion in completions)
+
+        if hide_private:
+            completions = [
+                completion for completion in completions if not is_private(completion)
+            ]
+
+        return completions
+
+
+class ReprFailedError(Exception):
+    "Raised when the repr() call in `DictionaryCompleter` fails."
+
+
+try:
+    import builtins
+
+    _builtin_names = dir(builtins)
+except ImportError:  # Python 2.
+    _builtin_names = []
+
+
+def _get_style_for_jedi_completion(
+    jedi_completion: jedi.api.classes.Completion,
+) -> str:
+    """
+    Return completion style to use for this name.
+    """
+    name = jedi_completion.name_with_symbols
+
+    if jedi_completion.type == "param":
+        return "class:completion.param"
+
+    if name in _builtin_names:
+        return "class:completion.builtin"
+
+    if keyword.iskeyword(name):
+        return "class:completion.keyword"
+
+    return ""
diff --git a/ptpython/__init__.py b/src/ptpython/contrib/__init__.py
similarity index 100%
rename from ptpython/__init__.py
rename to src/ptpython/contrib/__init__.py
diff --git a/ptpython/contrib/asyncssh_repl.py b/src/ptpython/contrib/asyncssh_repl.py
similarity index 52%
rename from ptpython/contrib/asyncssh_repl.py
rename to src/ptpython/contrib/asyncssh_repl.py
index 02b8fd9b..a86737b6 100644
--- a/ptpython/contrib/asyncssh_repl.py
+++ b/src/ptpython/contrib/asyncssh_repl.py
@@ -6,76 +6,71 @@
 should make sure not to use Python 3-only syntax, because this
 package should be installable in Python 2 as well!
 """
-from __future__ import unicode_literals
+
+from __future__ import annotations
 
 import asyncio
-import asyncssh
+from typing import Any, AnyStr, TextIO, cast
 
-from prompt_toolkit.input import PipeInput
-from prompt_toolkit.interface import CommandLineInterface
-from prompt_toolkit.layout.screen import Size
-from prompt_toolkit.shortcuts import create_asyncio_eventloop
-from prompt_toolkit.terminal.vt100_output import Vt100_Output
+import asyncssh
+from prompt_toolkit.data_structures import Size
+from prompt_toolkit.input import create_pipe_input
+from prompt_toolkit.output.vt100 import Vt100_Output
 
+from ptpython.python_input import _GetNamespace, _Namespace
 from ptpython.repl import PythonRepl
 
-__all__ = (
-    'ReplSSHServerSession',
-)
+__all__ = ["ReplSSHServerSession"]
 
 
-class ReplSSHServerSession(asyncssh.SSHServerSession):
+class ReplSSHServerSession(asyncssh.SSHServerSession[str]):
     """
     SSH server session that runs a Python REPL.
 
     :param get_globals: callable that returns the current globals.
     :param get_locals: (optional) callable that returns the current locals.
     """
-    def __init__(self, get_globals, get_locals=None):
-        assert callable(get_globals)
-        assert get_locals is None or callable(get_locals)
 
-        self._chan = None
+    def __init__(
+        self, get_globals: _GetNamespace, get_locals: _GetNamespace | None = None
+    ) -> None:
+        self._chan: Any = None
 
-        def _globals():
+        def _globals() -> _Namespace:
             data = get_globals()
-            data.setdefault('print', self._print)
+            data.setdefault("print", self._print)
             return data
 
-        repl = PythonRepl(get_globals=_globals,
-                          get_locals=get_locals or _globals)
-
-        # Disable open-in-editor and system prompt. Because it would run and
-        # display these commands on the server side, rather than in the SSH
-        # client.
-        repl.enable_open_in_editor = False
-        repl.enable_system_bindings = False
-
         # PipInput object, for sending input in the CLI.
         # (This is something that we can use in the prompt_toolkit event loop,
         # but still write date in manually.)
-        self._input_pipe = PipeInput()
+        self._input_pipe = create_pipe_input()
 
         # Output object. Don't render to the real stdout, but write everything
         # in the SSH channel.
-        class Stdout(object):
-            def write(s, data):
+        class Stdout:
+            def write(s, data: str) -> None:
                 if self._chan is not None:
-                    self._chan.write(data.replace('\n', '\r\n'))
+                    data = data.replace("\n", "\r\n")
+                    self._chan.write(data)
 
-            def flush(s):
+            def flush(s) -> None:
                 pass
 
-        # Create command line interface.
-        self.cli = CommandLineInterface(
-            application=repl.create_application(),
-            eventloop=create_asyncio_eventloop(),
+        self.repl = PythonRepl(
+            get_globals=_globals,
+            get_locals=get_locals or _globals,
             input=self._input_pipe,
-            output=Vt100_Output(Stdout(), self._get_size))
+            output=Vt100_Output(cast(TextIO, Stdout()), self._get_size),
+        )
 
-        self._callbacks = self.cli.create_eventloop_callbacks()
+        # Disable open-in-editor and system prompt. Because it would run and
+        # display these commands on the server side, rather than in the SSH
+        # client.
+        self.repl.enable_open_in_editor = False
+        self.repl.enable_system_bindings = False
 
-    def _get_size(self):
+    def _get_size(self) -> Size:
         """
         Callable that returns the current `Size`, required by Vt100_Output.
         """
@@ -85,49 +80,47 @@ def _get_size(self):
             width, height, pixwidth, pixheight = self._chan.get_terminal_size()
             return Size(rows=height, columns=width)
 
-    def connection_made(self, chan):
+    def connection_made(self, chan: Any) -> None:
         """
         Client connected, run repl in coroutine.
         """
         self._chan = chan
 
         # Run REPL interface.
-        f = asyncio.async(self.cli.run_async())
+        f = asyncio.ensure_future(self.repl.run_async())
 
         # Close channel when done.
-        def done(_):
+        def done(_: object) -> None:
             chan.close()
             self._chan = None
+
         f.add_done_callback(done)
 
-    def shell_requested(self):
+    def shell_requested(self) -> bool:
         return True
 
-    def terminal_size_changed(self, width, height, pixwidth, pixheight):
+    def terminal_size_changed(
+        self, width: int, height: int, pixwidth: int, pixheight: int
+    ) -> None:
         """
         When the terminal size changes, report back to CLI.
         """
-        self._callbacks.terminal_size_changed()
+        self.repl.app._on_resize()
 
-    def data_received(self, data, datatype):
+    def data_received(self, data: AnyStr, datatype: int | None) -> None:
         """
         When data is received, send to inputstream of the CLI and repaint.
         """
-        self._input_pipe.send(data)
+        self._input_pipe.send(data)  # type: ignore
 
-    def _print(self, *data, **kw):
+    def _print(
+        self, *data: object, sep: str = " ", end: str = "\n", file: Any = None
+    ) -> None:
         """
-        _print(self, *data, sep=' ', end='\n', file=None)
-
         Alternative 'print' function that prints back into the SSH channel.
         """
         # Pop keyword-only arguments. (We cannot use the syntax from the
         # signature. Otherwise, Python2 will give a syntax error message when
         # installing.)
-        sep = kw.pop('sep', ' ')
-        end = kw.pop('end', '\n')
-        _ = kw.pop('file', None)
-        assert not kw, 'Too many keyword-only arguments'
-
-        data = sep.join(map(str, data))
-        self._chan.write(data + end)
+        data_as_str = sep.join(map(str, data))
+        self._chan.write(data_as_str + end)
diff --git a/ptpython/contrib/__init__.py b/src/ptpython/entry_points/__init__.py
similarity index 100%
rename from ptpython/contrib/__init__.py
rename to src/ptpython/entry_points/__init__.py
diff --git a/src/ptpython/entry_points/run_ptipython.py b/src/ptpython/entry_points/run_ptipython.py
new file mode 100644
index 00000000..b660a0ac
--- /dev/null
+++ b/src/ptpython/entry_points/run_ptipython.py
@@ -0,0 +1,82 @@
+#!/usr/bin/env python
+from __future__ import annotations
+
+import os
+import sys
+
+from .run_ptpython import create_parser, get_config_and_history_file
+
+
+def run(user_ns=None):
+    a = create_parser().parse_args()
+
+    config_file, history_file = get_config_and_history_file(a)
+
+    # If IPython is not available, show message and exit here with error status
+    # code.
+    try:
+        import IPython
+    except ImportError:
+        print("IPython not found. Please install IPython (pip install ipython).")
+        sys.exit(1)
+    else:
+        from ptpython.ipython import embed
+        from ptpython.repl import enable_deprecation_warnings, run_config
+
+    # Add the current directory to `sys.path`.
+    if sys.path[0] != "":
+        sys.path.insert(0, "")
+
+    # When a file has been given, run that, otherwise start the shell.
+    if a.args and not a.interactive:
+        sys.argv = a.args
+        path = a.args[0]
+        with open(path, "rb") as f:
+            code = compile(f.read(), path, "exec")
+            exec(code, {"__name__": "__main__", "__file__": path})
+    else:
+        enable_deprecation_warnings()
+
+        # Create an empty namespace for this interactive shell. (If we don't do
+        # that, all the variables from this function will become available in
+        # the IPython shell.)
+        if user_ns is None:
+            user_ns = {}
+
+        # Startup path
+        startup_paths = []
+        if "PYTHONSTARTUP" in os.environ:
+            startup_paths.append(os.environ["PYTHONSTARTUP"])
+
+        # --interactive
+        if a.interactive:
+            startup_paths.append(a.args[0])
+            sys.argv = a.args
+
+        # exec scripts from startup paths
+        for path in startup_paths:
+            if os.path.exists(path):
+                with open(path, "rb") as f:
+                    code = compile(f.read(), path, "exec")
+                    exec(code, user_ns, user_ns)
+            else:
+                print(f"File not found: {path}\n\n")
+                sys.exit(1)
+
+        # Apply config file
+        def configure(repl):
+            if os.path.exists(config_file):
+                run_config(repl, config_file)
+
+        # Run interactive shell.
+        embed(
+            vi_mode=a.vi,
+            history_filename=history_file,
+            configure=configure,
+            user_ns=user_ns,
+            title="IPython REPL (ptipython)",
+        )
+
+
+if __name__ == "__main__":
+    run()
diff --git a/src/ptpython/entry_points/run_ptpython.py b/src/ptpython/entry_points/run_ptpython.py
new file mode 100644
index 00000000..d083858d
--- /dev/null
+++ b/src/ptpython/entry_points/run_ptpython.py
@@ -0,0 +1,234 @@
+#!/usr/bin/env python
+"""
+ptpython: Interactive Python shell.
+
+positional arguments:
+  args                  Script and arguments
+
+optional arguments:
+  -h, --help            show this help message and exit
+  --vi                  Enable Vi key bindings
+  -i, --interactive     Start interactive shell after executing this file.
+  --asyncio             Run an asyncio event loop to support top-level "await".
+  --light-bg            Run on a light background (use dark colors for text).
+  --dark-bg             Run on a dark background (use light colors for text).
+  --config-file CONFIG_FILE
+                        Location of configuration file.
+  --history-file HISTORY_FILE
+                        Location of history file.
+  -V, --version         show program's version number and exit
+
+environment variables:
+  PTPYTHON_CONFIG_HOME: a configuration directory to use
+  PYTHONSTARTUP: file executed on interactive startup (no default)
+"""
+
+from __future__ import annotations
+
+import argparse
+import asyncio
+import os
+import pathlib
+import sys
+from importlib import metadata
+from textwrap import dedent
+from typing import Protocol
+
+import appdirs
+from prompt_toolkit.formatted_text import HTML
+from prompt_toolkit.shortcuts import print_formatted_text
+
+from ptpython.repl import PythonRepl, embed, enable_deprecation_warnings, run_config
+
+__all__ = ["create_parser", "get_config_and_history_file", "run"]
+
+
+class _SupportsWrite(Protocol):
+    def write(self, s: str, /) -> object: ...
+
+
+class _Parser(argparse.ArgumentParser):
+    def print_help(self, file: _SupportsWrite | None = None) -> None:
+        super().print_help()
+        print(
+            dedent(
+                """
+                environment variables:
+                  PTPYTHON_CONFIG_HOME: a configuration directory to use
+                  PYTHONSTARTUP: file executed on interactive startup (no default)
+                """,
+            ).rstrip(),
+        )
+
+
+def create_parser() -> _Parser:
+    parser = _Parser(description="ptpython: Interactive Python shell.")
+    parser.add_argument("--vi", action="store_true", help="Enable Vi key bindings")
+    parser.add_argument(
+        "-i",
+        "--interactive",
+        action="store_true",
+        help="Start interactive shell after executing this file.",
+    )
+    parser.add_argument(
+        "--asyncio",
+        action="store_true",
+        help='Run an asyncio event loop to support top-level "await".',
+    )
+    parser.add_argument(
+        "--light-bg",
+        action="store_true",
+        help="Run on a light background (use dark colors for text).",
+    )
+    parser.add_argument(
+        "--dark-bg",
+        action="store_true",
+        help="Run on a dark background (use light colors for text).",
+    )
+    parser.add_argument(
+        "--config-file", type=str, help="Location of configuration file."
+    )
+    parser.add_argument("--history-file", type=str, help="Location of history file.")
+    parser.add_argument(
+        "-V",
+        "--version",
+        action="version",
+        version=metadata.version("ptpython"),
+    )
+    parser.add_argument("args", nargs="*", help="Script and arguments")
+    return parser
+
+
+def get_config_and_history_file(namespace: argparse.Namespace) -> tuple[str, str]:
+    """
+    Check which config/history files to use, ensure that the directories for
+    these files exist, and return the config and history path.
+    """
+    config_dir = os.environ.get(
+        "PTPYTHON_CONFIG_HOME",
+        appdirs.user_config_dir("ptpython", "prompt_toolkit"),
+    )
+    data_dir = appdirs.user_data_dir("ptpython", "prompt_toolkit")
+
+    # Create directories.
+    for d in (config_dir, data_dir):
+        pathlib.Path(d).mkdir(parents=True, exist_ok=True)
+
+    # Determine config file to be used.
+    config_file = os.path.join(config_dir, "config.py")
+    legacy_config_file = os.path.join(os.path.expanduser("~/.ptpython"), "config.py")
+
+    warnings = []
+
+    # Config file
+    if namespace.config_file:
+        # Override config_file.
+        config_file = os.path.expanduser(namespace.config_file)
+
+    elif os.path.isfile(legacy_config_file):
+        # Warn about the legacy configuration file.
+        warnings.append(
+            HTML(
+                "    <i>~/.ptpython/config.py</i> is deprecated, move your configuration to <i>%s</i>\n"
+            )
+            % config_file
+        )
+        config_file = legacy_config_file
+
+    # Determine history file to be used.
+    history_file = os.path.join(data_dir, "history")
+    legacy_history_file = os.path.join(os.path.expanduser("~/.ptpython"), "history")
+
+    if namespace.history_file:
+        # Override history_file.
+        history_file = os.path.expanduser(namespace.history_file)
+
+    elif os.path.isfile(legacy_history_file):
+        # Warn about the legacy history file.
+        warnings.append(
+            HTML(
+                "    <i>~/.ptpython/history</i> is deprecated, move your history to <i>%s</i>\n"
+            )
+            % history_file
+        )
+        history_file = legacy_history_file
+
+    # Print warnings.
+    if warnings:
+        print_formatted_text(HTML("<u>Warning:</u>"))
+        for w in warnings:
+            print_formatted_text(w)
+
+    return config_file, history_file
+
+
+def run() -> None:
+    a = create_parser().parse_args()
+
+    config_file, history_file = get_config_and_history_file(a)
+
+    # Startup path
+    startup_paths = []
+    if "PYTHONSTARTUP" in os.environ:
+        startup_paths.append(os.environ["PYTHONSTARTUP"])
+
+    # --interactive
+    if a.interactive and a.args:
+        # Note that we shouldn't run PYTHONSTARTUP when -i is given.
+        startup_paths = [a.args[0]]
+        sys.argv = a.args
+
+    # Add the current directory to `sys.path`.
+    if sys.path[0] != "":
+        sys.path.insert(0, "")
+
+    # When a file has been given, run that, otherwise start the shell.
+    if a.args and not a.interactive:
+        sys.argv = a.args
+        path = a.args[0]
+        with open(path, "rb") as f:
+            code = compile(f.read(), path, "exec")
+            # NOTE: We have to pass a dict as namespace. Omitting this argument
+            #       causes imports to not be found. See issue #326.
+            #       However, an empty dict sets __name__ to 'builtins', which
+            #       breaks `if __name__ == '__main__'` checks. See issue #444.
+            exec(code, {"__name__": "__main__", "__file__": path})
+
+    # Run interactive shell.
+    else:
+        enable_deprecation_warnings()
+
+        # Apply config file
+        def configure(repl: PythonRepl) -> None:
+            if os.path.exists(config_file):
+                run_config(repl, config_file)
+
+            # Adjust colors if dark/light background flag has been given.
+            if a.light_bg:
+                repl.min_brightness = 0.0
+                repl.max_brightness = 0.60
+            elif a.dark_bg:
+                repl.min_brightness = 0.60
+                repl.max_brightness = 1.0
+
+        import __main__
+
+        embed_result = embed(  # type: ignore
+            vi_mode=a.vi,
+            history_filename=history_file,
+            configure=configure,
+            locals=__main__.__dict__,
+            globals=__main__.__dict__,
+            startup_paths=startup_paths,
+            title="Python REPL (ptpython)",
+            return_asyncio_coroutine=a.asyncio,
+        )
+
+        if a.asyncio:
+            print("Starting ptpython asyncio REPL")
+            print('Use "await" directly instead of "asyncio.run()".')
+            asyncio.run(embed_result)
+
+
+if __name__ == "__main__":
+    run()
diff --git a/ptpython/eventloop.py b/src/ptpython/eventloop.py
similarity index 72%
rename from ptpython/eventloop.py
rename to src/ptpython/eventloop.py
index b51e877b..a6462748 100644
--- a/ptpython/eventloop.py
+++ b/src/ptpython/eventloop.py
@@ -3,30 +3,33 @@
 events when it's loaded and while we are waiting for input at the REPL. This
 way we don't block the UI of for instance ``turtle`` and other Tk libraries.
 
-(Normally Tkinter registeres it's callbacks in ``PyOS_InputHook`` to integrate
+(Normally Tkinter registers it's callbacks in ``PyOS_InputHook`` to integrate
 in readline. ``prompt-toolkit`` doesn't understand that input hook, but this
 will fix it for Tk.)
 """
-from prompt_toolkit.shortcuts import create_eventloop as _create_eventloop
+
+from __future__ import annotations
+
 import sys
 import time
 
-__all__ = (
-    'create_eventloop',
-)
+from prompt_toolkit.eventloop import InputHookContext
 
+__all__ = ["inputhook"]
 
-def _inputhook_tk(inputhook_context):
+
+def _inputhook_tk(inputhook_context: InputHookContext) -> None:
     """
     Inputhook for Tk.
     Run the Tk eventloop until prompt-toolkit needs to process the next input.
     """
     # Get the current TK application.
     import _tkinter  # Keep this imports inline!
-    from six.moves import tkinter
-    root = tkinter._default_root
+    import tkinter
+
+    root = tkinter._default_root  # type: ignore
 
-    def wait_using_filehandler():
+    def wait_using_filehandler() -> None:
         """
         Run the TK eventloop until the file handler that we got from the
         inputhook becomes readable.
@@ -34,7 +37,8 @@ def wait_using_filehandler():
         # Add a handler that sets the stop flag when `prompt-toolkit` has input
         # to process.
         stop = [False]
-        def done(*a):
+
+        def done(*a: object) -> None:
             stop[0] = True
 
         root.createfilehandler(inputhook_context.fileno(), _tkinter.READABLE, done)
@@ -46,30 +50,26 @@ def done(*a):
 
         root.deletefilehandler(inputhook_context.fileno())
 
-    def wait_using_polling():
+    def wait_using_polling() -> None:
         """
         Windows TK doesn't support 'createfilehandler'.
         So, run the TK eventloop and poll until input is ready.
         """
         while not inputhook_context.input_is_ready():
             while root.dooneevent(_tkinter.ALL_EVENTS | _tkinter.DONT_WAIT):
-                 pass
+                pass
             # Sleep to make the CPU idle, but not too long, so that the UI
             # stays responsive.
-            time.sleep(.01)
+            time.sleep(0.01)
 
     if root is not None:
-        if hasattr(root, 'createfilehandler'):
+        if hasattr(root, "createfilehandler"):
             wait_using_filehandler()
         else:
             wait_using_polling()
 
 
-def _inputhook(inputhook_context):
+def inputhook(inputhook_context: InputHookContext) -> None:
     # Only call the real input hook when the 'Tkinter' library was loaded.
-    if 'Tkinter' in sys.modules or 'tkinter' in sys.modules:
+    if "Tkinter" in sys.modules or "tkinter" in sys.modules:
         _inputhook_tk(inputhook_context)
-
-
-def create_eventloop():
-    return _create_eventloop(inputhook=_inputhook)
diff --git a/ptpython/filters.py b/src/ptpython/filters.py
similarity index 53%
rename from ptpython/filters.py
rename to src/ptpython/filters.py
index 1180af13..a2079fd3 100644
--- a/ptpython/filters.py
+++ b/src/ptpython/filters.py
@@ -1,37 +1,39 @@
-from __future__ import unicode_literals
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
 
 from prompt_toolkit.filters import Filter
 
-__all__ = (
-    'HasSignature',
-    'ShowSidebar',
-    'ShowDocstring',
-)
+if TYPE_CHECKING:
+    from .python_input import PythonInput
+
+__all__ = ["HasSignature", "ShowSidebar", "ShowSignature", "ShowDocstring"]
 
 
 class PythonInputFilter(Filter):
-    def __init__(self, python_input):
+    def __init__(self, python_input: PythonInput) -> None:
+        super().__init__()
         self.python_input = python_input
 
-    def __call__(self, cli):
+    def __call__(self) -> bool:
         raise NotImplementedError
 
 
 class HasSignature(PythonInputFilter):
-    def __call__(self, cli):
+    def __call__(self) -> bool:
         return bool(self.python_input.signatures)
 
 
 class ShowSidebar(PythonInputFilter):
-    def __call__(self, cli):
+    def __call__(self) -> bool:
         return self.python_input.show_sidebar
 
 
 class ShowSignature(PythonInputFilter):
-    def __call__(self, cli):
+    def __call__(self) -> bool:
         return self.python_input.show_signature
 
 
 class ShowDocstring(PythonInputFilter):
-    def __call__(self, cli):
+    def __call__(self) -> bool:
         return self.python_input.show_docstring
diff --git a/src/ptpython/history_browser.py b/src/ptpython/history_browser.py
new file mode 100644
index 00000000..101a6c5c
--- /dev/null
+++ b/src/ptpython/history_browser.py
@@ -0,0 +1,691 @@
+"""
+Utility to easily select lines from the history and execute them again.
+
+`create_history_application` creates an `Application` instance that runs will
+run as a sub application of the Repl/PythonInput.
+"""
+
+from __future__ import annotations
+
+from functools import partial
+from typing import TYPE_CHECKING, Callable
+
+from prompt_toolkit.application import Application
+from prompt_toolkit.application.current import get_app
+from prompt_toolkit.buffer import Buffer
+from prompt_toolkit.document import Document
+from prompt_toolkit.enums import DEFAULT_BUFFER
+from prompt_toolkit.filters import Condition, has_focus
+from prompt_toolkit.formatted_text.base import StyleAndTextTuples
+from prompt_toolkit.formatted_text.utils import fragment_list_to_text
+from prompt_toolkit.history import History
+from prompt_toolkit.key_binding import KeyBindings
+from prompt_toolkit.key_binding.key_processor import KeyPressEvent
+from prompt_toolkit.layout.containers import (
+    ConditionalContainer,
+    Container,
+    Float,
+    FloatContainer,
+    HSplit,
+    ScrollOffsets,
+    VSplit,
+    Window,
+    WindowAlign,
+    WindowRenderInfo,
+)
+from prompt_toolkit.layout.controls import (
+    BufferControl,
+    FormattedTextControl,
+    UIContent,
+)
+from prompt_toolkit.layout.dimension import Dimension as D
+from prompt_toolkit.layout.layout import Layout
+from prompt_toolkit.layout.margins import Margin, ScrollbarMargin
+from prompt_toolkit.layout.processors import (
+    Processor,
+    Transformation,
+    TransformationInput,
+)
+from prompt_toolkit.lexers import PygmentsLexer
+from prompt_toolkit.mouse_events import MouseEvent
+from prompt_toolkit.widgets import Frame
+from prompt_toolkit.widgets.toolbars import ArgToolbar, SearchToolbar
+from pygments.lexers import Python3Lexer as PythonLexer
+from pygments.lexers import RstLexer
+
+from ptpython.layout import get_inputmode_fragments
+
+from .utils import if_mousedown
+
+if TYPE_CHECKING:
+    from typing_extensions import TypeAlias
+
+    from .python_input import PythonInput
+
+HISTORY_COUNT = 2000
+
+__all__ = ["HistoryLayout", "PythonHistory"]
+
+E: TypeAlias = KeyPressEvent
+
+HELP_TEXT = """
+This interface is meant to select multiple lines from the
+history and execute them together.
+
+Typical usage
+-------------
+
+1. Move the ``cursor up`` in the history pane, until the
+   cursor is on the first desired line.
+2. Hold down the ``space bar``, or press it multiple
+   times. Each time it will select one line and move to
+   the next one. Each selected line will appear on the
+   right side.
+3. When all the required lines are displayed on the right
+   side, press ``Enter``. This will go back to the Python
+   REPL and show these lines as the current input. They
+   can still be edited from there.
+
+Key bindings
+------------
+
+Many Emacs and Vi navigation key bindings should work.
+Press ``F4`` to switch between Emacs and Vi mode.
+
+Additional bindings:
+
+- ``Space``: Select or delect a line.
+- ``Tab``: Move the focus between the history and input
+  pane. (Alternative: ``Ctrl-W``)
+- ``Ctrl-C``: Cancel. Ignore the result and go back to
+  the REPL. (Alternatives: ``q`` and ``Control-G``.)
+- ``Enter``: Accept the result and go back to the REPL.
+- ``F1``: Show/hide help. Press ``Enter`` to quit this
+  help message.
+
+Further, remember that searching works like in Emacs
+(using ``Ctrl-R``) or Vi (using ``/``).
+"""
+
+
+class BORDER:
+    "Box drawing characters."
+
+    HORIZONTAL = "\u2501"
+    VERTICAL = "\u2503"
+    TOP_LEFT = "\u250f"
+    TOP_RIGHT = "\u2513"
+    BOTTOM_LEFT = "\u2517"
+    BOTTOM_RIGHT = "\u251b"
+    LIGHT_VERTICAL = "\u2502"
+
+
+def _create_popup_window(title: str, body: Container) -> Frame:
+    """
+    Return the layout for a pop-up window. It consists of a title bar showing
+    the `title` text, and a body layout. The window is surrounded by borders.
+    """
+    return Frame(body=body, title=title)
+
+
+class HistoryLayout:
+    """
+    Create and return a `Container` instance for the history
+    application.
+    """
+
+    def __init__(self, history: PythonHistory) -> None:
+        search_toolbar = SearchToolbar()
+
+        self.help_buffer_control = BufferControl(
+            buffer=history.help_buffer, lexer=PygmentsLexer(RstLexer)
+        )
+
+        help_window = _create_popup_window(
+            title="History Help",
+            body=Window(
+                content=self.help_buffer_control,
+                right_margins=[ScrollbarMargin(display_arrows=True)],
+                scroll_offsets=ScrollOffsets(top=2, bottom=2),
+            ),
+        )
+
+        self.default_buffer_control = BufferControl(
+            buffer=history.default_buffer,
+            input_processors=[GrayExistingText(history.history_mapping)],
+            lexer=PygmentsLexer(PythonLexer),
+        )
+
+        self.history_buffer_control = BufferControl(
+            buffer=history.history_buffer,
+            lexer=PygmentsLexer(PythonLexer),
+            search_buffer_control=search_toolbar.control,
+            preview_search=True,
+        )
+
+        history_window = Window(
+            content=self.history_buffer_control,
+            wrap_lines=False,
+            left_margins=[HistoryMargin(history)],
+            scroll_offsets=ScrollOffsets(top=2, bottom=2),
+        )
+
+        self.root_container = HSplit(
+            [
+                #  Top title bar.
+                Window(
+                    content=FormattedTextControl(_get_top_toolbar_fragments),
+                    align=WindowAlign.CENTER,
+                    style="class:status-toolbar",
+                ),
+                FloatContainer(
+                    content=VSplit(
+                        [
+                            # Left side: history.
+                            history_window,
+                            # Separator.
+                            Window(
+                                width=D.exact(1),
+                                char=BORDER.LIGHT_VERTICAL,
+                                style="class:separator",
+                            ),
+                            # Right side: result.
+                            Window(
+                                content=self.default_buffer_control,
+                                wrap_lines=False,
+                                left_margins=[ResultMargin(history)],
+                                scroll_offsets=ScrollOffsets(top=2, bottom=2),
+                            ),
+                        ]
+                    ),
+                    floats=[
+                        # Help text as a float.
+                        Float(
+                            width=60,
+                            top=3,
+                            bottom=2,
+                            content=ConditionalContainer(
+                                content=help_window,
+                                filter=has_focus(history.help_buffer),
+                            ),
+                        )
+                    ],
+                ),
+                # Bottom toolbars.
+                ArgToolbar(),
+                search_toolbar,
+                Window(
+                    content=FormattedTextControl(
+                        partial(_get_bottom_toolbar_fragments, history=history)
+                    ),
+                    style="class:status-toolbar",
+                ),
+            ]
+        )
+
+        self.layout = Layout(self.root_container, history_window)
+
+
+def _get_top_toolbar_fragments() -> StyleAndTextTuples:
+    return [("class:status-bar.title", "History browser - Insert from history")]
+
+
+def _get_bottom_toolbar_fragments(history: PythonHistory) -> StyleAndTextTuples:
+    python_input = history.python_input
+
+    @if_mousedown
+    def f1(mouse_event: MouseEvent) -> None:
+        _toggle_help(history)
+
+    @if_mousedown
+    def tab(mouse_event: MouseEvent) -> None:
+        _select_other_window(history)
+
+    return (
+        [("class:status-toolbar", " ")]
+        + get_inputmode_fragments(python_input)
+        + [
+            ("class:status-toolbar", " "),
+            ("class:status-toolbar.key", "[Space]"),
+            ("class:status-toolbar", " Toggle "),
+            ("class:status-toolbar.key", "[Tab]", tab),
+            ("class:status-toolbar", " Focus ", tab),
+            ("class:status-toolbar.key", "[Enter]"),
+            ("class:status-toolbar", " Accept "),
+            ("class:status-toolbar.key", "[F1]", f1),
+            ("class:status-toolbar", " Help ", f1),
+        ]
+    )
+
+
+class HistoryMargin(Margin):
+    """
+    Margin for the history buffer.
+    This displays a green bar for the selected entries.
+    """
+
+    def __init__(self, history: PythonHistory) -> None:
+        self.history_buffer = history.history_buffer
+        self.history_mapping = history.history_mapping
+
+    def get_width(self, get_ui_content: Callable[[], UIContent]) -> int:
+        return 2
+
+    def create_margin(
+        self, window_render_info: WindowRenderInfo, width: int, height: int
+    ) -> StyleAndTextTuples:
+        document = self.history_buffer.document
+
+        lines_starting_new_entries = self.history_mapping.lines_starting_new_entries
+        selected_lines = self.history_mapping.selected_lines
+
+        current_lineno = document.cursor_position_row
+
+        visible_line_to_input_line = window_render_info.visible_line_to_input_line
+        result: StyleAndTextTuples = []
+
+        for y in range(height):
+            line_number = visible_line_to_input_line.get(y)
+
+            # Show stars at the start of each entry.
+            # (Visualises multiline entries.)
+            if line_number in lines_starting_new_entries:
+                char = "*"
+            else:
+                char = " "
+
+            if line_number in selected_lines:
+                t = "class:history-line,selected"
+            else:
+                t = "class:history-line"
+
+            if line_number == current_lineno:
+                t = t + ",current"
+
+            result.append((t, char))
+            result.append(("", "\n"))
+
+        return result
+
+
+class ResultMargin(Margin):
+    """
+    The margin to be shown in the result pane.
+    """
+
+    def __init__(self, history: PythonHistory) -> None:
+        self.history_mapping = history.history_mapping
+        self.history_buffer = history.history_buffer
+
+    def get_width(self, get_ui_content: Callable[[], UIContent]) -> int:
+        return 2
+
+    def create_margin(
+        self, window_render_info: WindowRenderInfo, width: int, height: int
+    ) -> StyleAndTextTuples:
+        document = self.history_buffer.document
+
+        current_lineno = document.cursor_position_row
+        offset = (
+            self.history_mapping.result_line_offset
+        )  # original_document.cursor_position_row
+
+        visible_line_to_input_line = window_render_info.visible_line_to_input_line
+
+        result: StyleAndTextTuples = []
+
+        for y in range(height):
+            line_number = visible_line_to_input_line.get(y)
+
+            if (
+                line_number is None
+                or line_number < offset
+                or line_number >= offset + len(self.history_mapping.selected_lines)
+            ):
+                t = ""
+            elif line_number == current_lineno:
+                t = "class:history-line,selected,current"
+            else:
+                t = "class:history-line,selected"
+
+            result.append((t, " "))
+            result.append(("", "\n"))
+
+        return result
+
+    def invalidation_hash(self, document: Document) -> int:
+        return document.cursor_position_row
+
+
+class GrayExistingText(Processor):
+    """
+    Turn the existing input, before and after the inserted code gray.
+    """
+
+    def __init__(self, history_mapping: HistoryMapping) -> None:
+        self.history_mapping = history_mapping
+        self._lines_before = len(
+            history_mapping.original_document.text_before_cursor.splitlines()
+        )
+
+    def apply_transformation(
+        self, transformation_input: TransformationInput
+    ) -> Transformation:
+        lineno = transformation_input.lineno
+        fragments = transformation_input.fragments
+
+        if lineno < self._lines_before or lineno >= self._lines_before + len(
+            self.history_mapping.selected_lines
+        ):
+            text = fragment_list_to_text(fragments)
+            return Transformation(fragments=[("class:history.existing-input", text)])
+        else:
+            return Transformation(fragments=fragments)
+
+
+class HistoryMapping:
+    """
+    Keep a list of all the lines from the history and the selected lines.
+    """
+
+    def __init__(
+        self,
+        history: PythonHistory,
+        python_history: History,
+        original_document: Document,
+    ) -> None:
+        self.history = history
+        self.python_history = python_history
+        self.original_document = original_document
+
+        self.lines_starting_new_entries = set()
+        self.selected_lines: set[int] = set()
+
+        # Process history.
+        history_strings = python_history.get_strings()
+        history_lines: list[str] = []
+
+        for entry_nr, entry in list(enumerate(history_strings))[-HISTORY_COUNT:]:
+            self.lines_starting_new_entries.add(len(history_lines))
+
+            for line in entry.splitlines():
+                history_lines.append(line)
+
+        if len(history_strings) > HISTORY_COUNT:
+            history_lines[0] = (
+                f"# *** History has been truncated to {HISTORY_COUNT} lines ***"
+            )
+
+        self.history_lines = history_lines
+        self.concatenated_history = "\n".join(history_lines)
+
+        # Line offset.
+        if self.original_document.text_before_cursor:
+            self.result_line_offset = self.original_document.cursor_position_row + 1
+        else:
+            self.result_line_offset = 0
+
+    def get_new_document(self, cursor_pos: int | None = None) -> Document:
+        """
+        Create a `Document` instance that contains the resulting text.
+        """
+        lines = []
+
+        # Original text, before cursor.
+        if self.original_document.text_before_cursor:
+            lines.append(self.original_document.text_before_cursor)
+
+        # Selected entries from the history.
+        for line_no in sorted(self.selected_lines):
+            lines.append(self.history_lines[line_no])
+
+        # Original text, after cursor.
+        if self.original_document.text_after_cursor:
+            lines.append(self.original_document.text_after_cursor)
+
+        # Create `Document` with cursor at the right position.
+        text = "\n".join(lines)
+        if cursor_pos is not None and cursor_pos > len(text):
+            cursor_pos = len(text)
+        return Document(text, cursor_pos)
+
+    def update_default_buffer(self) -> None:
+        b = self.history.default_buffer
+
+        b.set_document(self.get_new_document(b.cursor_position), bypass_readonly=True)
+
+
+def _toggle_help(history: PythonHistory) -> None:
+    "Display/hide help."
+    help_buffer_control = history.history_layout.help_buffer_control
+
+    if history.app.layout.current_control == help_buffer_control:
+        history.app.layout.focus_previous()
+    else:
+        history.app.layout.current_control = help_buffer_control
+
+
+def _select_other_window(history: PythonHistory) -> None:
+    "Toggle focus between left/right window."
+    current_buffer = history.app.current_buffer
+    layout = history.history_layout.layout
+
+    if current_buffer == history.history_buffer:
+        layout.current_control = history.history_layout.default_buffer_control
+
+    elif current_buffer == history.default_buffer:
+        layout.current_control = history.history_layout.history_buffer_control
+
+
+def create_key_bindings(
+    history: PythonHistory,
+    python_input: PythonInput,
+    history_mapping: HistoryMapping,
+) -> KeyBindings:
+    """
+    Key bindings.
+    """
+    bindings = KeyBindings()
+    handle = bindings.add
+
+    @handle(" ", filter=has_focus(history.history_buffer))
+    def _(event: E) -> None:
+        """
+        Space: select/deselect line from history pane.
+        """
+        b = event.current_buffer
+        line_no = b.document.cursor_position_row
+
+        if not history_mapping.history_lines:
+            # If we've no history, then nothing to do
+            return
+
+        if line_no in history_mapping.selected_lines:
+            # Remove line.
+            history_mapping.selected_lines.remove(line_no)
+            history_mapping.update_default_buffer()
+        else:
+            # Add line.
+            history_mapping.selected_lines.add(line_no)
+            history_mapping.update_default_buffer()
+
+            # Update cursor position
+            default_buffer = history.default_buffer
+            default_lineno = (
+                sorted(history_mapping.selected_lines).index(line_no)
+                + history_mapping.result_line_offset
+            )
+            default_buffer.cursor_position = (
+                default_buffer.document.translate_row_col_to_index(default_lineno, 0)
+            )
+
+        # Also move the cursor to the next line. (This way they can hold
+        # space to select a region.)
+        b.cursor_position = b.document.translate_row_col_to_index(line_no + 1, 0)
+
+    @handle(" ", filter=has_focus(DEFAULT_BUFFER))
+    @handle("delete", filter=has_focus(DEFAULT_BUFFER))
+    @handle("c-h", filter=has_focus(DEFAULT_BUFFER))
+    def _(event: E) -> None:
+        """
+        Space: remove line from default pane.
+        """
+        b = event.current_buffer
+        line_no = b.document.cursor_position_row - history_mapping.result_line_offset
+
+        if line_no >= 0:
+            try:
+                history_lineno = sorted(history_mapping.selected_lines)[line_no]
+            except IndexError:
+                pass  # When `selected_lines` is an empty set.
+            else:
+                history_mapping.selected_lines.remove(history_lineno)
+
+            history_mapping.update_default_buffer()
+
+    help_focussed = has_focus(history.help_buffer)
+    main_buffer_focussed = has_focus(history.history_buffer) | has_focus(
+        history.default_buffer
+    )
+
+    @handle("tab", filter=main_buffer_focussed)
+    @handle("c-x", filter=main_buffer_focussed, eager=True)
+    # Eager: ignore the Emacs [Ctrl-X Ctrl-X] binding.
+    @handle("c-w", filter=main_buffer_focussed)
+    def _(event: E) -> None:
+        "Select other window."
+        _select_other_window(history)
+
+    @handle("f4")
+    def _(event: E) -> None:
+        "Switch between Emacs/Vi mode."
+        python_input.vi_mode = not python_input.vi_mode
+
+    @handle("f1")
+    def _(event: E) -> None:
+        "Display/hide help."
+        _toggle_help(history)
+
+    @handle("enter", filter=help_focussed)
+    @handle("c-c", filter=help_focussed)
+    @handle("c-g", filter=help_focussed)
+    @handle("escape", filter=help_focussed)
+    def _(event: E) -> None:
+        "Leave help."
+        event.app.layout.focus_previous()
+
+    @handle("q", filter=main_buffer_focussed)
+    @handle("f3", filter=main_buffer_focussed)
+    @handle("c-c", filter=main_buffer_focussed)
+    @handle("c-g", filter=main_buffer_focussed)
+    def _(event: E) -> None:
+        "Cancel and go back."
+        event.app.exit(result=None)
+
+    @handle("enter", filter=main_buffer_focussed)
+    def _(event: E) -> None:
+        "Accept input."
+        event.app.exit(result=history.default_buffer.text)
+
+    enable_system_bindings = Condition(lambda: python_input.enable_system_bindings)
+
+    @handle("c-z", filter=enable_system_bindings)
+    def _(event: E) -> None:
+        "Suspend to background."
+        event.app.suspend_to_background()
+
+    return bindings
+
+
+class PythonHistory:
+    def __init__(self, python_input: PythonInput, original_document: Document) -> None:
+        """
+        Create an `Application` for the history screen.
+        This has to be run as a sub application of `python_input`.
+
+        When this application runs and returns, it returns the selected lines.
+        """
+        self.python_input = python_input
+
+        history_mapping = HistoryMapping(self, python_input.history, original_document)
+        self.history_mapping = history_mapping
+
+        document = Document(history_mapping.concatenated_history)
+        document = Document(
+            document.text,
+            cursor_position=document.cursor_position
+            + document.get_start_of_line_position(),
+        )
+
+        def accept_handler(buffer: Buffer) -> bool:
+            get_app().exit(result=self.default_buffer.text)
+            return False
+
+        self.history_buffer = Buffer(
+            document=document,
+            on_cursor_position_changed=self._history_buffer_pos_changed,
+            accept_handler=accept_handler,
+            read_only=True,
+        )
+
+        self.default_buffer = Buffer(
+            name=DEFAULT_BUFFER,
+            document=history_mapping.get_new_document(),
+            on_cursor_position_changed=self._default_buffer_pos_changed,
+            read_only=True,
+        )
+
+        self.help_buffer = Buffer(document=Document(HELP_TEXT, 0), read_only=True)
+
+        self.history_layout = HistoryLayout(self)
+
+        self.app: Application[str] = Application(
+            layout=self.history_layout.layout,
+            full_screen=True,
+            style=python_input._current_style,
+            style_transformation=python_input.style_transformation,
+            mouse_support=Condition(lambda: python_input.enable_mouse_support),
+            key_bindings=create_key_bindings(self, python_input, history_mapping),
+        )
+
+    def _default_buffer_pos_changed(self, _: Buffer) -> None:
+        """When the cursor changes in the default buffer. Synchronize with
+        history buffer."""
+        # Only when this buffer has the focus.
+        if self.app.current_buffer == self.default_buffer:
+            try:
+                line_no = (
+                    self.default_buffer.document.cursor_position_row
+                    - self.history_mapping.result_line_offset
+                )
+
+                if line_no < 0:  # When the cursor is above the inserted region.
+                    raise IndexError
+
+                history_lineno = sorted(self.history_mapping.selected_lines)[line_no]
+            except IndexError:
+                pass
+            else:
+                self.history_buffer.cursor_position = (
+                    self.history_buffer.document.translate_row_col_to_index(
+                        history_lineno, 0
+                    )
+                )
+
+    def _history_buffer_pos_changed(self, _: Buffer) -> None:
+        """When the cursor changes in the history buffer. Synchronize."""
+        # Only when this buffer has the focus.
+        if self.app.current_buffer == self.history_buffer:
+            line_no = self.history_buffer.document.cursor_position_row
+
+            if line_no in self.history_mapping.selected_lines:
+                default_lineno = (
+                    sorted(self.history_mapping.selected_lines).index(line_no)
+                    + self.history_mapping.result_line_offset
+                )
+
+                self.default_buffer.cursor_position = (
+                    self.default_buffer.document.translate_row_col_to_index(
+                        default_lineno, 0
+                    )
+                )
diff --git a/src/ptpython/ipython.py b/src/ptpython/ipython.py
new file mode 100644
index 00000000..0692214d
--- /dev/null
+++ b/src/ptpython/ipython.py
@@ -0,0 +1,339 @@
+"""
+
+Adaptor for using the input system of `prompt_toolkit` with the IPython
+backend.
+
+This gives a powerful interactive shell that has a nice user interface, but
+also the power of for instance all the %-magic functions that IPython has to
+offer.
+
+"""
+
+from __future__ import annotations
+
+from typing import Iterable
+from warnings import warn
+
+from IPython import utils as ipy_utils
+from IPython.core.inputtransformer2 import TransformerManager
+from IPython.terminal.embed import InteractiveShellEmbed as _InteractiveShellEmbed
+from IPython.terminal.ipapp import load_default_config
+from prompt_toolkit.completion import (
+    CompleteEvent,
+    Completer,
+    Completion,
+    PathCompleter,
+    WordCompleter,
+)
+from prompt_toolkit.contrib.completers import SystemCompleter
+from prompt_toolkit.contrib.regular_languages.compiler import compile
+from prompt_toolkit.contrib.regular_languages.completion import GrammarCompleter
+from prompt_toolkit.contrib.regular_languages.lexer import GrammarLexer
+from prompt_toolkit.document import Document
+from prompt_toolkit.formatted_text import AnyFormattedText, PygmentsTokens
+from prompt_toolkit.lexers import PygmentsLexer, SimpleLexer
+from prompt_toolkit.styles import Style
+from pygments.lexers import BashLexer, PythonLexer
+
+from ptpython.prompt_style import PromptStyle
+
+from .completer import PythonCompleter
+from .python_input import PythonInput
+from .repl import PyCF_ALLOW_TOP_LEVEL_AWAIT
+from .style import default_ui_style
+from .validator import PythonValidator
+
+__all__ = ["embed"]
+
+
+class IPythonPrompt(PromptStyle):
+    """
+    Style for IPython >5.0, use the prompt_toolkit tokens directly.
+    """
+
+    def __init__(self, prompts):
+        self.prompts = prompts
+
+    def in_prompt(self) -> AnyFormattedText:
+        return PygmentsTokens(self.prompts.in_prompt_tokens())
+
+    def in2_prompt(self, width: int) -> AnyFormattedText:
+        return PygmentsTokens(self.prompts.continuation_prompt_tokens())
+
+    def out_prompt(self) -> AnyFormattedText:
+        return []
+
+
+class IPythonValidator(PythonValidator):
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+        self.isp = TransformerManager()
+
+    def validate(self, document: Document) -> None:
+        document = Document(text=self.isp.transform_cell(document.text))
+        super().validate(document)
+
+
+def create_ipython_grammar():
+    """
+    Return compiled IPython grammar.
+    """
+    return compile(
+        r"""
+        \s*
+        (
+            (?P<percent>%)(
+                (?P<magic>pycat|run|loadpy|load)  \s+ (?P<py_filename>[^\s]+)  |
+                (?P<magic>cat)                    \s+ (?P<filename>[^\s]+)     |
+                (?P<magic>pushd|cd|ls)            \s+ (?P<directory>[^\s]+)    |
+                (?P<magic>pdb)                    \s+ (?P<pdb_arg>[^\s]+)      |
+                (?P<magic>autocall)               \s+ (?P<autocall_arg>[^\s]+) |
+                (?P<magic>time|timeit|prun)       \s+ (?P<python>.+)           |
+                (?P<magic>psource|pfile|pinfo|pinfo2) \s+ (?P<python>.+)       |
+                (?P<magic>system)                 \s+ (?P<system>.+)           |
+                (?P<magic>unalias)                \s+ (?P<alias_name>.+)       |
+                (?P<magic>[^\s]+)   .* |
+            ) .*            |
+            !(?P<system>.+) |
+            (?![%!]) (?P<python>.+)
+        )
+        \s*
+    """
+    )
+
+
+def create_completer(
+    get_globals,
+    get_locals,
+    magics_manager,
+    alias_manager,
+    get_enable_dictionary_completion,
+):
+    g = create_ipython_grammar()
+
+    return GrammarCompleter(
+        g,
+        {
+            "python": PythonCompleter(
+                get_globals, get_locals, get_enable_dictionary_completion
+            ),
+            "magic": MagicsCompleter(magics_manager),
+            "alias_name": AliasCompleter(alias_manager),
+            "pdb_arg": WordCompleter(["on", "off"], ignore_case=True),
+            "autocall_arg": WordCompleter(["0", "1", "2"], ignore_case=True),
+            "py_filename": PathCompleter(
+                only_directories=False, file_filter=lambda name: name.endswith(".py")
+            ),
+            "filename": PathCompleter(only_directories=False),
+            "directory": PathCompleter(only_directories=True),
+            "system": SystemCompleter(),
+        },
+    )
+
+
+def create_lexer():
+    g = create_ipython_grammar()
+
+    return GrammarLexer(
+        g,
+        lexers={
+            "percent": SimpleLexer("class:pygments.operator"),
+            "magic": SimpleLexer("class:pygments.keyword"),
+            "filename": SimpleLexer("class:pygments.name"),
+            "python": PygmentsLexer(PythonLexer),
+            "system": PygmentsLexer(BashLexer),
+        },
+    )
+
+
+class MagicsCompleter(Completer):
+    def __init__(self, magics_manager):
+        self.magics_manager = magics_manager
+
+    def get_completions(
+        self, document: Document, complete_event: CompleteEvent
+    ) -> Iterable[Completion]:
+        text = document.text_before_cursor.lstrip()
+
+        for m in sorted(self.magics_manager.magics["line"]):
+            if m.startswith(text):
+                yield Completion(f"{m}", -len(text))
+
+
+class AliasCompleter(Completer):
+    def __init__(self, alias_manager):
+        self.alias_manager = alias_manager
+
+    def get_completions(
+        self, document: Document, complete_event: CompleteEvent
+    ) -> Iterable[Completion]:
+        text = document.text_before_cursor.lstrip()
+        # aliases = [a for a, _ in self.alias_manager.aliases]
+        aliases = self.alias_manager.aliases
+
+        for a, cmd in sorted(aliases, key=lambda a: a[0]):
+            if a.startswith(text):
+                yield Completion(f"{a}", -len(text), display_meta=cmd)
+
+
+class IPythonInput(PythonInput):
+    """
+    Override our `PythonCommandLineInterface` to add IPython specific stuff.
+    """
+
+    def __init__(self, ipython_shell, *a, **kw):
+        kw["_completer"] = create_completer(
+            kw["get_globals"],
+            kw["get_globals"],
+            ipython_shell.magics_manager,
+            ipython_shell.alias_manager,
+            lambda: self.enable_dictionary_completion,
+        )
+        kw["_lexer"] = create_lexer()
+        kw["_validator"] = IPythonValidator(get_compiler_flags=self.get_compiler_flags)
+
+        super().__init__(*a, **kw)
+        self.ipython_shell = ipython_shell
+
+        self.all_prompt_styles["ipython"] = IPythonPrompt(ipython_shell.prompts)
+        self.prompt_style = "ipython"
+
+        # UI style for IPython. Add tokens that are used by IPython>5.0
+        style_dict = {}
+        style_dict.update(default_ui_style)
+        style_dict.update(
+            {
+                "pygments.prompt": "#009900",
+                "pygments.prompt-num": "#00ff00 bold",
+                "pygments.out-prompt": "#990000",
+                "pygments.out-prompt-num": "#ff0000 bold",
+            }
+        )
+
+        self.ui_styles = {"default": Style.from_dict(style_dict)}
+        self.use_ui_colorscheme("default")
+
+    def get_compiler_flags(self):
+        flags = super().get_compiler_flags()
+        if self.ipython_shell.autoawait:
+            flags |= PyCF_ALLOW_TOP_LEVEL_AWAIT
+        return flags
+
+
+class InteractiveShellEmbed(_InteractiveShellEmbed):
+    """
+    Override the `InteractiveShellEmbed` from IPython, to replace the front-end
+    with our input shell.
+
+    :param configure: Callable for configuring the repl.
+    """
+
+    def __init__(self, *a, **kw):
+        vi_mode = kw.pop("vi_mode", False)
+        history_filename = kw.pop("history_filename", None)
+        configure = kw.pop("configure", None)
+        title = kw.pop("title", None)
+
+        # Don't ask IPython to confirm for exit. We have our own exit prompt.
+        self.confirm_exit = False
+
+        super().__init__(*a, **kw)
+
+        def get_globals():
+            return self.user_ns
+
+        python_input = IPythonInput(
+            self,
+            get_globals=get_globals,
+            vi_mode=vi_mode,
+            history_filename=history_filename,
+        )
+
+        if title:
+            python_input.terminal_title = title
+
+        if configure:
+            configure(python_input)
+            python_input.prompt_style = "ipython"  # Don't take from config.
+
+        self.python_input = python_input
+
+    def prompt_for_code(self) -> str:
+        try:
+            return self.python_input.app.run()
+        except KeyboardInterrupt:
+            self.python_input.default_buffer.document = Document()
+            return ""
+
+
+def initialize_extensions(shell, extensions):
+    """
+    Partial copy of `InteractiveShellApp.init_extensions` from IPython.
+    """
+    try:
+        iter(extensions)
+    except TypeError:
+        pass  # no extensions found
+    else:
+        for ext in extensions:
+            try:
+                shell.extension_manager.load_extension(ext)
+            except:
+                warn(
+                    f"Error in loading extension: {ext}"
+                    + f"\nCheck your config files in {ipy_utils.path.get_ipython_dir()}"
+                )
+                shell.showtraceback()
+
+
+def run_exec_lines(shell, exec_lines):
+    """
+    Partial copy of  run_exec_lines code from IPython.core.shellapp .
+    """
+    try:
+        iter(exec_lines)
+    except TypeError:
+        pass
+    else:
+        try:
+            for line in exec_lines:
+                try:
+                    shell.run_cell(line, store_history=False)
+                except:
+                    shell.showtraceback()
+        except:
+            shell.showtraceback()
+
+
+def embed(**kwargs):
+    """
+    Copied from `IPython/terminal/embed.py`, but using our `InteractiveShellEmbed` instead.
+    """
+    config = kwargs.get("config")
+    header = kwargs.pop("header", "")
+    compile_flags = kwargs.pop("compile_flags", None)
+    if config is None:
+        config = load_default_config()
+        config.InteractiveShellEmbed = config.TerminalInteractiveShell
+        kwargs["config"] = config
+    shell = InteractiveShellEmbed.instance(**kwargs)
+    initialize_extensions(shell, config["InteractiveShellApp"]["extensions"])
+    run_exec_lines(shell, config["InteractiveShellApp"]["exec_lines"])
+    run_startup_scripts(shell)
+    shell(header=header, stack_depth=2, compile_flags=compile_flags)
+
+
+def run_startup_scripts(shell):
+    """
+    Contributed by linyuxu:
+    https://github.com/prompt-toolkit/ptpython/issues/126#issue-161242480
+    """
+    import glob
+    import os
+
+    startup_dir = shell.profile_dir.startup_dir
+    startup_files = []
+    startup_files += glob.glob(os.path.join(startup_dir, "*.py"))
+    startup_files += glob.glob(os.path.join(startup_dir, "*.ipy"))
+    for file in startup_files:
+        shell.run_cell(open(file).read())
diff --git a/src/ptpython/key_bindings.py b/src/ptpython/key_bindings.py
new file mode 100644
index 00000000..48c5f5ae
--- /dev/null
+++ b/src/ptpython/key_bindings.py
@@ -0,0 +1,339 @@
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+from prompt_toolkit.application import get_app
+from prompt_toolkit.buffer import Buffer
+from prompt_toolkit.document import Document
+from prompt_toolkit.enums import DEFAULT_BUFFER
+from prompt_toolkit.filters import (
+    Condition,
+    emacs_insert_mode,
+    emacs_mode,
+    has_focus,
+    has_selection,
+    vi_insert_mode,
+)
+from prompt_toolkit.key_binding import KeyBindings
+from prompt_toolkit.key_binding.bindings.named_commands import get_by_name
+from prompt_toolkit.key_binding.key_processor import KeyPressEvent
+from prompt_toolkit.keys import Keys
+
+from .utils import document_is_multiline_python
+
+if TYPE_CHECKING:
+    from typing_extensions import TypeAlias
+
+    from .python_input import PythonInput
+
+__all__ = [
+    "load_python_bindings",
+    "load_sidebar_bindings",
+    "load_confirm_exit_bindings",
+]
+
+E: TypeAlias = KeyPressEvent
+
+
+@Condition
+def tab_should_insert_whitespace() -> bool:
+    """
+    When the 'tab' key is pressed with only whitespace character before the
+    cursor, do autocompletion. Otherwise, insert indentation.
+
+    Except for the first character at the first line. Then always do a
+    completion. It doesn't make sense to start the first line with
+    indentation.
+    """
+    b = get_app().current_buffer
+    before_cursor = b.document.current_line_before_cursor
+
+    return bool(b.text and (not before_cursor or before_cursor.isspace()))
+
+
+def load_python_bindings(python_input: PythonInput) -> KeyBindings:
+    """
+    Custom key bindings.
+    """
+    bindings = KeyBindings()
+
+    sidebar_visible = Condition(lambda: python_input.show_sidebar)
+    handle = bindings.add
+
+    @handle("c-l")
+    def _(event: E) -> None:
+        """
+        Clear whole screen and render again -- also when the sidebar is visible.
+        """
+        event.app.renderer.clear()
+
+    @handle("c-z")
+    def _(event: E) -> None:
+        """
+        Suspend.
+        """
+        if python_input.enable_system_bindings:
+            event.app.suspend_to_background()
+
+    # Delete word before cursor, but use all Python symbols as separators
+    # (WORD=False).
+    handle("c-w")(get_by_name("backward-kill-word"))
+
+    @handle("f2")
+    def _(event: E) -> None:
+        """
+        Show/hide sidebar.
+        """
+        python_input.show_sidebar = not python_input.show_sidebar
+        if python_input.show_sidebar:
+            event.app.layout.focus(python_input.ptpython_layout.sidebar)
+        else:
+            event.app.layout.focus_last()
+
+    @handle("f3")
+    def _(event: E) -> None:
+        """
+        Select from the history.
+        """
+        python_input.enter_history()
+
+    @handle("f4")
+    def _(event: E) -> None:
+        """
+        Toggle between Vi and Emacs mode.
+        """
+        python_input.vi_mode = not python_input.vi_mode
+
+    @handle("f6")
+    def _(event: E) -> None:
+        """
+        Enable/Disable paste mode.
+        """
+        python_input.paste_mode = not python_input.paste_mode
+
+    @handle(
+        "tab", filter=~sidebar_visible & ~has_selection & tab_should_insert_whitespace
+    )
+    def _(event: E) -> None:
+        """
+        When tab should insert whitespace, do that instead of completion.
+        """
+        event.app.current_buffer.insert_text("    ")
+
+    @Condition
+    def is_multiline() -> bool:
+        return document_is_multiline_python(python_input.default_buffer.document)
+
+    @handle(
+        "enter",
+        filter=~sidebar_visible
+        & ~has_selection
+        & (vi_insert_mode | emacs_insert_mode)
+        & has_focus(DEFAULT_BUFFER)
+        & ~is_multiline,
+    )
+    @handle(Keys.Escape, Keys.Enter, filter=~sidebar_visible & emacs_mode)
+    def _(event: E) -> None:
+        """
+        Accept input (for single line input).
+        """
+        b = event.current_buffer
+
+        if b.validate():
+            # When the cursor is at the end, and we have an empty line:
+            # drop the empty lines, but return the value.
+            b.document = Document(
+                text=b.text.rstrip(), cursor_position=len(b.text.rstrip())
+            )
+
+            b.validate_and_handle()
+
+    @handle(
+        "enter",
+        filter=~sidebar_visible
+        & ~has_selection
+        & (vi_insert_mode | emacs_insert_mode)
+        & has_focus(DEFAULT_BUFFER)
+        & is_multiline,
+    )
+    def _(event: E) -> None:
+        """
+        Behaviour of the Enter key.
+
+        Auto indent after newline/Enter.
+        (When not in Vi navigation mode, and when multiline is enabled.)
+        """
+        b = event.current_buffer
+        empty_lines_required = python_input.accept_input_on_enter or 10000
+
+        def at_the_end(b: Buffer) -> bool:
+            """we consider the cursor at the end when there is no text after
+            the cursor, or only whitespace."""
+            text = b.document.text_after_cursor
+            return text == "" or (text.isspace() and "\n" not in text)
+
+        if python_input.paste_mode:
+            # In paste mode, always insert text.
+            b.insert_text("\n")
+
+        elif at_the_end(b) and b.document.text.replace(" ", "").endswith(
+            "\n" * (empty_lines_required - 1)
+        ):
+            # When the cursor is at the end, and we have an empty line:
+            # drop the empty lines, but return the value.
+            if b.validate():
+                b.document = Document(
+                    text=b.text.rstrip(), cursor_position=len(b.text.rstrip())
+                )
+
+                b.validate_and_handle()
+        else:
+            auto_newline(b)
+
+    @handle(
+        "c-d",
+        filter=~sidebar_visible
+        & has_focus(python_input.default_buffer)
+        & Condition(
+            lambda:
+            # The current buffer is empty.
+            not get_app().current_buffer.text
+        ),
+    )
+    def _(event: E) -> None:
+        """
+        Override Control-D exit, to ask for confirmation.
+        """
+        if python_input.confirm_exit:
+            # Show exit confirmation and focus it (focusing is important for
+            # making sure the default buffer key bindings are not active).
+            python_input.show_exit_confirmation = True
+            python_input.app.layout.focus(
+                python_input.ptpython_layout.exit_confirmation
+            )
+        else:
+            event.app.exit(exception=EOFError)
+
+    @handle("c-c", filter=has_focus(python_input.default_buffer))
+    def _(event: E) -> None:
+        "Abort when Control-C has been pressed."
+        event.app.exit(exception=KeyboardInterrupt, style="class:aborting")
+
+    return bindings
+
+
+def load_sidebar_bindings(python_input: PythonInput) -> KeyBindings:
+    """
+    Load bindings for the navigation in the sidebar.
+    """
+    bindings = KeyBindings()
+
+    handle = bindings.add
+    sidebar_visible = Condition(lambda: python_input.show_sidebar)
+
+    @handle("up", filter=sidebar_visible)
+    @handle("c-p", filter=sidebar_visible)
+    @handle("k", filter=sidebar_visible)
+    def _(event: E) -> None:
+        "Go to previous option."
+        python_input.selected_option_index = (
+            python_input.selected_option_index - 1
+        ) % python_input.option_count
+
+    @handle("down", filter=sidebar_visible)
+    @handle("c-n", filter=sidebar_visible)
+    @handle("j", filter=sidebar_visible)
+    def _(event: E) -> None:
+        "Go to next option."
+        python_input.selected_option_index = (
+            python_input.selected_option_index + 1
+        ) % python_input.option_count
+
+    @handle("right", filter=sidebar_visible)
+    @handle("l", filter=sidebar_visible)
+    @handle(" ", filter=sidebar_visible)
+    def _(event: E) -> None:
+        "Select next value for current option."
+        option = python_input.selected_option
+        option.activate_next()
+
+    @handle("left", filter=sidebar_visible)
+    @handle("h", filter=sidebar_visible)
+    def _(event: E) -> None:
+        "Select previous value for current option."
+        option = python_input.selected_option
+        option.activate_previous()
+
+    @handle("c-c", filter=sidebar_visible)
+    @handle("c-d", filter=sidebar_visible)
+    @handle("c-d", filter=sidebar_visible)
+    @handle("enter", filter=sidebar_visible)
+    @handle("escape", filter=sidebar_visible)
+    def _(event: E) -> None:
+        "Hide sidebar."
+        python_input.show_sidebar = False
+        event.app.layout.focus_last()
+
+    return bindings
+
+
+def load_confirm_exit_bindings(python_input: PythonInput) -> KeyBindings:
+    """
+    Handle yes/no key presses when the exit confirmation is shown.
+    """
+    bindings = KeyBindings()
+
+    handle = bindings.add
+    confirmation_visible = Condition(lambda: python_input.show_exit_confirmation)
+
+    @handle("y", filter=confirmation_visible)
+    @handle("Y", filter=confirmation_visible)
+    @handle("enter", filter=confirmation_visible)
+    @handle("c-d", filter=confirmation_visible)
+    def _(event: E) -> None:
+        """
+        Really quit.
+        """
+        event.app.exit(exception=EOFError, style="class:exiting")
+
+    @handle(Keys.Any, filter=confirmation_visible)
+    def _(event: E) -> None:
+        """
+        Cancel exit.
+        """
+        python_input.show_exit_confirmation = False
+        python_input.app.layout.focus_previous()
+
+    return bindings
+
+
+def auto_newline(buffer: Buffer) -> None:
+    r"""
+    Insert \n at the cursor position. Also add necessary padding.
+    """
+    insert_text = buffer.insert_text
+
+    if buffer.document.current_line_after_cursor:
+        # When we are in the middle of a line. Always insert a newline.
+        insert_text("\n")
+    else:
+        # Go to new line, but also add indentation.
+        current_line = buffer.document.current_line_before_cursor.rstrip()
+        insert_text("\n")
+
+        # Unident if the last line ends with 'pass', remove four spaces.
+        unindent = current_line.rstrip().endswith(" pass")
+
+        # Copy whitespace from current line
+        current_line2 = current_line[4:] if unindent else current_line
+
+        for c in current_line2:
+            if c.isspace():
+                insert_text(c)
+            else:
+                break
+
+        # If the last line ends with a colon, add four extra spaces.
+        if current_line[-1:] == ":":
+            for x in range(4):
+                insert_text(" ")
diff --git a/src/ptpython/layout.py b/src/ptpython/layout.py
new file mode 100644
index 00000000..9768598e
--- /dev/null
+++ b/src/ptpython/layout.py
@@ -0,0 +1,772 @@
+"""
+Creation of the `Layout` instance for the Python input/REPL.
+"""
+
+from __future__ import annotations
+
+import platform
+import sys
+from enum import Enum
+from inspect import _ParameterKind as ParameterKind
+from typing import TYPE_CHECKING, Any
+
+from prompt_toolkit.application import get_app
+from prompt_toolkit.enums import DEFAULT_BUFFER, SEARCH_BUFFER
+from prompt_toolkit.filters import (
+    Condition,
+    has_focus,
+    is_done,
+    renderer_height_is_known,
+)
+from prompt_toolkit.formatted_text import fragment_list_width, to_formatted_text
+from prompt_toolkit.formatted_text.base import StyleAndTextTuples
+from prompt_toolkit.key_binding.vi_state import InputMode
+from prompt_toolkit.layout.containers import (
+    AnyContainer,
+    ConditionalContainer,
+    Container,
+    Float,
+    FloatContainer,
+    HSplit,
+    ScrollOffsets,
+    VSplit,
+    Window,
+)
+from prompt_toolkit.layout.controls import BufferControl, FormattedTextControl
+from prompt_toolkit.layout.dimension import AnyDimension, Dimension
+from prompt_toolkit.layout.layout import Layout
+from prompt_toolkit.layout.margins import PromptMargin
+from prompt_toolkit.layout.menus import CompletionsMenu, MultiColumnCompletionsMenu
+from prompt_toolkit.layout.processors import (
+    AppendAutoSuggestion,
+    ConditionalProcessor,
+    DisplayMultipleCursors,
+    HighlightIncrementalSearchProcessor,
+    HighlightMatchingBracketProcessor,
+    HighlightSelectionProcessor,
+    Processor,
+    TabsProcessor,
+)
+from prompt_toolkit.lexers import Lexer, SimpleLexer
+from prompt_toolkit.mouse_events import MouseEvent
+from prompt_toolkit.selection import SelectionType
+from prompt_toolkit.widgets.toolbars import (
+    ArgToolbar,
+    CompletionsToolbar,
+    SearchToolbar,
+    SystemToolbar,
+    ValidationToolbar,
+)
+
+from .filters import HasSignature, ShowDocstring, ShowSidebar, ShowSignature
+from .prompt_style import PromptStyle
+from .utils import if_mousedown
+
+if TYPE_CHECKING:
+    from .python_input import OptionCategory, PythonInput
+
+__all__ = ["PtPythonLayout", "CompletionVisualisation"]
+
+
+class CompletionVisualisation(Enum):
+    "Visualisation method for the completions."
+
+    NONE = "none"
+    POP_UP = "pop-up"
+    MULTI_COLUMN = "multi-column"
+    TOOLBAR = "toolbar"
+
+
+def show_completions_toolbar(python_input: PythonInput) -> Condition:
+    return Condition(
+        lambda: python_input.completion_visualisation == CompletionVisualisation.TOOLBAR
+    )
+
+
+def show_completions_menu(python_input: PythonInput) -> Condition:
+    return Condition(
+        lambda: python_input.completion_visualisation == CompletionVisualisation.POP_UP
+    )
+
+
+def show_multi_column_completions_menu(python_input: PythonInput) -> Condition:
+    return Condition(
+        lambda: python_input.completion_visualisation
+        == CompletionVisualisation.MULTI_COLUMN
+    )
+
+
+def python_sidebar(python_input: PythonInput) -> Window:
+    """
+    Create the `Layout` for the sidebar with the configurable options.
+    """
+
+    def get_text_fragments() -> StyleAndTextTuples:
+        tokens: StyleAndTextTuples = []
+
+        def append_category(category: OptionCategory[Any]) -> None:
+            tokens.extend(
+                [
+                    ("class:sidebar", "  "),
+                    ("class:sidebar.title", f"   {category.title:36}"),
+                    ("class:sidebar", "\n"),
+                ]
+            )
+
+        def append(index: int, label: str, status: str) -> None:
+            selected = index == python_input.selected_option_index
+
+            @if_mousedown
+            def select_item(mouse_event: MouseEvent) -> None:
+                python_input.selected_option_index = index
+
+            @if_mousedown
+            def goto_next(mouse_event: MouseEvent) -> None:
+                "Select item and go to next value."
+                python_input.selected_option_index = index
+                option = python_input.selected_option
+                option.activate_next()
+
+            sel = ",selected" if selected else ""
+
+            tokens.append(("class:sidebar" + sel, " >" if selected else "  "))
+            tokens.append(("class:sidebar.label" + sel, f"{label:24}", select_item))
+            tokens.append(("class:sidebar.status" + sel, " ", select_item))
+            tokens.append(("class:sidebar.status" + sel, f"{status}", goto_next))
+
+            if selected:
+                tokens.append(("[SetCursorPosition]", ""))
+
+            tokens.append(
+                ("class:sidebar.status" + sel, " " * (13 - len(status)), goto_next)
+            )
+            tokens.append(("class:sidebar", "<" if selected else ""))
+            tokens.append(("class:sidebar", "\n"))
+
+        i = 0
+        for category in python_input.options:
+            append_category(category)
+
+            for option in category.options:
+                append(i, option.title, str(option.get_current_value()))
+                i += 1
+
+        tokens.pop()  # Remove last newline.
+
+        return tokens
+
+    class Control(FormattedTextControl):
+        def move_cursor_down(self) -> None:
+            python_input.selected_option_index += 1
+
+        def move_cursor_up(self) -> None:
+            python_input.selected_option_index -= 1
+
+    return Window(
+        Control(get_text_fragments),
+        style="class:sidebar",
+        width=Dimension.exact(43),
+        height=Dimension(min=3),
+        scroll_offsets=ScrollOffsets(top=1, bottom=1),
+    )
+
+
+def python_sidebar_navigation(python_input: PythonInput) -> Window:
+    """
+    Create the `Layout` showing the navigation information for the sidebar.
+    """
+
+    def get_text_fragments() -> StyleAndTextTuples:
+        # Show navigation info.
+        return [
+            ("class:sidebar", "    "),
+            ("class:sidebar.key", "[Arrows]"),
+            ("class:sidebar", " "),
+            ("class:sidebar.description", "Navigate"),
+            ("class:sidebar", " "),
+            ("class:sidebar.key", "[Enter]"),
+            ("class:sidebar", " "),
+            ("class:sidebar.description", "Hide menu"),
+        ]
+
+    return Window(
+        FormattedTextControl(get_text_fragments),
+        style="class:sidebar",
+        width=Dimension.exact(43),
+        height=Dimension.exact(1),
+    )
+
+
+def python_sidebar_help(python_input: PythonInput) -> Container:
+    """
+    Create the `Layout` for the help text for the current item in the sidebar.
+    """
+    token = "class:sidebar.helptext"
+
+    def get_current_description() -> str:
+        """
+        Return the description of the selected option.
+        """
+        i = 0
+        for category in python_input.options:
+            for option in category.options:
+                if i == python_input.selected_option_index:
+                    return option.description
+                i += 1
+        return ""
+
+    def get_help_text() -> StyleAndTextTuples:
+        return [(token, get_current_description())]
+
+    return ConditionalContainer(
+        content=Window(
+            FormattedTextControl(get_help_text),
+            style=token,
+            height=Dimension(min=3),
+            wrap_lines=True,
+        ),
+        filter=ShowSidebar(python_input)
+        & Condition(lambda: python_input.show_sidebar_help)
+        & ~is_done,
+    )
+
+
+def signature_toolbar(python_input: PythonInput) -> Container:
+    """
+    Return the `Layout` for the signature.
+    """
+
+    def get_text_fragments() -> StyleAndTextTuples:
+        result: StyleAndTextTuples = []
+        append = result.append
+        Signature = "class:signature-toolbar"
+
+        if python_input.signatures:
+            sig = python_input.signatures[0]  # Always take the first one.
+
+            append((Signature, " "))
+            try:
+                append((Signature, sig.name))
+            except IndexError:
+                # Workaround for #37: https://github.com/jonathanslenders/python-prompt-toolkit/issues/37
+                # See also: https://github.com/davidhalter/jedi/issues/490
+                return []
+
+            append((Signature + ",operator", "("))
+
+            got_positional_only = False
+            got_keyword_only = False
+
+            for i, p in enumerate(sig.parameters):
+                # Detect transition between positional-only and not positional-only.
+                if p.kind == ParameterKind.POSITIONAL_ONLY:
+                    got_positional_only = True
+                if got_positional_only and p.kind != ParameterKind.POSITIONAL_ONLY:
+                    got_positional_only = False
+                    append((Signature, "/"))
+                    append((Signature + ",operator", ", "))
+
+                if not got_keyword_only and p.kind == ParameterKind.KEYWORD_ONLY:
+                    got_keyword_only = True
+                    append((Signature, "*"))
+                    append((Signature + ",operator", ", "))
+
+                sig_index = getattr(sig, "index", 0)
+
+                if i == sig_index:
+                    # Note: we use `_Param.description` instead of
+                    #       `_Param.name`, that way we also get the '*' before args.
+                    append((Signature + ",current-name", p.description))
+                else:
+                    append((Signature, p.description))
+
+                if p.default:
+                    # NOTE: For the jedi-based completion, the default is
+                    #       currently still part of the name.
+                    append((Signature, f"={p.default}"))
+
+                append((Signature + ",operator", ", "))
+
+            if sig.parameters:
+                # Pop last comma
+                result.pop()
+
+            append((Signature + ",operator", ")"))
+            append((Signature, " "))
+        return result
+
+    return ConditionalContainer(
+        content=Window(
+            FormattedTextControl(get_text_fragments), height=Dimension.exact(1)
+        ),
+        # Show only when there is a signature
+        filter=HasSignature(python_input)
+        &
+        # Signature needs to be shown.
+        ShowSignature(python_input)
+        &
+        # And no sidebar is visible.
+        ~ShowSidebar(python_input)
+        &
+        # Not done yet.
+        ~is_done,
+    )
+
+
+class PythonPromptMargin(PromptMargin):
+    """
+    Create margin that displays the prompt.
+    It shows something like "In [1]:".
+    """
+
+    def __init__(self, python_input: PythonInput) -> None:
+        self.python_input = python_input
+
+        def get_prompt_style() -> PromptStyle:
+            return python_input.all_prompt_styles[python_input.prompt_style]
+
+        def get_prompt() -> StyleAndTextTuples:
+            return to_formatted_text(get_prompt_style().in_prompt())
+
+        def get_continuation(
+            width: int, line_number: int, is_soft_wrap: bool
+        ) -> StyleAndTextTuples:
+            if python_input.show_line_numbers and not is_soft_wrap:
+                text = f"{line_number + 1} ".rjust(width)
+                return [("class:line-number", text)]
+            else:
+                return to_formatted_text(get_prompt_style().in2_prompt(width))
+
+        super().__init__(get_prompt, get_continuation)
+
+
+def status_bar(python_input: PythonInput) -> Container:
+    """
+    Create the `Layout` for the status bar.
+    """
+    TB = "class:status-toolbar"
+
+    @if_mousedown
+    def toggle_paste_mode(mouse_event: MouseEvent) -> None:
+        python_input.paste_mode = not python_input.paste_mode
+
+    @if_mousedown
+    def enter_history(mouse_event: MouseEvent) -> None:
+        python_input.enter_history()
+
+    def get_text_fragments() -> StyleAndTextTuples:
+        python_buffer = python_input.default_buffer
+
+        result: StyleAndTextTuples = []
+        append = result.append
+
+        append((TB, " "))
+        result.extend(get_inputmode_fragments(python_input))
+        append((TB, " "))
+
+        # Position in history.
+        append(
+            (
+                TB,
+                f"{python_buffer.working_index + 1}/{len(python_buffer._working_lines)} ",
+            )
+        )
+
+        # Shortcuts.
+        app = get_app()
+        if (
+            not python_input.vi_mode
+            and app.current_buffer == python_input.search_buffer
+        ):
+            append((TB, "[Ctrl-G] Cancel search [Enter] Go to this position."))
+        elif bool(app.current_buffer.selection_state) and not python_input.vi_mode:
+            # Emacs cut/copy keys.
+            append((TB, "[Ctrl-W] Cut [Meta-W] Copy [Ctrl-Y] Paste [Ctrl-G] Cancel"))
+        else:
+            result.extend(
+                [
+                    (TB + " class:status-toolbar.key", "[F3]", enter_history),
+                    (TB, " History ", enter_history),
+                    (TB + " class:status-toolbar.key", "[F6]", toggle_paste_mode),
+                    (TB, " ", toggle_paste_mode),
+                ]
+            )
+
+            if python_input.paste_mode:
+                append(
+                    (TB + " class:paste-mode-on", "Paste mode (on)", toggle_paste_mode)
+                )
+            else:
+                append((TB, "Paste mode", toggle_paste_mode))
+
+        return result
+
+    return ConditionalContainer(
+        content=Window(content=FormattedTextControl(get_text_fragments), style=TB),
+        filter=~is_done
+        & renderer_height_is_known
+        & Condition(
+            lambda: python_input.show_status_bar
+            and not python_input.show_exit_confirmation
+        ),
+    )
+
+
+def get_inputmode_fragments(python_input: PythonInput) -> StyleAndTextTuples:
+    """
+    Return current input mode as a list of (token, text) tuples for use in a
+    toolbar.
+    """
+    app = get_app()
+
+    @if_mousedown
+    def toggle_vi_mode(mouse_event: MouseEvent) -> None:
+        python_input.vi_mode = not python_input.vi_mode
+
+    token = "class:status-toolbar"
+    input_mode_t = "class:status-toolbar.input-mode"
+
+    mode = app.vi_state.input_mode
+    result: StyleAndTextTuples = []
+    append = result.append
+
+    if python_input.title:
+        result.extend(to_formatted_text(python_input.title))
+
+    append((input_mode_t, "[F4] ", toggle_vi_mode))
+
+    # InputMode
+    if python_input.vi_mode:
+        recording_register = app.vi_state.recording_register
+        if recording_register:
+            append((token, " "))
+            append((token + " class:record", f"RECORD({recording_register})"))
+            append((token, " - "))
+
+        if app.current_buffer.selection_state is not None:
+            if app.current_buffer.selection_state.type == SelectionType.LINES:
+                append((input_mode_t, "Vi (VISUAL LINE)", toggle_vi_mode))
+            elif app.current_buffer.selection_state.type == SelectionType.CHARACTERS:
+                append((input_mode_t, "Vi (VISUAL)", toggle_vi_mode))
+                append((token, " "))
+            elif app.current_buffer.selection_state.type == SelectionType.BLOCK:
+                append((input_mode_t, "Vi (VISUAL BLOCK)", toggle_vi_mode))
+                append((token, " "))
+        elif mode in (InputMode.INSERT, "vi-insert-multiple"):
+            append((input_mode_t, "Vi (INSERT)", toggle_vi_mode))
+            append((token, "  "))
+        elif mode == InputMode.NAVIGATION:
+            append((input_mode_t, "Vi (NAV)", toggle_vi_mode))
+            append((token, "     "))
+        elif mode == InputMode.REPLACE:
+            append((input_mode_t, "Vi (REPLACE)", toggle_vi_mode))
+            append((token, " "))
+    else:
+        if app.emacs_state.is_recording:
+            append((token, " "))
+            append((token + " class:record", "RECORD"))
+            append((token, " - "))
+
+        append((input_mode_t, "Emacs", toggle_vi_mode))
+        append((token, " "))
+
+    return result
+
+
+def show_sidebar_button_info(python_input: PythonInput) -> Container:
+    """
+    Create `Layout` for the information in the right-bottom corner.
+    (The right part of the status bar.)
+    """
+
+    @if_mousedown
+    def toggle_sidebar(mouse_event: MouseEvent) -> None:
+        "Click handler for the menu."
+        python_input.show_sidebar = not python_input.show_sidebar
+
+    version = sys.version_info
+    tokens: StyleAndTextTuples = [
+        ("class:status-toolbar.key", "[F2]", toggle_sidebar),
+        ("class:status-toolbar", " Menu", toggle_sidebar),
+        ("class:status-toolbar", " - "),
+        (
+            "class:status-toolbar.python-version",
+            f"{platform.python_implementation()} {version[0]}.{version[1]}.{version[2]}",
+        ),
+        ("class:status-toolbar", " "),
+    ]
+    width = fragment_list_width(tokens)
+
+    def get_text_fragments() -> StyleAndTextTuples:
+        # Python version
+        return tokens
+
+    return ConditionalContainer(
+        content=Window(
+            FormattedTextControl(get_text_fragments),
+            style="class:status-toolbar",
+            height=Dimension.exact(1),
+            width=Dimension.exact(width),
+        ),
+        filter=~is_done
+        & renderer_height_is_known
+        & Condition(
+            lambda: python_input.show_status_bar
+            and not python_input.show_exit_confirmation
+        ),
+    )
+
+
+def create_exit_confirmation(
+    python_input: PythonInput, style: str = "class:exit-confirmation"
+) -> Container:
+    """
+    Create `Layout` for the exit message.
+    """
+
+    def get_text_fragments() -> StyleAndTextTuples:
+        # Show "Do you really want to exit?"
+        return [
+            (style, f"\n {python_input.exit_message} ([y]/n) "),
+            ("[SetCursorPosition]", ""),
+            (style, "  \n"),
+        ]
+
+    visible = ~is_done & Condition(lambda: python_input.show_exit_confirmation)
+
+    return ConditionalContainer(
+        content=Window(
+            FormattedTextControl(get_text_fragments, focusable=True), style=style
+        ),
+        filter=visible,
+    )
+
+
+def meta_enter_message(python_input: PythonInput) -> Container:
+    """
+    Create the `Layout` for the 'Meta+Enter` message.
+    """
+
+    def get_text_fragments() -> StyleAndTextTuples:
+        return [("class:accept-message", " [Meta+Enter] Execute ")]
+
+    @Condition
+    def extra_condition() -> bool:
+        "Only show when..."
+        b = python_input.default_buffer
+
+        return (
+            python_input.show_meta_enter_message
+            and (
+                not b.document.is_cursor_at_the_end
+                or python_input.accept_input_on_enter is None
+            )
+            and "\n" in b.text
+        )
+
+    visible = ~is_done & has_focus(DEFAULT_BUFFER) & extra_condition
+
+    return ConditionalContainer(
+        content=Window(FormattedTextControl(get_text_fragments)), filter=visible
+    )
+
+
+class PtPythonLayout:
+    def __init__(
+        self,
+        python_input: PythonInput,
+        lexer: Lexer,
+        extra_body: AnyContainer | None = None,
+        extra_toolbars: list[AnyContainer] | None = None,
+        extra_buffer_processors: list[Processor] | None = None,
+        input_buffer_height: AnyDimension | None = None,
+    ) -> None:
+        D = Dimension
+        extra_body_list: list[AnyContainer] = [extra_body] if extra_body else []
+        extra_toolbars = extra_toolbars or []
+
+        input_buffer_height = input_buffer_height or D(min=6)
+
+        search_toolbar = SearchToolbar(python_input.search_buffer)
+
+        def create_python_input_window() -> Window:
+            def menu_position() -> int | None:
+                """
+                When there is no autocompletion menu to be shown, and we have a
+                signature, set the pop-up position at `bracket_start`.
+                """
+                b = python_input.default_buffer
+
+                if python_input.signatures:
+                    row, col = python_input.signatures[0].bracket_start
+                    index = b.document.translate_row_col_to_index(row - 1, col)
+                    return index
+                return None
+
+            return Window(
+                BufferControl(
+                    buffer=python_input.default_buffer,
+                    search_buffer_control=search_toolbar.control,
+                    lexer=lexer,
+                    include_default_input_processors=False,
+                    input_processors=[
+                        ConditionalProcessor(
+                            processor=HighlightIncrementalSearchProcessor(),
+                            filter=has_focus(SEARCH_BUFFER)
+                            | has_focus(search_toolbar.control),
+                        ),
+                        HighlightSelectionProcessor(),
+                        DisplayMultipleCursors(),
+                        TabsProcessor(),
+                        # Show matching parentheses, but only while editing.
+                        ConditionalProcessor(
+                            processor=HighlightMatchingBracketProcessor(chars="[](){}"),
+                            filter=has_focus(DEFAULT_BUFFER)
+                            & ~is_done
+                            & Condition(
+                                lambda: python_input.highlight_matching_parenthesis
+                            ),
+                        ),
+                        ConditionalProcessor(
+                            processor=AppendAutoSuggestion(), filter=~is_done
+                        ),
+                    ]
+                    + (extra_buffer_processors or []),
+                    menu_position=menu_position,
+                    # Make sure that we always see the result of an reverse-i-search:
+                    preview_search=True,
+                ),
+                left_margins=[PythonPromptMargin(python_input)],
+                # Scroll offsets. The 1 at the bottom is important to make sure
+                # the cursor is never below the "Press [Meta+Enter]" message
+                # which is a float.
+                scroll_offsets=ScrollOffsets(bottom=1, left=4, right=4),
+                # As long as we're editing, prefer a minimal height of 6.
+                height=(
+                    lambda: (
+                        None
+                        if get_app().is_done or python_input.show_exit_confirmation
+                        else input_buffer_height
+                    )
+                ),
+                wrap_lines=Condition(lambda: python_input.wrap_lines),
+            )
+
+        sidebar = python_sidebar(python_input)
+        self.exit_confirmation = create_exit_confirmation(python_input)
+
+        self.root_container = HSplit(
+            [
+                VSplit(
+                    [
+                        HSplit(
+                            [
+                                FloatContainer(
+                                    content=HSplit(
+                                        [create_python_input_window()] + extra_body_list
+                                    ),
+                                    floats=[
+                                        Float(
+                                            xcursor=True,
+                                            ycursor=True,
+                                            content=HSplit(
+                                                [
+                                                    signature_toolbar(python_input),
+                                                    ConditionalContainer(
+                                                        content=CompletionsMenu(
+                                                            scroll_offset=(
+                                                                lambda: python_input.completion_menu_scroll_offset
+                                                            ),
+                                                            max_height=12,
+                                                        ),
+                                                        filter=show_completions_menu(
+                                                            python_input
+                                                        ),
+                                                    ),
+                                                    ConditionalContainer(
+                                                        content=MultiColumnCompletionsMenu(),
+                                                        filter=show_multi_column_completions_menu(
+                                                            python_input
+                                                        ),
+                                                    ),
+                                                ]
+                                            ),
+                                        ),
+                                        Float(
+                                            left=2,
+                                            bottom=1,
+                                            content=self.exit_confirmation,
+                                        ),
+                                        Float(
+                                            bottom=0,
+                                            right=0,
+                                            height=1,
+                                            content=meta_enter_message(python_input),
+                                            hide_when_covering_content=True,
+                                        ),
+                                        Float(
+                                            bottom=1,
+                                            left=1,
+                                            right=0,
+                                            content=python_sidebar_help(python_input),
+                                        ),
+                                    ],
+                                ),
+                                ArgToolbar(),
+                                search_toolbar,
+                                SystemToolbar(),
+                                ValidationToolbar(),
+                                ConditionalContainer(
+                                    content=CompletionsToolbar(),
+                                    filter=show_completions_toolbar(python_input)
+                                    & ~is_done,
+                                ),
+                                # Docstring region.
+                                ConditionalContainer(
+                                    content=Window(
+                                        height=D.exact(1),
+                                        char="\u2500",
+                                        style="class:separator",
+                                    ),
+                                    filter=HasSignature(python_input)
+                                    & ShowDocstring(python_input)
+                                    & ~is_done,
+                                ),
+                                ConditionalContainer(
+                                    content=Window(
+                                        BufferControl(
+                                            buffer=python_input.docstring_buffer,
+                                            lexer=SimpleLexer(style="class:docstring"),
+                                            # lexer=PythonLexer,
+                                        ),
+                                        height=D(max=12),
+                                    ),
+                                    filter=HasSignature(python_input)
+                                    & ShowDocstring(python_input)
+                                    & ~is_done,
+                                ),
+                            ]
+                        ),
+                        ConditionalContainer(
+                            content=HSplit(
+                                [
+                                    sidebar,
+                                    Window(style="class:sidebar,separator", height=1),
+                                    python_sidebar_navigation(python_input),
+                                ]
+                            ),
+                            filter=ShowSidebar(python_input) & ~is_done,
+                        ),
+                    ]
+                )
+            ]
+            + extra_toolbars
+            + [
+                VSplit(
+                    [status_bar(python_input), show_sidebar_button_info(python_input)]
+                )
+            ]
+        )
+
+        self.layout = Layout(self.root_container)
+        self.sidebar = sidebar
diff --git a/src/ptpython/lexer.py b/src/ptpython/lexer.py
new file mode 100644
index 00000000..d925e95c
--- /dev/null
+++ b/src/ptpython/lexer.py
@@ -0,0 +1,30 @@
+from __future__ import annotations
+
+from typing import Callable
+
+from prompt_toolkit.document import Document
+from prompt_toolkit.formatted_text import StyleAndTextTuples
+from prompt_toolkit.lexers import Lexer, PygmentsLexer
+from pygments.lexers import BashLexer
+from pygments.lexers import Python3Lexer as PythonLexer
+
+__all__ = ["PtpythonLexer"]
+
+
+class PtpythonLexer(Lexer):
+    """
+    Lexer for ptpython input.
+
+    If the input starts with an exclamation mark, use a Bash lexer, otherwise,
+    use a Python 3 lexer.
+    """
+
+    def __init__(self, python_lexer: Lexer | None = None) -> None:
+        self.python_lexer = python_lexer or PygmentsLexer(PythonLexer)
+        self.system_lexer = PygmentsLexer(BashLexer)
+
+    def lex_document(self, document: Document) -> Callable[[int], StyleAndTextTuples]:
+        if document.text.startswith("!"):
+            return self.system_lexer.lex_document(document)
+
+        return self.python_lexer.lex_document(document)
diff --git a/src/ptpython/printer.py b/src/ptpython/printer.py
new file mode 100644
index 00000000..a3578de7
--- /dev/null
+++ b/src/ptpython/printer.py
@@ -0,0 +1,439 @@
+from __future__ import annotations
+
+import traceback
+from dataclasses import dataclass
+from enum import Enum
+from typing import Generator, Iterable
+
+from prompt_toolkit.formatted_text import (
+    HTML,
+    AnyFormattedText,
+    FormattedText,
+    OneStyleAndTextTuple,
+    StyleAndTextTuples,
+    fragment_list_width,
+    merge_formatted_text,
+    to_formatted_text,
+)
+from prompt_toolkit.formatted_text.utils import split_lines
+from prompt_toolkit.input import Input
+from prompt_toolkit.key_binding import KeyBindings, KeyPressEvent
+from prompt_toolkit.output import Output
+from prompt_toolkit.shortcuts import PromptSession, print_formatted_text
+from prompt_toolkit.styles import BaseStyle, StyleTransformation
+from prompt_toolkit.styles.pygments import pygments_token_to_classname
+from prompt_toolkit.utils import get_cwidth
+from pygments.lexers import PythonLexer, PythonTracebackLexer
+
+__all__ = ["OutputPrinter"]
+
+# Never reformat results larger than this:
+MAX_REFORMAT_SIZE = 1_000_000
+
+
+@dataclass
+class OutputPrinter:
+    """
+    Result printer.
+
+    Usage::
+
+        printer = OutputPrinter(...)
+        printer.display_result(...)
+        printer.display_exception(...)
+    """
+
+    output: Output
+    input: Input
+    style: BaseStyle
+    title: AnyFormattedText
+    style_transformation: StyleTransformation
+
+    def display_result(
+        self,
+        result: object,
+        *,
+        out_prompt: AnyFormattedText,
+        reformat: bool,
+        highlight: bool,
+        paginate: bool,
+    ) -> None:
+        """
+        Show __repr__ (or `__pt_repr__`) for an `eval` result and print to output.
+
+        :param reformat: Reformat result using 'black' before printing if the
+            result is parsable as Python code.
+        :param highlight: Syntax highlight the result.
+        :param paginate: Show paginator when the result does not fit on the
+            screen.
+        """
+        out_prompt = to_formatted_text(out_prompt)
+        out_prompt_width = fragment_list_width(out_prompt)
+
+        result = self._insert_out_prompt_and_split_lines(
+            self._format_result_output(
+                result,
+                reformat=reformat,
+                highlight=highlight,
+                line_length=self.output.get_size().columns - out_prompt_width,
+                paginate=paginate,
+            ),
+            out_prompt=out_prompt,
+        )
+        self._display_result(result, paginate=paginate)
+
+    def display_exception(
+        self, e: BaseException, *, highlight: bool, paginate: bool
+    ) -> None:
+        """
+        Render an exception.
+        """
+        result = self._insert_out_prompt_and_split_lines(
+            self._format_exception_output(e, highlight=highlight),
+            out_prompt="",
+        )
+        self._display_result(result, paginate=paginate)
+
+    def display_style_and_text_tuples(
+        self,
+        result: Iterable[OneStyleAndTextTuple],
+        *,
+        paginate: bool,
+    ) -> None:
+        self._display_result(
+            self._insert_out_prompt_and_split_lines(result, out_prompt=""),
+            paginate=paginate,
+        )
+
+    def _display_result(
+        self,
+        lines: Iterable[StyleAndTextTuples],
+        *,
+        paginate: bool,
+    ) -> None:
+        if paginate:
+            self._print_paginated_formatted_text(lines)
+        else:
+            for line in lines:
+                self._print_formatted_text(line)
+
+        self.output.flush()
+
+    def _print_formatted_text(self, line: StyleAndTextTuples, end: str = "\n") -> None:
+        print_formatted_text(
+            FormattedText(line),
+            style=self.style,
+            style_transformation=self.style_transformation,
+            include_default_pygments_style=False,
+            output=self.output,
+            end=end,
+        )
+
+    def _format_result_output(
+        self,
+        result: object,
+        *,
+        reformat: bool,
+        highlight: bool,
+        line_length: int,
+        paginate: bool,
+    ) -> Generator[OneStyleAndTextTuple, None, None]:
+        """
+        Format __repr__ for an `eval` result.
+
+        Note: this can raise `KeyboardInterrupt` if either calling `__repr__`,
+              `__pt_repr__` or formatting the output with "Black" takes to long
+              and the user presses Control-C.
+        """
+        # If __pt_repr__ is present, take this. This can return prompt_toolkit
+        # formatted text.
+        try:
+            if hasattr(result, "__pt_repr__"):
+                formatted_result_repr = to_formatted_text(
+                    getattr(result, "__pt_repr__")()
+                )
+                yield from formatted_result_repr
+                return
+        except (GeneratorExit, KeyboardInterrupt):
+            raise  # Don't catch here.
+        except:
+            # For bad code, `__getattr__` can raise something that's not an
+            # `AttributeError`. This happens already when calling `hasattr()`.
+            pass
+
+        # Call `__repr__` of given object first, to turn it in a string.
+        try:
+            result_repr = repr(result)
+        except KeyboardInterrupt:
+            raise  # Don't catch here.
+        except BaseException as e:
+            # Calling repr failed.
+            self.display_exception(e, highlight=highlight, paginate=paginate)
+            return
+
+        # Determine whether it's valid Python code. If not,
+        # reformatting/highlighting won't be applied.
+        if len(result_repr) < MAX_REFORMAT_SIZE:
+            try:
+                compile(result_repr, "", "eval")
+            except SyntaxError:
+                valid_python = False
+            else:
+                valid_python = True
+        else:
+            valid_python = False
+
+        if valid_python and reformat:
+            # Inline import. Slightly speed up start-up time if black is
+            # not used.
+            try:
+                import black
+
+                if not hasattr(black, "Mode"):
+                    raise ImportError
+            except ImportError:
+                pass  # no Black package in your installation
+            else:
+                result_repr = black.format_str(
+                    result_repr,
+                    mode=black.Mode(line_length=line_length),
+                )
+
+        if valid_python and highlight:
+            yield from _lex_python_result(result_repr)
+        else:
+            yield ("", result_repr)
+
+    def _insert_out_prompt_and_split_lines(
+        self, result: Iterable[OneStyleAndTextTuple], out_prompt: AnyFormattedText
+    ) -> Iterable[StyleAndTextTuples]:
+        r"""
+        Split styled result in lines (based on the \n characters in the result)
+        an insert output prompt on whitespace in front of each line. (This does
+        not yet do the soft wrapping.)
+
+        Yield lines as a result.
+        """
+        out_prompt = to_formatted_text(out_prompt)
+        out_prompt_width = fragment_list_width(out_prompt)
+        prefix = ("", " " * out_prompt_width)
+
+        for i, line in enumerate(split_lines(result)):
+            if i == 0:
+                line = [*out_prompt, *line]
+            else:
+                line = [prefix, *line]
+            yield line
+
+    def _apply_soft_wrapping(
+        self, lines: Iterable[StyleAndTextTuples]
+    ) -> Iterable[StyleAndTextTuples]:
+        """
+        Apply soft wrapping to the given lines. Wrap according to the terminal
+        width. Insert whitespace in front of each wrapped line to align it with
+        the output prompt.
+        """
+        line_length = self.output.get_size().columns
+
+        # Iterate over hard wrapped lines.
+        for lineno, line in enumerate(lines):
+            columns_in_buffer = 0
+            current_line: list[OneStyleAndTextTuple] = []
+
+            for style, text, *_ in line:
+                for c in text:
+                    width = get_cwidth(c)
+
+                    # (Soft) wrap line if it doesn't fit.
+                    if columns_in_buffer + width > line_length:
+                        yield current_line
+                        columns_in_buffer = 0
+                        current_line = []
+
+                    columns_in_buffer += width
+                    current_line.append((style, c))
+
+            yield current_line
+
+    def _print_paginated_formatted_text(
+        self, lines: Iterable[StyleAndTextTuples]
+    ) -> None:
+        """
+        Print formatted text, using --MORE-- style pagination.
+        (Avoid filling up the terminal's scrollback buffer.)
+        """
+        lines = self._apply_soft_wrapping(lines)
+        pager_prompt = create_pager_prompt(
+            self.style, self.title, output=self.output, input=self.input
+        )
+
+        abort = False
+        print_all = False
+
+        # Max number of lines allowed in the buffer before painting.
+        size = self.output.get_size()
+        max_rows = size.rows - 1
+
+        # Page buffer.
+        page: StyleAndTextTuples = []
+
+        def show_pager() -> None:
+            nonlocal abort, max_rows, print_all
+
+            # Run pager prompt in another thread.
+            # Same as for the input. This prevents issues with nested event
+            # loops.
+            pager_result = pager_prompt.prompt(in_thread=True)
+
+            if pager_result == PagerResult.ABORT:
+                print("...")
+                abort = True
+
+            elif pager_result == PagerResult.NEXT_LINE:
+                max_rows = 1
+
+            elif pager_result == PagerResult.NEXT_PAGE:
+                max_rows = size.rows - 1
+
+            elif pager_result == PagerResult.PRINT_ALL:
+                print_all = True
+
+        # Loop over lines. Show --MORE-- prompt when page is filled.
+        rows = 0
+
+        for lineno, line in enumerate(lines):
+            page.extend(line)
+            page.append(("", "\n"))
+            rows += 1
+
+            if rows >= max_rows:
+                self._print_formatted_text(page, end="")
+                page = []
+                rows = 0
+
+                if not print_all:
+                    show_pager()
+                    if abort:
+                        return
+
+        self._print_formatted_text(page)
+
+    def _format_exception_output(
+        self, e: BaseException, highlight: bool
+    ) -> Generator[OneStyleAndTextTuple, None, None]:
+        if e.__cause__:
+            yield from self._format_exception_output(e.__cause__, highlight=highlight)
+            yield (
+                "",
+                "\nThe above exception was the direct cause of the following exception:\n\n",
+            )
+        elif e.__context__:
+            yield from self._format_exception_output(e.__context__, highlight=highlight)
+            yield (
+                "",
+                "\nDuring handling of the above exception, another exception occurred:\n\n",
+            )
+
+        tblist = list(traceback.extract_tb(e.__traceback__))
+
+        for line_nr, tb_tuple in enumerate(tblist):
+            if tb_tuple[0] == "<stdin>":
+                tblist = tblist[line_nr:]
+                break
+
+        tb_list = traceback.format_list(tblist)
+        if tb_list:
+            tb_list.insert(0, "Traceback (most recent call last):\n")
+        tb_list.extend(traceback.format_exception_only(type(e), e))
+
+        tb_str = "".join(tb_list)
+
+        # Format exception and write to output.
+        # (We use the default style. Most other styles result
+        # in unreadable colors for the traceback.)
+        if highlight:
+            for index, tokentype, text in PythonTracebackLexer().get_tokens_unprocessed(
+                tb_str
+            ):
+                yield ("class:" + pygments_token_to_classname(tokentype), text)
+        else:
+            yield ("", tb_str)
+
+
+class PagerResult(Enum):
+    ABORT = "ABORT"
+    NEXT_LINE = "NEXT_LINE"
+    NEXT_PAGE = "NEXT_PAGE"
+    PRINT_ALL = "PRINT_ALL"
+
+
+def create_pager_prompt(
+    style: BaseStyle,
+    title: AnyFormattedText = "",
+    input: Input | None = None,
+    output: Output | None = None,
+) -> PromptSession[PagerResult]:
+    """
+    Create a "--MORE--" prompt for paginated output.
+    """
+    bindings = KeyBindings()
+
+    @bindings.add("enter")
+    @bindings.add("down")
+    def next_line(event: KeyPressEvent) -> None:
+        event.app.exit(result=PagerResult.NEXT_LINE)
+
+    @bindings.add("space")
+    def next_page(event: KeyPressEvent) -> None:
+        event.app.exit(result=PagerResult.NEXT_PAGE)
+
+    @bindings.add("a")
+    def print_all(event: KeyPressEvent) -> None:
+        event.app.exit(result=PagerResult.PRINT_ALL)
+
+    @bindings.add("q")
+    @bindings.add("c-c")
+    @bindings.add("c-d")
+    @bindings.add("escape", eager=True)
+    def no(event: KeyPressEvent) -> None:
+        event.app.exit(result=PagerResult.ABORT)
+
+    @bindings.add("<any>")
+    def _(event: KeyPressEvent) -> None:
+        "Disallow inserting other text."
+        pass
+
+    session: PromptSession[PagerResult] = PromptSession(
+        merge_formatted_text(
+            [
+                title,
+                HTML(
+                    "<status-toolbar>"
+                    "<more> -- MORE -- </more> "
+                    "<key>[Enter]</key> Scroll "
+                    "<key>[Space]</key> Next page "
+                    "<key>[a]</key> Print all "
+                    "<key>[q]</key> Quit "
+                    "</status-toolbar>: "
+                ),
+            ]
+        ),
+        key_bindings=bindings,
+        erase_when_done=True,
+        style=style,
+        input=input,
+        output=output,
+    )
+    return session
+
+
+def _lex_python_result(result: str) -> Generator[tuple[str, str], None, None]:
+    "Return token list for Python string."
+    lexer = PythonLexer()
+    # Use `get_tokens_unprocessed`, so that we get exactly the same string,
+    # without line endings appended. `print_formatted_text` already appends a
+    # line ending, and otherwise we'll have two line endings.
+    tokens = lexer.get_tokens_unprocessed(result)
+
+    for index, tokentype, text in tokens:
+        yield ("class:" + pygments_token_to_classname(tokentype), text)
diff --git a/src/ptpython/prompt_style.py b/src/ptpython/prompt_style.py
new file mode 100644
index 00000000..465c3dbe
--- /dev/null
+++ b/src/ptpython/prompt_style.py
@@ -0,0 +1,79 @@
+from __future__ import annotations
+
+from abc import ABCMeta, abstractmethod
+from typing import TYPE_CHECKING
+
+from prompt_toolkit.formatted_text import AnyFormattedText
+
+if TYPE_CHECKING:
+    from .python_input import PythonInput
+
+__all__ = ["PromptStyle", "IPythonPrompt", "ClassicPrompt"]
+
+
+class PromptStyle(metaclass=ABCMeta):
+    """
+    Base class for all prompts.
+    """
+
+    @abstractmethod
+    def in_prompt(self) -> AnyFormattedText:
+        "Return the input tokens."
+        return []
+
+    @abstractmethod
+    def in2_prompt(self, width: int) -> AnyFormattedText:
+        """
+        Tokens for every following input line.
+
+        :param width: The available width. This is coming from the width taken
+                      by `in_prompt`.
+        """
+        return []
+
+    @abstractmethod
+    def out_prompt(self) -> AnyFormattedText:
+        "Return the output tokens."
+        return []
+
+
+class IPythonPrompt(PromptStyle):
+    """
+    A prompt resembling the IPython prompt.
+    """
+
+    def __init__(self, python_input: PythonInput) -> None:
+        self.python_input = python_input
+
+    def in_prompt(self) -> AnyFormattedText:
+        return [
+            ("class:in", "In ["),
+            ("class:in.number", f"{self.python_input.current_statement_index}"),
+            ("class:in", "]: "),
+        ]
+
+    def in2_prompt(self, width: int) -> AnyFormattedText:
+        return [("class:in", "...: ".rjust(width))]
+
+    def out_prompt(self) -> AnyFormattedText:
+        return [
+            ("class:out", "Out["),
+            ("class:out.number", f"{self.python_input.current_statement_index}"),
+            ("class:out", "]:"),
+            ("", " "),
+        ]
+
+
+class ClassicPrompt(PromptStyle):
+    """
+    The classic Python prompt.
+    """
+
+    def in_prompt(self) -> AnyFormattedText:
+        return [("class:prompt", ">>> ")]
+
+    def in2_prompt(self, width: int) -> AnyFormattedText:
+        return [("class:prompt.dots", "...")]
+
+    def out_prompt(self) -> AnyFormattedText:
+        return []
diff --git a/ptpython/entry_points/__init__.py b/src/ptpython/py.typed
similarity index 100%
rename from ptpython/entry_points/__init__.py
rename to src/ptpython/py.typed
diff --git a/src/ptpython/python_input.py b/src/ptpython/python_input.py
new file mode 100644
index 00000000..b1773643
--- /dev/null
+++ b/src/ptpython/python_input.py
@@ -0,0 +1,1120 @@
+"""
+Application for reading Python input.
+This can be used for creation of Python REPLs.
+"""
+
+from __future__ import annotations
+
+from asyncio import get_running_loop
+from functools import partial
+from typing import TYPE_CHECKING, Any, Callable, Dict, Generic, Mapping, TypeVar, Union
+
+from prompt_toolkit.application import Application, get_app
+from prompt_toolkit.auto_suggest import (
+    AutoSuggestFromHistory,
+    ConditionalAutoSuggest,
+    ThreadedAutoSuggest,
+)
+from prompt_toolkit.buffer import Buffer
+from prompt_toolkit.completion import (
+    Completer,
+    ConditionalCompleter,
+    DynamicCompleter,
+    FuzzyCompleter,
+    ThreadedCompleter,
+    merge_completers,
+)
+from prompt_toolkit.cursor_shapes import (
+    AnyCursorShapeConfig,
+    CursorShape,
+    DynamicCursorShapeConfig,
+    ModalCursorShapeConfig,
+)
+from prompt_toolkit.document import Document
+from prompt_toolkit.enums import DEFAULT_BUFFER, EditingMode
+from prompt_toolkit.filters import Condition, FilterOrBool
+from prompt_toolkit.formatted_text import AnyFormattedText
+from prompt_toolkit.history import (
+    FileHistory,
+    History,
+    InMemoryHistory,
+    ThreadedHistory,
+)
+from prompt_toolkit.input import Input
+from prompt_toolkit.key_binding import (
+    ConditionalKeyBindings,
+    KeyBindings,
+    merge_key_bindings,
+)
+from prompt_toolkit.key_binding.bindings.auto_suggest import load_auto_suggest_bindings
+from prompt_toolkit.key_binding.bindings.open_in_editor import (
+    load_open_in_editor_bindings,
+)
+from prompt_toolkit.key_binding.key_bindings import Binding, KeyHandlerCallable
+from prompt_toolkit.key_binding.key_processor import KeyPressEvent
+from prompt_toolkit.key_binding.vi_state import InputMode
+from prompt_toolkit.keys import Keys
+from prompt_toolkit.layout.containers import AnyContainer
+from prompt_toolkit.layout.dimension import AnyDimension
+from prompt_toolkit.layout.processors import Processor
+from prompt_toolkit.lexers import DynamicLexer, Lexer, SimpleLexer
+from prompt_toolkit.output import ColorDepth, Output
+from prompt_toolkit.styles import (
+    AdjustBrightnessStyleTransformation,
+    BaseStyle,
+    ConditionalStyleTransformation,
+    DynamicStyle,
+    SwapLightAndDarkStyleTransformation,
+    merge_style_transformations,
+)
+from prompt_toolkit.utils import is_windows
+from prompt_toolkit.validation import ConditionalValidator, Validator
+
+from .completer import CompletePrivateAttributes, HidePrivateCompleter, PythonCompleter
+from .history_browser import PythonHistory
+from .key_bindings import (
+    load_confirm_exit_bindings,
+    load_python_bindings,
+    load_sidebar_bindings,
+)
+from .layout import CompletionVisualisation, PtPythonLayout
+from .lexer import PtpythonLexer
+from .prompt_style import ClassicPrompt, IPythonPrompt, PromptStyle
+from .signatures import Signature, get_signatures_using_eval, get_signatures_using_jedi
+from .style import generate_style, get_all_code_styles, get_all_ui_styles
+from .utils import unindent_code
+from .validator import PythonValidator
+
+# Isort introduces a SyntaxError, if we'd write `import __future__`.
+# https://github.com/PyCQA/isort/issues/2100
+__future__ = __import__("__future__")
+
+
+__all__ = ["PythonInput"]
+
+
+if TYPE_CHECKING:
+    from typing_extensions import Protocol
+
+    class _SupportsLessThan(Protocol):
+        # Taken from typeshed. _T_lt is used by "sorted", which needs anything
+        # sortable.
+        def __lt__(self, __other: Any) -> bool: ...
+
+
+_T_lt = TypeVar("_T_lt", bound="_SupportsLessThan")
+_T_kh = TypeVar("_T_kh", bound=Union[KeyHandlerCallable, Binding])
+
+
+class OptionCategory(Generic[_T_lt]):
+    def __init__(self, title: str, options: list[Option[_T_lt]]) -> None:
+        self.title = title
+        self.options = options
+
+
+class Option(Generic[_T_lt]):
+    """
+    Ptpython configuration option that can be shown and modified from the
+    sidebar.
+
+    :param title: Text.
+    :param description: Text.
+    :param get_values: Callable that returns a dictionary mapping the
+            possible values to callbacks that activate these value.
+    :param get_current_value: Callable that returns the current, active value.
+    """
+
+    def __init__(
+        self,
+        title: str,
+        description: str,
+        get_current_value: Callable[[], _T_lt],
+        # We accept `object` as return type for the select functions, because
+        # often they return an unused boolean. Maybe this can be improved.
+        get_values: Callable[[], Mapping[_T_lt, Callable[[], object]]],
+    ) -> None:
+        self.title = title
+        self.description = description
+        self.get_current_value = get_current_value
+        self.get_values = get_values
+
+    @property
+    def values(self) -> Mapping[_T_lt, Callable[[], object]]:
+        return self.get_values()
+
+    def activate_next(self, _previous: bool = False) -> None:
+        """
+        Activate next value.
+        """
+        current = self.get_current_value()
+        options = sorted(self.values.keys())
+
+        # Get current index.
+        try:
+            index = options.index(current)
+        except ValueError:
+            index = 0
+
+        # Go to previous/next index.
+        if _previous:
+            index -= 1
+        else:
+            index += 1
+
+        # Call handler for this option.
+        next_option = options[index % len(options)]
+        self.values[next_option]()
+
+    def activate_previous(self) -> None:
+        """
+        Activate previous value.
+        """
+        self.activate_next(_previous=True)
+
+
+COLOR_DEPTHS = {
+    ColorDepth.DEPTH_1_BIT: "Monochrome",
+    ColorDepth.DEPTH_4_BIT: "ANSI Colors",
+    ColorDepth.DEPTH_8_BIT: "256 colors",
+    ColorDepth.DEPTH_24_BIT: "True color",
+}
+
+_Namespace = Dict[str, Any]
+_GetNamespace = Callable[[], _Namespace]
+
+
+class PythonInput:
+    """
+    Prompt for reading Python input.
+
+    ::
+
+        python_input = PythonInput(...)
+        python_code = python_input.app.run()
+
+    :param create_app: When `False`, don't create and manage a prompt_toolkit
+                       application. The default is `True` and should only be set
+                       to false if PythonInput is being embedded in a separate
+                       prompt_toolkit application.
+    """
+
+    def __init__(
+        self,
+        get_globals: _GetNamespace | None = None,
+        get_locals: _GetNamespace | None = None,
+        history_filename: str | None = None,
+        vi_mode: bool = False,
+        color_depth: ColorDepth | None = None,
+        # Input/output.
+        input: Input | None = None,
+        output: Output | None = None,
+        # For internal use.
+        extra_key_bindings: KeyBindings | None = None,
+        create_app: bool = True,
+        _completer: Completer | None = None,
+        _validator: Validator | None = None,
+        _lexer: Lexer | None = None,
+        _extra_buffer_processors: list[Processor] | None = None,
+        _extra_layout_body: AnyContainer | None = None,
+        _extra_toolbars: list[AnyContainer] | None = None,
+        _input_buffer_height: AnyDimension | None = None,
+    ) -> None:
+        self.get_globals: _GetNamespace = get_globals or (lambda: {})
+        self.get_locals: _GetNamespace = get_locals or self.get_globals
+
+        self.completer = _completer or PythonCompleter(
+            self.get_globals,
+            self.get_locals,
+            lambda: self.enable_dictionary_completion,
+        )
+
+        self._completer = HidePrivateCompleter(
+            # If fuzzy is enabled, first do fuzzy completion, but always add
+            # the non-fuzzy completions, if somehow the fuzzy completer didn't
+            # find them. (Due to the way the cursor position is moved in the
+            # fuzzy completer, some completions will not always be found by the
+            # fuzzy completer, but will be found with the normal completer.)
+            merge_completers(
+                [
+                    ConditionalCompleter(
+                        FuzzyCompleter(DynamicCompleter(lambda: self.completer)),
+                        Condition(lambda: self.enable_fuzzy_completion),
+                    ),
+                    DynamicCompleter(lambda: self.completer),
+                ],
+                deduplicate=True,
+            ),
+            lambda: self.complete_private_attributes,
+        )
+        self._validator = _validator or PythonValidator(self.get_compiler_flags)
+        self._lexer = PtpythonLexer(_lexer)
+
+        self.history: History
+        if history_filename:
+            self.history = ThreadedHistory(FileHistory(history_filename))
+        else:
+            self.history = InMemoryHistory()
+
+        self._input_buffer_height = _input_buffer_height
+        self._extra_layout_body = _extra_layout_body
+        self._extra_toolbars = _extra_toolbars or []
+        self._extra_buffer_processors = _extra_buffer_processors or []
+
+        self.extra_key_bindings = extra_key_bindings or KeyBindings()
+
+        # Settings.
+        self.title: AnyFormattedText = ""
+        self.show_signature: bool = False
+        self.show_docstring: bool = False
+        self.show_meta_enter_message: bool = True
+        self.completion_visualisation: CompletionVisualisation = (
+            CompletionVisualisation.MULTI_COLUMN
+        )
+        self.completion_menu_scroll_offset: int = 1
+
+        self.show_line_numbers: bool = False
+        self.show_status_bar: bool = True
+        self.wrap_lines: bool = True
+        self.complete_while_typing: bool = True
+        self.paste_mode: bool = (
+            False  # When True, don't insert whitespace after newline.
+        )
+        self.confirm_exit: bool = (
+            True  # Ask for confirmation when Control-D is pressed.
+        )
+        self.accept_input_on_enter: int = 2  # Accept when pressing Enter 'n' times.
+        # 'None' means that meta-enter is always required.
+        self.enable_open_in_editor: bool = True
+        self.enable_system_bindings: bool = True
+        self.enable_input_validation: bool = True
+        self.enable_auto_suggest: bool = False
+        self.enable_mouse_support: bool = False
+        self.enable_history_search: bool = False  # When True, like readline, going
+        # back in history will filter the
+        # history on the records starting
+        # with the current input.
+
+        self.enable_syntax_highlighting: bool = True
+        self.enable_fuzzy_completion: bool = False
+        self.enable_dictionary_completion: bool = False  # Also eval-based completion.
+        self.complete_private_attributes: CompletePrivateAttributes = (
+            CompletePrivateAttributes.ALWAYS
+        )
+        self.swap_light_and_dark: bool = False
+        self.highlight_matching_parenthesis: bool = False
+        self.show_sidebar: bool = False  # Currently show the sidebar.
+
+        # Pager.
+        self.enable_output_formatting: bool = False
+        self.enable_pager: bool = False
+
+        # When the sidebar is visible, also show the help text.
+        self.show_sidebar_help: bool = True
+
+        # Currently show 'Do you really want to exit?'
+        self.show_exit_confirmation: bool = False
+
+        # The title to be displayed in the terminal. (None or string.)
+        self.terminal_title: str | None = None
+
+        self.exit_message: str = "Do you really want to exit?"
+        self.insert_blank_line_after_output: bool = True  # (For the REPL.)
+        self.insert_blank_line_after_input: bool = False  # (For the REPL.)
+
+        # The buffers.
+        self.default_buffer = self._create_buffer()
+        self.search_buffer: Buffer = Buffer()
+        self.docstring_buffer: Buffer = Buffer(read_only=True)
+
+        # Cursor shapes.
+        self.cursor_shape_config = "Block"
+        self.all_cursor_shape_configs: dict[str, AnyCursorShapeConfig] = {
+            "Block": CursorShape.BLOCK,
+            "Underline": CursorShape.UNDERLINE,
+            "Beam": CursorShape.BEAM,
+            "Modal (vi)": ModalCursorShapeConfig(),
+            "Blink block": CursorShape.BLINKING_BLOCK,
+            "Blink under": CursorShape.BLINKING_UNDERLINE,
+            "Blink beam": CursorShape.BLINKING_BEAM,
+        }
+
+        # Tokens to be shown at the prompt.
+        self.prompt_style: str = "classic"  # The currently active style.
+
+        # Styles selectable from the menu.
+        self.all_prompt_styles: dict[str, PromptStyle] = {
+            "ipython": IPythonPrompt(self),
+            "classic": ClassicPrompt(),
+        }
+
+        #: Load styles.
+        self.code_styles: dict[str, BaseStyle] = get_all_code_styles()
+        self.ui_styles = get_all_ui_styles()
+        self._current_code_style_name: str = "default"
+        self._current_ui_style_name: str = "default"
+
+        if is_windows():
+            self._current_code_style_name = "win32"
+
+        self._current_style = self._generate_style()
+        self.color_depth: ColorDepth = color_depth or ColorDepth.default()
+
+        self.max_brightness: float = 1.0
+        self.min_brightness: float = 0.0
+
+        # Options to be configurable from the sidebar.
+        self.options = self._create_options()
+        self.selected_option_index: int = 0
+
+        #: Incrementing integer counting the current statement.
+        self.current_statement_index: int = 1
+
+        # Code signatures. (This is set asynchronously after a timeout.)
+        self.signatures: list[Signature] = []
+
+        # Boolean indicating whether we have a signatures thread running.
+        # (Never run more than one at the same time.)
+        self._get_signatures_thread_running: bool = False
+
+        # Get into Vi navigation mode at startup
+        self.vi_start_in_navigation_mode: bool = False
+
+        # Preserve last used Vi input mode between main loop iterations
+        self.vi_keep_last_used_mode: bool = False
+
+        self.style_transformation = merge_style_transformations(
+            [
+                ConditionalStyleTransformation(
+                    SwapLightAndDarkStyleTransformation(),
+                    filter=Condition(lambda: self.swap_light_and_dark),
+                ),
+                AdjustBrightnessStyleTransformation(
+                    lambda: self.min_brightness, lambda: self.max_brightness
+                ),
+            ]
+        )
+        self.ptpython_layout = PtPythonLayout(
+            self,
+            lexer=DynamicLexer(
+                lambda: self._lexer
+                if self.enable_syntax_highlighting
+                else SimpleLexer()
+            ),
+            input_buffer_height=self._input_buffer_height,
+            extra_buffer_processors=self._extra_buffer_processors,
+            extra_body=self._extra_layout_body,
+            extra_toolbars=self._extra_toolbars,
+        )
+
+        # Create an app if requested. If not, the global get_app() is returned
+        # for self.app via property getter.
+        if create_app:
+            self._app: Application[str] | None = self._create_application(input, output)
+            # Setting vi_mode will not work unless the prompt_toolkit
+            # application has been created.
+            if vi_mode:
+                self.app.editing_mode = EditingMode.VI
+        else:
+            self._app = None
+
+    def get_input_prompt(self) -> AnyFormattedText:
+        return self.all_prompt_styles[self.prompt_style].in_prompt()
+
+    def get_output_prompt(self) -> AnyFormattedText:
+        return self.all_prompt_styles[self.prompt_style].out_prompt()
+
+    def _accept_handler(self, buff: Buffer) -> bool:
+        app = get_app()
+        app.exit(result=buff.text)
+        app.pre_run_callables.append(buff.reset)
+        return True  # Keep text, we call 'reset' later on.
+
+    @property
+    def option_count(self) -> int:
+        "Return the total amount of options. (In all categories together.)"
+        return sum(len(category.options) for category in self.options)
+
+    @property
+    def selected_option(self) -> Option[Any]:
+        "Return the currently selected option."
+        i = 0
+        for category in self.options:
+            for o in category.options:
+                if i == self.selected_option_index:
+                    return o
+                else:
+                    i += 1
+
+        raise ValueError("Nothing selected")
+
+    def get_compiler_flags(self) -> int:
+        """
+        Give the current compiler flags by looking for _Feature instances
+        in the globals.
+        """
+        flags = 0
+
+        for value in self.get_globals().values():
+            try:
+                if isinstance(value, __future__._Feature):
+                    f = value.compiler_flag
+                    flags |= f
+            except BaseException:
+                # get_compiler_flags should never raise to not run into an
+                # `Unhandled exception in event loop`
+
+                # See: https://github.com/prompt-toolkit/ptpython/issues/351
+                # An exception can be raised when some objects in the globals
+                # raise an exception in a custom `__getattribute__`.
+                pass
+
+        return flags
+
+    def add_key_binding(
+        self,
+        *keys: Keys | str,
+        filter: FilterOrBool = True,
+        eager: FilterOrBool = False,
+        is_global: FilterOrBool = False,
+        save_before: Callable[[KeyPressEvent], bool] = (lambda e: True),
+        record_in_macro: FilterOrBool = True,
+    ) -> Callable[[_T_kh], _T_kh]:
+        """
+        Shortcut for adding new key bindings.
+        (Mostly useful for a config.py file, that receives
+        a PythonInput/Repl instance as input.)
+
+        All arguments are identical to prompt_toolkit's `KeyBindings.add`.
+
+        ::
+
+            @python_input.add_key_binding(Keys.ControlX, filter=...)
+            def handler(event):
+                ...
+        """
+        return self.extra_key_bindings.add(
+            *keys,
+            filter=filter,
+            eager=eager,
+            is_global=is_global,
+            save_before=save_before,
+            record_in_macro=record_in_macro,
+        )
+
+    def install_code_colorscheme(self, name: str, style: BaseStyle) -> None:
+        """
+        Install a new code color scheme.
+        """
+        self.code_styles[name] = style
+
+    def use_code_colorscheme(self, name: str) -> None:
+        """
+        Apply new colorscheme. (By name.)
+        """
+        assert name in self.code_styles
+
+        self._current_code_style_name = name
+        self._current_style = self._generate_style()
+
+    def install_ui_colorscheme(self, name: str, style: BaseStyle) -> None:
+        """
+        Install a new UI color scheme.
+        """
+        self.ui_styles[name] = style
+
+    def use_ui_colorscheme(self, name: str) -> None:
+        """
+        Apply new colorscheme. (By name.)
+        """
+        assert name in self.ui_styles
+
+        self._current_ui_style_name = name
+        self._current_style = self._generate_style()
+
+    def _use_color_depth(self, depth: ColorDepth) -> None:
+        self.color_depth = depth
+
+    def _set_min_brightness(self, value: float) -> None:
+        self.min_brightness = value
+        self.max_brightness = max(self.max_brightness, value)
+
+    def _set_max_brightness(self, value: float) -> None:
+        self.max_brightness = value
+        self.min_brightness = min(self.min_brightness, value)
+
+    def _generate_style(self) -> BaseStyle:
+        """
+        Create new Style instance.
+        (We don't want to do this on every key press, because each time the
+        renderer receives a new style class, he will redraw everything.)
+        """
+        return generate_style(
+            self.code_styles[self._current_code_style_name],
+            self.ui_styles[self._current_ui_style_name],
+        )
+
+    def _create_options(self) -> list[OptionCategory[Any]]:
+        """
+        Create a list of `Option` instances for the options sidebar.
+        """
+
+        def enable(attribute: str, value: Any = True) -> bool:
+            setattr(self, attribute, value)
+
+            # Return `True`, to be able to chain this in the lambdas below.
+            return True
+
+        def disable(attribute: str) -> bool:
+            setattr(self, attribute, False)
+            return True
+
+        def simple_option(
+            title: str,
+            description: str,
+            field_name: str,
+            values: tuple[str, str] = ("off", "on"),
+        ) -> Option[str]:
+            "Create Simple on/of option."
+
+            def get_current_value() -> str:
+                return values[bool(getattr(self, field_name))]
+
+            def get_values() -> dict[str, Callable[[], bool]]:
+                return {
+                    values[1]: lambda: enable(field_name),
+                    values[0]: lambda: disable(field_name),
+                }
+
+            return Option(
+                title=title,
+                description=description,
+                get_values=get_values,
+                get_current_value=get_current_value,
+            )
+
+        brightness_values = [1.0 / 20 * value for value in range(0, 21)]
+
+        return [
+            OptionCategory(
+                "Input",
+                [
+                    Option(
+                        title="Editing mode",
+                        description="Vi or emacs key bindings.",
+                        get_current_value=lambda: ["Emacs", "Vi"][self.vi_mode],
+                        get_values=lambda: {
+                            "Emacs": lambda: disable("vi_mode"),
+                            "Vi": lambda: enable("vi_mode"),
+                        },
+                    ),
+                    Option(
+                        title="Cursor shape",
+                        description="Change the cursor style, possibly according "
+                        "to the Vi input mode.",
+                        get_current_value=lambda: self.cursor_shape_config,
+                        get_values=lambda: {
+                            s: partial(enable, "cursor_shape_config", s)
+                            for s in self.all_cursor_shape_configs
+                        },
+                    ),
+                    simple_option(
+                        title="Paste mode",
+                        description="When enabled, don't indent automatically.",
+                        field_name="paste_mode",
+                    ),
+                    Option(
+                        title="Complete while typing",
+                        description="Generate autocompletions automatically while typing. "
+                        'Don\'t require pressing TAB. (Not compatible with "History search".)',
+                        get_current_value=lambda: ["off", "on"][
+                            self.complete_while_typing
+                        ],
+                        get_values=lambda: {
+                            "on": lambda: enable("complete_while_typing")
+                            and disable("enable_history_search"),
+                            "off": lambda: disable("complete_while_typing"),
+                        },
+                    ),
+                    Option(
+                        title="Complete private attrs",
+                        description="Show or hide private attributes in the completions. "
+                        "'If no public' means: show private attributes only if no public "
+                        "matches are found or if an underscore was typed.",
+                        get_current_value=lambda: {
+                            CompletePrivateAttributes.NEVER: "Never",
+                            CompletePrivateAttributes.ALWAYS: "Always",
+                            CompletePrivateAttributes.IF_NO_PUBLIC: "If no public",
+                        }[self.complete_private_attributes],
+                        get_values=lambda: {
+                            "Never": lambda: enable(
+                                "complete_private_attributes",
+                                CompletePrivateAttributes.NEVER,
+                            ),
+                            "Always": lambda: enable(
+                                "complete_private_attributes",
+                                CompletePrivateAttributes.ALWAYS,
+                            ),
+                            "If no public": lambda: enable(
+                                "complete_private_attributes",
+                                CompletePrivateAttributes.IF_NO_PUBLIC,
+                            ),
+                        },
+                    ),
+                    Option(
+                        title="Enable fuzzy completion",
+                        description="Enable fuzzy completion.",
+                        get_current_value=lambda: ["off", "on"][
+                            self.enable_fuzzy_completion
+                        ],
+                        get_values=lambda: {
+                            "on": lambda: enable("enable_fuzzy_completion"),
+                            "off": lambda: disable("enable_fuzzy_completion"),
+                        },
+                    ),
+                    Option(
+                        title="Dictionary completion",
+                        description="Enable experimental dictionary/list completion.\n"
+                        'WARNING: this does "eval" on fragments of\n'
+                        "         your Python input and is\n"
+                        "         potentially unsafe.",
+                        get_current_value=lambda: ["off", "on"][
+                            self.enable_dictionary_completion
+                        ],
+                        get_values=lambda: {
+                            "on": lambda: enable("enable_dictionary_completion"),
+                            "off": lambda: disable("enable_dictionary_completion"),
+                        },
+                    ),
+                    Option(
+                        title="History search",
+                        description="When pressing the up-arrow, filter the history on input starting "
+                        'with the current text. (Not compatible with "Complete while typing".)',
+                        get_current_value=lambda: ["off", "on"][
+                            self.enable_history_search
+                        ],
+                        get_values=lambda: {
+                            "on": lambda: enable("enable_history_search")
+                            and disable("complete_while_typing"),
+                            "off": lambda: disable("enable_history_search"),
+                        },
+                    ),
+                    simple_option(
+                        title="Mouse support",
+                        description="Respond to mouse clicks and scrolling for positioning the cursor, "
+                        "selecting text and scrolling through windows.",
+                        field_name="enable_mouse_support",
+                    ),
+                    simple_option(
+                        title="Confirm on exit",
+                        description="Require confirmation when exiting.",
+                        field_name="confirm_exit",
+                    ),
+                    simple_option(
+                        title="Input validation",
+                        description="In case of syntax errors, move the cursor to the error "
+                        "instead of showing a traceback of a SyntaxError.",
+                        field_name="enable_input_validation",
+                    ),
+                    simple_option(
+                        title="Auto suggestion",
+                        description="Auto suggest inputs by looking at the history. "
+                        "Pressing right arrow or Ctrl-E will complete the entry.",
+                        field_name="enable_auto_suggest",
+                    ),
+                    Option(
+                        title="Accept input on enter",
+                        description="Amount of ENTER presses required to execute input when the cursor "
+                        "is at the end of the input. (Note that META+ENTER will always execute.)",
+                        get_current_value=lambda: str(
+                            self.accept_input_on_enter or "meta-enter"
+                        ),
+                        get_values=lambda: {
+                            "2": lambda: enable("accept_input_on_enter", 2),
+                            "3": lambda: enable("accept_input_on_enter", 3),
+                            "4": lambda: enable("accept_input_on_enter", 4),
+                            "meta-enter": lambda: enable("accept_input_on_enter", None),
+                        },
+                    ),
+                ],
+            ),
+            OptionCategory(
+                "Display",
+                [
+                    Option(
+                        title="Completions",
+                        description="Visualisation to use for displaying the completions. (Multiple columns, one column, a toolbar or nothing.)",
+                        get_current_value=lambda: self.completion_visualisation.value,
+                        get_values=lambda: {
+                            CompletionVisualisation.NONE.value: lambda: enable(
+                                "completion_visualisation", CompletionVisualisation.NONE
+                            ),
+                            CompletionVisualisation.POP_UP.value: lambda: enable(
+                                "completion_visualisation",
+                                CompletionVisualisation.POP_UP,
+                            ),
+                            CompletionVisualisation.MULTI_COLUMN.value: lambda: enable(
+                                "completion_visualisation",
+                                CompletionVisualisation.MULTI_COLUMN,
+                            ),
+                            CompletionVisualisation.TOOLBAR.value: lambda: enable(
+                                "completion_visualisation",
+                                CompletionVisualisation.TOOLBAR,
+                            ),
+                        },
+                    ),
+                    Option(
+                        title="Prompt",
+                        description="Visualisation of the prompt. ('>>>' or 'In [1]:')",
+                        get_current_value=lambda: self.prompt_style,
+                        get_values=lambda: {
+                            s: partial(enable, "prompt_style", s)
+                            for s in self.all_prompt_styles
+                        },
+                    ),
+                    simple_option(
+                        title="Blank line after input",
+                        description="Insert a blank line after the input.",
+                        field_name="insert_blank_line_after_input",
+                    ),
+                    simple_option(
+                        title="Blank line after output",
+                        description="Insert a blank line after the output.",
+                        field_name="insert_blank_line_after_output",
+                    ),
+                    simple_option(
+                        title="Show signature",
+                        description="Display function signatures.",
+                        field_name="show_signature",
+                    ),
+                    simple_option(
+                        title="Show docstring",
+                        description="Display function docstrings.",
+                        field_name="show_docstring",
+                    ),
+                    simple_option(
+                        title="Show line numbers",
+                        description="Show line numbers when the input consists of multiple lines.",
+                        field_name="show_line_numbers",
+                    ),
+                    simple_option(
+                        title="Show Meta+Enter message",
+                        description="Show the [Meta+Enter] message when this key combination is required to execute commands. "
+                        + "(This is the case when a simple [Enter] key press will insert a newline.",
+                        field_name="show_meta_enter_message",
+                    ),
+                    simple_option(
+                        title="Wrap lines",
+                        description="Wrap lines instead of scrolling horizontally.",
+                        field_name="wrap_lines",
+                    ),
+                    simple_option(
+                        title="Show status bar",
+                        description="Show the status bar at the bottom of the terminal.",
+                        field_name="show_status_bar",
+                    ),
+                    simple_option(
+                        title="Show sidebar help",
+                        description="When the sidebar is visible, also show this help text.",
+                        field_name="show_sidebar_help",
+                    ),
+                    simple_option(
+                        title="Highlight parenthesis",
+                        description="Highlight matching parenthesis, when the cursor is on or right after one.",
+                        field_name="highlight_matching_parenthesis",
+                    ),
+                    simple_option(
+                        title="Reformat output (black)",
+                        description="Reformat outputs using Black, if possible (experimental).",
+                        field_name="enable_output_formatting",
+                    ),
+                    simple_option(
+                        title="Enable pager for output",
+                        description="Use a pager for displaying outputs that don't "
+                        "fit on the screen.",
+                        field_name="enable_pager",
+                    ),
+                ],
+            ),
+            OptionCategory(
+                "Colors",
+                [
+                    simple_option(
+                        title="Syntax highlighting",
+                        description="Use colors for syntax highlighting",
+                        field_name="enable_syntax_highlighting",
+                    ),
+                    simple_option(
+                        title="Swap light/dark colors",
+                        description="Swap light and dark colors.",
+                        field_name="swap_light_and_dark",
+                    ),
+                    Option(
+                        title="Code",
+                        description="Color scheme to use for the Python code.",
+                        get_current_value=lambda: self._current_code_style_name,
+                        get_values=lambda: {
+                            name: partial(self.use_code_colorscheme, name)
+                            for name in self.code_styles
+                        },
+                    ),
+                    Option(
+                        title="User interface",
+                        description="Color scheme to use for the user interface.",
+                        get_current_value=lambda: self._current_ui_style_name,
+                        get_values=lambda: {
+                            name: partial(self.use_ui_colorscheme, name)
+                            for name in self.ui_styles
+                        },
+                    ),
+                    Option(
+                        title="Color depth",
+                        description="Monochrome (1 bit), 16 ANSI colors (4 bit),\n256 colors (8 bit), or 24 bit.",
+                        get_current_value=lambda: COLOR_DEPTHS[self.color_depth],
+                        get_values=lambda: {
+                            name: partial(self._use_color_depth, depth)
+                            for depth, name in COLOR_DEPTHS.items()
+                        },
+                    ),
+                    Option(
+                        title="Min brightness",
+                        description="Minimum brightness for the color scheme (default=0.0).",
+                        get_current_value=lambda: f"{self.min_brightness:.2f}",
+                        get_values=lambda: {
+                            f"{value:.2f}": partial(self._set_min_brightness, value)
+                            for value in brightness_values
+                        },
+                    ),
+                    Option(
+                        title="Max brightness",
+                        description="Maximum brightness for the color scheme (default=1.0).",
+                        get_current_value=lambda: f"{self.max_brightness:.2f}",
+                        get_values=lambda: {
+                            f"{value:.2f}": partial(self._set_max_brightness, value)
+                            for value in brightness_values
+                        },
+                    ),
+                ],
+            ),
+        ]
+
+    def _create_application(
+        self, input: Input | None, output: Output | None
+    ) -> Application[str]:
+        """
+        Create an `Application` instance.
+        """
+        return Application(
+            layout=self.ptpython_layout.layout,
+            key_bindings=merge_key_bindings(
+                [
+                    load_python_bindings(self),
+                    load_auto_suggest_bindings(),
+                    load_sidebar_bindings(self),
+                    load_confirm_exit_bindings(self),
+                    ConditionalKeyBindings(
+                        load_open_in_editor_bindings(),
+                        Condition(lambda: self.enable_open_in_editor),
+                    ),
+                    # Extra key bindings should not be active when the sidebar is visible.
+                    ConditionalKeyBindings(
+                        self.extra_key_bindings,
+                        Condition(lambda: not self.show_sidebar),
+                    ),
+                ]
+            ),
+            color_depth=lambda: self.color_depth,
+            paste_mode=Condition(lambda: self.paste_mode),
+            mouse_support=Condition(lambda: self.enable_mouse_support),
+            style=DynamicStyle(lambda: self._current_style),
+            style_transformation=self.style_transformation,
+            include_default_pygments_style=False,
+            reverse_vi_search_direction=True,
+            cursor=DynamicCursorShapeConfig(
+                lambda: self.all_cursor_shape_configs[self.cursor_shape_config]
+            ),
+            input=input,
+            output=output,
+        )
+
+    def _create_buffer(self) -> Buffer:
+        """
+        Create the `Buffer` for the Python input.
+        """
+        python_buffer = Buffer(
+            name=DEFAULT_BUFFER,
+            complete_while_typing=Condition(lambda: self.complete_while_typing),
+            enable_history_search=Condition(lambda: self.enable_history_search),
+            tempfile_suffix=".py",
+            history=self.history,
+            completer=ThreadedCompleter(self._completer),
+            validator=ConditionalValidator(
+                self._validator, Condition(lambda: self.enable_input_validation)
+            ),
+            auto_suggest=ConditionalAutoSuggest(
+                ThreadedAutoSuggest(AutoSuggestFromHistory()),
+                Condition(lambda: self.enable_auto_suggest),
+            ),
+            accept_handler=self._accept_handler,
+            on_text_changed=self._on_input_timeout,
+        )
+
+        return python_buffer
+
+    @property
+    def editing_mode(self) -> EditingMode:
+        return self.app.editing_mode
+
+    @editing_mode.setter
+    def editing_mode(self, value: EditingMode) -> None:
+        self.app.editing_mode = value
+
+    @property
+    def vi_mode(self) -> bool:
+        return self.editing_mode == EditingMode.VI
+
+    @vi_mode.setter
+    def vi_mode(self, value: bool) -> None:
+        if value:
+            self.editing_mode = EditingMode.VI
+        else:
+            self.editing_mode = EditingMode.EMACS
+
+    @property
+    def app(self) -> Application[str]:
+        if self._app is None:
+            return get_app()
+        return self._app
+
+    def _on_input_timeout(self, buff: Buffer) -> None:
+        """
+        When there is no input activity,
+        in another thread, get the signature of the current code.
+        """
+
+        def get_signatures_in_executor(document: Document) -> list[Signature]:
+            # First, get signatures from Jedi. If we didn't found any and if
+            # "dictionary completion" (eval-based completion) is enabled, then
+            # get signatures using eval.
+            signatures = get_signatures_using_jedi(
+                document, self.get_locals(), self.get_globals()
+            )
+            if not signatures and self.enable_dictionary_completion:
+                signatures = get_signatures_using_eval(
+                    document, self.get_locals(), self.get_globals()
+                )
+
+            return signatures
+
+        app = self.app
+
+        async def on_timeout_task() -> None:
+            loop = get_running_loop()
+
+            # Never run multiple get-signature threads.
+            if self._get_signatures_thread_running:
+                return
+            self._get_signatures_thread_running = True
+
+            try:
+                while True:
+                    document = buff.document
+                    signatures = await loop.run_in_executor(
+                        None, get_signatures_in_executor, document
+                    )
+
+                    # If the text didn't change in the meantime, take these
+                    # signatures. Otherwise, try again.
+                    if buff.text == document.text:
+                        break
+            finally:
+                self._get_signatures_thread_running = False
+
+            # Set signatures and redraw.
+            self.signatures = signatures
+
+            # Set docstring in docstring buffer.
+            if signatures:
+                self.docstring_buffer.reset(
+                    document=Document(signatures[0].docstring, cursor_position=0)
+                )
+            else:
+                self.docstring_buffer.reset()
+
+            app.invalidate()
+
+        if app.is_running:
+            app.create_background_task(on_timeout_task())
+
+    def on_reset(self) -> None:
+        self.signatures = []
+
+    def enter_history(self) -> None:
+        """
+        Display the history.
+        """
+        app = self.app
+        app.vi_state.input_mode = InputMode.NAVIGATION
+
+        history = PythonHistory(self, self.default_buffer.document)
+
+        import asyncio
+
+        from prompt_toolkit.application import in_terminal
+
+        async def do_in_terminal() -> None:
+            async with in_terminal():
+                result = await history.app.run_async()
+                if result is not None:
+                    self.default_buffer.text = result
+
+                app.vi_state.input_mode = InputMode.INSERT
+
+        asyncio.ensure_future(do_in_terminal())
+
+    def read(self) -> str:
+        """
+        Read the input.
+
+        This will run the Python input user interface in another thread, wait
+        for input to be accepted and return that. By running the UI in another
+        thread, we avoid issues regarding possibly nested event loops.
+
+        This can raise EOFError, when Control-D is pressed.
+        """
+
+        # Capture the current input_mode in order to restore it after reset,
+        # for ViState.reset() sets it to InputMode.INSERT unconditionally and
+        # doesn't accept any arguments.
+        def pre_run(
+            last_input_mode: InputMode = self.app.vi_state.input_mode,
+        ) -> None:
+            if self.vi_keep_last_used_mode:
+                self.app.vi_state.input_mode = last_input_mode
+
+            if not self.vi_keep_last_used_mode and self.vi_start_in_navigation_mode:
+                self.app.vi_state.input_mode = InputMode.NAVIGATION
+
+        # Run the UI.
+        while True:
+            try:
+                result = self.app.run(pre_run=pre_run, in_thread=True)
+
+                if result.lstrip().startswith("\x1a"):
+                    # When the input starts with Ctrl-Z, quit the REPL.
+                    # (Important for Windows users.)
+                    raise EOFError
+
+                # Remove leading whitespace.
+                # (Users can add extra indentation, which happens for
+                # instance because of copy/pasting code.)
+                result = unindent_code(result)
+
+                if result and not result.isspace():
+                    if self.insert_blank_line_after_input:
+                        self.app.output.write("\n")
+
+                    return result
+            except KeyboardInterrupt:
+                # Abort - try again.
+                self.signatures = []
+                self.default_buffer.document = Document()
diff --git a/src/ptpython/repl.py b/src/ptpython/repl.py
new file mode 100644
index 00000000..469ed694
--- /dev/null
+++ b/src/ptpython/repl.py
@@ -0,0 +1,618 @@
+"""
+Utility for creating a Python repl.
+
+::
+
+    from ptpython.repl import embed
+    embed(globals(), locals(), vi_mode=False)
+
+"""
+
+from __future__ import annotations
+
+import asyncio
+import builtins
+import os
+import signal
+import sys
+import traceback
+import types
+import warnings
+from dis import COMPILER_FLAG_NAMES
+from pathlib import Path
+from typing import (
+    Any,
+    Callable,
+    ContextManager,
+    Coroutine,
+    Iterable,
+    Literal,
+    NoReturn,
+    Sequence,
+    overload,
+)
+
+from prompt_toolkit.formatted_text import OneStyleAndTextTuple
+from prompt_toolkit.patch_stdout import patch_stdout as patch_stdout_context
+from prompt_toolkit.shortcuts import (
+    clear_title,
+    set_title,
+)
+from prompt_toolkit.utils import DummyContext
+from pygments.lexers import PythonTracebackLexer  # noqa: F401
+
+from .printer import OutputPrinter
+from .python_input import PythonInput
+
+PyCF_ALLOW_TOP_LEVEL_AWAIT: int
+try:
+    from ast import PyCF_ALLOW_TOP_LEVEL_AWAIT  # type: ignore
+except ImportError:
+    PyCF_ALLOW_TOP_LEVEL_AWAIT = 0
+
+
+__all__ = [
+    "PythonRepl",
+    "enable_deprecation_warnings",
+    "run_config",
+    "embed",
+    "exit",
+    "ReplExit",
+]
+
+
+def _get_coroutine_flag() -> int | None:
+    for k, v in COMPILER_FLAG_NAMES.items():
+        if v == "COROUTINE":
+            return k
+
+    # Flag not found.
+    return None
+
+
+COROUTINE_FLAG: int | None = _get_coroutine_flag()
+
+
+def _has_coroutine_flag(code: types.CodeType) -> bool:
+    if COROUTINE_FLAG is None:
+        # Not supported on this Python version.
+        return False
+
+    return bool(code.co_flags & COROUTINE_FLAG)
+
+
+class PythonRepl(PythonInput):
+    def __init__(self, *a, **kw) -> None:
+        self._startup_paths: Sequence[str | Path] | None = kw.pop("startup_paths", None)
+        super().__init__(*a, **kw)
+        self._load_start_paths()
+
+    def _load_start_paths(self) -> None:
+        "Start the Read-Eval-Print Loop."
+        if self._startup_paths:
+            for path in self._startup_paths:
+                if os.path.exists(path):
+                    with open(path, "rb") as f:
+                        code = compile(f.read(), path, "exec")
+                        exec(code, self.get_globals(), self.get_locals())
+                else:
+                    output = self.app.output
+                    output.write(f"WARNING | File not found: {path}\n\n")
+
+    def run_and_show_expression(self, expression: str) -> None:
+        try:
+            # Eval.
+            try:
+                result = self.eval(expression)
+            except KeyboardInterrupt:
+                # KeyboardInterrupt doesn't inherit from Exception.
+                raise
+            except SystemExit:
+                raise
+            except ReplExit:
+                raise
+            except BaseException as e:
+                self._handle_exception(e)
+            else:
+                if isinstance(result, exit):
+                    # When `exit` is evaluated without parentheses.
+                    # Automatically trigger the `ReplExit` exception.
+                    raise ReplExit
+
+                # Print.
+                if result is not None:
+                    self._show_result(result)
+                    if self.insert_blank_line_after_output:
+                        self.app.output.write("\n")
+
+                # Loop.
+                self.current_statement_index += 1
+                self.signatures = []
+
+        except KeyboardInterrupt as e:
+            # Handle all possible `KeyboardInterrupt` errors. This can
+            # happen during the `eval`, but also during the
+            # `show_result` if something takes too long.
+            # (Try/catch is around the whole block, because we want to
+            # prevent that a Control-C keypress terminates the REPL in
+            # any case.)
+            self._handle_keyboard_interrupt(e)
+
+    def _get_output_printer(self) -> OutputPrinter:
+        return OutputPrinter(
+            output=self.app.output,
+            input=self.app.input,
+            style=self._current_style,
+            style_transformation=self.style_transformation,
+            title=self.title,
+        )
+
+    def _show_result(self, result: object) -> None:
+        self._get_output_printer().display_result(
+            result=result,
+            out_prompt=self.get_output_prompt(),
+            reformat=self.enable_output_formatting,
+            highlight=self.enable_syntax_highlighting,
+            paginate=self.enable_pager,
+        )
+
+    def run(self) -> None:
+        """
+        Run the REPL loop.
+        """
+        if self.terminal_title:
+            set_title(self.terminal_title)
+
+        self._add_to_namespace()
+
+        try:
+            while True:
+                # Pull text from the user.
+                try:
+                    text = self.read()
+                except EOFError:
+                    return
+                except BaseException:
+                    # Something went wrong while reading input.
+                    # (E.g., a bug in the completer that propagates. Don't
+                    # crash the REPL.)
+                    traceback.print_exc()
+                    continue
+
+                # Run it; display the result (or errors if applicable).
+                try:
+                    self.run_and_show_expression(text)
+                except ReplExit:
+                    return
+        finally:
+            if self.terminal_title:
+                clear_title()
+            self._remove_from_namespace()
+
+    async def run_and_show_expression_async(self, text: str) -> Any:
+        loop = asyncio.get_running_loop()
+        system_exit: SystemExit | None = None
+
+        try:
+            try:
+                # Create `eval` task. Ensure that control-c will cancel this
+                # task.
+                async def eval() -> Any:
+                    nonlocal system_exit
+                    try:
+                        return await self.eval_async(text)
+                    except SystemExit as e:
+                        # Don't propagate SystemExit in `create_task()`. That
+                        # will kill the event loop. We want to handle it
+                        # gracefully.
+                        system_exit = e
+
+                task = asyncio.create_task(eval())
+                loop.add_signal_handler(signal.SIGINT, lambda *_: task.cancel())
+                result = await task
+
+                if system_exit is not None:
+                    raise system_exit
+            except KeyboardInterrupt:
+                # KeyboardInterrupt doesn't inherit from Exception.
+                raise
+            except SystemExit:
+                raise
+            except BaseException as e:
+                self._handle_exception(e)
+            else:
+                # Print.
+                if result is not None:
+                    await loop.run_in_executor(None, lambda: self._show_result(result))
+
+                # Loop.
+                self.current_statement_index += 1
+                self.signatures = []
+                # Return the result for future consumers.
+                return result
+            finally:
+                loop.remove_signal_handler(signal.SIGINT)
+
+        except KeyboardInterrupt as e:
+            # Handle all possible `KeyboardInterrupt` errors. This can
+            # happen during the `eval`, but also during the
+            # `show_result` if something takes too long.
+            # (Try/catch is around the whole block, because we want to
+            # prevent that a Control-C keypress terminates the REPL in
+            # any case.)
+            self._handle_keyboard_interrupt(e)
+
+    async def run_async(self) -> None:
+        """
+        Run the REPL loop, but run the blocking parts in an executor, so that
+        we don't block the event loop. Both the input and output (which can
+        display a pager) will run in a separate thread with their own event
+        loop, this way ptpython's own event loop won't interfere with the
+        asyncio event loop from where this is called.
+
+        The "eval" however happens in the current thread, which is important.
+        (Both for control-C to work, as well as for the code to see the right
+        thread in which it was embedded).
+        """
+        loop = asyncio.get_running_loop()
+
+        if self.terminal_title:
+            set_title(self.terminal_title)
+
+        self._add_to_namespace()
+
+        try:
+            while True:
+                try:
+                    # Read.
+                    try:
+                        text = await loop.run_in_executor(None, self.read)
+                    except EOFError:
+                        return
+                    except BaseException:
+                        # Something went wrong while reading input.
+                        # (E.g., a bug in the completer that propagates. Don't
+                        # crash the REPL.)
+                        traceback.print_exc()
+                        continue
+
+                    # Eval.
+                    await self.run_and_show_expression_async(text)
+
+                except KeyboardInterrupt as e:
+                    # XXX: This does not yet work properly. In some situations,
+                    # `KeyboardInterrupt` exceptions can end up in the event
+                    # loop selector.
+                    self._handle_keyboard_interrupt(e)
+                except SystemExit:
+                    return
+        finally:
+            if self.terminal_title:
+                clear_title()
+            self._remove_from_namespace()
+
+    def eval(self, line: str) -> object:
+        """
+        Evaluate the line and print the result.
+        """
+        # WORKAROUND: Due to a bug in Jedi, the current directory is removed
+        # from sys.path. See: https://github.com/davidhalter/jedi/issues/1148
+        if "" not in sys.path:
+            sys.path.insert(0, "")
+
+        if line.lstrip().startswith("!"):
+            # Run as shell command
+            os.system(line[1:])
+        else:
+            # Try eval first
+            try:
+                code = self._compile_with_flags(line, "eval")
+            except SyntaxError:
+                pass
+            else:
+                # No syntax errors for eval. Do eval.
+                result = eval(code, self.get_globals(), self.get_locals())
+
+                if _has_coroutine_flag(code):
+                    result = asyncio.get_running_loop().run_until_complete(result)
+
+                self._store_eval_result(result)
+                return result
+
+            # If not a valid `eval` expression, run using `exec` instead.
+            # Note that we shouldn't run this in the `except SyntaxError` block
+            # above, then `sys.exc_info()` would not report the right error.
+            # See issue: https://github.com/prompt-toolkit/ptpython/issues/435
+            code = self._compile_with_flags(line, "exec")
+            result = eval(code, self.get_globals(), self.get_locals())
+
+            if _has_coroutine_flag(code):
+                result = asyncio.get_running_loop().run_until_complete(result)
+
+        return None
+
+    async def eval_async(self, line: str) -> object:
+        """
+        Evaluate the line and print the result.
+        """
+        # WORKAROUND: Due to a bug in Jedi, the current directory is removed
+        # from sys.path. See: https://github.com/davidhalter/jedi/issues/1148
+        if "" not in sys.path:
+            sys.path.insert(0, "")
+
+        if line.lstrip().startswith("!"):
+            # Run as shell command
+            os.system(line[1:])
+        else:
+            # Try eval first
+            try:
+                code = self._compile_with_flags(line, "eval")
+            except SyntaxError:
+                pass
+            else:
+                # No syntax errors for eval. Do eval.
+                result = eval(code, self.get_globals(), self.get_locals())
+
+                if _has_coroutine_flag(code):
+                    result = await result
+
+                self._store_eval_result(result)
+                return result
+
+            # If not a valid `eval` expression, compile as `exec` expression
+            # but still run with eval to get an awaitable in case of a
+            # awaitable expression.
+            code = self._compile_with_flags(line, "exec")
+            result = eval(code, self.get_globals(), self.get_locals())
+
+            if _has_coroutine_flag(code):
+                result = await result
+
+        return None
+
+    def _store_eval_result(self, result: object) -> None:
+        locals: dict[str, Any] = self.get_locals()
+        locals["_"] = locals[f"_{self.current_statement_index}"] = result
+
+    def get_compiler_flags(self) -> int:
+        return super().get_compiler_flags() | PyCF_ALLOW_TOP_LEVEL_AWAIT
+
+    def _compile_with_flags(self, code: str, mode: str) -> Any:
+        "Compile code with the right compiler flags."
+        return compile(
+            code,
+            "<stdin>",
+            mode,
+            flags=self.get_compiler_flags(),
+            dont_inherit=True,
+        )
+
+    def _handle_exception(self, e: BaseException) -> None:
+        # Required for pdb.post_mortem() to work.
+        t, v, tb = sys.exc_info()
+        sys.last_type, sys.last_value, sys.last_traceback = t, v, tb
+
+        self._get_output_printer().display_exception(
+            e,
+            highlight=self.enable_syntax_highlighting,
+            paginate=self.enable_pager,
+        )
+
+    def _handle_keyboard_interrupt(self, e: KeyboardInterrupt) -> None:
+        output = self.app.output
+
+        output.write("\rKeyboardInterrupt\n\n")
+        output.flush()
+
+    def _add_to_namespace(self) -> None:
+        """
+        Add ptpython built-ins to global namespace.
+        """
+        globals = self.get_globals()
+
+        # Add a 'get_ptpython', similar to 'get_ipython'
+        def get_ptpython() -> PythonInput:
+            return self
+
+        globals["get_ptpython"] = get_ptpython
+        globals["exit"] = exit()
+
+    def _remove_from_namespace(self) -> None:
+        """
+        Remove added symbols from the globals.
+        """
+        globals = self.get_globals()
+        del globals["get_ptpython"]
+
+    def print_paginated_formatted_text(
+        self,
+        formatted_text: Iterable[OneStyleAndTextTuple],
+        end: str = "\n",
+    ) -> None:
+        # Warning: This is mainly here backwards-compatibility. Some projects
+        # call `print_paginated_formatted_text` on the Repl object.
+        self._get_output_printer().display_style_and_text_tuples(
+            formatted_text, paginate=True
+        )
+
+
+def enable_deprecation_warnings() -> None:
+    """
+    Show deprecation warnings, when they are triggered directly by actions in
+    the REPL. This is recommended to call, before calling `embed`.
+
+    e.g. This will show an error message when the user imports the 'sha'
+         library on Python 2.7.
+    """
+    warnings.filterwarnings("default", category=DeprecationWarning, module="__main__")
+
+
+DEFAULT_CONFIG_FILE = "~/.config/ptpython/config.py"
+
+
+def run_config(repl: PythonInput, config_file: str | None = None) -> None:
+    """
+    Execute REPL config file.
+
+    :param repl: `PythonInput` instance.
+    :param config_file: Path of the configuration file.
+    """
+    explicit_config_file = config_file is not None
+
+    # Expand tildes.
+    config_file = os.path.expanduser(
+        config_file if config_file is not None else DEFAULT_CONFIG_FILE
+    )
+
+    def enter_to_continue() -> None:
+        input("\nPress ENTER to continue...")
+
+    # Check whether this file exists.
+    if not os.path.exists(config_file):
+        if explicit_config_file:
+            print(f"Impossible to read {config_file}")
+            enter_to_continue()
+        return
+
+    # Run the config file in an empty namespace.
+    try:
+        namespace: dict[str, Any] = {}
+
+        with open(config_file, "rb") as f:
+            code = compile(f.read(), config_file, "exec")
+            exec(code, namespace, namespace)
+
+        # Now we should have a 'configure' method in this namespace. We call this
+        # method with the repl as an argument.
+        if "configure" in namespace:
+            namespace["configure"](repl)
+
+    except Exception:
+        traceback.print_exc()
+        enter_to_continue()
+
+
+class exit:
+    """
+    Exit the ptpython REPL.
+    """
+
+    # This custom exit function ensures that the `embed` function returns from
+    # where we are embedded, and Python doesn't close `sys.stdin` like
+    # the default `exit` from `_sitebuiltins.Quitter` does.
+
+    def __call__(self) -> NoReturn:
+        raise ReplExit
+
+    def __repr__(self) -> str:
+        # (Same message as the built-in Python REPL.)
+        return "Use exit() or Ctrl-D (i.e. EOF) to exit"
+
+
+class ReplExit(Exception):
+    """
+    Exception raised by ptpython's exit function.
+    """
+
+
+@overload
+def embed(
+    globals: dict[str, Any] | None = ...,
+    locals: dict[str, Any] | None = ...,
+    configure: Callable[[PythonRepl], None] | None = ...,
+    vi_mode: bool = ...,
+    history_filename: str | None = ...,
+    title: str | None = ...,
+    startup_paths: Sequence[str | Path] | None = ...,
+    patch_stdout: bool = ...,
+    return_asyncio_coroutine: Literal[False] = ...,
+) -> None: ...
+
+
+@overload
+def embed(
+    globals: dict[str, Any] | None = ...,
+    locals: dict[str, Any] | None = ...,
+    configure: Callable[[PythonRepl], None] | None = ...,
+    vi_mode: bool = ...,
+    history_filename: str | None = ...,
+    title: str | None = ...,
+    startup_paths: Sequence[str | Path] | None = ...,
+    patch_stdout: bool = ...,
+    return_asyncio_coroutine: Literal[True] = ...,
+) -> Coroutine[Any, Any, None]: ...
+
+
+def embed(
+    globals: dict[str, Any] | None = None,
+    locals: dict[str, Any] | None = None,
+    configure: Callable[[PythonRepl], None] | None = None,
+    vi_mode: bool = False,
+    history_filename: str | None = None,
+    title: str | None = None,
+    startup_paths: Sequence[str | Path] | None = None,
+    patch_stdout: bool = False,
+    return_asyncio_coroutine: bool = False,
+) -> None | Coroutine[Any, Any, None]:
+    """
+    Call this to embed  Python shell at the current point in your program.
+    It's similar to `IPython.embed` and `bpython.embed`. ::
+
+        from prompt_toolkit.contrib.repl import embed
+        embed(globals(), locals())
+
+    :param vi_mode: Boolean. Use Vi instead of Emacs key bindings.
+    :param configure: Callable that will be called with the `PythonRepl` as a first
+                      argument, to trigger configuration.
+    :param title: Title to be displayed in the terminal titlebar. (None or string.)
+    :param patch_stdout:  When true, patch `sys.stdout` so that background
+        threads that are printing will print nicely above the prompt.
+    """
+    # Default globals/locals
+    if globals is None:
+        globals = {
+            "__name__": "__main__",
+            "__package__": None,
+            "__doc__": None,
+            "__builtins__": builtins,
+        }
+
+    locals = locals or globals
+
+    def get_globals() -> dict[str, Any]:
+        return globals
+
+    def get_locals() -> dict[str, Any]:
+        return locals
+
+    # Create REPL.
+    repl = PythonRepl(
+        get_globals=get_globals,
+        get_locals=get_locals,
+        vi_mode=vi_mode,
+        history_filename=history_filename,
+        startup_paths=startup_paths,
+    )
+
+    if title:
+        repl.terminal_title = title
+
+    if configure:
+        configure(repl)
+
+    # Start repl.
+    patch_context: ContextManager[None] = (
+        patch_stdout_context() if patch_stdout else DummyContext()
+    )
+
+    if return_asyncio_coroutine:
+
+        async def coroutine() -> None:
+            with patch_context:
+                await repl.run_async()
+
+        return coroutine()  # type: ignore
+    else:
+        with patch_context:
+            repl.run()
+        return None
diff --git a/src/ptpython/signatures.py b/src/ptpython/signatures.py
new file mode 100644
index 00000000..b3e5c914
--- /dev/null
+++ b/src/ptpython/signatures.py
@@ -0,0 +1,271 @@
+"""
+Helpers for retrieving the function signature of the function call that we are
+editing.
+
+Either with the Jedi library, or using `inspect.signature` if Jedi fails and we
+can use `eval()` to evaluate the function object.
+"""
+
+from __future__ import annotations
+
+import inspect
+from inspect import Signature as InspectSignature
+from inspect import _ParameterKind as ParameterKind
+from typing import TYPE_CHECKING, Any, Sequence
+
+from prompt_toolkit.document import Document
+
+from .completer import DictionaryCompleter
+from .utils import get_jedi_script_from_document
+
+if TYPE_CHECKING:
+    import jedi.api.classes
+
+__all__ = ["Signature", "get_signatures_using_jedi", "get_signatures_using_eval"]
+
+
+class Parameter:
+    def __init__(
+        self,
+        name: str,
+        annotation: str | None,
+        default: str | None,
+        kind: ParameterKind,
+    ) -> None:
+        self.name = name
+        self.kind = kind
+
+        self.annotation = annotation
+        self.default = default
+
+    def __repr__(self) -> str:
+        return f"Parameter(name={self.name!r})"
+
+    @property
+    def description(self) -> str:
+        """
+        Name + annotation.
+        """
+        description = self.name
+
+        if self.annotation is not None:
+            description += f": {self.annotation}"
+
+        return description
+
+
+class Signature:
+    """
+    Signature definition used wrap around both Jedi signatures and
+    python-inspect signatures.
+
+    :param index: Parameter index of the current cursor position.
+    :param bracket_start: (line, column) tuple for the open bracket that starts
+        the function call.
+    """
+
+    def __init__(
+        self,
+        name: str,
+        docstring: str,
+        parameters: Sequence[Parameter],
+        index: int | None = None,
+        returns: str = "",
+        bracket_start: tuple[int, int] = (0, 0),
+    ) -> None:
+        self.name = name
+        self.docstring = docstring
+        self.parameters = parameters
+        self.index = index
+        self.returns = returns
+        self.bracket_start = bracket_start
+
+    @classmethod
+    def from_inspect_signature(
+        cls,
+        name: str,
+        docstring: str,
+        signature: InspectSignature,
+        index: int,
+    ) -> Signature:
+        parameters = []
+
+        def get_annotation_name(annotation: object) -> str:
+            """
+            Get annotation as string from inspect signature.
+            """
+            try:
+                # In case the annotation is a class like "int", "float", ...
+                return str(annotation.__name__)  # type: ignore
+            except AttributeError:
+                pass  # No attribute `__name__`, e.g., in case of `List[int]`.
+
+            annotation = str(annotation)
+            if annotation.startswith("typing."):
+                annotation = annotation[len("typing:") :]
+            return annotation
+
+        for p in signature.parameters.values():
+            parameters.append(
+                Parameter(
+                    name=p.name,
+                    annotation=get_annotation_name(p.annotation),
+                    default=repr(p.default)
+                    if p.default is not inspect.Parameter.empty
+                    else None,
+                    kind=p.kind,
+                )
+            )
+
+        return cls(
+            name=name,
+            docstring=docstring,
+            parameters=parameters,
+            index=index,
+            returns="",
+        )
+
+    @classmethod
+    def from_jedi_signature(cls, signature: jedi.api.classes.Signature) -> Signature:
+        parameters = []
+
+        for p in signature.params:
+            if p is None:
+                # We just hit the "*".
+                continue
+
+            parameters.append(
+                Parameter(
+                    name=p.to_string(),  # p.name, (`to_string()` already includes the annotation).
+                    annotation=None,  # p.infer_annotation()
+                    default=None,  # p.infer_default()
+                    kind=p.kind,
+                )
+            )
+
+        docstring = signature.docstring()
+        if not isinstance(docstring, str):
+            docstring = docstring.decode("utf-8")
+
+        return cls(
+            name=signature.name,
+            docstring=docstring,
+            parameters=parameters,
+            index=signature.index,
+            returns="",
+            bracket_start=signature.bracket_start,
+        )
+
+    def __repr__(self) -> str:
+        return f"Signature({self.name!r}, parameters={self.parameters!r})"
+
+
+def get_signatures_using_jedi(
+    document: Document, locals: dict[str, Any], globals: dict[str, Any]
+) -> list[Signature]:
+    script = get_jedi_script_from_document(document, locals, globals)
+
+    # Show signatures in help text.
+    if not script:
+        return []
+
+    try:
+        signatures = script.get_signatures()
+    except ValueError:
+        # e.g. in case of an invalid \\x escape.
+        signatures = []
+    except Exception:
+        # Sometimes we still get an exception (TypeError), because
+        # of probably bugs in jedi. We can silence them.
+        # See: https://github.com/davidhalter/jedi/issues/492
+        signatures = []
+    else:
+        # Try to access the params attribute just once. For Jedi
+        # signatures containing the keyword-only argument star,
+        # this will crash when retrieving it the first time with
+        # AttributeError. Every following time it works.
+        # See: https://github.com/jonathanslenders/ptpython/issues/47
+        #      https://github.com/davidhalter/jedi/issues/598
+        try:
+            if signatures:
+                signatures[0].params
+        except AttributeError:
+            pass
+
+    return [Signature.from_jedi_signature(sig) for sig in signatures]
+
+
+def get_signatures_using_eval(
+    document: Document, locals: dict[str, Any], globals: dict[str, Any]
+) -> list[Signature]:
+    """
+    Look for the signature of the function before the cursor position without
+    use of Jedi. This uses a similar approach as the `DictionaryCompleter` of
+    running `eval()` over the detected function name.
+    """
+    # Look for open parenthesis, before cursor position.
+    pos = document.cursor_position - 1
+
+    paren_mapping = {")": "(", "}": "{", "]": "["}
+    paren_stack = [
+        ")"
+    ]  # Start stack with closing ')'. We are going to look for the matching open ')'.
+    comma_count = 0  # Number of comma's between start of function call and cursor pos.
+    found_start = False  # Found something.
+
+    while pos >= 0:
+        char = document.text[pos]
+        if char in ")]}":
+            paren_stack.append(char)
+        elif char in "([{":
+            if not paren_stack:
+                # Open paren, while no closing paren was found. Mouse cursor is
+                # positioned in nested parentheses. Not at the "top-level" of a
+                # function call.
+                break
+            if paren_mapping[paren_stack[-1]] != char:
+                # Unmatching parentheses: syntax error?
+                break
+
+            paren_stack.pop()
+
+            if len(paren_stack) == 0:
+                found_start = True
+                break
+
+        elif char == "," and len(paren_stack) == 1:
+            comma_count += 1
+
+        pos -= 1
+
+    if not found_start:
+        return []
+
+    # We found the start of the function call. Now look for the object before
+    # this position on which we can do an 'eval' to retrieve the function
+    # object.
+    obj = DictionaryCompleter(lambda: globals, lambda: locals).eval_expression(
+        Document(document.text, cursor_position=pos), locals
+    )
+    if obj is None:
+        return []
+
+    try:
+        name = obj.__name__  # type:ignore
+    except Exception:
+        name = obj.__class__.__name__
+
+    try:
+        signature = inspect.signature(obj)  # type: ignore
+    except TypeError:
+        return []  # Not a callable object.
+    except ValueError:
+        return []  # No signature found, like for build-ins like "print".
+
+    try:
+        doc = obj.__doc__ or ""
+    except:
+        doc = ""
+
+    # TODO: `index` is not yet correct when dealing with keyword-only arguments.
+    return [Signature.from_inspect_signature(name, doc, signature, index=comma_count)]
diff --git a/src/ptpython/style.py b/src/ptpython/style.py
new file mode 100644
index 00000000..c5a04e58
--- /dev/null
+++ b/src/ptpython/style.py
@@ -0,0 +1,175 @@
+from __future__ import annotations
+
+from prompt_toolkit.styles import BaseStyle, Style, merge_styles
+from prompt_toolkit.styles.pygments import style_from_pygments_cls
+from prompt_toolkit.utils import is_conemu_ansi, is_windows, is_windows_vt100_supported
+from pygments.styles import get_all_styles, get_style_by_name
+
+__all__ = ["get_all_code_styles", "get_all_ui_styles", "generate_style"]
+
+
+def get_all_code_styles() -> dict[str, BaseStyle]:
+    """
+    Return a mapping from style names to their classes.
+    """
+    result: dict[str, BaseStyle] = {
+        name: style_from_pygments_cls(get_style_by_name(name))
+        for name in get_all_styles()
+    }
+    result["win32"] = Style.from_dict(win32_code_style)
+    return result
+
+
+def get_all_ui_styles() -> dict[str, BaseStyle]:
+    """
+    Return a dict mapping {ui_style_name -> style_dict}.
+    """
+    return {
+        "default": Style.from_dict(default_ui_style),
+        "blue": Style.from_dict(blue_ui_style),
+    }
+
+
+def generate_style(python_style: BaseStyle, ui_style: BaseStyle) -> BaseStyle:
+    """
+    Generate Pygments Style class from two dictionaries
+    containing style rules.
+    """
+    return merge_styles([python_style, ui_style])
+
+
+# Code style for Windows consoles. They support only 16 colors,
+# so we choose a combination that displays nicely.
+win32_code_style = {
+    "pygments.comment": "#00ff00",
+    "pygments.keyword": "#44ff44",
+    "pygments.number": "",
+    "pygments.operator": "",
+    "pygments.string": "#ff44ff",
+    "pygments.name": "",
+    "pygments.name.decorator": "#ff4444",
+    "pygments.name.class": "#ff4444",
+    "pygments.name.function": "#ff4444",
+    "pygments.name.builtin": "#ff4444",
+    "pygments.name.attribute": "",
+    "pygments.name.constant": "",
+    "pygments.name.entity": "",
+    "pygments.name.exception": "",
+    "pygments.name.label": "",
+    "pygments.name.namespace": "",
+    "pygments.name.tag": "",
+    "pygments.name.variable": "",
+}
+
+
+default_ui_style = {
+    "control-character": "ansiblue",
+    # Classic prompt.
+    "prompt": "bold",
+    "prompt.dots": "noinherit",
+    # (IPython <5.0) Prompt: "In [1]:"
+    "in": "bold #008800",
+    "in.number": "",
+    # Return value.
+    "out": "#ff0000",
+    "out.number": "#ff0000",
+    # Completions.
+    "completion.builtin": "",
+    "completion.param": "#006666 italic",
+    "completion.keyword": "fg:#008800",
+    "completion.keyword fuzzymatch.inside": "fg:#008800",
+    "completion.keyword fuzzymatch.outside": "fg:#44aa44",
+    # Separator between windows. (Used above docstring.)
+    "separator": "#bbbbbb",
+    # System toolbar
+    "system-toolbar": "#22aaaa noinherit",
+    # "arg" toolbar.
+    "arg-toolbar": "#22aaaa noinherit",
+    "arg-toolbar.text": "noinherit",
+    # Signature toolbar.
+    "signature-toolbar": "bg:#44bbbb #000000",
+    "signature-toolbar current-name": "bg:#008888 #ffffff bold",
+    "signature-toolbar operator": "#000000 bold",
+    "docstring": "#888888",
+    # Validation toolbar.
+    "validation-toolbar": "bg:#440000 #aaaaaa",
+    # Status toolbar.
+    "status-toolbar": "bg:#222222 #aaaaaa",
+    "status-toolbar.title": "underline",
+    "status-toolbar.inputmode": "bg:#222222 #ffffaa",
+    "status-toolbar.key": "bg:#000000 #888888",
+    "status-toolbar key": "bg:#000000 #888888",
+    "status-toolbar.pastemodeon": "bg:#aa4444 #ffffff",
+    "status-toolbar.pythonversion": "bg:#222222 #ffffff bold",
+    "status-toolbar paste-mode-on": "bg:#aa4444 #ffffff",
+    "record": "bg:#884444 white",
+    "status-toolbar more": "#ffff44",
+    "status-toolbar.input-mode": "#ffff44",
+    # The options sidebar.
+    "sidebar": "bg:#bbbbbb #000000",
+    "sidebar.title": "bg:#668866 #ffffff",
+    "sidebar.label": "bg:#bbbbbb #222222",
+    "sidebar.status": "bg:#dddddd #000011",
+    "sidebar.label selected": "bg:#222222 #eeeeee",
+    "sidebar.status selected": "bg:#444444 #ffffff bold",
+    "sidebar.separator": "underline",
+    "sidebar.key": "bg:#bbddbb #000000 bold",
+    "sidebar.key.description": "bg:#bbbbbb #000000",
+    "sidebar.helptext": "bg:#fdf6e3 #000011",
+    #        # Styling for the history layout.
+    #        history.line:                          '',
+    #        history.line.selected:                 'bg:#008800  #000000',
+    #        history.line.current:                  'bg:#ffffff #000000',
+    #        history.line.selected.current:         'bg:#88ff88 #000000',
+    #        history.existinginput:                  '#888888',
+    # Help Window.
+    "window-border": "#aaaaaa",
+    "window-title": "bg:#bbbbbb #000000",
+    # Meta-enter message.
+    "accept-message": "bg:#ffff88 #444444",
+    # Exit confirmation.
+    "exit-confirmation": "bg:#884444 #ffffff",
+}
+
+
+# Some changes to get a bit more contrast on Windows consoles.
+# (They only support 16 colors.)
+if is_windows() and not is_conemu_ansi() and not is_windows_vt100_supported():
+    default_ui_style.update(
+        {
+            "sidebar.title": "bg:#00ff00 #ffffff",
+            "exitconfirmation": "bg:#ff4444 #ffffff",
+            "toolbar.validation": "bg:#ff4444 #ffffff",
+            "menu.completions.completion": "bg:#ffffff #000000",
+            "menu.completions.completion.current": "bg:#aaaaaa #000000",
+        }
+    )
+
+
+blue_ui_style = {}
+blue_ui_style.update(default_ui_style)
+# blue_ui_style.update({
+#        # Line numbers.
+#        Token.LineNumber:                             '#aa6666',
+#
+#        # Highlighting of search matches in document.
+#        Token.SearchMatch:                            '#ffffff bg:#4444aa',
+#        Token.SearchMatch.Current:                    '#ffffff bg:#44aa44',
+#
+#        # Highlighting of select text in document.
+#        Token.SelectedText:                           '#ffffff bg:#6666aa',
+#
+#        # Completer toolbar.
+#        Token.Toolbar.Completions:                    'bg:#44bbbb #000000',
+#        Token.Toolbar.Completions.Arrow:              'bg:#44bbbb #000000 bold',
+#        Token.Toolbar.Completions.Completion:         'bg:#44bbbb #000000',
+#        Token.Toolbar.Completions.Completion.Current: 'bg:#008888 #ffffff',
+#
+#        # Completer menu.
+#        Token.Menu.Completions.Completion:            'bg:#44bbbb #000000',
+#        Token.Menu.Completions.Completion.Current:    'bg:#008888 #ffffff',
+#        Token.Menu.Completions.Meta:                  'bg:#449999 #000000',
+#        Token.Menu.Completions.Meta.Current:          'bg:#00aaaa #000000',
+#        Token.Menu.Completions.ProgressBar:           'bg:#aaaaaa',
+#        Token.Menu.Completions.ProgressButton:        'bg:#000000',
+# })
diff --git a/src/ptpython/utils.py b/src/ptpython/utils.py
new file mode 100644
index 00000000..92cfc2a1
--- /dev/null
+++ b/src/ptpython/utils.py
@@ -0,0 +1,213 @@
+"""
+For internal use only.
+"""
+
+from __future__ import annotations
+
+import re
+from typing import TYPE_CHECKING, Any, Callable, Iterable, TypeVar, cast
+
+from prompt_toolkit.document import Document
+from prompt_toolkit.formatted_text import to_formatted_text
+from prompt_toolkit.formatted_text.utils import fragment_list_to_text
+from prompt_toolkit.mouse_events import MouseEvent, MouseEventType
+
+if TYPE_CHECKING:
+    from jedi import Interpreter
+
+    # See: prompt_toolkit/key_binding/key_bindings.py
+    # Annotating these return types as `object` is what works best, because
+    # `NotImplemented` is typed `Any`.
+    NotImplementedOrNone = object
+
+__all__ = [
+    "has_unclosed_brackets",
+    "get_jedi_script_from_document",
+    "document_is_multiline_python",
+    "unindent_code",
+]
+
+
+def has_unclosed_brackets(text: str) -> bool:
+    """
+    Starting at the end of the string. If we find an opening bracket
+    for which we didn't had a closing one yet, return True.
+    """
+    stack = []
+
+    # Ignore braces inside strings
+    text = re.sub(r"""('[^']*'|"[^"]*")""", "", text)  # XXX: handle escaped quotes.!
+
+    for c in reversed(text):
+        if c in "])}":
+            stack.append(c)
+
+        elif c in "[({":
+            if stack:
+                if (
+                    (c == "[" and stack[-1] == "]")
+                    or (c == "{" and stack[-1] == "}")
+                    or (c == "(" and stack[-1] == ")")
+                ):
+                    stack.pop()
+            else:
+                # Opening bracket for which we didn't had a closing one.
+                return True
+
+    return False
+
+
+def get_jedi_script_from_document(
+    document: Document, locals: dict[str, Any], globals: dict[str, Any]
+) -> Interpreter:
+    import jedi  # We keep this import in-line, to improve start-up time.
+
+    # Importing Jedi is 'slow'.
+
+    try:
+        return jedi.Interpreter(
+            document.text,
+            path="input-text",
+            namespaces=[locals, globals],
+        )
+    except ValueError:
+        # Invalid cursor position.
+        # ValueError('`column` parameter is not in a valid range.')
+        return None
+    except AttributeError:
+        # Workaround for #65: https://github.com/jonathanslenders/python-prompt-toolkit/issues/65
+        # See also: https://github.com/davidhalter/jedi/issues/508
+        return None
+    except IndexError:
+        # Workaround Jedi issue #514: for https://github.com/davidhalter/jedi/issues/514
+        return None
+    except KeyError:
+        # Workaround for a crash when the input is "u'", the start of a unicode string.
+        return None
+    except Exception:
+        # Workaround for: https://github.com/jonathanslenders/ptpython/issues/91
+        return None
+
+
+_multiline_string_delims = re.compile("""[']{3}|["]{3}""")
+
+
+def document_is_multiline_python(document: Document) -> bool:
+    """
+    Determine whether this is a multiline Python document.
+    """
+
+    def ends_in_multiline_string() -> bool:
+        """
+        ``True`` if we're inside a multiline string at the end of the text.
+        """
+        delims = _multiline_string_delims.findall(document.text)
+        opening = None
+        for delim in delims:
+            if opening is None:
+                opening = delim
+            elif delim == opening:
+                opening = None
+        return bool(opening)
+
+    if "\n" in document.text or ends_in_multiline_string():
+        return True
+
+    def line_ends_with_colon() -> bool:
+        return document.current_line.rstrip()[-1:] == ":"
+
+    # If we just typed a colon, or still have open brackets, always insert a real newline.
+    if (
+        line_ends_with_colon()
+        or (
+            document.is_cursor_at_the_end
+            and has_unclosed_brackets(document.text_before_cursor)
+        )
+        or document.text.startswith("@")
+    ):
+        return True
+
+    # If the character before the cursor is a backslash (line continuation
+    # char), insert a new line.
+    elif document.text_before_cursor[-1:] == "\\":
+        return True
+
+    return False
+
+
+_T = TypeVar("_T", bound=Callable[[MouseEvent], None])
+
+
+def if_mousedown(handler: _T) -> _T:
+    """
+    Decorator for mouse handlers.
+    Only handle event when the user pressed mouse down.
+
+    (When applied to a token list. Scroll events will bubble up and are handled
+    by the Window.)
+    """
+
+    def handle_if_mouse_down(mouse_event: MouseEvent) -> NotImplementedOrNone:
+        if mouse_event.event_type == MouseEventType.MOUSE_DOWN:
+            return handler(mouse_event)
+        else:
+            return NotImplemented
+
+    return cast(_T, handle_if_mouse_down)
+
+
+_T_type = TypeVar("_T_type", bound=type)
+
+
+def ptrepr_to_repr(cls: _T_type) -> _T_type:
+    """
+    Generate a normal `__repr__` method for classes that have a `__pt_repr__`.
+    """
+    if not hasattr(cls, "__pt_repr__"):
+        raise TypeError(
+            "@ptrepr_to_repr can only be applied to classes that have a `__pt_repr__` method."
+        )
+
+    def __repr__(self: object) -> str:
+        assert hasattr(cls, "__pt_repr__")
+        return fragment_list_to_text(to_formatted_text(cls.__pt_repr__(self)))
+
+    cls.__repr__ = __repr__  # type:ignore
+    return cls
+
+
+def unindent_code(text: str) -> str:
+    """
+    Remove common leading whitespace when all lines are indented.
+    """
+    lines = text.splitlines(keepends=True)
+
+    # Look for common prefix.
+    common_prefix = _common_whitespace_prefix(lines)
+
+    # Remove indentation.
+    lines = [line[len(common_prefix) :] for line in lines]
+
+    return "".join(lines)
+
+
+def _common_whitespace_prefix(strings: Iterable[str]) -> str:
+    """
+    Return common prefix for a list of lines.
+    This will ignore lines that contain whitespace only.
+    """
+    # Ignore empty lines and lines that have whitespace only.
+    strings = [s for s in strings if not s.isspace() and not len(s) == 0]
+
+    if not strings:
+        return ""
+
+    else:
+        s1 = min(strings)
+        s2 = max(strings)
+
+        for i, c in enumerate(s1):
+            if c != s2[i] or c not in " \t":
+                return s1[:i]
+
+        return s1
diff --git a/ptpython/validator.py b/src/ptpython/validator.py
similarity index 52%
rename from ptpython/validator.py
rename to src/ptpython/validator.py
index 103697b5..cf2ee542 100644
--- a/ptpython/validator.py
+++ b/src/ptpython/validator.py
@@ -1,10 +1,13 @@
-from __future__ import unicode_literals
+from __future__ import annotations
 
-from prompt_toolkit.validation import Validator, ValidationError
+from typing import Callable
 
-__all__ = (
-    'PythonValidator',
-)
+from prompt_toolkit.document import Document
+from prompt_toolkit.validation import ValidationError, Validator
+
+from .utils import unindent_code
+
+__all__ = ["PythonValidator"]
 
 
 class PythonValidator(Validator):
@@ -14,16 +17,24 @@ class PythonValidator(Validator):
     :param get_compiler_flags: Callable that returns the currently
         active compiler flags.
     """
-    def __init__(self, get_compiler_flags=None):
+
+    def __init__(self, get_compiler_flags: Callable[[], int] | None = None) -> None:
         self.get_compiler_flags = get_compiler_flags
 
-    def validate(self, document):
+    def validate(self, document: Document) -> None:
         """
         Check input for Python syntax errors.
         """
+        text = unindent_code(document.text)
+
         # When the input starts with Ctrl-Z, always accept. This means EOF in a
         # Python REPL.
-        if document.text.startswith('\x1a'):
+        if text.startswith("\x1a"):
+            return
+
+        # When the input starts with an exclamation mark. Accept as shell
+        # command.
+        if text.lstrip().startswith("!"):
             return
 
         try:
@@ -32,17 +43,20 @@ def validate(self, document):
             else:
                 flags = 0
 
-            compile(document.text, '<input>', 'exec', flags=flags, dont_inherit=True)
+            compile(text, "<input>", "exec", flags=flags, dont_inherit=True)
         except SyntaxError as e:
             # Note, the 'or 1' for offset is required because Python 2.7
             # gives `None` as offset in case of '4=4' as input. (Looks like
             # fixed in Python 3.)
-            index = document.translate_row_col_to_index(e.lineno - 1,  (e.offset or 1) - 1)
-            raise ValidationError(index, 'Syntax Error')
+            # TODO: This is not correct if indentation was removed.
+            index = document.translate_row_col_to_index(
+                (e.lineno or 1) - 1, (e.offset or 1) - 1
+            )
+            raise ValidationError(index, f"Syntax Error: {e}")
         except TypeError as e:
             # e.g. "compile() expected string without null bytes"
             raise ValidationError(0, str(e))
         except ValueError as e:
             # In Python 2, compiling "\x9" (an invalid escape sequence) raises
             # ValueError instead of SyntaxError.
-            raise ValidationError(0, 'Syntax Error: %s' % e)
+            raise ValidationError(0, f"Syntax Error: {e}")
diff --git a/tests/run_tests.py b/tests/run_tests.py
deleted file mode 100755
index a23fddec..00000000
--- a/tests/run_tests.py
+++ /dev/null
@@ -1,26 +0,0 @@
-#!/usr/bin/env python
-from __future__ import unicode_literals
-
-import unittest
-
-
-# For now there are no tests here.
-# However this is sufficient for Travis to do at least a syntax check.
-# That way we are at least sure to restrict to the Python 2.6 syntax.
-
-import ptpython.completer
-import ptpython.filters
-#import ptpython.ipython
-import ptpython.eventloop
-import ptpython.history_browser
-import ptpython.key_bindings
-import ptpython.layout
-import ptpython.python_input
-import ptpython.repl
-import ptpython.style
-import ptpython.utils
-import ptpython.validator
-
-
-if __name__ == '__main__':
-    unittest.main()
diff --git a/tests/test_dummy.py b/tests/test_dummy.py
new file mode 100755
index 00000000..922c6a39
--- /dev/null
+++ b/tests/test_dummy.py
@@ -0,0 +1,31 @@
+#!/usr/bin/env python
+from __future__ import annotations
+
+import ptpython.completer
+import ptpython.eventloop
+import ptpython.filters
+import ptpython.history_browser
+import ptpython.key_bindings
+import ptpython.layout
+import ptpython.python_input
+import ptpython.repl
+import ptpython.style
+import ptpython.utils
+import ptpython.validator
+
+# For now there are no tests here.
+# However this is sufficient to do at least a syntax check.
+
+
+def test_dummy() -> None:
+    assert ptpython.completer
+    assert ptpython.eventloop
+    assert ptpython.filters
+    assert ptpython.history_browser
+    assert ptpython.key_bindings
+    assert ptpython.layout
+    assert ptpython.python_input
+    assert ptpython.repl
+    assert ptpython.style
+    assert ptpython.utils
+    assert ptpython.validator
diff --git a/tox.ini b/tox.ini
deleted file mode 100644
index 40a358ab..00000000
--- a/tox.ini
+++ /dev/null
@@ -1,10 +0,0 @@
-# Tox (http://tox.testrun.org/) is a tool for running tests
-# in multiple virtualenvs. This configuration file will run the
-# test suite on all supported python versions. To use it, "pip install tox"
-# and then run "tox" from this directory.
-
-[tox]
-envlist = py26, py27, py33, py34, pypy, pypy3
-
-[testenv]
-commands = {toxinidir}/tests/run_tests.py
