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:
+29
-169
@@ -8,19 +8,20 @@ import time
|
||||
from typing import Optional
|
||||
|
||||
from PyQt5.QtCore import Qt, QSize
|
||||
from PyQt5.QtGui import QFont, QIcon, QKeySequence
|
||||
from PyQt5.QtGui import QFont, QIcon
|
||||
from PyQt5.QtWidgets import (
|
||||
QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QListWidget, QListWidgetItem,
|
||||
QPushButton, QLineEdit, QLabel, QSplitter, QTabWidget, QPlainTextEdit,
|
||||
QGroupBox, QFormLayout, QSpinBox, QMessageBox, QStatusBar, QAction,
|
||||
QFileDialog, QInputDialog, QToolBar, QApplication, QStyle, QShortcut,
|
||||
QPushButton, QLineEdit, QLabel, QSplitter, QTabWidget,
|
||||
QGroupBox, QFormLayout, QMessageBox, QStatusBar, QAction,
|
||||
QFileDialog, QInputDialog, QToolBar, QApplication, QStyle,
|
||||
)
|
||||
|
||||
from core.manager import ConnectionManager
|
||||
from core.ai_agent import AIAgent
|
||||
from .workers import ConnectWorker, CommandWorker
|
||||
from .workers import ConnectWorker
|
||||
from .widgets import FileBrowser, MonitorPanel, AIChatPanel
|
||||
from .config_dialog import AIConfigDialog
|
||||
from .terminal_panel import TerminalPanel
|
||||
|
||||
|
||||
APP_NAME = "SSHClient"
|
||||
@@ -37,7 +38,6 @@ class MainWindow(QMainWindow):
|
||||
self.agent = AIAgent()
|
||||
|
||||
self.current_host_id: Optional[str] = None
|
||||
self.cmd_worker: Optional[CommandWorker] = None
|
||||
self.connect_worker: Optional[ConnectWorker] = None
|
||||
|
||||
self._build_ui()
|
||||
@@ -103,8 +103,8 @@ class MainWindow(QMainWindow):
|
||||
self.tabs.setDocumentMode(True)
|
||||
|
||||
# Tab1: 终端
|
||||
self.terminal = self._build_terminal_tab()
|
||||
self.tabs.addTab(self.terminal, "⌨ 终端")
|
||||
self.terminal_panel = TerminalPanel()
|
||||
self.tabs.addTab(self.terminal_panel, "⌨ 终端")
|
||||
|
||||
# Tab2: 文件浏览
|
||||
self.file_browser = FileBrowser(self.manager)
|
||||
@@ -121,83 +121,6 @@ class MainWindow(QMainWindow):
|
||||
splitter.addWidget(self.tabs)
|
||||
splitter.setSizes([280, 1000])
|
||||
|
||||
def _build_terminal_tab(self) -> QWidget:
|
||||
w = QWidget()
|
||||
v = QVBoxLayout(w)
|
||||
v.setContentsMargins(6, 6, 6, 6)
|
||||
v.setSpacing(4)
|
||||
|
||||
# 命令输入
|
||||
cmd_row = QHBoxLayout()
|
||||
self.cmd_input = QLineEdit()
|
||||
self.cmd_input.setPlaceholderText("输入命令,回车执行 (例如: ls -la /tmp)")
|
||||
self.cmd_input.returnPressed.connect(self._run_command)
|
||||
QShortcut(QKeySequence("Ctrl+Return"), self.cmd_input,
|
||||
activated=self._run_command)
|
||||
self.btn_run = QPushButton("执行")
|
||||
self.btn_run.clicked.connect(self._run_command)
|
||||
self.timeout_spin = QSpinBox()
|
||||
self.timeout_spin.setRange(5, 600)
|
||||
self.timeout_spin.setValue(30)
|
||||
self.timeout_spin.setSuffix(" 秒")
|
||||
cmd_row.addWidget(QLabel("$"))
|
||||
cmd_row.addWidget(self.cmd_input, 1)
|
||||
cmd_row.addWidget(QLabel("超时:"))
|
||||
cmd_row.addWidget(self.timeout_spin)
|
||||
cmd_row.addWidget(self.btn_run)
|
||||
v.addLayout(cmd_row)
|
||||
|
||||
# 快速命令栏
|
||||
quick_row = QHBoxLayout()
|
||||
quick_row.addWidget(QLabel("常用:"))
|
||||
for label, cmd in [
|
||||
("pwd && uname -a", "pwd && uname -a"),
|
||||
("df -h", "df -h"),
|
||||
("free -h", "free -h"),
|
||||
("top -bn1 | head -20", "top -bn1 | head -20"),
|
||||
("netstat -tlnp", "netstat -tlnp 2>/dev/null || ss -tlnp"),
|
||||
("ls /etc", "ls -la /etc"),
|
||||
]:
|
||||
b = QPushButton(label)
|
||||
b.clicked.connect(lambda _, c=cmd: self.cmd_input.setText(c))
|
||||
quick_row.addWidget(b)
|
||||
quick_row.addStretch(1)
|
||||
v.addLayout(quick_row)
|
||||
|
||||
# 输出
|
||||
self.output = QPlainTextEdit()
|
||||
self.output.setReadOnly(True)
|
||||
self.output.setStyleSheet("""
|
||||
QPlainTextEdit {
|
||||
background: #0c0c0c;
|
||||
color: #e0e0e0;
|
||||
font-family: Consolas, 'Courier New', monospace;
|
||||
font-size: 10pt;
|
||||
}
|
||||
""")
|
||||
v.addWidget(self.output, 1)
|
||||
|
||||
# 输出操作
|
||||
bottom = QHBoxLayout()
|
||||
self.btn_clear_out = QPushButton("清空")
|
||||
self.btn_clear_out.clicked.connect(self.output.clear)
|
||||
self.btn_copy = QPushButton("复制输出")
|
||||
self.btn_copy.clicked.connect(lambda: QApplication.clipboard().setText(self.output.toPlainText()))
|
||||
self.btn_debug = QPushButton("🔍 诊断")
|
||||
self.btn_debug.setToolTip("打印 SSH 连接和 worker 状态")
|
||||
self.btn_debug.clicked.connect(self._debug_state)
|
||||
self.btn_test = QPushButton("▶ 测试命令")
|
||||
self.btn_test.setToolTip("执行 'echo hello-sshclient && pwd && uname -a' 验证连通性")
|
||||
self.btn_test.clicked.connect(lambda: self.cmd_input.setText("echo hello-sshclient && pwd && uname -a"))
|
||||
bottom.addWidget(self.btn_clear_out)
|
||||
bottom.addWidget(self.btn_copy)
|
||||
bottom.addWidget(self.btn_debug)
|
||||
bottom.addStretch(1)
|
||||
bottom.addWidget(self.btn_test)
|
||||
v.addLayout(bottom)
|
||||
|
||||
return w
|
||||
|
||||
def _build_menu(self):
|
||||
menubar = self.menuBar()
|
||||
# 文件
|
||||
@@ -248,14 +171,27 @@ class MainWindow(QMainWindow):
|
||||
items = self.host_list.selectedItems()
|
||||
if not items:
|
||||
self.current_host_id = None
|
||||
self.terminal_input_set_enabled(False)
|
||||
self.terminal_panel.close_shell()
|
||||
self.terminal_panel._set_status("未选择主机", "#888")
|
||||
self.terminal_panel.bottom_label.setText(
|
||||
"提示: 连接主机后这里会出现真实的 shell 提示符,可直接键入命令"
|
||||
)
|
||||
return
|
||||
host_id = items[0].data(Qt.UserRole)
|
||||
self.current_host_id = host_id
|
||||
# 切换主机时关闭旧 shell
|
||||
self.terminal_panel.close_shell()
|
||||
self.file_browser.set_host(host_id)
|
||||
self.monitor.set_host(host_id)
|
||||
self.ai_panel.set_host(host_id)
|
||||
self._refresh_status_indicator()
|
||||
# 如果已连接,立即打开新 shell
|
||||
conn = self.manager.get_connection(host_id)
|
||||
if conn and conn.connected:
|
||||
self.terminal_panel.attach(conn)
|
||||
self.tabs.setCurrentIndex(0) # 切到终端 Tab 让用户看到
|
||||
else:
|
||||
self.terminal_panel._set_status(f"未连接: 请点击「🔌 连接」", "#c62828")
|
||||
|
||||
def _refresh_status_indicator(self):
|
||||
if not self.current_host_id:
|
||||
@@ -265,11 +201,9 @@ class MainWindow(QMainWindow):
|
||||
if c and c.connected:
|
||||
self.conn_status_label.setText(f"🟢 已连接: {c.username}@{c.host}:{c.port}")
|
||||
self.conn_status_label.setStyleSheet("color: #2e7d32; padding: 4px;")
|
||||
self.terminal_input_set_enabled(True)
|
||||
else:
|
||||
self.conn_status_label.setText("🔴 未连接")
|
||||
self.conn_status_label.setStyleSheet("color: #c62828; padding: 4px;")
|
||||
self.terminal_input_set_enabled(False)
|
||||
|
||||
def terminal_input_set_enabled(self, enabled: bool):
|
||||
self.cmd_input.setEnabled(enabled)
|
||||
@@ -345,6 +279,11 @@ class MainWindow(QMainWindow):
|
||||
# 同步到 UI
|
||||
self.file_browser.set_host(host_id)
|
||||
self.monitor.set_host(host_id)
|
||||
# 打开 shell
|
||||
conn = self.manager.get_connection(host_id)
|
||||
if conn:
|
||||
self.terminal_panel.attach(conn)
|
||||
self.tabs.setCurrentIndex(0) # 切到终端 Tab
|
||||
else:
|
||||
QMessageBox.critical(self, "连接失败", msg)
|
||||
self.statusBar().showMessage(f"连接失败: {msg}", 5000)
|
||||
@@ -353,90 +292,11 @@ class MainWindow(QMainWindow):
|
||||
def _do_disconnect(self):
|
||||
if not self.current_host_id:
|
||||
return
|
||||
self.terminal_panel.close_shell()
|
||||
self.terminal_panel._set_status("已断开", "#888")
|
||||
self.manager.disconnect(self.current_host_id)
|
||||
self.statusBar().showMessage("已断开", 3000)
|
||||
self._refresh_status_indicator()
|
||||
# 监控如果开着也会自己检测到
|
||||
|
||||
# ============================================================
|
||||
# 命令执行
|
||||
# ============================================================
|
||||
def _run_command(self):
|
||||
cmd = self.cmd_input.text().strip()
|
||||
if not cmd:
|
||||
return
|
||||
if not self.current_host_id:
|
||||
QMessageBox.warning(self, "提示", "请先连接主机")
|
||||
return
|
||||
conn = self.manager.get_connection(self.current_host_id)
|
||||
if not conn or not conn.connected:
|
||||
QMessageBox.warning(self, "提示", "当前主机未连接")
|
||||
return
|
||||
# 先在输出区显示命令(即使后续 worker 失败也能看到用户输入了啥)
|
||||
self._append_out(f"\n$ {cmd}")
|
||||
self.cmd_input.clear()
|
||||
self.btn_run.setEnabled(False)
|
||||
self.btn_run.setText("执行中...")
|
||||
timeout = self.timeout_spin.value()
|
||||
self.cmd_worker = CommandWorker(conn, cmd, timeout)
|
||||
self.cmd_worker.finished_with.connect(self._on_cmd_done)
|
||||
self.cmd_worker.start()
|
||||
# 强制刷新滚动到底
|
||||
self.output.repaint()
|
||||
|
||||
def _on_cmd_done(self, code: int, out: str, err: str):
|
||||
self.btn_run.setEnabled(True)
|
||||
self.btn_run.setText("执行")
|
||||
if out:
|
||||
self._append_out(out)
|
||||
else:
|
||||
self._append_out("(stdout 为空)")
|
||||
if err:
|
||||
self._append_out_err(err)
|
||||
self._append_out(f"[exit={code}]")
|
||||
self.statusBar().showMessage(f"命令完成 (退出码 {code})", 3000)
|
||||
# 强制刷新 + 滚到底
|
||||
self.output.repaint()
|
||||
|
||||
def _append_out(self, text: str):
|
||||
# 不再 rstrip("\n"),保留原样;appendPlainText 本身会处理换行
|
||||
self.output.appendPlainText(text)
|
||||
# 滚到底
|
||||
sb = self.output.verticalScrollBar()
|
||||
sb.setValue(sb.maximum())
|
||||
self.output.ensureCursorVisible()
|
||||
|
||||
def _append_out_err(self, text: str):
|
||||
self.output.appendPlainText("[ERR] " + text.replace("\n", "\n[ERR] "))
|
||||
sb = self.output.verticalScrollBar()
|
||||
sb.setValue(sb.maximum())
|
||||
|
||||
def _debug_state(self):
|
||||
"""诊断按钮:打印当前 SSH 连接和 worker 状态"""
|
||||
lines = ["=== Debug State ==="]
|
||||
lines.append(f"current_host_id: {self.current_host_id}")
|
||||
if self.current_host_id:
|
||||
h = self.manager.get_host(self.current_host_id)
|
||||
lines.append(f"host config: {h}")
|
||||
conn = self.manager.get_connection(self.current_host_id)
|
||||
if conn:
|
||||
lines.append(f"conn.connected: {conn.connected}")
|
||||
lines.append(f"conn.client: {conn.client}")
|
||||
lines.append(f"conn.sftp: {conn.sftp}")
|
||||
if conn.client:
|
||||
try:
|
||||
transport = conn.client.get_transport()
|
||||
lines.append(f"transport active: {transport.is_active() if transport else 'None'}")
|
||||
except Exception as e:
|
||||
lines.append(f"transport error: {e}")
|
||||
else:
|
||||
lines.append("conn: None (not connected)")
|
||||
lines.append(f"cmd_worker: {self.cmd_worker}")
|
||||
if self.cmd_worker:
|
||||
lines.append(f"cmd_worker.isRunning: {self.cmd_worker.isRunning()}")
|
||||
lines.append("=== End Debug ===")
|
||||
self._append_out("\n".join(lines))
|
||||
self.statusBar().showMessage("诊断信息已输出", 3000)
|
||||
|
||||
# ============================================================
|
||||
# AI / 关于
|
||||
|
||||
Reference in New Issue
Block a user