From cfc3423081e3ff5d2cc624a6a8acfd216cfbdfc7 Mon Sep 17 00:00:00 2001 From: Yongtao Huang Date: Wed, 28 Jan 2026 14:39:53 +0800 Subject: [PATCH 1/4] Fix Windows VT EOL wrap by syncing real console cursor Windows VT terminals do not consistently wrap the cursor when a line exactly fills the terminal width. Previously we assumed a wrap always happened, which could desynchronize the logical cursor from the real console cursor and break subsequent cursor movement. This change queries the real cursor position and updates posxy accordingly, and adds regression tests for both wrap and no-wrap cases. Signed-off-by: Yongtao Huang --- Lib/_pyrepl/windows_console.py | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/Lib/_pyrepl/windows_console.py b/Lib/_pyrepl/windows_console.py index 303af8a354ff00..bb1a7751153e1e 100644 --- a/Lib/_pyrepl/windows_console.py +++ b/Lib/_pyrepl/windows_console.py @@ -272,9 +272,22 @@ def __write_changed_line( self.__write(newline[x_pos:]) if wlen(newline) == self.width: - # If we wrapped we want to start at the next line - self._move_relative(0, y + 1) - self.posxy = 0, y + 1 + if self.__vt_support: + info = CONSOLE_SCREEN_BUFFER_INFO() + if not GetConsoleScreenBufferInfo(OutHandle, info): + raise WinError(GetLastError()) + win_y = int(info.dwCursorPosition.Y - info.srWindow.Top) + expected = y - self.__offset + if win_y == expected + 1: + # Terminal wrapped to next row. + self.posxy = 0, y + 1 + else: + # Terminal did not wrap; cursor stays at end-of-line. + self.posxy = self.width, y + else: + # If we wrapped we want to start at the next line + self._move_relative(0, y + 1) + self.posxy = 0, y + 1 else: self.posxy = wlen(newline), y From 0acecdc64c1459dd076a6f3d39b9901fcaedc227 Mon Sep 17 00:00:00 2001 From: Yongtao Huang Date: Wed, 28 Jan 2026 14:41:33 +0800 Subject: [PATCH 2/4] Add test case --- Lib/test/test_pyrepl/test_pyrepl.py | 74 ++++++++++++++++++++++++++++- 1 file changed, 73 insertions(+), 1 deletion(-) diff --git a/Lib/test/test_pyrepl/test_pyrepl.py b/Lib/test/test_pyrepl/test_pyrepl.py index 35a1733787e7a2..0ea214b409ec96 100644 --- a/Lib/test/test_pyrepl/test_pyrepl.py +++ b/Lib/test/test_pyrepl/test_pyrepl.py @@ -12,7 +12,7 @@ import tempfile from pkgutil import ModuleInfo from unittest import TestCase, skipUnless, skipIf, SkipTest -from unittest.mock import patch +from unittest.mock import Mock, patch from test.support import force_not_colorized, make_clean_env, Py_DEBUG from test.support import has_subprocess_support, SHORT_TIMEOUT, STDLIB_DIR from test.support.import_helper import import_module @@ -2105,3 +2105,75 @@ def test_ctrl_d_single_line_end_no_newline(self): ) reader, _ = handle_all_events(events) self.assertEqual("hello", "".join(reader.buffer)) + + +@skipUnless(sys.platform == "win32", "Windows console VT behavior only") +class TestWindowsConsoleVtEolWrap(TestCase): + """ + When a line exactly fills the terminal width, VT terminals differ on whether + the cursor immediately wraps to the next row. In VT mode we must synchronize + our logical cursor position with the real console cursor. + """ + def _make_console_like(self, *, width: int, offset: int, vt: bool): + from _pyrepl import windows_console as wc + + con = object.__new__(wc.WindowsConsole) + + # Minimal state needed by __write_changed_line() + con.width = width + con.screen = [] + con.posxy = (0, 0) + setattr(con, "_WindowsConsole__offset", offset) + setattr(con, "_WindowsConsole__vt_support", vt) + + # Stub out side-effecting methods used by __write_changed_line() + con._hide_cursor = Mock() + con._erase_to_end = Mock() + con._move_relative = Mock() + con.move_cursor = Mock() + setattr(con, "_WindowsConsole__write", Mock()) + + return con, wc + + def test_vt_exact_width_line_did_wrap(self): + # Terminal wrapped to next row: posxy should become (0, y+1). + width = 10 + y = 3 + con, wc = self._make_console_like(width=width, offset=0, vt=True) + + def fake_gcsbi(_h, info): + info.dwCursorPosition.X = 0 + # Visible window top = 0, cursor now on next visible row + info.srWindow.Top = 0 + info.dwCursorPosition.Y = y + 1 + return True + + with patch.object(wc, "GetConsoleScreenBufferInfo", side_effect=fake_gcsbi), \ + patch.object(wc, "OutHandle", 1): + old = "" + new = "a" * width + wc.WindowsConsole._WindowsConsole__write_changed_line(con, y, old, new, 0) + self.assertEqual(con.posxy, (0, y + 1)) + self.assertNotIn((0, y + 1), [c.args for c in con._move_relative.mock_calls]) + + def test_vt_exact_width_line_did_not_wrap(self): + # Terminal did NOT wrap yet: posxy should stay at (width, y). + width = 10 + y = 3 + con, wc = self._make_console_like(width=width, offset=0, vt=True) + + def fake_gcsbi(_h, info): + info.dwCursorPosition.X = width + info.srWindow.Top = 0 + # Cursor remains on the same visible row + info.dwCursorPosition.Y = y + return True + + with patch.object(wc, "GetConsoleScreenBufferInfo", side_effect=fake_gcsbi), \ + patch.object(wc, "OutHandle", 1): + old = "" + new = "a" * width + wc.WindowsConsole._WindowsConsole__write_changed_line(con, y, old, new, 0) + + self.assertEqual(con.posxy, (width, y)) + self.assertNotIn((0, y + 1), [c.args for c in con._move_relative.mock_calls]) From a2d1786afbb90dfbb8d50f1b453bdaa9b0d09356 Mon Sep 17 00:00:00 2001 From: "blurb-it[bot]" <43283697+blurb-it[bot]@users.noreply.github.com> Date: Wed, 28 Jan 2026 06:50:39 +0000 Subject: [PATCH 3/4] =?UTF-8?q?=F0=9F=93=9C=F0=9F=A4=96=20Added=20by=20blu?= =?UTF-8?q?rb=5Fit.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../next/Library/2026-01-28-06-50-37.gh-issue-144259.Xslknn.rst | 1 + 1 file changed, 1 insertion(+) create mode 100644 Misc/NEWS.d/next/Library/2026-01-28-06-50-37.gh-issue-144259.Xslknn.rst diff --git a/Misc/NEWS.d/next/Library/2026-01-28-06-50-37.gh-issue-144259.Xslknn.rst b/Misc/NEWS.d/next/Library/2026-01-28-06-50-37.gh-issue-144259.Xslknn.rst new file mode 100644 index 00000000000000..e35780501a2aad --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-01-28-06-50-37.gh-issue-144259.Xslknn.rst @@ -0,0 +1 @@ +Fix Windows VT REPL cursor desynchronization when a line exactly fills the terminal width. From e085733efe3dc09b3b7073e4cc80dbdd86fdeec9 Mon Sep 17 00:00:00 2001 From: Yongtao Huang Date: Wed, 28 Jan 2026 14:55:16 +0800 Subject: [PATCH 4/4] Post fix based on lint --- Lib/test/test_pyrepl/test_pyrepl.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Lib/test/test_pyrepl/test_pyrepl.py b/Lib/test/test_pyrepl/test_pyrepl.py index 0ea214b409ec96..667bb0d3a73a6d 100644 --- a/Lib/test/test_pyrepl/test_pyrepl.py +++ b/Lib/test/test_pyrepl/test_pyrepl.py @@ -2111,7 +2111,7 @@ def test_ctrl_d_single_line_end_no_newline(self): class TestWindowsConsoleVtEolWrap(TestCase): """ When a line exactly fills the terminal width, VT terminals differ on whether - the cursor immediately wraps to the next row. In VT mode we must synchronize + the cursor immediately wraps to the next row. In VT mode we must synchronize our logical cursor position with the real console cursor. """ def _make_console_like(self, *, width: int, offset: int, vt: bool):