https://github.com/python/cpython/commit/f5bd07700ecd0f02cc424666b871f51de03b9232
commit: f5bd07700ecd0f02cc424666b871f51de03b9232
branch: main
author: Bartosz Sławecki <[email protected]>
committer: ambv <[email protected]>
date: 2026-10-20T11:55:45-07:00
summary:

gh-148228: Enable error overlays in the REPL (GH-148229)

files:
A Misc/NEWS.d/next/Library/2026-07-21-00-02-11.gh-issue-148228.D3rP8m.rst
M Doc/whatsnew/3.16.rst
M Lib/_colorize.py
M Lib/_pyrepl/commands.py
M Lib/_pyrepl/layout.py
M Lib/_pyrepl/reader.py
M Lib/_pyrepl/readline.py
M Lib/test/test_pyrepl/test_reader.py

diff --git a/Doc/whatsnew/3.16.rst b/Doc/whatsnew/3.16.rst
index 58f129bedcb3713..e54cf2e8bf6f9a6 100644
--- a/Doc/whatsnew/3.16.rst
+++ b/Doc/whatsnew/3.16.rst
@@ -108,6 +108,15 @@ Other language changes
   (Contributed by Kumar Aditya in :gh:`157361`.)
 
 
+Default interactive shell
+=========================
+
+* The :term:`REPL` now displays error messages when editing commands cannot be
+  performed, such as when tab completion finds no matches or a history search
+  fails.
+  (Contributed by Bartosz Sławecki in :gh:`148228`.)
+
+
 New modules
 ===========
 
diff --git a/Lib/_colorize.py b/Lib/_colorize.py
index 5f44a3aa05eb8f6..cfea60310ad55bc 100644
--- a/Lib/_colorize.py
+++ b/Lib/_colorize.py
@@ -402,6 +402,7 @@ class Pickletools(ThemeSection):
 @dataclass(frozen=True, kw_only=True)
 class Syntax(ThemeSection):
     prompt: str = ANSIColors.BOLD_MAGENTA
+    error: str = ANSIColors.BOLD_RED
     keyword: str = ANSIColors.BOLD_BLUE
     keyword_constant: str = ANSIColors.BOLD_BLUE
     builtin: str = ANSIColors.CYAN
diff --git a/Lib/_pyrepl/commands.py b/Lib/_pyrepl/commands.py
index cf8b1a6c1e4ec4d..a3b541525aee103 100644
--- a/Lib/_pyrepl/commands.py
+++ b/Lib/_pyrepl/commands.py
@@ -262,7 +262,7 @@ def do(self) -> None:
                     r.select_item(r.historyi - 1)
                     return
                 r.pos = 0
-                r.error("start of buffer")
+                r.debug("start of buffer")
                 return
 
             if (
@@ -294,7 +294,7 @@ def do(self) -> None:
                     r.pos = r.eol(0)
                     return
                 r.pos = len(b)
-                r.error("end of buffer")
+                r.debug("end of buffer")
                 return
 
             if (
@@ -320,7 +320,7 @@ def do(self) -> None:
             if p >= 0:
                 r.pos = p
             else:
-                self.reader.error("start of buffer")
+                self.reader.debug("start of buffer")
 
 
 class right(MotionCommand):
@@ -332,7 +332,7 @@ def do(self) -> None:
             if p <= len(b):
                 r.pos = p
             else:
-                self.reader.error("end of buffer")
+                self.reader.debug("end of buffer")
 
 
 class beginning_of_line(MotionCommand):
@@ -396,7 +396,7 @@ def do(self) -> None:
         b = r.buffer
         s = r.pos - 1
         if s < 0:
-            r.error("cannot transpose at start of buffer")
+            r.debug("cannot transpose at start of buffer")
         else:
             if s == len(b):
                 s -= 1
@@ -419,7 +419,7 @@ def do(self) -> None:
                 del b[r.pos]
                 changed_from = r.pos if changed_from is None else 
min(changed_from, r.pos)
             else:
-                self.reader.error("can't backspace at start")
+                self.reader.debug("can't backspace at start")
         if changed_from is not None:
             r.invalidate_buffer(changed_from)
 
@@ -445,7 +445,7 @@ def do(self) -> None:
                 del b[r.pos]
                 changed_from = r.pos if changed_from is None else 
min(changed_from, r.pos)
             else:
-                self.reader.error("end of buffer")
+                self.reader.debug("end of buffer")
         if changed_from is not None:
             r.invalidate_buffer(changed_from)
 
@@ -466,15 +466,13 @@ def do(self) -> None:
 
 class invalid_key(Command):
     def do(self) -> None:
-        pending = self.reader.console.getpending()
-        s = "".join(self.event) + pending.data
-        self.reader.error("`%r' not bound" % s)
+        self.reader.console.getpending()
+        self.reader.error("no command is bound to this key")
 
 
 class invalid_command(Command):
     def do(self) -> None:
-        s = self.event_name
-        self.reader.error("command `%s' not known" % s)
+        self.reader.error(f"command {self.event_name!r} is not implemented")
 
 
 class show_history(Command):
diff --git a/Lib/_pyrepl/layout.py b/Lib/_pyrepl/layout.py
index cff0821cb95f72b..7cbd582bf9fb384 100644
--- a/Lib/_pyrepl/layout.py
+++ b/Lib/_pyrepl/layout.py
@@ -140,6 +140,23 @@ class LayoutResult:
     line_end_offsets: tuple[int, ...]
 
 
+def wrapped_row_end(
+    widths: tuple[int, ...],
+    start: int,
+    available_width: int,
+) -> int:
+    """Return index of *widths* to split on to fit new row within 
*available_width*.
+
+    If the first width does not fit, split after it anyway ("force progress").
+    """
+    end = start
+    column = 0
+    while end < len(widths) and column + widths[end] <= available_width:
+        column += widths[end]
+        end += 1
+    return end if end > start else start + 1
+
+
 def layout_content_lines(
     lines: tuple[ContentLine, ...],
     width: int,
@@ -207,16 +224,12 @@ def layout_content_lines(
         total = len(body)
         while True:
             # Find how many characters fit on this row.
-            index_to_wrap_before = 0
-            column = 0
-            for char_width in body_widths[start:]:
-                if column + char_width + current_prompt_width >= width:
-                    break
-                index_to_wrap_before += 1
-                column += char_width
-
-            if index_to_wrap_before == 0 and start < total:
-                index_to_wrap_before = 1  # force progress
+            end = wrapped_row_end(
+                body_widths,
+                start,
+                width - current_prompt_width - 1,
+            )
+            index_to_wrap_before = end - start
 
             at_line_end = (start + index_to_wrap_before) >= total
             if at_line_end:
@@ -230,7 +243,6 @@ def layout_content_lines(
                 suffix_width = 1
                 buffer_advance = index_to_wrap_before
 
-            end = start + index_to_wrap_before
             row_fragments = body[start:end]
             row_widths = body_widths[start:end]
             line_end_offsets.append(offset)
diff --git a/Lib/_pyrepl/reader.py b/Lib/_pyrepl/reader.py
index b5686409c37cdb8..d552214f322d76d 100644
--- a/Lib/_pyrepl/reader.py
+++ b/Lib/_pyrepl/reader.py
@@ -35,6 +35,7 @@
     process_prompt as build_prompt_content,
 )
 from .layout import LayoutMap, LayoutResult, LayoutRow, WrappedRow, 
layout_content_lines
+from .layout import wrapped_row_end
 from .render import RenderCell, RenderLine, RenderedScreen, ScreenOverlay
 from .utils import ANSI_ESCAPE_SEQUENCE, ColorSpan, THEME, StyleRef, gen_colors
 from .trace import trace
@@ -287,6 +288,7 @@ class Reader:
     ps3: str = "|.. "
     ps4: str = R"\__ "
     kill_ring: list[list[str]] = field(default_factory=list)
+    error_prefix: str = "! "
     msg: str = ""
     arg: int | None = None
     finished: bool = False
@@ -585,15 +587,16 @@ def _render_message_lines(self) -> tuple[RenderLine, ...]:
         width = self.console.width
         render_lines: list[RenderLine] = []
         for message_line in self.msg.split("\n"):
-            # If self.msg is larger than console width, make it fit.
-            # TODO: try to split between words?
-            if not message_line:
-                render_lines.append(RenderLine.from_rendered_text(""))
+            line = RenderLine.from_rendered_text(message_line)
+            if not line.cells:
+                render_lines.append(line)
                 continue
-            for offset in range(0, len(message_line), width):
-                render_lines.append(
-                    RenderLine.from_rendered_text(message_line[offset : offset 
+ width])
-                )
+            widths = tuple(cell.width for cell in line.cells)
+            start = 0
+            while start < len(line.cells):
+                end = wrapped_row_end(widths, start, width)
+                
render_lines.append(RenderLine.from_cells(line.cells[start:end]))
+                start = end
         return tuple(render_lines)
 
     def get_screen_overlays(self) -> tuple[ScreenOverlay, ...]:
@@ -874,10 +877,19 @@ def finish(self) -> None:
         """Called when a command signals that we're finished."""
         pass
 
+    def debug(self, msg: str) -> None:
+        # Uncomment for debugging:
+        # self.error(f"[debug] {msg}")
+        pass
+
     def error(self, msg: str = "none") -> None:
-        self.msg = "! " + msg + " "
-        self.invalidate_message()
+        error_prefix = self.error_prefix
+        if self.can_colorize:
+            t = THEME()
+            error_prefix = f"{t.error}{error_prefix}{t.reset}"
         self.console.beep()
+        self.msg = error_prefix + msg
+        self.invalidate_message()
 
     def update_screen(self) -> None:
         if self.invalidation.is_cursor_only:
diff --git a/Lib/_pyrepl/readline.py b/Lib/_pyrepl/readline.py
index a8af6cb425d0c99..4232a868a34e9ad 100644
--- a/Lib/_pyrepl/readline.py
+++ b/Lib/_pyrepl/readline.py
@@ -120,9 +120,6 @@ def __post_init__(self) -> None:
         self.commands["backspace_dedent"] = backspace_dedent
         self.commands["backspace-dedent"] = backspace_dedent
 
-    def error(self, msg: str = "none") -> None:
-        pass  # don't show error messages by default
-
     def get_stem(self) -> str:
         b = self.buffer
         p = self.pos - 1
@@ -344,7 +341,7 @@ def do(self) -> None:
             del b[r.pos : r.pos + repeat]
             r.invalidate_buffer(r.pos)
         else:
-            self.reader.error("can't backspace at start")
+            self.reader.debug("can't backspace at start")
 
 
 # ____________________________________________________________
diff --git a/Lib/test/test_pyrepl/test_reader.py 
b/Lib/test/test_pyrepl/test_reader.py
index 0b32ead357c2c0d..fd9900d9a32b3d2 100644
--- a/Lib/test/test_pyrepl/test_reader.py
+++ b/Lib/test/test_pyrepl/test_reader.py
@@ -4,7 +4,8 @@
 from textwrap import dedent
 from unittest import TestCase
 from unittest.mock import MagicMock
-from test.support import force_colorized_test_class, 
force_not_colorized_test_class
+from test.support import force_colorized, force_colorized_test_class
+from test.support import force_not_colorized_test_class
 
 from .support import handle_all_events, handle_events_narrow_console
 from .support import ScreenEqualMixin, code_to_events
@@ -72,6 +73,31 @@ def test_calc_screen_wrap_wide_characters(self):
         reader, _ = handle_events_narrow_console(events)
         self.assert_screen_equal(reader, f"{8*"a"}\\\n樂")
 
+    def test_messages_wrap_on_tight_screens(self):
+        console = prepare_console((), width=3)
+        reader = prepare_reader(console)
+
+        reader.msg = "\x1b[31mabcdef\x1b[0m"
+        lines = reader._render_message_lines()
+        self.assertEqual(
+            [line.text for line in lines],
+            ["\x1b[31mabc\x1b[0m", "\x1b[31mdef\x1b[0m"],
+        )
+
+        reader.msg = "ab樂c"
+        console.width = 4
+        lines = reader._render_message_lines()
+        self.assertEqual([line.text for line in lines], ["ab樂", "c"])
+        self.assertEqual([line.width for line in lines], [4, 1])
+
+        reader.msg = "ab\N{COMBINING ACUTE ACCENT}cd"
+        console.width = 3
+        lines = reader._render_message_lines()
+        self.assertEqual(
+            [line.text for line in lines],
+            ["ab\N{COMBINING ACUTE ACCENT}c", "d"],
+        )
+
     def test_calc_screen_wrap_three_lines(self):
         events = code_to_events(20 * "a")
         reader, _ = handle_events_narrow_console(events)
@@ -431,6 +457,109 @@ def test_setpos_from_xy_for_non_printing_char(self):
         reader.setpos_from_xy(8, 0)
         self.assertEqual(reader.pos, 7)
 
+
+@force_not_colorized_test_class
+class TestErrorMessages(TestCase):
+    @force_colorized
+    def test_colorized_error_message(self):
+        console = prepare_console(code_to_events("\x19"))
+        reader = prepare_reader(console)
+
+        reader.handle1()
+
+        theme = default_theme.syntax
+        self.assertEqual(
+            reader.msg,
+            f"{theme.error}! {theme.reset}nothing to yank",
+        )
+
+    def _test_error_message(
+        self,
+        keys: str,
+        expected: str,
+        *,
+        command: str | None = None,
+        binding: str | None = None,
+        **reader_kwargs,
+    ):
+        console = prepare_console(code_to_events(keys))
+        reader = prepare_reader(console, **reader_kwargs)
+        if command is not None:
+            reader.bind(binding or keys, command)
+
+        while not reader.msg:
+            reader.handle1()
+
+        self.assertEqual(reader.msg, f"! {expected}")
+        self.assertEqual(reader.screen[-1], f"! {expected}")
+        console.beep.assert_called_once()
+
+    def test_invalid_key_message(self):
+        self._test_error_message("\x1bo", "no command is bound to this key")
+
+    def test_unimplemented_command_message(self):
+        self._test_error_message(
+            "x",
+            "command 'missing-command' is not implemented",
+            command="missing-command",
+        )
+
+    def test_nothing_to_yank_message(self):
+        self._test_error_message("\x19", "nothing to yank")
+
+    def test_yank_pop_with_empty_kill_ring_message(self):
+        self._test_error_message("\x1by", "nothing to yank")
+
+    def test_yank_pop_after_other_command_message(self):
+        self._test_error_message(
+            "x\x01\x0b\x1by",
+            "previous command was not a yank",
+        )
+
+    def test_start_of_history_message(self):
+        self._test_error_message("\x10", "start of history list")
+
+    def test_end_of_history_message(self):
+        self._test_error_message("\x0e", "end of history list")
+
+    def test_yank_arg_at_beginning_of_history_message(self):
+        self._test_error_message(
+            "\x1b.\x1b.\x1b.",
+            "beginning of history list",
+            history=["one"],
+            historyi=1,
+        )
+
+    def test_yank_arg_with_no_argument_message(self):
+        self._test_error_message(
+            "\x1b.",
+            "no such arg",
+            history=[""],
+            historyi=1,
+        )
+
+    def test_empty_incremental_search_backspace_message(self):
+        self._test_error_message(
+            "x",
+            "nothing to rubout",
+            command="isearch-backspace",
+        )
+
+    def test_history_search_not_found_message(self):
+        self._test_error_message(
+            "missingx",
+            "not found",
+            command="history-search-backward",
+            binding="x",
+        )
+
+    def test_incremental_search_not_found_message(self):
+        self._test_error_message("\x12x", "not found")
+
+    def test_no_completion_matches_message(self):
+        self._test_error_message("does_not_exist\t", "no matches")
+
+
 @force_colorized_test_class
 class TestReaderInColor(ScreenEqualMixin, TestCase):
     def test_syntax_highlighting_basic(self):
diff --git 
a/Misc/NEWS.d/next/Library/2026-07-21-00-02-11.gh-issue-148228.D3rP8m.rst 
b/Misc/NEWS.d/next/Library/2026-07-21-00-02-11.gh-issue-148228.D3rP8m.rst
new file mode 100644
index 000000000000000..d00d072fb2da08b
--- /dev/null
+++ b/Misc/NEWS.d/next/Library/2026-07-21-00-02-11.gh-issue-148228.D3rP8m.rst
@@ -0,0 +1,3 @@
+The :term:`REPL` now displays error messages when editing commands cannot be
+performed, such as when tab completion finds no matches or a history search
+fails. Patch by Bartosz Sławecki.

_______________________________________________
Python-checkins mailing list -- [email protected]
To unsubscribe send an email to [email protected]
https://mail.python.org/mailman3//lists/python-checkins.python.org
Member address: [email protected]

Reply via email to