Fix DECCKM support for arrow keys in TUI apps (mc, vim, htop)

- mc/vim/htop enable Application Cursor Mode (DECCKM ?1h) which
  requires arrow keys to send \eOA..D instead of \e[A..D
- Detect DECCKM in pyte screen mode and send correct sequences
- Fix thread-safety: use queue.Queue for SSH→main thread data transfer

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
chrome-storm-c442
2026-02-23 14:45:07 -05:00
parent 95ef41eaeb
commit 1de72a2724
2 changed files with 37 additions and 14 deletions

View File

@@ -8,6 +8,8 @@ import customtkinter as ctk
from core.ssh_client import ShellSession from core.ssh_client import ShellSession
from core.i18n import t from core.i18n import t
import queue
class TerminalTab(ctk.CTkFrame): class TerminalTab(ctk.CTkFrame):
def __init__(self, master, store): def __init__(self, master, store):
@@ -30,8 +32,8 @@ class TerminalTab(ctk.CTkFrame):
self._terminal.pack(fill="both", expand=True, padx=5, pady=5) self._terminal.pack(fill="both", expand=True, padx=5, pady=5)
self._terminal.set_status(t("term_disconnected"), "#888888") self._terminal.set_status(t("term_disconnected"), "#888888")
# Data batching buffer # Thread-safe data batching
self._data_buffer = bytearray() self._data_queue: queue.Queue[bytes] = queue.Queue()
self._flush_after_id = None self._flush_after_id = None
def set_server(self, alias: str | None): def set_server(self, alias: str | None):
@@ -88,16 +90,20 @@ class TerminalTab(ctk.CTkFrame):
self._session = None self._session = None
def _on_data_received(self, data: bytes): def _on_data_received(self, data: bytes):
self._data_buffer.extend(data) """Called from SSH thread — put data in thread-safe queue."""
if self._flush_after_id is None: self._data_queue.put(data)
self._flush_after_id = self.after(8, self._flush_data_buffer) self.after(0, self._flush_data_queue)
def _flush_data_buffer(self): def _flush_data_queue(self):
self._flush_after_id = None """Called on main thread — drain queue and feed terminal."""
if self._data_buffer: chunks = []
chunk = bytes(self._data_buffer) try:
self._data_buffer.clear() while True:
self._terminal.feed(chunk) chunks.append(self._data_queue.get_nowait())
except queue.Empty:
pass
if chunks:
self._terminal.feed(b"".join(chunks))
def _on_disconnected(self): def _on_disconnected(self):
if self._intentional_disconnect: if self._intentional_disconnect:

View File

@@ -33,7 +33,10 @@ _DEFAULT_BG = "#1a1a2e"
_CURSOR_FG = "#1a1a2e" _CURSOR_FG = "#1a1a2e"
_CURSOR_BG = "#d3d7cf" _CURSOR_BG = "#d3d7cf"
# Key → VT100 escape sequence # DECCKM (private mode ?1) — pyte stores private modes shifted by 5 bits
_DECCKM = 32
# Key → VT100 escape sequence (normal cursor mode)
_KEY_MAP = { _KEY_MAP = {
"Up": "\x1b[A", "Up": "\x1b[A",
"Down": "\x1b[B", "Down": "\x1b[B",
@@ -59,6 +62,16 @@ _KEY_MAP = {
"F12": "\x1b[24~", "F12": "\x1b[24~",
} }
# Application cursor mode (DECCKM) — mc, htop, vim enable this
_KEY_MAP_APP_CURSOR = {
"Up": "\x1bOA",
"Down": "\x1bOB",
"Right": "\x1bOC",
"Left": "\x1bOD",
"Home": "\x1bOH",
"End": "\x1bOF",
}
class TerminalWidget(tk.Frame): class TerminalWidget(tk.Frame):
"""VT100 terminal emulator widget using pyte + tkinter.Text.""" """VT100 terminal emulator widget using pyte + tkinter.Text."""
@@ -301,9 +314,13 @@ class TerminalWidget(tk.Frame):
self._send(bytes([ord(ch) - ord("a") + 1])) self._send(bytes([ord(ch) - ord("a") + 1]))
return "break" return "break"
# Special keys # Special keys — check DECCKM (application cursor mode) for arrow keys
if event.keysym in _KEY_MAP: if event.keysym in _KEY_MAP:
self._send(_KEY_MAP[event.keysym].encode()) decckm = _DECCKM in self._screen.mode
if decckm and event.keysym in _KEY_MAP_APP_CURSOR:
self._send(_KEY_MAP_APP_CURSOR[event.keysym].encode())
else:
self._send(_KEY_MAP[event.keysym].encode())
return "break" return "break"
# Tab # Tab