refactor(cli): simplify input handling — drop prompt-toolkit, use readline
This commit is contained in:
parent
5a20f3681d
commit
dfa173323c
@ -6,7 +6,6 @@ import os
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import select
|
import select
|
||||||
import sys
|
import sys
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
import typer
|
import typer
|
||||||
from rich.console import Console
|
from rich.console import Console
|
||||||
@ -21,12 +20,15 @@ app = typer.Typer(
|
|||||||
)
|
)
|
||||||
|
|
||||||
console = Console()
|
console = Console()
|
||||||
_READLINE: Any | None = None
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Lightweight CLI input: readline for arrow keys / history, termios for flush
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_READLINE = None
|
||||||
_HISTORY_FILE: Path | None = None
|
_HISTORY_FILE: Path | None = None
|
||||||
_HISTORY_HOOK_REGISTERED = False
|
_HISTORY_HOOK_REGISTERED = False
|
||||||
_USING_LIBEDIT = False
|
_USING_LIBEDIT = False
|
||||||
_PROMPT_SESSION: Any | None = None
|
|
||||||
_PROMPT_SESSION_LABEL: Any = None
|
|
||||||
|
|
||||||
|
|
||||||
def _flush_pending_tty_input() -> None:
|
def _flush_pending_tty_input() -> None:
|
||||||
@ -40,7 +42,6 @@ def _flush_pending_tty_input() -> None:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
import termios
|
import termios
|
||||||
|
|
||||||
termios.tcflush(fd, termios.TCIFLUSH)
|
termios.tcflush(fd, termios.TCIFLUSH)
|
||||||
return
|
return
|
||||||
except Exception:
|
except Exception:
|
||||||
@ -67,75 +68,16 @@ def _save_history() -> None:
|
|||||||
|
|
||||||
|
|
||||||
def _enable_line_editing() -> None:
|
def _enable_line_editing() -> None:
|
||||||
"""Best-effort enable readline/libedit line editing for arrow keys/history."""
|
"""Enable readline for arrow keys, line editing, and persistent history."""
|
||||||
global _READLINE, _HISTORY_FILE, _HISTORY_HOOK_REGISTERED, _USING_LIBEDIT
|
global _READLINE, _HISTORY_FILE, _HISTORY_HOOK_REGISTERED, _USING_LIBEDIT
|
||||||
global _PROMPT_SESSION, _PROMPT_SESSION_LABEL
|
|
||||||
|
|
||||||
history_file = Path.home() / ".nanobot" / "history" / "cli_history"
|
history_file = Path.home() / ".nanobot" / "history" / "cli_history"
|
||||||
history_file.parent.mkdir(parents=True, exist_ok=True)
|
history_file.parent.mkdir(parents=True, exist_ok=True)
|
||||||
_HISTORY_FILE = history_file
|
_HISTORY_FILE = history_file
|
||||||
|
|
||||||
# Preferred path: prompt_toolkit handles wrapped wide-char rendering better.
|
|
||||||
try:
|
|
||||||
from prompt_toolkit import PromptSession
|
|
||||||
from prompt_toolkit.formatted_text import ANSI
|
|
||||||
from prompt_toolkit.history import FileHistory
|
|
||||||
from prompt_toolkit.key_binding import KeyBindings
|
|
||||||
|
|
||||||
key_bindings = KeyBindings()
|
|
||||||
|
|
||||||
@key_bindings.add("enter")
|
|
||||||
def _accept_input(event) -> None:
|
|
||||||
_clear_visual_nav_state(event.current_buffer)
|
|
||||||
event.current_buffer.validate_and_handle()
|
|
||||||
|
|
||||||
@key_bindings.add("up")
|
|
||||||
def _handle_up(event) -> None:
|
|
||||||
count = event.arg if event.arg and event.arg > 0 else 1
|
|
||||||
moved = _move_buffer_cursor_visual_from_render(
|
|
||||||
buffer=event.current_buffer,
|
|
||||||
event=event,
|
|
||||||
delta=-1,
|
|
||||||
count=count,
|
|
||||||
)
|
|
||||||
if not moved:
|
|
||||||
event.current_buffer.history_backward(count=count)
|
|
||||||
_clear_visual_nav_state(event.current_buffer)
|
|
||||||
|
|
||||||
@key_bindings.add("down")
|
|
||||||
def _handle_down(event) -> None:
|
|
||||||
count = event.arg if event.arg and event.arg > 0 else 1
|
|
||||||
moved = _move_buffer_cursor_visual_from_render(
|
|
||||||
buffer=event.current_buffer,
|
|
||||||
event=event,
|
|
||||||
delta=1,
|
|
||||||
count=count,
|
|
||||||
)
|
|
||||||
if not moved:
|
|
||||||
event.current_buffer.history_forward(count=count)
|
|
||||||
_clear_visual_nav_state(event.current_buffer)
|
|
||||||
|
|
||||||
_PROMPT_SESSION = PromptSession(
|
|
||||||
history=FileHistory(str(history_file)),
|
|
||||||
multiline=True,
|
|
||||||
wrap_lines=True,
|
|
||||||
complete_while_typing=False,
|
|
||||||
key_bindings=key_bindings,
|
|
||||||
)
|
|
||||||
_PROMPT_SESSION.default_buffer.on_text_changed += (
|
|
||||||
lambda _event: _clear_visual_nav_state(_PROMPT_SESSION.default_buffer)
|
|
||||||
)
|
|
||||||
_PROMPT_SESSION_LABEL = ANSI("\x1b[1;34mYou:\x1b[0m ")
|
|
||||||
_READLINE = None
|
|
||||||
_USING_LIBEDIT = False
|
|
||||||
return
|
|
||||||
except Exception:
|
|
||||||
_PROMPT_SESSION = None
|
|
||||||
_PROMPT_SESSION_LABEL = None
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import readline
|
import readline
|
||||||
except Exception:
|
except ImportError:
|
||||||
return
|
return
|
||||||
|
|
||||||
_READLINE = readline
|
_READLINE = readline
|
||||||
@ -170,135 +112,12 @@ def _prompt_text() -> str:
|
|||||||
return "\001\033[1;34m\002You:\001\033[0m\002 "
|
return "\001\033[1;34m\002You:\001\033[0m\002 "
|
||||||
|
|
||||||
|
|
||||||
def _read_interactive_input() -> str:
|
|
||||||
"""Read user input with stable prompt rendering (sync fallback)."""
|
|
||||||
return input(_prompt_text())
|
|
||||||
|
|
||||||
|
|
||||||
async def _read_interactive_input_async() -> str:
|
async def _read_interactive_input_async() -> str:
|
||||||
"""Read user input safely inside the interactive asyncio loop."""
|
"""Read user input with arrow keys and history (runs input() in a thread)."""
|
||||||
if _PROMPT_SESSION is not None:
|
|
||||||
try:
|
try:
|
||||||
return await _PROMPT_SESSION.prompt_async(_PROMPT_SESSION_LABEL)
|
return await asyncio.to_thread(input, _prompt_text())
|
||||||
except EOFError as exc:
|
except EOFError as exc:
|
||||||
raise KeyboardInterrupt from exc
|
raise KeyboardInterrupt from exc
|
||||||
try:
|
|
||||||
return await asyncio.to_thread(_read_interactive_input)
|
|
||||||
except EOFError as exc:
|
|
||||||
raise KeyboardInterrupt from exc
|
|
||||||
|
|
||||||
|
|
||||||
def _choose_visual_rowcol(
|
|
||||||
rowcol_to_yx: dict[tuple[int, int], tuple[int, int]],
|
|
||||||
current_rowcol: tuple[int, int],
|
|
||||||
delta: int,
|
|
||||||
preferred_x: int | None = None,
|
|
||||||
) -> tuple[tuple[int, int] | None, int | None]:
|
|
||||||
"""Choose next logical row/col by rendered screen coordinates."""
|
|
||||||
if delta not in (-1, 1):
|
|
||||||
return None, preferred_x
|
|
||||||
|
|
||||||
current_yx = rowcol_to_yx.get(current_rowcol)
|
|
||||||
if current_yx is None:
|
|
||||||
same_row = [
|
|
||||||
(rowcol, yx)
|
|
||||||
for rowcol, yx in rowcol_to_yx.items()
|
|
||||||
if rowcol[0] == current_rowcol[0]
|
|
||||||
]
|
|
||||||
if not same_row:
|
|
||||||
return None, preferred_x
|
|
||||||
_, current_yx = min(same_row, key=lambda item: abs(item[0][1] - current_rowcol[1]))
|
|
||||||
|
|
||||||
target_x = current_yx[1] if preferred_x is None else preferred_x
|
|
||||||
target_y = current_yx[0] + delta
|
|
||||||
candidates = [(rowcol, yx) for rowcol, yx in rowcol_to_yx.items() if yx[0] == target_y]
|
|
||||||
if not candidates:
|
|
||||||
return None, preferred_x
|
|
||||||
|
|
||||||
best_rowcol, _ = min(
|
|
||||||
candidates,
|
|
||||||
key=lambda item: (abs(item[1][1] - target_x), item[1][1] < target_x, item[1][1]),
|
|
||||||
)
|
|
||||||
return best_rowcol, target_x
|
|
||||||
|
|
||||||
|
|
||||||
def _clear_visual_nav_state(buffer: Any) -> None:
|
|
||||||
"""Reset cached vertical-navigation anchor state."""
|
|
||||||
setattr(buffer, "_nanobot_visual_pref_x", None)
|
|
||||||
setattr(buffer, "_nanobot_visual_last_dir", None)
|
|
||||||
setattr(buffer, "_nanobot_visual_last_cursor", None)
|
|
||||||
setattr(buffer, "_nanobot_visual_last_text", None)
|
|
||||||
|
|
||||||
|
|
||||||
def _can_reuse_visual_anchor(buffer: Any, delta: int) -> bool:
|
|
||||||
"""Reuse anchor only for uninterrupted vertical navigation."""
|
|
||||||
return (
|
|
||||||
getattr(buffer, "_nanobot_visual_last_dir", None) == delta
|
|
||||||
and getattr(buffer, "_nanobot_visual_last_cursor", None) == buffer.cursor_position
|
|
||||||
and getattr(buffer, "_nanobot_visual_last_text", None) == buffer.text
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _remember_visual_anchor(buffer: Any, delta: int) -> None:
|
|
||||||
"""Remember current state as anchor baseline for repeated up/down."""
|
|
||||||
setattr(buffer, "_nanobot_visual_last_dir", delta)
|
|
||||||
setattr(buffer, "_nanobot_visual_last_cursor", buffer.cursor_position)
|
|
||||||
setattr(buffer, "_nanobot_visual_last_text", buffer.text)
|
|
||||||
|
|
||||||
|
|
||||||
def _move_buffer_cursor_visual_from_render(
|
|
||||||
buffer: Any,
|
|
||||||
event: Any,
|
|
||||||
delta: int,
|
|
||||||
count: int,
|
|
||||||
) -> bool:
|
|
||||||
"""Move cursor across rendered screen rows (soft-wrap/CJK aware)."""
|
|
||||||
try:
|
|
||||||
window = event.app.layout.current_window
|
|
||||||
render_info = getattr(window, "render_info", None)
|
|
||||||
rowcol_to_yx = getattr(render_info, "_rowcol_to_yx", None)
|
|
||||||
if not isinstance(rowcol_to_yx, dict) or not rowcol_to_yx:
|
|
||||||
return False
|
|
||||||
except Exception:
|
|
||||||
return False
|
|
||||||
|
|
||||||
moved_any = False
|
|
||||||
preferred_x = (
|
|
||||||
getattr(buffer, "_nanobot_visual_pref_x", None)
|
|
||||||
if _can_reuse_visual_anchor(buffer, delta)
|
|
||||||
else None
|
|
||||||
)
|
|
||||||
steps = max(1, count)
|
|
||||||
|
|
||||||
for _ in range(steps):
|
|
||||||
doc = buffer.document
|
|
||||||
current_rowcol = (doc.cursor_position_row, doc.cursor_position_col)
|
|
||||||
next_rowcol, preferred_x = _choose_visual_rowcol(
|
|
||||||
rowcol_to_yx=rowcol_to_yx,
|
|
||||||
current_rowcol=current_rowcol,
|
|
||||||
delta=delta,
|
|
||||||
preferred_x=preferred_x,
|
|
||||||
)
|
|
||||||
if next_rowcol is None:
|
|
||||||
break
|
|
||||||
|
|
||||||
try:
|
|
||||||
new_position = doc.translate_row_col_to_index(*next_rowcol)
|
|
||||||
except Exception:
|
|
||||||
break
|
|
||||||
if new_position == buffer.cursor_position:
|
|
||||||
break
|
|
||||||
|
|
||||||
buffer.cursor_position = new_position
|
|
||||||
moved_any = True
|
|
||||||
|
|
||||||
if moved_any:
|
|
||||||
setattr(buffer, "_nanobot_visual_pref_x", preferred_x)
|
|
||||||
_remember_visual_anchor(buffer, delta)
|
|
||||||
else:
|
|
||||||
_clear_visual_nav_state(buffer)
|
|
||||||
|
|
||||||
return moved_any
|
|
||||||
|
|
||||||
|
|
||||||
def version_callback(value: bool):
|
def version_callback(value: bool):
|
||||||
|
|||||||
@ -32,7 +32,6 @@ dependencies = [
|
|||||||
"python-telegram-bot[socks]>=21.0",
|
"python-telegram-bot[socks]>=21.0",
|
||||||
"lark-oapi>=1.0.0",
|
"lark-oapi>=1.0.0",
|
||||||
"socksio>=1.0.0",
|
"socksio>=1.0.0",
|
||||||
"prompt-toolkit>=3.0.47",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user