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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lithe-cli
3
- Version: 0.2.3
3
+ Version: 0.2.4
4
4
  Summary: Command-line interface for the lithe agent kernel: chat, one-shot runs, undo, and run inspection.
5
5
  License: MIT License
6
6
 
@@ -1,3 +1,3 @@
1
1
  """lithe-cli: the command-line interface for the lithe agent kernel."""
2
2
 
3
- __version__ = "0.2.3"
3
+ __version__ = "0.2.4"
@@ -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
- line = input(ui.prompt()).strip()
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
- getpass is fully silent, so a pasted key looks like nothing happened
53
- and users retype it by hand. This reader shows one star per character
54
- (paste wrappers and editing keys echo nothing), supports backspace and
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
- try:
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)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lithe-cli
3
- Version: 0.2.3
3
+ Version: 0.2.4
4
4
  Summary: Command-line interface for the lithe agent kernel: chat, one-shot runs, undo, and run inspection.
5
5
  License: MIT License
6
6
 
@@ -8,6 +8,7 @@ lithe_cli/config.py
8
8
  lithe_cli/main.py
9
9
  lithe_cli/py.typed
10
10
  lithe_cli/setup.py
11
+ lithe_cli/ttyio.py
11
12
  lithe_cli/ui.py
12
13
  lithe_cli.egg-info/PKG-INFO
13
14
  lithe_cli.egg-info/SOURCES.txt
@@ -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