fix: maintain pyte screen state in TerminalSession for accurate screenshots

Instead of trying to replay a truncated byte buffer through pyte, this
change maintains a pyte Screen object within TerminalSession that gets
updated as terminal data flows through. This provides accurate terminal
state for screenshots without issues from buffer truncation.

Key changes:
- Add pyte Screen and Stream to TerminalSession
- Update screen state as data arrives via _update_screen()
- Add get_screen_lines() to return current screen state
- Resize pyte screen when terminal size changes
- Update local_server to use get_screen_lines() directly
- Remove _apply_carriage_returns() workaround

This properly fixes the tmux status bar 'creeping up' issue by ensuring
the screenshot always reflects the actual terminal state.
This commit is contained in:
GitHub Copilot
2026-01-24 10:33:31 +00:00
parent a58c434eaf
commit 894fb2eaaf
4 changed files with 87 additions and 70 deletions
+6 -26
View File
@@ -14,7 +14,6 @@ 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
@@ -34,8 +33,6 @@ log = logging.getLogger("textual-web")
DISCONNECT_RESIZE = (132, 45)
# Avoid heavy screenshot rendering from processing unbounded output.
SCREENSHOT_MAX_BYTES = 65536
SCREENSHOT_CACHE_SECONDS = 1.0
SCREENSHOT_MAX_CACHE_SECONDS = 60.0
@@ -108,19 +105,6 @@ def _rewrite_svg_fonts(svg: str) -> str:
return svg
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 handles cursor positioning, screen clearing, and other terminal control
codes that cause issues like tmux status bars "creeping up" in screenshots.
"""
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:
def mark_route_activity(self, route_key: str) -> None:
self._route_last_activity[route_key] = asyncio.get_event_loop().time()
@@ -473,7 +457,7 @@ class LocalServer:
)
session_process = self.session_manager.get_session_by_route_key(RouteKey(route_key))
if session_process is None or not hasattr(session_process, "get_replay_buffer"):
if session_process is None or not hasattr(session_process, "get_screen_lines"):
raise web.HTTPNotFound(text="Session not found")
# If nothing has changed since the last render, serve cached screenshot without
@@ -485,10 +469,10 @@ class LocalServer:
if cached_response is not None:
return cached_response
replay_data = await session_process.get_replay_buffer() # type: ignore[func-returns-value]
if len(replay_data) > SCREENSHOT_MAX_BYTES:
replay_data = replay_data[-SCREENSHOT_MAX_BYTES:]
ansi_text = replay_data.decode("utf-8", errors="replace")
# Get screen lines directly from the terminal session's pyte screen
# This provides accurate terminal state without replay buffer truncation issues
lines = await session_process.get_screen_lines() # type: ignore[union-attr]
screen_text = "\n".join(lines)
try:
width = int(request.query.get("width", "120"))
@@ -502,10 +486,6 @@ class LocalServer:
height = DISCONNECT_RESIZE[1]
height = max(5, min(200, height))
# 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)
cached = self._screenshot_cache.get(route_key)
@@ -539,7 +519,7 @@ class LocalServer:
def _render_svg() -> str:
console = Console(record=True, width=width, height=height, file=io.StringIO())
decoder = AnsiDecoder()
for renderable in decoder.decode(ansi_text):
for renderable in decoder.decode(screen_text):
console.print(renderable)
return console.export_svg(