Complete terminal rewrite for full TUI compatibility
- Alternate screen buffer (smcup/rmcup) for mc, vim, htop, less - 256-color and truecolor rendering (dynamic tag creation) - Reverse video and strikethrough attributes - write_process_input bridge (DA/DSR responses to SSH) - DECCKM application cursor keys for arrow navigation - Alt+key combinations (mc panels, readline word nav) - Shift+Arrow / Ctrl+Arrow with xterm modifier encoding - Shift+Tab (backtab) for reverse completion - Bracketed paste mode for clean multi-line paste - Mouse tracking (basic, button, any-event, SGR extended) - Incremental UTF-8 decoder (handles split multi-byte) - Cursor hidden state (DECTCEM) respected - pyte dirty set for optimized rendering - Numpad key support - Professional copy/paste UX: - Ctrl+C always sends SIGINT - Ctrl+Shift+C/V for copy/paste - Right-click context menu with Copy/Paste/Select All - Double-click to select word - Smooth mousewheel scroll (3 lines in alt screen) - Thread-safe session management (all state on main thread) - Resize debounce reduced to 100ms - Send errors trigger disconnect notification Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -2,14 +2,13 @@
|
||||
Terminal tab — persistent interactive SSH shell via ShellSession + TerminalWidget.
|
||||
"""
|
||||
|
||||
import queue
|
||||
import threading
|
||||
import time
|
||||
import customtkinter as ctk
|
||||
from core.ssh_client import ShellSession
|
||||
from core.i18n import t
|
||||
|
||||
import queue
|
||||
|
||||
|
||||
class TerminalTab(ctk.CTkFrame):
|
||||
def __init__(self, master, store):
|
||||
@@ -32,9 +31,8 @@ class TerminalTab(ctk.CTkFrame):
|
||||
self._terminal.pack(fill="both", expand=True, padx=5, pady=5)
|
||||
self._terminal.set_status(t("term_disconnected"), "#888888")
|
||||
|
||||
# Thread-safe data batching
|
||||
# Thread-safe data queue
|
||||
self._data_queue: queue.Queue[bytes] = queue.Queue()
|
||||
self._flush_after_id = None
|
||||
|
||||
def set_server(self, alias: str | None):
|
||||
if alias == self._current_alias:
|
||||
@@ -70,12 +68,16 @@ class TerminalTab(ctk.CTkFrame):
|
||||
session.on_data = self._on_data_received
|
||||
session.on_disconnect = self._on_disconnected
|
||||
session.connect()
|
||||
self._session = session
|
||||
self._reconnect_count = 0
|
||||
self.after(0, lambda: self._terminal.set_status(
|
||||
t("term_connected").format(alias=alias), "#44cc44"
|
||||
))
|
||||
self.after(0, self._terminal.focus_terminal)
|
||||
|
||||
# Set session on main thread to avoid races
|
||||
def _set_session():
|
||||
self._session = session
|
||||
self._reconnect_count = 0
|
||||
self._terminal.set_status(
|
||||
t("term_connected").format(alias=alias), "#44cc44"
|
||||
)
|
||||
self._terminal.focus_terminal()
|
||||
self.after(0, _set_session)
|
||||
except Exception as e:
|
||||
self.after(0, lambda: self._terminal.set_status(
|
||||
t("term_connect_failed").format(error=str(e)), "#ff4444"
|
||||
@@ -85,9 +87,10 @@ class TerminalTab(ctk.CTkFrame):
|
||||
|
||||
def _disconnect(self):
|
||||
self._intentional_disconnect = True
|
||||
if self._session:
|
||||
self._session.disconnect()
|
||||
self._session = None
|
||||
session = self._session
|
||||
self._session = None
|
||||
if session:
|
||||
session.disconnect()
|
||||
|
||||
def _on_data_received(self, data: bytes):
|
||||
"""Called from SSH thread — put data in thread-safe queue."""
|
||||
@@ -106,37 +109,39 @@ class TerminalTab(ctk.CTkFrame):
|
||||
self._terminal.feed(b"".join(chunks))
|
||||
|
||||
def _on_disconnected(self):
|
||||
if self._intentional_disconnect:
|
||||
self.after(0, lambda: self._terminal.set_status(
|
||||
t("term_disconnected"), "#888888"
|
||||
))
|
||||
return
|
||||
"""Called from SSH read thread."""
|
||||
def _handle():
|
||||
if self._intentional_disconnect:
|
||||
self._terminal.set_status(t("term_disconnected"), "#888888")
|
||||
return
|
||||
|
||||
self._session = None
|
||||
self._session = None
|
||||
|
||||
if self._reconnect_count < self._max_reconnect:
|
||||
self._reconnect_count += 1
|
||||
n = self._reconnect_count
|
||||
mx = self._max_reconnect
|
||||
self.after(0, lambda: self._terminal.set_status(
|
||||
t("term_reconnecting").format(n=n, max=mx), "#ccaa00"
|
||||
))
|
||||
if self._reconnect_count < self._max_reconnect:
|
||||
self._reconnect_count += 1
|
||||
n = self._reconnect_count
|
||||
mx = self._max_reconnect
|
||||
self._terminal.set_status(
|
||||
t("term_reconnecting").format(n=n, max=mx), "#ccaa00"
|
||||
)
|
||||
|
||||
def _retry():
|
||||
time.sleep(1)
|
||||
if not self._intentional_disconnect and self._current_alias:
|
||||
self.after(0, self._connect)
|
||||
def _retry():
|
||||
time.sleep(1)
|
||||
if not self._intentional_disconnect and self._current_alias:
|
||||
self.after(0, self._connect)
|
||||
|
||||
threading.Thread(target=_retry, daemon=True).start()
|
||||
else:
|
||||
self.after(0, lambda: self._terminal.set_status(
|
||||
t("term_reconnect_fail"), "#ff4444"
|
||||
))
|
||||
threading.Thread(target=_retry, daemon=True).start()
|
||||
else:
|
||||
self._terminal.set_status(t("term_reconnect_fail"), "#ff4444")
|
||||
|
||||
self.after(0, _handle)
|
||||
|
||||
def _send_to_shell(self, data: bytes):
|
||||
if self._session and self._session.connected:
|
||||
self._session.send(data)
|
||||
session = self._session # local ref for thread safety
|
||||
if session and session.connected:
|
||||
session.send(data)
|
||||
|
||||
def _on_resize(self, cols: int, rows: int):
|
||||
if self._session and self._session.connected:
|
||||
self._session.resize(cols, rows)
|
||||
session = self._session # local ref for thread safety
|
||||
if session and session.connected:
|
||||
session.resize(cols, rows)
|
||||
|
||||
Reference in New Issue
Block a user