My Python input utility for console chatbots - multiline input method, with editing and mid-prompt commands

This is a utility method that I’ve refined and used for hundreds of API calls - a broad replacement for input() for about every AI prompting scenario: where you want to type naturally within a Python console-based application at a terminal with scrollback, without writing up or expecting a full TUI application with a windowed terminal takeover.

What this is not: some bash command line to send a prompt. It is a utility for a full chatbot application, that may have its own rich display of output and a robust set of internal commands.

What it does that is not available elsewhere:

  • multi-line input, letting you just type and paste text and file contents and write some more including blank lines, with no accidental premature sends;

  • send via CTRL+ENTER, for no confusion and accidental sends between platforms that train you on SHIFT+ENTER when a linefeed is needed (ChatGPT) and others that use that as a send command;

  • maintains a draft, so that you can issue a single-word slash commands seen as a “turn” to your application mid-prompt on a new line…when you think of it (such as /model, /load, /new, etc), and return to composing an AI prompt after your app services those commands;

  • provides a non-truncated scrollback of input that is copyable out of a terminal without linefeeds at line width;

  • lets you arrow-up and edit previous lines and create inserts in a typical terminal;

  • Provides its own configurable line of prompt, which can include help about how to send or /help guidance;

  • Has fallbacks to work about anywhere and still be multi-line; TTY, shell, dumb terminal, Python REPL, even a Jupyter notebook in VSCode works, and you can send with slash alone when there is no CTRL or Command key to successfully capture;

  • a simpler stateless caller that can almost drop in for input();

  • coded with lazy imports and internals only - a standalone class to even just paste at the top level of some existing code;

  • have prompt_toolkit and keyboard available in the environment for the best experience

  • a little demo app is included, to see how it works or for teaching an AI how to integrate.


A redundant AI composition about the use cases

MultilineInput(): stateful multiline input for command-aware console apps

Python console programs are starting to feel less like calculators and more like conversations. A user may be writing a long prompt, pasting a stack trace, composing a commit message, or describing a deployment problem — and halfway through, they may need to ask the application for help, switch context, browse a file, change a model, clear state, inspect history, or run another command without throwing the draft away.

MultilineInput exists for that moment.

It is a small, reusable input primitive for applications that need both long-form text and live application control. The user can write naturally across multiple lines, then send with a portable marker: / alone on a line. But when the latest line is a single-word slash command such as /help, /browse, /settings, /model, or /history, that command can interrupt the input. The unfinished draft stays in the session, the command is returned to the application, and after the application handles it, the user comes back to the same message they were already composing.

That creates interaction patterns that are awkward or fragile with ordinary input() and too specific to rebuild every time on top of a full prompt framework:

  • Draft a long AI prompt, type /browse, inspect a file, then resume the same prompt.

  • Paste an error log, type /help, learn the available commands, then continue instead of starting over.

  • Compose a multi-paragraph instruction, type /model, switch the application backend, and return to the draft.

  • Build a chat, REPL, coding assistant, local agent shell, or workflow console where application commands are available mid-thought rather than only before or after input.

The point is not to replace rich CLI frameworks with advanced windowed drawing. It is to replace a complete absence of input methods that service what you want to send to an AI in the Python eco-system, where a survey reveals a lot of prompt_toolkit wrappers without improvement.

It is also written to survive the messy reality of Python execution environments. In a real terminal with prompt_toolkit available, it can use editable multiline buffers, restored drafts, and supported key bindings. In IDLE, non-TTY shells, redirected input, or less capable environments, it falls back to line-oriented collection while still preserving and replaying drafts around commands. Optional integrations are used only when they work; the portable path remains plain text: / sends, single-word slash commands interrupt, and // escapes a literal slash line.

That makes it foundationally useful because it turns “stateful text entry with live commands” into a dependable building block. Applications no longer have to choose between a one-line input(), a heavyweight UI, or a custom half-working editor. They can accept serious multiline human input, keep the user in flow, and still let the application respond to commands at the exact moment the user needs them.

What it cannot do is just as important. MultilineInput is not a shell, not a command dispatcher, not a full TUI framework, and not a replacement for prompt_toolkit. It returns command tokens; your application decides what those commands mean. It cannot make every environment expose Ctrl+Enter or Command+Enter, so modified-Enter sending is advertised only when the active frontend can support it. In plain input mode, already-entered lines cannot be edited in place; they can be retained, shown, and replayed. In other words, it does not pretend every TTY, REPL, OS, or shell is equally capable — it degrades deliberately so the core experience still works.

Scroll through the code for docstrings and multi_input_demo() that also stand alone as documentation - and saved as a file, can simply be run to try it out.

Pasted right here - no GitHub repo to promote or plug.
My library is saved as chatbot_input_utils.py - but the plurality in the name is not needed for this final solution.


Docstring for the class itself is here, stripped out of the code to get under the character limit of the next post with the Python.

    """
    Stateful multiline console input with slash-command interruption.

    Optional integrations are imported lazily when available:
      prompt_toolkit >= 3.0.0 - editable multiline buffers in real terminals
      keyboard       - best-effort modified-Enter detection

    Configuration attributes:
      - prompt_message: prompt text printed before any guidance.
      - help_enabled: include or omit additional prompt guidance such as
        "/help for commands".
      - sending_help: include or omit the "how to send" prompt guidance.
      - command_interrupts_enabled: when true, single-word slash commands on the
        latest line interrupt and preserve a draft. When false, application
        slash commands are recognized only as the first input line. The built-in
        /back editor command remains active.
      - transcript_echo_enabled: in prompt_toolkit mode, erase the transient
        edit buffer after submission and print the returned input once as normal
        terminal output. This gives scrollback a complete transcript of long
        multiline submissions without requiring the calling application to know
        about prompt_toolkit.

    Prompt text is composed from class attributes. Applications may subclass
    and replace DEFAULT_PROMPT, PROMPT_SEND_HINT, PROMPT_MODIFIED_ENTER_HINT,
    and PROMPT_ADDITIONAL_HELP to match their command vocabulary. Empty prompt
    fragments are omitted cleanly. Prompt templates may use {send_marker},
    {back_command}, and {modified_enter}.

    Default behavior:
      - Multi-line message: press Enter for new lines.
      - Send message: type a line containing only "/" and press Enter.
      - Send message: when the prompt advertises it, use CTRL-ENTER, or
        COMMAND-ENTER on macOS. The prompt advertises this only when the active
        input frontend can register that modified-Enter key path for the
        current process.
      - Command interruption: type a single-word slash command such as
        "/browse" or "/help" on the latest line and press Enter. The command
        is returned by itself, while earlier draft lines are preserved for the
        next prompt. Slash-starting lines with spaces are normal message text.
        Set command_interrupts_enabled to false to keep this behavior only for
        the first input line.
      - Built-in line backup: type "/back" on a new line to restore the draft
        in the input editor instead of returning a command to the caller. This
        is handled entirely inside the session; applications may document it but
        do not need to dispatch it.
      - Escape: start any line with "//" to make it a literal "/" line rather
        than a command marker. For example, "//help" becomes "/help" in the
        eventual message.

    UX notes:
      - Real terminals use prompt_toolkit when available, giving editable
        multi-line buffers and restored drafts.
      - IDLE and non-TTY shells fall back to line-oriented input. In that mode
        already-entered draft lines cannot be edited in place, but they are
        shown and retained while commands run or when "/back" replays the
        draft.
      - In IDLE and other non-TTY shells, Ctrl+Enter cannot be detected via
        stdin alone; if the optional `keyboard` package is available, it is used
        as an out-of-band key detector to treat modified Enter as "send".
       - In prompt_toolkit mode, ordinary Enter inserts a new line. The portable
        send path remains a line containing only "/". Ctrl+Enter is not assumed
        to be a prompt_toolkit key; if modified-Enter is advertised, it is only
        because the current process can register a supported prompt_toolkit
        sequence or the optional `keyboard` bridge for the desired Ctrl+Enter.
      - If a terminal-like host passes the cheap environment gate but
        prompt_toolkit cannot initialize its frontend, the session silently
        disables prompt_toolkit and retries with line-oriented input.
    """

chatbot_input_util library file

"""Multiline input utilities for chatbot-style console programs.

The module exposes :class:`MultilineInput`, a standalone stateful input
collector that returns complete multiline messages while still allowing
single-word slash commands to interrupt the draft. Interrupted drafts are
retained by the session so callers can run an application command and then
return the user to the message they were composing.

The module also exposes :func:`input_multiline`, a stateless, input-like helper.
It creates an internal session for one completed input operation, disables
mid-input application command interruption, and discards the session when the
operation finishes.

Supported runtime:

- Python 3.10 or newer. The module uses built-in generic annotations and PEP
  604 unions without postponed annotation evaluation.
- prompt_toolkit is optional. When installed, the supported floor is
  prompt_toolkit 3.0.0. The integration uses PromptSession, KeyBindings,
  multiline prompts, default text, application exit results, current buffer
  access, and document cursor properties that exist in the 3.0 API.
- keyboard is optional. It is used as a best-effort modified-Enter
  detector where the active frontend cannot expose that key combination
  directly; when unavailable or unsupported, the slash send marker remains the
  portable send path.

The input session handles its own editor command, `/back`. Applications using
this class do not need to implement that command; they can simply document it
if it is useful for users. On a terminal, /back reprints draft input, and may
return caret to end-of-line.
"""

__all__ = ["MultilineInput", "input_multiline"]


class MultilineInput:
    """docstring"""
    class _PromptToolkitFrontendUnavailable(Exception):
        """Internal signal to retry the prompt without prompt_toolkit."""

    SEND_MARKER = "/"
    BACK_COMMAND = "/back"
    DEFAULT_PROMPT = "-- your message? --"
    PROMPT_SEND_HINT = "{send_marker!r} alone on a line to send."
    PROMPT_MODIFIED_ENTER_HINT = (
        "{modified_enter} to send, or {send_marker!r} alone on a line."
    )
    PROMPT_ADDITIONAL_HELP: tuple[str, ...] = ("/help for commands.",)
    MODIFIED_ENTER_BRIDGE_SECONDS = 0.35

    def __init__(self, prompt_message: str | None = None) -> None:
        self.prompt_message = (
            self.DEFAULT_PROMPT if prompt_message is None else prompt_message
        )
        self.help_enabled = True
        self.sending_help = True
        self.command_interrupts_enabled = True
        self.transcript_echo_enabled = True
        self._draft = ""
        self._keyboard_loaded = False
        self._keyboard: object | None = None
        self._hotkey_support_cache: dict[str, bool] = {}
        self._prompt_toolkit_available_cache: bool | None = None
        self._prompt_toolkit_modified_enter_cache: tuple[str, str] | None = None
        self._prompt_toolkit_modified_enter_checked = False
        self._prompt_toolkit_runtime_disabled = False
        # Internal debugging handle:
        # force prompt_toolkit even in IDLE / non-TTY / unsupported TERM contexts.
        self._FORCE_PTK = False

    @staticmethod
    def is_slash_command(line: str) -> bool:
        """
        Return True when a console line is exactly one slash command token.
        """
        value = line.rstrip()
        return (
            value.startswith("/")
            and not value.startswith("//")
            and len(value) > 1
            and not any(ch.isspace() for ch in value)
        )

    @classmethod
    def _is_slash_command(cls, line: str) -> bool:
        return cls.is_slash_command(line)

    @classmethod
    def _is_back_command(cls, line: str) -> bool:
        return (
            cls._is_slash_command(line)
            and line.rstrip().lower() == cls.BACK_COMMAND
        )

    @classmethod
    def _is_send_marker(cls, line: str) -> bool:
        return line.rstrip() == cls.SEND_MARKER

    @staticmethod
    def _postprocess(raw: str) -> str:
        lines = raw.splitlines()
        for i, line in enumerate(lines):
            if line.startswith("//"):
                lines[i] = "/" + line[2:]
        return "\n".join(lines)

    def _finalize_message(self, raw: str, *, strip: bool) -> str:
        self._draft = ""
        out = self._postprocess(raw)
        return out.strip() if strip else out

    @staticmethod
    def _modified_enter_candidates() -> tuple[tuple[str, str, str], ...]:
        """
        Return hotkey name, modifier name, and display label candidates.
        """
        import sys

        if sys.platform == "darwin":
            return (
                ("command+enter", "command", "Command+Enter"),
                ("cmd+enter", "cmd", "Command+Enter"),
                ("ctrl+enter", "ctrl", "Ctrl+Enter"),
            )

        return (("ctrl+enter", "ctrl", "Ctrl+Enter"),)

    @staticmethod
    def _is_keyboard_backend_error(exc: Exception) -> bool:
        error_types: tuple[type[BaseException], ...] = (
            AttributeError,
            ImportError,
            KeyError,
            RuntimeError,
            OSError,
            ValueError,
        )

        try:
            from subprocess import CalledProcessError
        except ImportError:
            return isinstance(exc, error_types)

        return isinstance(exc, error_types + (CalledProcessError,))

    def _load_keyboard(self) -> object | None:
        if self._keyboard_loaded:
            return self._keyboard

        self._keyboard_loaded = True
        try:
            import keyboard  # type: ignore  # pylint: disable=C0415
        except ImportError as exc:
            if exc.name and exc.name.startswith("keyboard"):
                return None
            raise

        self._keyboard = keyboard
        return self._keyboard

    def _keyboard_hotkey_supported(self, hotkey_name: str) -> bool:
        cached = self._hotkey_support_cache.get(hotkey_name)
        if cached is not None:
            return cached

        keyboard = self._load_keyboard()
        if keyboard is None:
            self._hotkey_support_cache[hotkey_name] = False
            return False

        try:
            hotkey = keyboard.add_hotkey(  # type: ignore[attr-defined]
                hotkey_name,
                lambda: None,
                suppress=False,
            )
        except Exception as exc:
            if not self._is_keyboard_backend_error(exc):
                raise
            self._hotkey_support_cache[hotkey_name] = False
            return False

        try:
            keyboard.remove_hotkey(hotkey)  # type: ignore[attr-defined]
        except Exception as exc:
            if not self._is_keyboard_backend_error(exc):
                raise

        self._hotkey_support_cache[hotkey_name] = True
        return True

    def _supported_modified_enter_candidate(
        self,
    ) -> tuple[str, str, str] | None:
        for candidate in self._modified_enter_candidates():
            hotkey_name, _, _ = candidate
            if self._keyboard_hotkey_supported(hotkey_name):
                return candidate
        return None

    def _add_modified_enter_hotkey(
        self,
        callback: object,
    ) -> tuple[object | None, list[object], tuple[str, str, str] | None]:
        keyboard = self._load_keyboard()
        if keyboard is None:
            return None, [], None

        for candidate in self._modified_enter_candidates():
            hotkey_name, _, _ = candidate
            if self._hotkey_support_cache.get(hotkey_name) is False:
                continue

            try:
                hotkey = keyboard.add_hotkey(  # type: ignore[attr-defined]
                    hotkey_name,
                    callback,
                    suppress=False,
                )
            except Exception as exc:
                if not self._is_keyboard_backend_error(exc):
                    raise
                self._hotkey_support_cache[hotkey_name] = False
                continue

            self._hotkey_support_cache[hotkey_name] = True
            return keyboard, [hotkey], candidate

        return keyboard, [], None

    @staticmethod
    def _remove_keyboard_hotkeys(
        keyboard: object | None,
        hotkeys: list[object],
    ) -> None:
        if keyboard is None:
            return

        for hotkey in hotkeys:
            try:
                keyboard.remove_hotkey(hotkey)  # type: ignore[attr-defined]
            except Exception as exc:
                if not MultilineInput._is_keyboard_backend_error(exc):
                    raise

    @staticmethod
    def _modified_enter_pressed(
        keyboard: object | None,
        candidate: tuple[str, str, str] | None,
    ) -> bool:
        if keyboard is None or candidate is None:
            return False

        _, modifier_name, _ = candidate
        try:
            return bool(keyboard.is_pressed(modifier_name))  # type: ignore[attr-defined]
        except Exception as exc:
            if not MultilineInput._is_keyboard_backend_error(exc):
                raise
            return False

    def _modified_enter_prompt_label(self, *, prompt_toolkit_mode: bool) -> str | None:
        if prompt_toolkit_mode:
            ptk_candidate = self._prompt_toolkit_modified_enter_candidate()
            if ptk_candidate is not None:
                _, label = ptk_candidate
                return label

        candidate = self._supported_modified_enter_candidate()
        if candidate is None:
            return None

        _, _, label = candidate
        return label

    @staticmethod
    def _prompt_toolkit_modified_enter_candidates() -> tuple[tuple[str, str], ...]:
        """
        Return prompt_toolkit key names that may represent modified Enter.

        Plain Enter is Control-M in many terminals, so this deliberately avoids
        aliases such as "c-m" and "c-j"; binding those would make ordinary
        Enter send the message. A two-key Escape-then-Enter sequence is
        intentionally not a candidate because the send chord is Ctrl+Enter.
        """
        return (("c-enter", "Ctrl+Enter"),)

    def _prompt_toolkit_modified_enter_candidate(self) -> tuple[str, str] | None:
        if self._prompt_toolkit_modified_enter_checked:
            return self._prompt_toolkit_modified_enter_cache

        try:
            from prompt_toolkit.key_binding import KeyBindings  # pylint: disable=C0415
        except ImportError as exc:
            if exc.name and exc.name.startswith("prompt_toolkit"):
                self._prompt_toolkit_modified_enter_checked = True
                return None
            raise

        for key_name, label in self._prompt_toolkit_modified_enter_candidates():
            bindings = KeyBindings()
            try:
                bindings.add(key_name)(lambda event: None)
            except ValueError:
                continue

            self._prompt_toolkit_modified_enter_cache = (key_name, label)
            self._prompt_toolkit_modified_enter_checked = True
            return key_name, label

        self._prompt_toolkit_modified_enter_checked = True
        return None

    def _format_prompt_fragment(
        self,
        template: str,
        *,
        modified_enter: str | None,
    ) -> str:
        return template.format(
            send_marker=self.SEND_MARKER,
            back_command=self.BACK_COMMAND,
            modified_enter=modified_enter or "",
        ).strip()

    def _prompt_additional_help(self) -> tuple[str, ...]:
        additional_help = self.PROMPT_ADDITIONAL_HELP
        if isinstance(additional_help, str):
            return (additional_help,)
        return tuple(additional_help)

    def _store_draft_and_return_command(
        self,
        draft_lines: list[str],
        command_line: str,
        *,
        strip: bool,
    ) -> str:
        # Keep the draft in its raw input form. Escaped slash lines such as
        # "//help" must remain escaped until the user finally sends the message;
        # otherwise a restored literal slash line could be mistaken for a command.
        self._draft = "\n".join(draft_lines)
        return command_line.strip() if strip else command_line

    def _should_return_command(
        self,
        command_line: str,
        draft_lines: list[str],
    ) -> bool:
        if not self._is_slash_command(command_line):
            return False

        if self._is_back_command(command_line):
            return True

        return self.command_interrupts_enabled or not draft_lines

    def _split_latest_command(
        self,
        raw: str,
        *,
        strip: bool,
    ) -> str | None:
        """
        Return a latest-line command and preserve prior text as draft.
        """
        lines = raw.splitlines()
        if not lines:
            return None

        command_line = lines[-1]
        draft_lines = lines[:-1]
        if not self._should_return_command(command_line, draft_lines):
            return None

        return self._store_draft_and_return_command(
            draft_lines,
            command_line,
            strip=strip,
        )

    def _format_prompt(self, *, prompt_toolkit_mode: bool) -> str:
        modified_enter_label = (
            self._modified_enter_prompt_label(
                prompt_toolkit_mode=prompt_toolkit_mode,
            )
            if self.sending_help
            else None
        )
        prompt_parts: list[str] = []

        base_prompt = self.prompt_message.strip()
        if base_prompt:
            prompt_parts.append(base_prompt)

        if self.sending_help:
            if modified_enter_label is None:
                send_help = self._format_prompt_fragment(
                    self.PROMPT_SEND_HINT,
                    modified_enter=None,
                )
            else:
                send_help = self._format_prompt_fragment(
                    self.PROMPT_MODIFIED_ENTER_HINT,
                    modified_enter=modified_enter_label,
                )
            if send_help:
                prompt_parts.append(send_help)

        if self.help_enabled:
            for additional_help in self._prompt_additional_help():
                help_text = self._format_prompt_fragment(
                    additional_help,
                    modified_enter=modified_enter_label,
                )
                if help_text:
                    prompt_parts.append(help_text)

        return " ".join(prompt_parts)

    def _print_prompt(self, *, prompt_toolkit_mode: bool) -> None:
        prompt = self._format_prompt(prompt_toolkit_mode=prompt_toolkit_mode)
        if prompt:
            print(prompt)

        if not self._draft:
            return

        # prompt_toolkit renders the restored draft in the editable input
        # buffer. Plain input has no editable buffer, so echo the retained
        # draft directly after the normal prompt header.
        if not prompt_toolkit_mode:
            print(self._draft)

    def _print_prompt_toolkit_transcript(self, text: str) -> None:
        if not self.transcript_echo_enabled or not text:
            return

        import sys

        sys.stdout.write(text)
        if not text.endswith("\n"):
            sys.stdout.write("\n")
        sys.stdout.flush()

    @staticmethod
    def _terminal_supports_prompt_toolkit() -> bool:
        import os
        import sys

        try:
            stdin_tty = sys.stdin.isatty()
            stdout_tty = sys.stdout.isatty()
        except (AttributeError, OSError, ValueError):
            return False

        in_idle = "idlelib" in sys.modules or (
            hasattr(sys.stdin, "__class__")
            and getattr(sys.stdin.__class__, "__module__", "").startswith("idlelib")
        )

        if os.name == "nt":
            term_ok = True
        else:
            term_ok = os.environ.get("TERM") not in (None, "", "dumb")

        return stdin_tty and stdout_tty and term_ok and not in_idle

    def _prompt_toolkit_available(self) -> bool:
        cached = self._prompt_toolkit_available_cache
        if cached is not None:
            return cached

        try:
            from prompt_toolkit import PromptSession  # pylint: disable=C0415
            from prompt_toolkit.key_binding import KeyBindings  # pylint: disable=C0415
        except ImportError as exc:
            if exc.name and exc.name.startswith("prompt_toolkit"):
                self._prompt_toolkit_available_cache = False
                return False
            raise

        self._prompt_toolkit_available_cache = bool(PromptSession and KeyBindings)
        return self._prompt_toolkit_available_cache

    @staticmethod
    def _prompt_toolkit_frontend_error_types() -> tuple[type[BaseException], ...]:
        """Return prompt_toolkit frontend failures that merit plain fallback."""
        import sys

        error_types: tuple[type[BaseException], ...] = (OSError, RuntimeError)
        if sys.platform != "win32":
            return error_types

        try:
            from prompt_toolkit.output.win32 import (  # pylint: disable=C0415
                NoConsoleScreenBufferError,
            )
        except ImportError as exc:
            if exc.name and exc.name.startswith("prompt_toolkit"):
                return error_types
            raise

        return error_types + (NoConsoleScreenBufferError,)

    def _collect_with_plain_input(self, *, strip: bool) -> str | None:
        # Line-based collector that works in IDLE and any non-TTY stdin environment.
        # Best-effort modified-Enter detection via optional `keyboard`.
        send_requested = False

        def _mark_send() -> None:
            nonlocal send_requested
            send_requested = True

        keyboard, hotkeys, active_candidate = self._add_modified_enter_hotkey(
            _mark_send,
        )

        def _mod_enter_send_requested() -> bool:
            nonlocal send_requested
            if send_requested:
                send_requested = False
                return True
            return self._modified_enter_pressed(keyboard, active_candidate)

        try:
            lines: list[str] = self._draft.splitlines() if self._draft else []

            try:
                first = input()
            except (EOFError, KeyboardInterrupt):
                return None

            if self._is_send_marker(first):
                return self._finalize_message("\n".join(lines), strip=strip)

            if self._should_return_command(first, lines):
                return self._store_draft_and_return_command(
                    lines,
                    first,
                    strip=strip,
                )

            lines.append(first)
            if _mod_enter_send_requested():
                return self._finalize_message("\n".join(lines), strip=strip)

            while True:
                try:
                    line = input()
                except EOFError:
                    break
                except KeyboardInterrupt:
                    return None

                if self._is_send_marker(line):
                    break

                if self._should_return_command(line, lines):
                    return self._store_draft_and_return_command(
                        lines,
                        line,
                        strip=strip,
                    )

                lines.append(line)
                if _mod_enter_send_requested():
                    break

            return self._finalize_message("\n".join(lines), strip=strip)
        finally:
            self._remove_keyboard_hotkeys(keyboard, hotkeys)

    def _collect_with_prompt_toolkit(self, *, strip: bool) -> str | None:
        # Real terminal: prompt_toolkit provides editable restored drafts.
        try:
            from prompt_toolkit import PromptSession  # pylint: disable=C0415
            from prompt_toolkit.key_binding import KeyBindings  # pylint: disable=C0415
        except ImportError as exc:
            if exc.name and exc.name.startswith("prompt_toolkit"):
                return self._collect_with_plain_input(strip=strip)
            raise

        keyboard: object | None = None
        hotkeys: list[object] = []
        ptk_frontend_error_types = self._prompt_toolkit_frontend_error_types()

        try:
            session = PromptSession(
                erase_when_done=self.transcript_echo_enabled,
            )
            kb = KeyBindings()
            ptk_modified_enter = self._prompt_toolkit_modified_enter_candidate()
            send_requested_at: float | None = None

            def _send_current_buffer(event):
                event.app.exit(result=event.app.current_buffer.text)

            def _mark_send() -> None:
                nonlocal send_requested_at
                from time import monotonic

                send_requested_at = monotonic()

            if ptk_modified_enter is None:
                keyboard, hotkeys, _ = self._add_modified_enter_hotkey(_mark_send)

            def _keyboard_send_requested() -> bool:
                nonlocal send_requested_at
                if send_requested_at is None:
                    return False

                requested_at = send_requested_at
                send_requested_at = None

                from time import monotonic

                return (
                    monotonic() - requested_at
                    <= self.MODIFIED_ENTER_BRIDGE_SECONDS
                )

            @kb.add("c-d")
            def _ctrl_d(event):  # type: ignore[no-redef]
                _send_current_buffer(event)

            if ptk_modified_enter is not None:
                ptk_modified_enter_key, _ = ptk_modified_enter

                @kb.add(ptk_modified_enter_key)
                def _modified_enter(event):  # type: ignore[no-redef]
                    _send_current_buffer(event)

            @kb.add("enter")
            def _enter(event):  # type: ignore[no-redef]
                buf = event.app.current_buffer
                doc = buf.document
                text = buf.text

                if _keyboard_send_requested():
                    event.app.exit(result=text)
                    return

                lines = text.splitlines()
                row = doc.cursor_position_row
                col = doc.cursor_position_col
                if lines and row == (len(lines) - 1):
                    current_line = doc.current_line
                    if (
                        self._is_send_marker(current_line)
                        and col == len(current_line)
                    ):
                        event.app.exit(result="\n".join(lines[:-1]))
                        return
                    if (
                        self._should_return_command(
                            current_line,
                            lines[:-1],
                        )
                        and col == len(current_line)
                    ):
                        event.app.exit(result=text)
                        return

                buf.insert_text("\n")

            try:
                raw = session.prompt(
                    "",
                    multiline=True,
                    key_bindings=kb,
                    default=self._draft,
                )
            except (EOFError, KeyboardInterrupt):
                return None

            command = self._split_latest_command(raw, strip=strip)
            if command is not None:
                if not self._is_back_command(command):
                    self._print_prompt_toolkit_transcript(command)
                return command

            message = self._finalize_message(raw, strip=strip)
            self._print_prompt_toolkit_transcript(message)
            return message
        except ptk_frontend_error_types as exc:
            if self._FORCE_PTK:
                raise
            self._prompt_toolkit_runtime_disabled = True
            raise self._PromptToolkitFrontendUnavailable() from exc
        finally:
            self._remove_keyboard_hotkeys(keyboard, hotkeys)

    def get_input(self, strip: bool = True) -> str | None:
        while True:
            use_ptk = (
                (
                    self._FORCE_PTK
                    or (
                        not self._prompt_toolkit_runtime_disabled
                        and self._terminal_supports_prompt_toolkit()
                    )
                )
                and self._prompt_toolkit_available()
            )
            self._print_prompt(prompt_toolkit_mode=use_ptk)
            try:
                if use_ptk:
                    result = self._collect_with_prompt_toolkit(strip=strip)
                else:
                    result = self._collect_with_plain_input(strip=strip)
            except self._PromptToolkitFrontendUnavailable:
                continue

            if result is None:
                return None

            if self._is_back_command(result):
                continue

            return result


def input_multiline(
    prompt: object = None,
    /,
    *,
    help: bool = True,  # pylint: disable=redefined-builtin
    sending_help: bool = True,
) -> str | None:
    """
    Return one complete multiline input value using an input-like API.

    The helper is stateless across calls. It creates a fresh
    MultilineInput, applies the prompt and guidance settings, disables
    mid-input application slash-command interruption, and discards the session
    after the input operation completes. A single-word slash command entered as
    the first line is still returned immediately. The built-in /back editor
    command remains available inside the one input operation.
    """
    session = MultilineInput(
        prompt_message=None if prompt is None else str(prompt),
    )
    session.help_enabled = help
    session.sending_help = sending_help
    session.command_interrupts_enabled = False
    return session.get_input(strip=False)


def multi_input_demo() -> None:
    """
    Run a small receiver loop demonstrating MultilineInput.
    """
    message_input = MultilineInput(
        prompt_message=MultilineInput.DEFAULT_PROMPT,
    )
    message_input.help_enabled = True
    message_input.sending_help = True
    message_input.command_interrupts_enabled = True

    def print_help() -> None:
        print("Demo commands:")
        print("  /help  Show this help text.")
        print("  /exit  Stop the demo.")
        print("  /quit  Stop the demo.")
        print("  /back  Restore the current draft in the input editor.")
        print("Any other single-word slash command is reported as received.")

    def handle_command(command: str) -> bool:
        """
        Return True when the receiver loop should continue.
        """
        print(f"Received command {command}")
        if command.lower() == "/help":
            print_help()
            return True
        if command.lower() in ["/exit", "/quit"]:
            print("Exiting...")
            return False
        return True

    print("MultilineInput() demo")

    while True:
        value = message_input.get_input()
        if value is None:
            print("Input closed.")
            return

        command = value.strip()
        if MultilineInput.is_slash_command(command):
            if not handle_command(command):
                return
            continue

        line_count = len(value.splitlines()) if value else 0
        print(f"Received message, {line_count} lines.")


if __name__ == "__main__":
    multi_input_demo()