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.
This commit is contained in:
GitHub Copilot
2026-01-24 10:23:31 +00:00
parent c873ed2b2e
commit 33da0e335c
3 changed files with 33 additions and 24 deletions
+1
View File
@@ -21,6 +21,7 @@ importlib-metadata = ">=6.0.0"
httpx = ">=0.27.0" httpx = ">=0.27.0"
tomli = { version = "^2.0.1", python = "<3.11" } tomli = { version = "^2.0.1", python = "<3.11" }
pyyaml = "^6.0.0" pyyaml = "^6.0.0"
pyte = "^0.8.0"
[tool.poetry.group.dev.dependencies] [tool.poetry.group.dev.dependencies]
pytest = "^8.0.0" pytest = "^8.0.0"
+13 -21
View File
@@ -14,6 +14,7 @@ from pathlib import Path
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
import aiohttp import aiohttp
import pyte
from aiohttp import WSMsgType, web from aiohttp import WSMsgType, web
from rich.ansi import AnsiDecoder from rich.ansi import AnsiDecoder
from rich.console import Console from rich.console import Console
@@ -107,26 +108,17 @@ def _rewrite_svg_fonts(svg: str) -> str:
return svg return svg
def _apply_carriage_returns(text: str) -> list[str]: def _apply_carriage_returns(text: str, width: int = 80, height: int = 24) -> list[str]:
"""Interpret \r as 'return to start of line' (overwrite), not a newline. """Use pyte terminal emulator to properly interpret ANSI escape sequences.
This prevents terminals that redraw a single line (progress bars, prompts) from This handles cursor positioning, screen clearing, and other terminal control
expanding into many duplicate lines in screenshots. codes that cause issues like tmux status bars "creeping up" in screenshots.
""" """
screen = pyte.Screen(width, height)
lines: list[str] = [] stream = pyte.Stream(screen)
current: list[str] = [] stream.feed(text)
for ch in text: # Return lines from the display, stripping trailing whitespace
if ch == "\r": return [line.rstrip() for line in screen.display]
current.clear()
elif ch == "\n":
lines.append("".join(current))
current.clear()
else:
current.append(ch)
if current:
lines.append("".join(current))
return lines
class LocalServer: class LocalServer:
@@ -490,9 +482,9 @@ class LocalServer:
height = DISCONNECT_RESIZE[1] height = DISCONNECT_RESIZE[1]
height = max(5, min(200, height)) height = max(5, min(200, height))
lines = _apply_carriage_returns(ansi_text) # Use pyte terminal emulator to get clean screen state
if len(lines) > height: lines = _apply_carriage_returns(ansi_text, width, height)
ansi_text = "\n".join(lines[-height:]) + "\n" ansi_text = "\n".join(lines)
now = asyncio.get_event_loop().time() now = asyncio.get_event_loop().time()
ttl = self._get_screenshot_cache_ttl(route_key, now) ttl = self._get_screenshot_cache_ttl(route_key, now)
+19 -3
View File
@@ -111,8 +111,24 @@ class TestLocalServerHelpers:
"""Tests for LocalServer helper methods.""" """Tests for LocalServer helper methods."""
def test_apply_carriage_returns_overwrites_line(self): def test_apply_carriage_returns_overwrites_line(self):
text = "hello\rworld\nnext" text = "hello\rworld\r\nnext"
assert _apply_carriage_returns(text) == ["world", "next"] # 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 @pytest.mark.asyncio
async def test_keyboard_interrupt_closes_sessions_and_websockets(self, server, monkeypatch): async def test_keyboard_interrupt_closes_sessions_and_websockets(self, server, monkeypatch):
@@ -755,7 +771,7 @@ class TestLocalServerMoreCoverage:
captured = {"len": None} captured = {"len": None}
def apply_cr(text: str): def apply_cr(text: str, width: int = 80, height: int = 24):
captured["len"] = len(text) captured["len"] = len(text)
return ["x"] return ["x"]