feat: real interactive terminal (Xshell/PuTTY style)

User feedback: 'your terminal can only send commands, not type
freely. Useless.'

Replaced the old 'single command + output' terminal with a true
interactive shell panel:

NEW: ui/terminal_panel.py
- Uses paramiko invoke_shell() to open a long-lived shell session
- Every keystroke is forwarded to the remote shell (not a textbox)
- Remote output (incl. ANSI color codes) is parsed and rendered
  inline in a QPlainTextEdit (16-color SGR, bold, underline, invert)
- Cursor kept on the current input line; user can't edit past output
- Local command history with up/down navigation (stores last 200)
- Standard terminal shortcuts: Ctrl+C (SIGINT), Ctrl+D (EOF),
  Ctrl+L (clear), Ctrl+A/E (line start/end), Ctrl+U/K/W
- Terminal resize forwarded to remote PTY (vim/top work correctly)
- Tab key forwarded to remote for completion

CHANGED: core/ssh_client.py
- Added open_shell(term_type, cols, rows) -> Channel
- Added resize_shell(chan, cols, rows)

CHANGED: ui/main_window.py
- Replaced old _build_terminal_tab (now removed) with TerminalPanel
- On host connect -> auto-open shell, switch to Terminal tab
- On host disconnect / switch -> close shell cleanly

NEW: test_terminal.py (5 tests, all pass)
- Real SSH connection, opens shell
- Sends 'ls /tmp' via simulated keypresses, verifies 162-line output
- Sends ANSI color command, verifies colored text appears
- Tests up-arrow history recall
- Verifies prompt returns after command completes

All existing tests still pass (core 6/6 + UI 3/3 + E2E 6/6 + terminal 5/5)
This commit is contained in:
Hermes
2026-07-28 21:36:49 +08:00
parent 8b5dacda58
commit 9dbd500399
4 changed files with 755 additions and 169 deletions
+21
View File
@@ -89,6 +89,27 @@ class SSHConnection:
self.client = None
self.connected = False
def open_shell(self, term_type: str = "xterm", cols: int = 80, rows: int = 24):
"""打开一个交互式 shell 会话(用于真正的终端模拟器)"""
if not self.connected or not self.client:
return None
try:
chan = self.client.invoke_shell(term=term_type, width=cols, height=rows)
chan.settimeout(0.0) # 非阻塞
return chan
except Exception as e:
self.last_error = f"打开 shell 失败: {e}"
return None
def resize_shell(self, chan, cols: int, rows: int):
"""终端大小变化时通知远端(vim/top 这类全屏程序需要)"""
if not chan:
return
try:
chan.resize_pty(width=cols, height=rows)
except Exception:
pass
def exec_command(self, command: str, timeout: int = 30) -> Tuple[int, str, str]:
"""执行远程命令;返回 (退出码, stdout, stderr)"""
if not self.connected or not self.client: