Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 65 additions & 10 deletions emrg/client/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

from __future__ import annotations

import asyncio, json, logging, os, platform, signal, subprocess, sys, time
import asyncio, json, logging, os, platform, signal, subprocess, sys, threading, time
try:
import fcntl # POSIX-only(TUI 非阻塞 stdin);Windows 无此模块
except ImportError: # pragma: no cover - Windows
Expand Down Expand Up @@ -845,18 +845,44 @@ async def _reconnect():

read_task = asyncio.create_task(read_server())

# ── SIGWINCH (terminal resize) handler ─────────────────
# ── Terminal resize handler ──────────────────────────────
# R123: Windows 无 SIGWINCH + ProactorEventLoop 不支持 add_signal_handler
# → 轮询线程每 500ms 检测 get_terminal_size 变化;POSIX 保持 SIGWINCH。
_resize_event = asyncio.Event()
_win_resize_thread: threading.Thread | None = None
_win_resize_stop = threading.Event()
_last_size = os.get_terminal_size() if sys.platform == "win32" else None

def _on_sigwinch() -> None:
_resize_event.set()

loop.add_signal_handler(signal.SIGWINCH, _on_sigwinch)

# ── Stdin reader (asyncio-native, no thread pool — rant #SIGWINCH-leak) ─
if fcntl is not None: # POSIX-only(Windows 无 fcntl,不跑 TUI)
_stdin_flags = fcntl.fcntl(stdin_fd, fcntl.F_GETFL)
fcntl.fcntl(stdin_fd, fcntl.F_SETFL, _stdin_flags | os.O_NONBLOCK)
if sys.platform == "win32":
def _poll_resize() -> None:
nonlocal _last_size
while not _win_resize_stop.is_set():
try:
size = os.get_terminal_size()
if size != _last_size:
_last_size = size
loop.call_soon_threadsafe(_resize_event.set)
except (OSError, ValueError):
pass
_win_resize_stop.wait(0.5)

_win_resize_thread = threading.Thread(
target=_poll_resize, name="emrg-resize-poll", daemon=True)
_win_resize_thread.start()
else:
sigwinch = getattr(signal, "SIGWINCH", None)
if sigwinch is not None:
loop.add_signal_handler(sigwinch, _on_sigwinch)

# ── Stdin reader ────────────────────────────────────────
# R123: Windows ProactorEventLoop 无 add_reader → daemon 线程阻塞
# os.read + call_soon_threadsafe 填充同一 stdin_queue;POSIX 保持
# asyncio-native add_reader + O_NONBLOCK(rant #SIGWINCH-leak)。
_win_stdin_thread: threading.Thread | None = None
_win_stdin_stop = threading.Event()

def _stdin_reader() -> None:
try:
Expand All @@ -865,8 +891,28 @@ def _stdin_reader() -> None:
stdin_queue.put_nowait(data)
except (BlockingIOError, InterruptedError):
pass
except OSError:
pass

if sys.platform == "win32":
def _win_stdin_loop() -> None:
while not _win_stdin_stop.is_set():
try:
data = os.read(stdin_fd, 4096)
if not data:
break
loop.call_soon_threadsafe(stdin_queue.put_nowait, data)
except (OSError, ValueError):
break

loop.add_reader(stdin_fd, _stdin_reader)
_win_stdin_thread = threading.Thread(
target=_win_stdin_loop, name="emrg-stdin-reader", daemon=True)
_win_stdin_thread.start()
else:
if fcntl is not None: # POSIX-only(Windows 无 fcntl)
_stdin_flags = fcntl.fcntl(stdin_fd, fcntl.F_GETFL)
fcntl.fcntl(stdin_fd, fcntl.F_SETFL, _stdin_flags | os.O_NONBLOCK)
loop.add_reader(stdin_fd, _stdin_reader)

def _handle_selector_nav(data: bytes, widget) -> bool:
"""Handle arrow key and j/k navigation for any selector widget.
Expand Down Expand Up @@ -1719,7 +1765,16 @@ def _is_image_token(s, i):
break
except Exception: logger.exception("TUI main loop crashed")
finally:
loop.remove_reader(stdin_fd)
# R123: Windows 无 add_reader(线程 reader)→ remove_reader 需保护;
# 停掉轮询/读线程(daemon=True 兜底,显式 stop 更干净)。
if sys.platform == "win32":
_win_resize_stop.set()
_win_stdin_stop.set()
else:
try:
loop.remove_reader(stdin_fd)
except (NotImplementedError, ValueError):
pass
logger.info("disconnecting from emrgd")
read_task.cancel()
try: await read_task
Expand Down
38 changes: 34 additions & 4 deletions emrg/client/python_tui/terminal.py
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,8 @@ def __post_init__(self) -> None:
self._enter_raw_mode()
# PR #205's add_reader+fcntl(stdin, O_NONBLOCK) leaks to stdout on
# macOS (pty fd sharing). Force stdout back to blocking write.
if sys.stdout.isatty():
# R123: fcntl is None on Windows — skip (no fcntl available).
if sys.stdout.isatty() and fcntl is not None:
try:
fcntl.fcntl(sys.stdout.fileno(), fcntl.F_SETFL,
os.O_WRONLY | os.O_APPEND)
Expand Down Expand Up @@ -340,11 +341,13 @@ def _get_lines(name: str) -> list[object]:
# fcntl guard: PR #205's add_reader+fcntl(stdin) may leak
# O_NONBLOCK to stdout via macOS pty sharing. Restore
# blocking on BlockingIOError and retry the flush.
# R123: fcntl is None on Windows — just flush.
try:
sys.stdout.flush()
except BlockingIOError:
fcntl.fcntl(sys.stdout.fileno(), fcntl.F_SETFL,
os.O_WRONLY | os.O_APPEND)
if fcntl is not None:
fcntl.fcntl(sys.stdout.fileno(), fcntl.F_SETFL,
os.O_WRONLY | os.O_APPEND)
sys.stdout.flush()

# Swap buffers
Expand Down Expand Up @@ -462,9 +465,28 @@ def shutdown(self) -> None:
# ── Raw mode ─────────────────────────────────────────────

def _enter_raw_mode(self) -> None:
"""Enable raw mode on stdin for direct key reading."""
"""Enable raw mode on stdin for direct key reading.

R123: Windows uses Win32Console (ctypes SetConsoleMode) — termios/tty
are None there; POSIX keeps the termios path unchanged.
"""
if not sys.stdin.isatty():
return
if sys.platform == "win32":
from emrg.client.python_tui.win32 import win32_console
try:
ok = win32_console.enable_raw_mode(sys.stdin.fileno())
if not ok:
return
self._raw_mode = True
sys.stdout.write(CURSOR_HIDE)
sys.stdout.write("\x1b[?2004h")
sys.stdout.flush()
except (OSError, AttributeError):
return
return
if termios is None or tty is None:
return
try:
self._original_termios = termios.tcgetattr(sys.stdin.fileno())
tty.setraw(sys.stdin.fileno())
Expand All @@ -478,6 +500,14 @@ def _enter_raw_mode(self) -> None:
def _exit_raw_mode(self) -> None:
"""Restore original terminal settings."""
self.restore_title()
if sys.platform == "win32":
from emrg.client.python_tui.win32 import win32_console
try:
win32_console.disable_raw_mode(sys.stdin.fileno())
self._raw_mode = False
except (OSError, AttributeError):
pass
return
if self._original_termios is not None:
try:
termios.tcsetattr(
Expand Down
105 changes: 105 additions & 0 deletions emrg/client/python_tui/win32.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
"""Win32 console helpers for the TUI (Windows only).

Provides raw-mode / ANSI-VT enabling via ctypes (no pywin32 dependency —
keeps the packaged runtime lean). Used by Terminal when sys.platform ==
"win32"; POSIX terminals keep using termios (rant 2026-08-05T15:54:28).

Raw mode on Windows mirrors the POSIX termios.tcgetattr/tty.setraw contract:
- disable line input / echo / processed input so os.read returns raw keys
- enable VT processing so ANSI escape sequences (CURSOR_HIDE etc.) work on
cmd/conhost (Windows Terminal enables VT by default)
- set the fd to binary mode so CRLF translation is off (key sequences like
the arrow prefix ESC [ A arrive byte-identical to POSIX)
"""

from __future__ import annotations

import ctypes
import msvcrt
import os
from ctypes import wintypes
from typing import Any

# ── Console input/output mode flags (wincon.h) ───────────────────────────
ENABLE_PROCESSED_INPUT = 0x0001
ENABLE_LINE_INPUT = 0x0002
ENABLE_ECHO_INPUT = 0x0004
ENABLE_VIRTUAL_TERMINAL_PROCESSING = 0x0004 # output mode
ENABLE_PROCESSED_OUTPUT = 0x0001
ENABLE_WINDOW_INPUT = 0x0008

# Raw mode = everything off except window input (keeps console resize events)
_RAW_INPUT_MODE = ENABLE_WINDOW_INPUT
# Output mode that makes ANSI/VT sequences work
_VT_OUTPUT_MODE = ENABLE_PROCESSED_OUTPUT | ENABLE_VIRTUAL_TERMINAL_PROCESSING


class Win32Console:
"""Raw-mode / VT support for a console handle (ctypes, no pywin32)."""

def __init__(self) -> None:
self._kernel32 = ctypes.windll.kernel32
self._saved_modes: dict[int, tuple[int, int]] = {} # fd -> (in, out)

# -- internal helpers ------------------------------------------------
@staticmethod
def _fd_to_handle(fd: int) -> int | None:
"""Return the Win32 HANDLE for a std fd (via _get_osfhandle)."""
try:
return msvcrt.get_osfhandle(fd)
except OSError:
return None

def _get_console_mode(self, handle: int) -> int | None:
mode = wintypes.DWORD()
if not self._kernel32.GetConsoleMode(handle, ctypes.byref(mode)):
return None
return int(mode.value)

def _set_console_mode(self, handle: int, mode: int) -> bool:
return bool(self._kernel32.SetConsoleMode(handle, wintypes.DWORD(mode)))

# -- public API -------------------------------------------------------
def enable_raw_mode(self, fd: int) -> bool:
"""Enable raw + VT mode on a console fd. Returns True on success."""
handle = self._fd_to_handle(fd)
if handle is None:
return False
in_mode = self._get_console_mode(handle)
out_mode = self._get_console_mode(self._fd_to_handle(1))
if in_mode is None:
return False
self._saved_modes[fd] = (in_mode, out_mode if out_mode is not None else 0)
ok_in = self._set_console_mode(handle, _RAW_INPUT_MODE)
ok_out = True
if out_mode is not None:
ok_out = self._set_console_mode(self._fd_to_handle(1), _VT_OUTPUT_MODE)
# Binary mode: no CRLF translation (key sequences byte-identical to POSIX)
try:
msvcrt.setmode(fd, os.O_BINARY)
except OSError:
pass
return bool(ok_in and ok_out)

def disable_raw_mode(self, fd: int) -> None:
"""Restore the console modes saved by enable_raw_mode."""
saved = self._saved_modes.pop(fd, None)
if saved is None:
return
in_mode, out_mode = saved
handle = self._fd_to_handle(fd)
if handle is not None and in_mode:
self._set_console_mode(handle, in_mode)
out_handle = self._fd_to_handle(1)
if out_handle is not None and out_mode:
self._set_console_mode(out_handle, out_mode)

@property
def active(self) -> bool:
return bool(self._saved_modes)


# Module-level singleton — saved console modes must survive across
# _enter_raw_mode / _exit_raw_mode calls (a fresh instance per call would
# lose _saved_modes and fail to restore the terminal).
win32_console = Win32Console()
Loading