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:
+52
-17
@@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user