From 33da0e335c97ba9742344edc84b9b3267e7b9a93 Mon Sep 17 00:00:00 2001 From: GitHub Copilot Date: Sat, 24 Jan 2026 10:23:31 +0000 Subject: [PATCH] fix: use pyte terminal emulator for screenshot rendering Replaces simple carriage return handling with pyte terminal emulator to properly interpret all ANSI escape sequences including cursor positioning. This fixes the tmux status bar 'creeping up' issue in screenshots. Adds pyte dependency to pyproject.toml. Resolves TODO item #2. --- pyproject.toml | 1 + src/textual_webterm/local_server.py | 34 +++++++++++------------------ tests/test_local_server_unit.py | 22 ++++++++++++++++--- 3 files changed, 33 insertions(+), 24 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 039dbe1..b326d71 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,6 +21,7 @@ importlib-metadata = ">=6.0.0" httpx = ">=0.27.0" tomli = { version = "^2.0.1", python = "<3.11" } pyyaml = "^6.0.0" +pyte = "^0.8.0" [tool.poetry.group.dev.dependencies] pytest = "^8.0.0" diff --git a/src/textual_webterm/local_server.py b/src/textual_webterm/local_server.py index e996645..b6b73bf 100644 --- a/src/textual_webterm/local_server.py +++ b/src/textual_webterm/local_server.py @@ -14,6 +14,7 @@ from pathlib import Path from typing import TYPE_CHECKING import aiohttp +import pyte from aiohttp import WSMsgType, web from rich.ansi import AnsiDecoder from rich.console import Console @@ -107,26 +108,17 @@ def _rewrite_svg_fonts(svg: str) -> str: return svg -def _apply_carriage_returns(text: str) -> list[str]: - """Interpret \r as 'return to start of line' (overwrite), not a newline. +def _apply_carriage_returns(text: str, width: int = 80, height: int = 24) -> list[str]: + """Use pyte terminal emulator to properly interpret ANSI escape sequences. - This prevents terminals that redraw a single line (progress bars, prompts) from - expanding into many duplicate lines in screenshots. + This handles cursor positioning, screen clearing, and other terminal control + codes that cause issues like tmux status bars "creeping up" in screenshots. """ - - lines: list[str] = [] - current: list[str] = [] - for ch in text: - if ch == "\r": - current.clear() - elif ch == "\n": - lines.append("".join(current)) - current.clear() - else: - current.append(ch) - if current: - lines.append("".join(current)) - return lines + screen = pyte.Screen(width, height) + stream = pyte.Stream(screen) + stream.feed(text) + # Return lines from the display, stripping trailing whitespace + return [line.rstrip() for line in screen.display] class LocalServer: @@ -490,9 +482,9 @@ class LocalServer: height = DISCONNECT_RESIZE[1] height = max(5, min(200, height)) - lines = _apply_carriage_returns(ansi_text) - if len(lines) > height: - ansi_text = "\n".join(lines[-height:]) + "\n" + # Use pyte terminal emulator to get clean screen state + lines = _apply_carriage_returns(ansi_text, width, height) + ansi_text = "\n".join(lines) now = asyncio.get_event_loop().time() ttl = self._get_screenshot_cache_ttl(route_key, now) diff --git a/tests/test_local_server_unit.py b/tests/test_local_server_unit.py index 4e6636e..51aa57d 100644 --- a/tests/test_local_server_unit.py +++ b/tests/test_local_server_unit.py @@ -111,8 +111,24 @@ class TestLocalServerHelpers: """Tests for LocalServer helper methods.""" def test_apply_carriage_returns_overwrites_line(self): - text = "hello\rworld\nnext" - assert _apply_carriage_returns(text) == ["world", "next"] + text = "hello\rworld\r\nnext" + # pyte terminal emulator interprets CR properly - overwrites hello with world + lines = _apply_carriage_returns(text, width=80, height=24) + # First line should have "world" (overwritten), second line "next" + assert lines[0] == "world" + assert lines[1] == "next" + + def test_apply_carriage_returns_handles_cursor_positioning(self): + # Simulate tmux-style cursor positioning to row 5, column 1 (\x1b[5;1H) + # Then clear to end of line (\x1b[K) and write new content + # Use \r\n for proper line endings + text = "line1\r\nline2\r\nline3\r\nline4\r\nline5\x1b[5;1H\x1b[Kupdated" + lines = _apply_carriage_returns(text, width=80, height=10) + # Line 5 (index 4) should be overwritten with "updated" + assert lines[4] == "updated" + # Previous lines should remain + assert lines[0] == "line1" + assert lines[1] == "line2" @pytest.mark.asyncio async def test_keyboard_interrupt_closes_sessions_and_websockets(self, server, monkeypatch): @@ -755,7 +771,7 @@ class TestLocalServerMoreCoverage: captured = {"len": None} - def apply_cr(text: str): + def apply_cr(text: str, width: int = 80, height: int = 24): captured["len"] = len(text) return ["x"]