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"]