Skip to content
Merged
Show file tree
Hide file tree
Changes from 17 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
2 changes: 1 addition & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ repos:
additional_dependencies: [
"numpy>=1.20",
"asyncssh>=2.9",
"blessed>=1.37.0",
"blessed>=1.42.0",
"types-setuptools",
"pytest",
]
Expand Down
12 changes: 6 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -182,30 +182,30 @@ The table below sums up my findings when I tried the most common terminal emulat
| Ghostty | Excellent | 24-bit colors | Good | Yes | 60 FPS | |
| Kitty | Excellent | 24-bit colors | Good | Yes | 60 FPS | |
| foot | Excellent | 24-bit colors | Good | Yes | 60 FPS | |
| Alacritty | Excellent | 24-bit colors | Good | Yes | 60 FPS | |
| Rio | Excellent | 24-bit colors | Good | Yes | 60 FPS | |
| Contour | Excellent | 24-bit colors | Good | Yes | 60 FPS | [Download latest for kitty support](https://github.com/contour-terminal/contour/releases) |
| Alacritty | Good | 24-bit colors | Good | No* | 60 FPS | *Fails kitty detection [due to reported bug](https://github.com/alacritty/alacritty/pull/8953) |
| Konsole | Good | 24-bit colors | Good | No | 60 FPS | |
| Gnome terminal | Good | 24-bit colors | Good | No | 60 FPS | |
| Terminator | Good | 24-bit colors | Good | No | 60 FPS | |
| XTerm | Good | 24-bit colors | Good | No | 60 FPS | No resize shortcuts, launch as ``xterm -tn xterm-256color`` |
| XTerm | Good | 24-bit colors | Good | No | 60 FPS | Ctrl+Right click to resize, "Unreadable" locks up XTerm |
| Rxvt | Good | 24-bit colors | Good | No | 60 FPS | No resize shortcuts |
| Terminology | Good | 24-bit colors | Light misalignments | No | 60 FPS | Font sizes under ~9pt create horizontal line artifacts |
| Termit | Ok | 24-bit colors | Good | No | 60 FPS | No window title |
| Mlterm | Ok | 24-bit colors | Light misalignments | No | 60 FPS | No resize shortcuts |
| Terminology | Ok | 24-bit colors | Possible misalignments | No | 30 FPS | Weird colors |
| Contour | Bad | 24-bit colors | Good | Broken | 60 FPS | [Bug (no release event!)](https://github.com/contour-terminal/contour/pull/1924) |

About MacOS:

| MacOS | Status | Colors | Unicode rendering | Kitty keyboard protocol | Performance | Comments |
|------------------|------------|---------------|---------------------------|-------------------------|-------------|--------------------------|
| iTerm2 | Excellent | 24-bit colors | Good | Yes | 60 FPS | |
| Terminal | Bad | 24-bit colors | Bad--adjust font spacing! | No | 30 FPS | A bit jittery |
| Terminal.app | Bad | 24-bit colors | Bad--adjust font spacing! | No | 30 FPS | A bit jittery |

About Windows:

| Windows | Status | Colors | Unicode rendering | Kitty keyboard protocol | Performance | Comments |
|--------------------|------------|---------------|------------------------|-------------------------|-------------|--------------------------|
| Windows terminal | Good | 24-bit colors | Good | Coming Soon | 60 FPS | [Download Preview for kitty support)](https://github.com/microsoft/terminal/releases) |
| Windows terminal | Excellent | 24-bit colors | Good | Yes | 60 FPS | [Download latest for kitty support)](https://github.com/microsoft/terminal/releases) |
| Cmder | Unplayable | 24-bit colors | Good | Yes | 2 FPS | No window title |
| Terminus | Unplayable | 24-bit colors | Misalignments | No | 10 FPS | |
| Command prompt | Bad | 24-bit colors | Good | No | 1 FPS | Slow/Unresponsive |
Expand Down
16 changes: 12 additions & 4 deletions gambaterm/colors.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,14 +24,22 @@ def report(self) -> str:
"""Return a human-readable report of the color mode."""
if self == ColorMode.COULD_NOT_DETECT:
return "Could not detect color mode"
if self == ColorMode.HAS_24_BIT_COLOR:
return "True color"
return f"{self.number_of_colors} colors"

def number_of_colors(self) -> int:
Comment thread
jquast marked this conversation as resolved.
"""Return a human-readable report of the color mode."""
if self == ColorMode.COULD_NOT_DETECT:
return 0
if self == ColorMode.HAS_2_BIT_COLOR:
return "4 colors"
return 4
if self == ColorMode.HAS_4_BIT_COLOR:
return "16 colors"
return 16
if self == ColorMode.HAS_8_BIT_COLOR:
return "256 colors"
return 256
if self == ColorMode.HAS_24_BIT_COLOR:
return "True color"
return 1 << 24
assert False


Expand Down
20 changes: 14 additions & 6 deletions gambaterm/keyboard_input.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@
Here is a list of terminals known to support this protocol:

- kitty https://sw.kovidgoyal.net/kitty/
- alacritty https://alacritty.org/
- ghostty https://ghostty.org/
- foot https://codeberg.org/dnkl/foot
- iTerm2 https://iterm2.com/
Expand Down Expand Up @@ -134,11 +133,20 @@ def pop_keystrokes(self) -> list[Keystroke]:
def is_kitty_keyboard_protocol_supported(
term: Terminal, timeout: float | None = None
) -> bool:
"""Check if the terminal supports the kitty keyboard protocol.

Some terminals (e.g. last release of Contour) responds to the kitty keyboard query but ignore
the flags we set, so we verify that report_events is actually enabled after requesting it.
"""
"""Check if the terminal supports the kitty keyboard protocol."""
# Some terminals (eg. last release of Contour) responds to the kitty keyboard query but ignores
# the modes that we set, it is not fully implementing them. And so we verify that report_events
# is actually enabled after requesting it.
#
# Other terminals (eg. last release of Alacritty), *do* support the kitty keyboard protocol
# flags that set, but fail to accurately report their state!
# https://github.com/alacritty/alacritty/pull/8953 -- it's too bad alacritty also doesn't
# support XTVERSION either, or we could conditionally return True for a version range!
#
# In a sense, we tradeoff: "ensure contour is not wrongly detected" (report_events not
# implemented) for Alacritty is wrongly detected as missing support for kitty (report_events not
# reported due to bug). I hope that Alacritty will accept the reported bug and next release will
# be OK.
state = term.get_kitty_keyboard_state(timeout=timeout)
if state is None:
return False
Expand Down
53 changes: 35 additions & 18 deletions gambaterm/remote_terminal.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,22 +11,16 @@
from blessed import Terminal as BlessedTerminal
from blessed.terminal import WINSZ

# Python's curses.setupterm() can only be called once per process — subsequent
# calls with a different terminal type are silently ignored. Since the SSH
# server handles multiple concurrent connections in threads, all RemoteTerminal
# instances share whatever terminal type was initialized first by the local
# Terminal(). We hardcode 'xterm-256color' as the kind since:
# 1. It's universally compatible with modern terminals
# 2. We use standard VT100/ANSI escape codes directly, not terminfo caps
# 3. It avoids issues where the first client's TERM value differs from subsequent
REMOTE_TERMINAL_TYPE = "xterm-256color"
Comment thread
jquast marked this conversation as resolved.


class RemoteTerminal(BlessedTerminal):
"""A blessed Terminal subclass for remote streams (SSH, telnet).

Stubs raw/cbreak mode (the remote connection is already raw) and
overrides size detection to use values provided by the server.
overrides size detection to use server protocol-negotiated values.

Callers should invoke ``get_xtgettcap()`` after initialization
to probe the terminal's true capabilities once the connection
is fully established.
"""

def __init__(
Expand All @@ -35,18 +29,41 @@ def __init__(
keyboard_fd: int,
rows: int,
columns: int,
kind: str | None = None,
) -> None:
self._rows = rows
self._columns = columns
super().__init__(kind=REMOTE_TERMINAL_TYPE, stream=stream, force_styling=True)
# Blessed only sets _keyboard_fd when stream is sys.__stdout__, so
# for remote pipes we must set it and initialize the decoder manually
self._keyboard_fd = keyboard_fd # type: ignore[assignment]
self._remote_keyboard_fd = keyboard_fd
super().__init__(
kind=kind,
stream=stream,
force_styling=True,
kind_fallback="xterm-256color",
)
# wire `_keyboard_fd` and enable `_is_a_tty` *after* class initialization.
self._keyboard_fd = self._remote_keyboard_fd # type: ignore[assignment]
self._is_a_tty = True
self._keyboard_decoder = codecs.getincrementaldecoder("UTF-8")()

@property
def is_a_tty(self) -> bool:
return True
def probe_xtgettcap(self, timeout: float = 1.0) -> None:
"""
Probe terminal capabilities via XTGETTCAP and apply results.

This allows to improved 'number_of_colors' detection, and, to "overlay" capabilities not
found in jinxed terminfo database but detected by XTGETTCAP: 'blink', 'sitm', 'ritm',
'cvvis', 'Smulx', 'Setulc', 'Ms', the same way that blessed.Terminal() would have but we
is_a_tty was detected False when we initialized it.

This method is not called or used by gambaterm-ssh or gambaterm-telnet, because the above
capabilities are not used and kitty keyboard support pretty reliably suggests 24-bit color
support. It is just here as a suggestion, and can be safely deleted.
Comment thread
jquast marked this conversation as resolved.
Outdated
"""
self._xtgettcap_cache = self._Terminal__init__xtgettcap() # type: ignore[assignment]
self.number_of_colors = self._Terminal__init__color_capabilities() # type: ignore[assignment]
if self._xtgettcap_cache.supported and self.does_styling:
self._jinxed_term.overlay_capabilities(
**self._xtgettcap_cache.make_jinxed_capabilities()
)

@contextlib.contextmanager
def raw(self) -> Generator[None, None, None]:
Expand Down
1 change: 1 addition & 0 deletions gambaterm/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,7 @@ def run(
height, width = new_height, new_width
refx, refy = get_ref(width, height, console)
color_mode = new_color_mode
term.number_of_colors = new_color_mode.number_of_colors()
Comment thread
jquast marked this conversation as resolved.
Outdated
last_frame.fill(0)

# Render frame with synchronized output mode (DEC 2026) to prevent flickering
Expand Down
10 changes: 8 additions & 2 deletions gambaterm/ssh.py
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,7 @@ async def ssh_process_handler(process: SSHServerProcess[str]) -> int:
terminal_type,
executor,
),
terminal_type=terminal_type,
Comment thread
jquast marked this conversation as resolved.
)


Expand Down Expand Up @@ -207,11 +208,16 @@ def ssh_terminal_handler(
else:
assert False

# Kitty keyboard protocol implies 24-bit color support
# It is possible, here, to probe XTGETTCAP which helps correct terminal.number_of_colors using
# 'RGB' and 'colors', and some special attributes like blink, underline et al., but since they
# are not used by gambaterm, it is not called unless we find better reason otherwise.
# terminal.probe_xtgettcap(timeout=1.0)

# In practice kitty keyboard protocol pretty well implies 24-bit color support already,
color_mode = app_config.color_mode or ColorMode.HAS_24_BIT_COLOR

print(
f"[Terminal Info] {username}: {terminal_type}, {input_source}, {terminal.width}x{terminal.height}"
f"[Terminal Info] {username}: term={terminal_type}, {input_source}, {terminal.width}x{terminal.height}"
)

try:
Expand Down
9 changes: 9 additions & 0 deletions gambaterm/ssh_app_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,11 +77,19 @@ async def process_to_terminal(
process: SSHServerProcess[str],
executor: ThreadPoolExecutor,
target: Callable[[RemoteTerminal], T],
terminal_type: str | None = None,
) -> T:
"""Create a blessed RemoteTerminal from an SSH process.

Once the redirections are set up, I/O become synchronous,
so we run the target function in a thread executor to avoid blocking the event loop

:param process: SSHServerProcess string
:param executor: ThreadPoolExecutor for running the game thread
:param target: callable receiving the RemoteTerminal, run in executor
:param terminal_type: terminal type from SSH SendEnv/AcceptEnv, `TERM` value
:returns: return value of *target*

"""
width, height, _, _ = process.get_terminal_size()
if width == height == 0:
Expand All @@ -94,6 +102,7 @@ def _target() -> T:
keyboard_fd=keyboard_fd,
rows=height,
columns=width,
kind=terminal_type,
)
with _bind_resize(process, ssh_term):
return target(ssh_term)
Expand Down
33 changes: 15 additions & 18 deletions gambaterm/telnet.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,7 @@
is_kitty_keyboard_protocol_supported,
)
from .remote_terminal import RemoteTerminal
from .telnet_app_session import (
telnet_to_terminal,
)
from .telnet_app_session import telnet_to_terminal


def _save_dir_name(username: str | None) -> str:
Expand All @@ -53,7 +51,6 @@ def thread_target(
terminal: RemoteTerminal,
console_callback: Callable[[], Console],
app_config: AppConfig,
color_mode: ColorMode,
username: str | None,
) -> int:
"""Run the emulator in a thread with the given RemoteTerminal."""
Expand All @@ -76,6 +73,14 @@ def thread_target(
print(f"< User `{username}` did not support keyboard protocol")
return 1

# It is possible, here, to probe XTGETTCAP which helps correct terminal.number_of_colors using
# 'RGB' and 'colors', and some special attributes like blink, underline et al., but since they
# are not used by gambaterm, it is not called unless we find better reason otherwise.
# terminal.probe_xtgettcap(timeout=1.0)

# In practice kitty keyboard protocol pretty well implies 24-bit color support already,
color_mode = app_config.color_mode or ColorMode.HAS_24_BIT_COLOR

try:
terminal.stream.write(
terminal.enter_fullscreen + terminal.clear + terminal.hide_cursor
Expand All @@ -90,6 +95,7 @@ def thread_target(
color_mode=color_mode,
break_after=app_config.break_after,
speed=app_config.speed,
use_cpr_sync=app_config.cpr_sync,
)
except (KeyboardInterrupt, EOFError):
return 0
Expand Down Expand Up @@ -244,20 +250,13 @@ async def _telnet_shell(
except (asyncio.TimeoutError, KeyError):
pass

terminal_type = writer.get_extra_info("TERM") or "unknown"
terminal_type = writer.get_extra_info("TERM") or None
username = writer.get_extra_info("USER") or None
print(
f"> Telnet client connected ({peer_host}:{peer_port})"
f"> Telnet client connected ({peer_host}:{peer_port} term={terminal_type})"
+ (f" user={username}" if username else "")
)

if terminal_type == "unknown":
print("Warning: terminal type not negotiated, assuming xterm-256color.")
terminal_type = "xterm-256color"

# Kitty keyboard protocol implies 24-bit color support
color_mode = app_config.color_mode or ColorMode.HAS_24_BIT_COLOR

if idle_timeout is not None:
stats_task = asyncio.create_task(
_log_connection_stats(reader, writer, peer_host, peer_port, idle_timeout)
Expand All @@ -267,10 +266,7 @@ async def _telnet_shell(

cols = writer.get_extra_info("cols") or 80
rows = writer.get_extra_info("rows") or 24
print(
f"[Terminal Info] {peer_host}: {terminal_type}, "
f"{color_mode.name}, {cols}x{rows}"
)
print(f"[Terminal Info] {peer_host}: ttype={terminal_type}, {cols}x{rows}")

try:
# Copy namespace and set telnet-specific save directory
Expand All @@ -289,13 +285,14 @@ async def _telnet_shell(
config = AppConfig(**vars(namespace))

def target(term: RemoteTerminal) -> int:
return thread_target(term, console_callback, config, color_mode, username)
return thread_target(term, console_callback, config, username)

return await telnet_to_terminal(
reader,
writer,
executor,
target,
terminal_type=terminal_type,
)
finally:
if stats_task is not None:
Expand Down
9 changes: 6 additions & 3 deletions gambaterm/telnet_app_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,15 +103,17 @@ async def telnet_to_terminal(
writer: TelnetWriter,
executor: ThreadPoolExecutor,
target: Callable[[RemoteTerminal], T],
terminal_type: str | None = None,
) -> T:
"""Create a RemoteTerminal and run *target* in a thread executor.

Sets up a pipe for output forwarding with paced delivery at 60 pps.
Sets up a pipe for i/o forwarding

:param writer: telnetlib3 writer
:param executor: ThreadPoolExecutor for running the game thread
:param target: callable receiving the RemoteTerminal, run in executor
:param input_read_fd: read end of the input pipe (keyboard_fd for blessed)
:param terminal_type: negotiated terminal type from telnet TTYPE or NEW-ENVIRON TERM
:returns: return value of *target*
"""
cols = writer.get_extra_info("cols") or 80
Expand All @@ -128,15 +130,16 @@ async def telnet_to_terminal(

def _target() -> T:
try:
# The output stream is created so that the remote terminal can be instanciated.
# However, it's not used in practice since the `run` function writes bytes directly to the file decriptor.
# The output stream is created so that the remote terminal can be instantiated.
# However, it's not used in practice since the `run` function writes bytes directly to the file descriptor.
# Still, this context manager is responsible for closing the `output_write_fd` file descriptor.
with open(output_write_fd, "w", newline="\r\n") as stream:
telnet_term = RemoteTerminal(
stream=stream,
keyboard_fd=input_read_fd,
rows=rows,
columns=cols,
kind=terminal_type,
)
with bind_resize_telnet(writer, telnet_term):
return target(telnet_term)
Expand Down
Loading
Loading