From 1de72a2724879d05526643e0c3150f8541cdc36f Mon Sep 17 00:00:00 2001 From: chrome-storm-c442 Date: Mon, 23 Feb 2026 14:45:07 -0500 Subject: [PATCH] Fix DECCKM support for arrow keys in TUI apps (mc, vim, htop) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- gui/tabs/terminal_tab.py | 28 +++++++++++++++++----------- gui/widgets/terminal_widget.py | 23 ++++++++++++++++++++--- 2 files changed, 37 insertions(+), 14 deletions(-) diff --git a/gui/tabs/terminal_tab.py b/gui/tabs/terminal_tab.py index 19ce2da..17445a2 100644 --- a/gui/tabs/terminal_tab.py +++ b/gui/tabs/terminal_tab.py @@ -8,6 +8,8 @@ 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): @@ -30,8 +32,8 @@ class TerminalTab(ctk.CTkFrame): self._terminal.pack(fill="both", expand=True, padx=5, pady=5) self._terminal.set_status(t("term_disconnected"), "#888888") - # Data batching buffer - self._data_buffer = bytearray() + # Thread-safe data batching + self._data_queue: queue.Queue[bytes] = queue.Queue() self._flush_after_id = None def set_server(self, alias: str | None): @@ -88,16 +90,20 @@ class TerminalTab(ctk.CTkFrame): self._session = None def _on_data_received(self, data: bytes): - self._data_buffer.extend(data) - if self._flush_after_id is None: - self._flush_after_id = self.after(8, self._flush_data_buffer) + """Called from SSH thread — put data in thread-safe queue.""" + self._data_queue.put(data) + self.after(0, self._flush_data_queue) - def _flush_data_buffer(self): - self._flush_after_id = None - if self._data_buffer: - chunk = bytes(self._data_buffer) - self._data_buffer.clear() - self._terminal.feed(chunk) + def _flush_data_queue(self): + """Called on main thread — drain queue and feed terminal.""" + chunks = [] + try: + while True: + chunks.append(self._data_queue.get_nowait()) + except queue.Empty: + pass + if chunks: + self._terminal.feed(b"".join(chunks)) def _on_disconnected(self): if self._intentional_disconnect: diff --git a/gui/widgets/terminal_widget.py b/gui/widgets/terminal_widget.py index 5192bbb..3032d55 100644 --- a/gui/widgets/terminal_widget.py +++ b/gui/widgets/terminal_widget.py @@ -33,7 +33,10 @@ _DEFAULT_BG = "#1a1a2e" _CURSOR_FG = "#1a1a2e" _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 = { "Up": "\x1b[A", "Down": "\x1b[B", @@ -59,6 +62,16 @@ _KEY_MAP = { "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): """VT100 terminal emulator widget using pyte + tkinter.Text.""" @@ -301,9 +314,13 @@ class TerminalWidget(tk.Frame): self._send(bytes([ord(ch) - ord("a") + 1])) return "break" - # Special keys + # Special keys — check DECCKM (application cursor mode) for arrow keys 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" # Tab