lithe-cli 0.2.3__tar.gz → 0.2.4__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {lithe_cli-0.2.3 → lithe_cli-0.2.4}/PKG-INFO +1 -1
- {lithe_cli-0.2.3 → lithe_cli-0.2.4}/lithe_cli/__init__.py +1 -1
- {lithe_cli-0.2.3 → lithe_cli-0.2.4}/lithe_cli/main.py +6 -1
- {lithe_cli-0.2.3 → lithe_cli-0.2.4}/lithe_cli/setup.py +6 -77
- lithe_cli-0.2.4/lithe_cli/ttyio.py +195 -0
- {lithe_cli-0.2.3 → lithe_cli-0.2.4}/lithe_cli.egg-info/PKG-INFO +1 -1
- {lithe_cli-0.2.3 → lithe_cli-0.2.4}/lithe_cli.egg-info/SOURCES.txt +1 -0
- {lithe_cli-0.2.3 → lithe_cli-0.2.4}/tests/test_cli.py +83 -0
- {lithe_cli-0.2.3 → lithe_cli-0.2.4}/LICENSE +0 -0
- {lithe_cli-0.2.3 → lithe_cli-0.2.4}/README.md +0 -0
- {lithe_cli-0.2.3 → lithe_cli-0.2.4}/lithe_cli/__main__.py +0 -0
- {lithe_cli-0.2.3 → lithe_cli-0.2.4}/lithe_cli/agent.py +0 -0
- {lithe_cli-0.2.3 → lithe_cli-0.2.4}/lithe_cli/config.py +0 -0
- {lithe_cli-0.2.3 → lithe_cli-0.2.4}/lithe_cli/py.typed +0 -0
- {lithe_cli-0.2.3 → lithe_cli-0.2.4}/lithe_cli/ui.py +0 -0
- {lithe_cli-0.2.3 → lithe_cli-0.2.4}/lithe_cli.egg-info/dependency_links.txt +0 -0
- {lithe_cli-0.2.3 → lithe_cli-0.2.4}/lithe_cli.egg-info/entry_points.txt +0 -0
- {lithe_cli-0.2.3 → lithe_cli-0.2.4}/lithe_cli.egg-info/requires.txt +0 -0
- {lithe_cli-0.2.3 → lithe_cli-0.2.4}/lithe_cli.egg-info/top_level.txt +0 -0
- {lithe_cli-0.2.3 → lithe_cli-0.2.4}/pyproject.toml +0 -0
- {lithe_cli-0.2.3 → lithe_cli-0.2.4}/setup.cfg +0 -0
|
@@ -31,6 +31,7 @@ from lithe.bundles import JsonlRunStore
|
|
|
31
31
|
from . import __version__
|
|
32
32
|
from .agent import build_registry, execute, sandbox_backend, undo
|
|
33
33
|
from .config import config_file, load_config, require_endpoint
|
|
34
|
+
from . import ttyio
|
|
34
35
|
from .ui import BOLD, CYAN, GREEN, RED, YELLOW, configure, truncate, ui
|
|
35
36
|
|
|
36
37
|
|
|
@@ -329,10 +330,13 @@ _CHAT_HELP = """可用命令:
|
|
|
329
330
|
def _chat_loop(cfg: Any) -> int:
|
|
330
331
|
store = JsonlRunStore(cfg.store_dir)
|
|
331
332
|
run_ids: list[str] = []
|
|
333
|
+
history: list[str] = []
|
|
332
334
|
print(ui.banner(__version__, cfg.model or "(scripted)", str(cfg.workspace_dir)))
|
|
333
335
|
while True:
|
|
334
336
|
try:
|
|
335
|
-
|
|
337
|
+
# raw cbreak reader, not input(): readline's redisplay can
|
|
338
|
+
# corrupt multibyte buffers on long wrapped lines
|
|
339
|
+
line = ttyio.read_line(ui.prompt(), history=history).strip()
|
|
336
340
|
except (EOFError, KeyboardInterrupt):
|
|
337
341
|
print()
|
|
338
342
|
break
|
|
@@ -347,6 +351,7 @@ def _chat_loop(cfg: Any) -> int:
|
|
|
347
351
|
continue
|
|
348
352
|
if not line:
|
|
349
353
|
continue
|
|
354
|
+
history.append(line)
|
|
350
355
|
if line in ("/exit", "/quit", "exit", "quit"):
|
|
351
356
|
break
|
|
352
357
|
if line == "/help":
|
|
@@ -14,7 +14,6 @@ from __future__ import annotations
|
|
|
14
14
|
|
|
15
15
|
import os
|
|
16
16
|
import sys
|
|
17
|
-
from getpass import getpass
|
|
18
17
|
from urllib.error import HTTPError, URLError
|
|
19
18
|
from urllib.request import Request, urlopen
|
|
20
19
|
|
|
@@ -26,12 +25,11 @@ from .config import (
|
|
|
26
25
|
load_saved_endpoint,
|
|
27
26
|
save_endpoint,
|
|
28
27
|
)
|
|
28
|
+
from .ttyio import _PASTE_MARKERS, read_line
|
|
29
29
|
from .ui import BOLD, CYAN, GREEN, RED, YELLOW, ui
|
|
30
30
|
|
|
31
31
|
_MAX_EMPTY_TRIES = 3
|
|
32
32
|
|
|
33
|
-
_PASTE_MARKERS = ("\x1b[200~", "\x1b[201~")
|
|
34
|
-
|
|
35
33
|
|
|
36
34
|
def _clean_answer(text: str) -> str:
|
|
37
35
|
"""Strip whitespace plus bracketed-paste wrappers from a typed/pasted reply.
|
|
@@ -42,87 +40,18 @@ def _clean_answer(text: str) -> str:
|
|
|
42
40
|
key would be saved with them glued on and fail auth later.
|
|
43
41
|
"""
|
|
44
42
|
for marker in _PASTE_MARKERS:
|
|
45
|
-
text = text.replace(marker, "")
|
|
43
|
+
text = text.replace(marker.decode(), "")
|
|
46
44
|
return text.strip()
|
|
47
45
|
|
|
48
46
|
|
|
49
47
|
def _read_masked(prompt: str = "") -> str:
|
|
50
48
|
"""getpass-style hidden read that echoes a ``*`` per accepted character.
|
|
51
49
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
Ctrl+U, and falls back to plain getpass where raw termios is
|
|
56
|
-
unavailable (no controlling terminal, Windows).
|
|
50
|
+
Thin wrapper over :func:`lithe_cli.ttyio.read_line`; the why lives
|
|
51
|
+
there. Falls back to plain ``input()`` without a TTY, never getpass
|
|
52
|
+
silence: every accepted character is acknowledged.
|
|
57
53
|
"""
|
|
58
|
-
|
|
59
|
-
import termios
|
|
60
|
-
except ImportError: # pragma: no cover — non-POSIX
|
|
61
|
-
return getpass(prompt)
|
|
62
|
-
try:
|
|
63
|
-
tty_fd = os.open("/dev/tty", os.O_RDWR)
|
|
64
|
-
except OSError: # no controlling terminal → silent fallback
|
|
65
|
-
return getpass(prompt)
|
|
66
|
-
|
|
67
|
-
chars: list[str] = []
|
|
68
|
-
pending = ""
|
|
69
|
-
|
|
70
|
-
def emit(text: str) -> None:
|
|
71
|
-
os.write(tty_fd, text.encode(errors="ignore"))
|
|
72
|
-
|
|
73
|
-
def plain(ch: str) -> str | None:
|
|
74
|
-
if ch in ("\r", "\n"):
|
|
75
|
-
return "".join(chars)
|
|
76
|
-
if ch in ("\x7f", "\b"): # backspace rubs out one star
|
|
77
|
-
if chars:
|
|
78
|
-
chars.pop()
|
|
79
|
-
emit("\b \b")
|
|
80
|
-
return None
|
|
81
|
-
if ch == "\x15": # Ctrl+U wipes the line
|
|
82
|
-
emit("\b \b" * len(chars))
|
|
83
|
-
chars.clear()
|
|
84
|
-
return None
|
|
85
|
-
if ch == "\x04": # Ctrl+D bails out on an empty line
|
|
86
|
-
if not chars:
|
|
87
|
-
raise EOFError
|
|
88
|
-
return "".join(chars)
|
|
89
|
-
if ch.isprintable():
|
|
90
|
-
chars.append(ch)
|
|
91
|
-
emit("*")
|
|
92
|
-
return None
|
|
93
|
-
|
|
94
|
-
old = termios.tcgetattr(tty_fd)
|
|
95
|
-
new = termios.tcgetattr(tty_fd)
|
|
96
|
-
new[3] &= ~(termios.ECHO | termios.ICANON) # 3 == lflags
|
|
97
|
-
new[6][termios.VMIN] = 1 # 6 == cc: read(1) blocks per char
|
|
98
|
-
new[6][termios.VTIME] = 0
|
|
99
|
-
try:
|
|
100
|
-
termios.tcsetattr(tty_fd, termios.TCSANOW, new)
|
|
101
|
-
emit(prompt)
|
|
102
|
-
while True:
|
|
103
|
-
byte = os.read(tty_fd, 1)
|
|
104
|
-
if not byte:
|
|
105
|
-
raise EOFError
|
|
106
|
-
ch = byte.decode(errors="replace")
|
|
107
|
-
if pending or ch == "\x1b":
|
|
108
|
-
candidate = pending + ch
|
|
109
|
-
if any(m.startswith(candidate) for m in _PASTE_MARKERS):
|
|
110
|
-
pending = "" if candidate in _PASTE_MARKERS else candidate
|
|
111
|
-
continue
|
|
112
|
-
pending = ""
|
|
113
|
-
line = None
|
|
114
|
-
for single in candidate: # not a wrapper: replay as input
|
|
115
|
-
line = plain(single)
|
|
116
|
-
if line is not None:
|
|
117
|
-
break
|
|
118
|
-
else:
|
|
119
|
-
line = plain(ch)
|
|
120
|
-
if line is not None:
|
|
121
|
-
emit("\n")
|
|
122
|
-
return line
|
|
123
|
-
finally:
|
|
124
|
-
termios.tcsetattr(tty_fd, termios.TCSANOW, old)
|
|
125
|
-
os.close(tty_fd)
|
|
54
|
+
return read_line(prompt, mask=True)
|
|
126
55
|
|
|
127
56
|
|
|
128
57
|
def stdin_is_interactive() -> bool:
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"""Raw-mode TTY line reading without GNU readline.
|
|
2
|
+
|
|
3
|
+
Why this exists: readline's redisplay machinery can diverge from its own
|
|
4
|
+
buffer on long multibyte lines (observed as ``input()`` raising
|
|
5
|
+
UnicodeDecodeError on a line the terminal echoed correctly — the buffer
|
|
6
|
+
lost a byte mid-character). We cannot fix C-level readline, so chat
|
|
7
|
+
input bypasses it entirely: this module reads bytes off ``/dev/tty`` in
|
|
8
|
+
cbreak mode, echoes exactly what arrived (the terminal wraps it — we
|
|
9
|
+
never compute columns), and supports the editing keys a chat REPL
|
|
10
|
+
needs: backspace, Ctrl+W, Ctrl+U, Up/Down history, Ctrl+C/Ctrl+D.
|
|
11
|
+
|
|
12
|
+
Bracketed-paste wrappers and unknown escape sequences (arrow keys other
|
|
13
|
+
than Up/Down, focus events, cursor replies…) are swallowed instead of
|
|
14
|
+
leaking into the returned line. Decoding is strict incremental UTF-8 —
|
|
15
|
+
a line that arrives broken raises UnicodeDecodeError for the caller to
|
|
16
|
+
drop with a hint, never silently mangled.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import codecs
|
|
22
|
+
import os
|
|
23
|
+
import sys
|
|
24
|
+
import unicodedata
|
|
25
|
+
|
|
26
|
+
_PASTE_MARKERS = (b"\x1b[200~", b"\x1b[201~")
|
|
27
|
+
_UP = (b"\x1b[A", b"\x1bOA")
|
|
28
|
+
_DOWN = (b"\x1b[B", b"\x1bOB")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _width(ch: str) -> int:
|
|
32
|
+
return 2 if unicodedata.east_asian_width(ch) in ("W", "F") else 1
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def read_line(
|
|
36
|
+
prompt: str = "",
|
|
37
|
+
*,
|
|
38
|
+
mask: bool = False,
|
|
39
|
+
history: list[str] | None = None,
|
|
40
|
+
input_fn=None,
|
|
41
|
+
) -> str:
|
|
42
|
+
"""Read one line from the TTY, echoing input (or ``*`` when masked).
|
|
43
|
+
|
|
44
|
+
Falls back to plain ``input()`` when there is no interactive
|
|
45
|
+
terminal (pipes, tests) or termios is unavailable. Raises
|
|
46
|
+
EOFError (Ctrl+D on empty), KeyboardInterrupt (Ctrl+C), and
|
|
47
|
+
UnicodeDecodeError for undecodable input.
|
|
48
|
+
"""
|
|
49
|
+
if input_fn is None:
|
|
50
|
+
import builtins
|
|
51
|
+
|
|
52
|
+
input_fn = builtins.input
|
|
53
|
+
if not (sys.stdin.isatty() and sys.stdout.isatty()):
|
|
54
|
+
return input_fn(prompt)
|
|
55
|
+
try:
|
|
56
|
+
import termios
|
|
57
|
+
except ImportError: # pragma: no cover — non-POSIX
|
|
58
|
+
return input_fn(prompt)
|
|
59
|
+
try:
|
|
60
|
+
tty_fd = os.open("/dev/tty", os.O_RDWR)
|
|
61
|
+
except OSError: # no controlling terminal
|
|
62
|
+
return input_fn(prompt)
|
|
63
|
+
|
|
64
|
+
chars: list[str] = []
|
|
65
|
+
pending = b""
|
|
66
|
+
hist_index: int | None = None
|
|
67
|
+
draft = ""
|
|
68
|
+
decoder = codecs.getincrementaldecoder("utf-8")()
|
|
69
|
+
|
|
70
|
+
def emit(text: str) -> None:
|
|
71
|
+
os.write(tty_fd, text.encode(errors="ignore"))
|
|
72
|
+
|
|
73
|
+
def cells(ch: str) -> int:
|
|
74
|
+
return 1 if mask else _width(ch)
|
|
75
|
+
|
|
76
|
+
def show(ch: str) -> None:
|
|
77
|
+
emit("*" if mask else ch)
|
|
78
|
+
|
|
79
|
+
def rub_out(ch: str) -> None:
|
|
80
|
+
emit("\b \b" * cells(ch))
|
|
81
|
+
|
|
82
|
+
def replace_line(text: str) -> None:
|
|
83
|
+
for ch in reversed(chars):
|
|
84
|
+
rub_out(ch)
|
|
85
|
+
chars.clear()
|
|
86
|
+
chars.extend(text)
|
|
87
|
+
for ch in text:
|
|
88
|
+
show(ch)
|
|
89
|
+
|
|
90
|
+
def move_history(delta: int) -> None:
|
|
91
|
+
nonlocal hist_index, draft
|
|
92
|
+
if not history:
|
|
93
|
+
return
|
|
94
|
+
if delta < 0:
|
|
95
|
+
if hist_index is None:
|
|
96
|
+
draft = "".join(chars)
|
|
97
|
+
hist_index = len(history) - 1
|
|
98
|
+
else:
|
|
99
|
+
hist_index = max(0, hist_index - 1)
|
|
100
|
+
replace_line(history[hist_index])
|
|
101
|
+
else:
|
|
102
|
+
if hist_index is None:
|
|
103
|
+
return
|
|
104
|
+
if hist_index + 1 >= len(history):
|
|
105
|
+
hist_index = None
|
|
106
|
+
replace_line(draft)
|
|
107
|
+
else:
|
|
108
|
+
hist_index += 1
|
|
109
|
+
replace_line(history[hist_index])
|
|
110
|
+
|
|
111
|
+
def finish() -> str:
|
|
112
|
+
chars.append(decoder.decode(b"", final=True))
|
|
113
|
+
emit("\n")
|
|
114
|
+
return "".join(chars)
|
|
115
|
+
|
|
116
|
+
def literal(byte: int) -> str | None:
|
|
117
|
+
if byte in (0x0D, 0x0A):
|
|
118
|
+
return finish()
|
|
119
|
+
if byte in (0x7F, 0x08): # backspace rubs one character out
|
|
120
|
+
if chars:
|
|
121
|
+
rub_out(chars.pop())
|
|
122
|
+
return None
|
|
123
|
+
if byte == 0x15: # Ctrl+U wipes the line
|
|
124
|
+
for ch in reversed(chars):
|
|
125
|
+
rub_out(ch)
|
|
126
|
+
chars.clear()
|
|
127
|
+
return None
|
|
128
|
+
if byte == 0x17: # Ctrl+W rubs the last word
|
|
129
|
+
while chars and chars[-1] == " ":
|
|
130
|
+
rub_out(chars.pop())
|
|
131
|
+
while chars and chars[-1] != " ":
|
|
132
|
+
rub_out(chars.pop())
|
|
133
|
+
return None
|
|
134
|
+
if byte == 0x04: # Ctrl+D bails out on an empty line
|
|
135
|
+
if not chars:
|
|
136
|
+
raise EOFError
|
|
137
|
+
return finish()
|
|
138
|
+
if byte >= 0x20: # printable bytes; controls are ignored
|
|
139
|
+
piece = decoder.decode(bytes([byte]))
|
|
140
|
+
for ch in piece:
|
|
141
|
+
chars.append(ch)
|
|
142
|
+
show(ch)
|
|
143
|
+
return None
|
|
144
|
+
|
|
145
|
+
def dispatch(seq: bytes) -> None:
|
|
146
|
+
if seq in _UP:
|
|
147
|
+
move_history(-1)
|
|
148
|
+
elif seq in _DOWN:
|
|
149
|
+
move_history(1)
|
|
150
|
+
|
|
151
|
+
old = termios.tcgetattr(tty_fd)
|
|
152
|
+
new = termios.tcgetattr(tty_fd)
|
|
153
|
+
new[3] &= ~(termios.ECHO | termios.ICANON) # 3 == lflags
|
|
154
|
+
new[6][termios.VMIN] = 1 # 6 == cc: read(1) blocks per byte
|
|
155
|
+
new[6][termios.VTIME] = 0
|
|
156
|
+
try:
|
|
157
|
+
termios.tcsetattr(tty_fd, termios.TCSANOW, new)
|
|
158
|
+
emit(prompt)
|
|
159
|
+
while True:
|
|
160
|
+
chunk = os.read(tty_fd, 1)
|
|
161
|
+
if not chunk:
|
|
162
|
+
raise EOFError
|
|
163
|
+
byte = chunk[0]
|
|
164
|
+
if pending:
|
|
165
|
+
cand = pending + chunk
|
|
166
|
+
head = cand[:2]
|
|
167
|
+
if any(m.startswith(cand) for m in _PASTE_MARKERS):
|
|
168
|
+
pending = b"" if cand in _PASTE_MARKERS else cand
|
|
169
|
+
elif head == b"\x1b[":
|
|
170
|
+
if 0x40 <= byte <= 0x7E:
|
|
171
|
+
dispatch(cand)
|
|
172
|
+
pending = b""
|
|
173
|
+
else:
|
|
174
|
+
pending = cand
|
|
175
|
+
elif head == b"\x1bO":
|
|
176
|
+
if 0x20 <= byte <= 0x7E:
|
|
177
|
+
dispatch(cand)
|
|
178
|
+
pending = b""
|
|
179
|
+
else:
|
|
180
|
+
pending = cand
|
|
181
|
+
else:
|
|
182
|
+
pending = b"" # lone ESC: drop it, take the byte
|
|
183
|
+
line = literal(byte)
|
|
184
|
+
if line is not None:
|
|
185
|
+
return line
|
|
186
|
+
continue
|
|
187
|
+
if byte == 0x1B:
|
|
188
|
+
pending = chunk
|
|
189
|
+
continue
|
|
190
|
+
line = literal(byte)
|
|
191
|
+
if line is not None:
|
|
192
|
+
return line
|
|
193
|
+
finally:
|
|
194
|
+
termios.tcsetattr(tty_fd, termios.TCSANOW, old)
|
|
195
|
+
os.close(tty_fd)
|
|
@@ -406,6 +406,89 @@ def test_wizard_accepts_bracketed_paste(tmp_path, monkeypatch):
|
|
|
406
406
|
}
|
|
407
407
|
|
|
408
408
|
|
|
409
|
+
def _pty_run(code: str, feed: list[bytes], settle: float = 0.35):
|
|
410
|
+
"""Fork a PTY, run `code`, feed byte chunks, return (output, exit_code)."""
|
|
411
|
+
import os
|
|
412
|
+
import pty
|
|
413
|
+
import sys
|
|
414
|
+
import time
|
|
415
|
+
|
|
416
|
+
pid, fd = pty.fork()
|
|
417
|
+
if pid == 0:
|
|
418
|
+
env = dict(os.environ, PYTHONPATH=os.getcwd())
|
|
419
|
+
os.execvpe(
|
|
420
|
+
sys.executable, [sys.executable, "-c", code], env
|
|
421
|
+
)
|
|
422
|
+
os._exit(1)
|
|
423
|
+
try:
|
|
424
|
+
out = b""
|
|
425
|
+
for chunk in feed:
|
|
426
|
+
time.sleep(settle)
|
|
427
|
+
out += _pty_drain(fd)
|
|
428
|
+
os.write(fd, chunk)
|
|
429
|
+
time.sleep(settle)
|
|
430
|
+
out += _pty_drain(fd)
|
|
431
|
+
_, status = os.waitpid(pid, 0)
|
|
432
|
+
return out.decode(errors="replace"), os.waitstatus_to_exitcode(status)
|
|
433
|
+
finally:
|
|
434
|
+
os.close(fd)
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def _pty_drain(fd) -> bytes:
|
|
438
|
+
import os
|
|
439
|
+
import select
|
|
440
|
+
|
|
441
|
+
out = b""
|
|
442
|
+
while select.select([fd], [], [], 0.2)[0]:
|
|
443
|
+
try:
|
|
444
|
+
chunk = os.read(fd, 4096)
|
|
445
|
+
except OSError:
|
|
446
|
+
break
|
|
447
|
+
if not chunk:
|
|
448
|
+
break
|
|
449
|
+
out += chunk
|
|
450
|
+
return out
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def test_read_line_multibyte_history_editing_and_paste():
|
|
454
|
+
# the exact long Chinese line that readline corrupted in the field
|
|
455
|
+
long_line = "你能使用python matplotlib 绘制 九大行星的运动动画,然后你把它运行起来。"
|
|
456
|
+
code = """
|
|
457
|
+
from lithe_cli import ttyio
|
|
458
|
+
h = []
|
|
459
|
+
r1 = ttyio.read_line('p1> ', history=h)
|
|
460
|
+
print('R1:' + repr(r1)); h.append(r1)
|
|
461
|
+
r2 = ttyio.read_line('p2> ', history=h)
|
|
462
|
+
print('R2:' + repr(r2)); h.append(r2)
|
|
463
|
+
r3 = ttyio.read_line('p3> ', history=h)
|
|
464
|
+
print('R3:' + repr(r3))
|
|
465
|
+
r4 = ttyio.read_line('k> ', mask=True)
|
|
466
|
+
print('R4:' + repr(r4))
|
|
467
|
+
r5 = ttyio.read_line('p5> ')
|
|
468
|
+
print('R5:' + repr(r5))
|
|
469
|
+
"""
|
|
470
|
+
# r1: IME-style bursts of the long line, with terminal focus junk injected
|
|
471
|
+
feed = []
|
|
472
|
+
msg = long_line.encode()
|
|
473
|
+
for i in range(0, len(msg), 6):
|
|
474
|
+
feed.append(msg[i : i + 6])
|
|
475
|
+
if i == 24:
|
|
476
|
+
feed.append(b"\x1b[I") # focus-in event must not pollute the line
|
|
477
|
+
feed.append(b"\r")
|
|
478
|
+
feed.append(b"ab\x7fc\r") # r2: backspace → "ac"
|
|
479
|
+
feed.append(b"\x1b[A\r") # r3: Up recalls "ac"
|
|
480
|
+
feed.append("密钥\r".encode()) # r4: masked non-ASCII stays intact
|
|
481
|
+
feed.append(b"\x1b[200~sk-x\x1b[201~\r") # r5: paste wrappers stripped
|
|
482
|
+
out, rc = _pty_run(code, feed)
|
|
483
|
+
assert rc == 0
|
|
484
|
+
assert "UnicodeDecodeError" not in out
|
|
485
|
+
assert f"R1:{long_line!r}" in out
|
|
486
|
+
assert "R2:'ac'" in out
|
|
487
|
+
assert "R3:'ac'" in out
|
|
488
|
+
assert "R4:'密钥'" in out
|
|
489
|
+
assert "R5:'sk-x'" in out
|
|
490
|
+
|
|
491
|
+
|
|
409
492
|
def test_read_masked_echoes_stars_and_swallows_paste_markers():
|
|
410
493
|
import os
|
|
411
494
|
import pty
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|