fix(wizard): read keypresses from the raw fd so arrow keys work

sys.stdin.read(1) on the buffered TextIOWrapper pulls the whole escape
sequence into its Python-level buffer, so the follow-up select() sees an
empty fd and every arrow/Home/End press degrades to bare Esc; the unread
'[B' tail then leaks into the next keypress. Read raw bytes via os.read()
and select() on the file descriptor instead, keeping the same key tokens
and classifications. Add pty-driven regression tests over a real cbreak
terminal covering CSI sequences, single-byte keys, bare Esc, and tail-leak.
This commit is contained in:
2026-08-27 08:07:33 +00:00
parent 85ddfb4a71
commit 12ca06ba0d
2 changed files with 161 additions and 10 deletions
+13 -10
View File
@@ -343,23 +343,26 @@ def _read_keypress() -> str:
"""Read one keystroke from stdin (cbreak mode); classify it."""
import select
char = sys.stdin.read(1)
if char == "\x1b":
fd = sys.stdin.fileno()
# Read raw bytes from the fd: a buffered text read would pull the whole
# escape sequence into its own buffer, leaving nothing for select to see.
first = os.read(fd, 1)
if first == b"\x1b":
# A bare Esc is not followed by more bytes; CSI sequences are. Peek briefly.
readable, _, _ = select.select([sys.stdin], [], [], 0.05)
readable, _, _ = select.select([fd], [], [], 0.05)
if not readable:
return _KEY_ESCAPE
tail = sys.stdin.read(2)
return _CSI_KEYS.get(tail, _KEY_OTHER)
if char in ("\r", "\n"):
tail = os.read(fd, 2)
return _CSI_KEYS.get(tail.decode("ascii", "replace"), _KEY_OTHER)
if first in (b"\r", b"\n"):
return _KEY_ENTER
if char == "\x01": # Ctrl-A
if first == b"\x01": # Ctrl-A
return _KEY_HOME
if char == "\x05": # Ctrl-E
if first == b"\x05": # Ctrl-E
return _KEY_END
if char == "k":
if first == b"k":
return _KEY_UP
if char == "j":
if first == b"j":
return _KEY_DOWN
return _KEY_OTHER