External Publication
Visit Post

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

OpenAI Developer Community July 4, 2026
Source

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()

Discussion in the ATmosphere

Loading comments...