myclaude -r: 修快速连按方向键被退出 + 隐藏光标 + 退出删除行不留空行

- _read_key 改按需读 CSI 序列(方向键读到终止符即返回),连按 \x1b[B 不再被吞进同一序列误判为 ESC 退出
- picker 全程隐藏光标(\x1b[?25l),退出恢复(\x1b[?25h),末尾不再突兀闪现光标
- 退出改用 Delete Line(\x1b[{n}M)删除块区域,替代逐行清空,不再留下 N 个空行
- tests: 新增快速连按方向键回归测试
This commit is contained in:
Zhengshou Lai
2026-08-15 22:38:15 +08:00
parent c342c4e94a
commit 75a72a8b24
2 changed files with 67 additions and 17 deletions
+52 -17
View File
@@ -829,6 +829,12 @@ def _read_key(fd: int, timeout: float = 0.3) -> str:
Handles single bytes (q, digits, Enter) and multi-byte escape sequences Handles single bytes (q, digits, Enter) and multi-byte escape sequences
(arrow keys, PageUp/PageDown). Returns "timeout" when idle. (arrow keys, PageUp/PageDown). Returns "timeout" when idle.
Escape sequences are consumed greedily but only up to the terminating
byte (``[A``/``[B``… or ``[5~``/``[6~``) — any further bytes belong to the
next keypress and are left in the buffer. This keeps rapid arrow-key
repeats from being merged into one unknown sequence (which would read as
ESC and quit the picker).
""" """
readable, _, _ = select.select([fd], [], [], timeout) readable, _, _ = select.select([fd], [], [], timeout)
if not readable: if not readable:
@@ -838,20 +844,7 @@ def _read_key(fd: int, timeout: float = 0.3) -> str:
except OSError: except OSError:
return "quit" return "quit"
if first == b"\x1b": if first == b"\x1b":
seq = first return _read_escape_seq(fd)
for _ in range(8):
readable, _, _ = select.select([fd], [], [], 0.1)
if not readable:
break
seq += os.read(fd, 1)
return {
b"\x1b[A": "up",
b"\x1b[B": "down",
b"\x1b[C": "right",
b"\x1b[D": "left",
b"\x1b[5~": "pgup",
b"\x1b[6~": "pgdown",
}.get(seq, "esc")
if first in (b"\r", b"\n"): if first in (b"\r", b"\n"):
return "enter" return "enter"
if first in (b"q", b"Q"): if first in (b"q", b"Q"):
@@ -865,6 +858,41 @@ def _read_key(fd: int, timeout: float = 0.3) -> str:
return "other" return "other"
def _read_escape_seq(fd: int) -> str:
"""Read one CSI escape sequence (``ESC [`` …), returning a symbolic key.
Reads only the bytes of one sequence: arrow keys are ``ESC [ A-D`` (3
bytes), PgUp/PgDn are ``ESC [ 5~`` / ``ESC [ 6~`` (4 bytes). It never
reads past the terminator, so rapid back-to-back arrow keys stay distinct
and don't merge into one unknown sequence (which would read as ESC).
"""
bracket = _read_byte_or_none(fd, 0.05)
if bracket != b"[":
return "esc" # bare ESC or unknown prefix
nxt = _read_byte_or_none(fd, 0.05)
if nxt is None:
return "esc"
if nxt in (b"A", b"B", b"C", b"D"):
return {"A": "up", "B": "down", "C": "right", "D": "left"}[nxt.decode()]
if nxt in (b"5", b"6"):
term = _read_byte_or_none(fd, 0.05)
if term == b"~":
return "pgup" if nxt == b"5" else "pgdown"
return "esc"
return "esc"
def _read_byte_or_none(fd: int, timeout: float) -> bytes | None:
"""Read one byte within ``timeout``, or None if nothing arrives."""
readable, _, _ = select.select([fd], [], [], timeout)
if not readable:
return None
try:
return os.read(fd, 1)
except OSError:
return None
def _picker_advance( def _picker_advance(
state: _PickerState, key: str state: _PickerState, key: str
) -> tuple[_PickerState, str, ClaudeSessionRow | str | None]: ) -> tuple[_PickerState, str, ClaudeSessionRow | str | None]:
@@ -1016,11 +1044,14 @@ def _draw_picker_block(lines: list[str], prev_height: int) -> None:
def _clear_picker_block(prev_height: int) -> None: def _clear_picker_block(prev_height: int) -> None:
"""Blank the picker block lines before exiting so no residue remains.""" """Delete the picker block lines before exiting so no residue remains.
Uses Delete Line (``CSI n M``) rather than blanking lines, so the block
disappears entirely instead of leaving N empty rows below the prompt.
"""
if prev_height: if prev_height:
sys.stdout.write(f"\x1b[{prev_height}A") sys.stdout.write(f"\x1b[{prev_height}A")
for _ in range(prev_height): sys.stdout.write(f"\x1b[{prev_height}M")
sys.stdout.write("\r\x1b[2K\n")
sys.stdout.flush() sys.stdout.flush()
@@ -1038,6 +1069,8 @@ def _pick_session(
prev_height = 0 prev_height = 0
try: try:
tty.setraw(fd) tty.setraw(fd)
sys.stdout.write("\x1b[?25l") # hide cursor while the picker owns the block
sys.stdout.flush()
while True: while True:
lines = _picker_block(state, chat_cwd, message) lines = _picker_block(state, chat_cwd, message)
_draw_picker_block(lines, prev_height) _draw_picker_block(lines, prev_height)
@@ -1055,6 +1088,8 @@ def _pick_session(
_clear_picker_block(prev_height) _clear_picker_block(prev_height)
return None return None
finally: finally:
sys.stdout.write("\x1b[?25h") # restore cursor on the way out
sys.stdout.flush()
termios.tcsetattr(fd, termios.TCSADRAIN, old) termios.tcsetattr(fd, termios.TCSADRAIN, old)
+15
View File
@@ -340,3 +340,18 @@ class TestReadKey:
def test_escape(self) -> None: def test_escape(self) -> None:
assert _read_key_from_bytes(b"\x1b") == "esc" assert _read_key_from_bytes(b"\x1b") == "esc"
def test_rapid_arrows_not_merged(self) -> None:
"""Back-to-back arrow keys stay distinct (no bleed into one ESC)."""
r, w = os.pipe()
try:
# 4 rapid down presses back-to-back, exactly as the terminal sends them
os.write(w, b"\x1b[B" * 4)
keys = [L._read_key(r) for _ in range(4)]
assert keys == ["down"] * 4
# bare ESC still reads as its own key afterwards
os.write(w, b"\x1b")
assert L._read_key(r) == "esc"
finally:
os.close(r)
os.close(w)