diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md
index 8599a1e..10905b2 100644
--- a/.github/copilot-instructions.md
+++ b/.github/copilot-instructions.md
@@ -1,4 +1,51 @@
-Use the Makefile to run linting, testing, and builds.
-Aim for good test coverage.
-Review tests periodically with a view to consolidate/parameterize and remove redundancy.
-Debug issues systematically. Search for and review documentation as needed.
+# Copilot Development Instructions
+
+## Makefile Usage (MANDATORY)
+
+Always use the Makefile for development tasks. Never run raw `pytest`, `ruff`, or `bun` commands directly.
+
+### Quick Reference
+
+| Task | Command | Description |
+|------|---------|-------------|
+| Run tests | `make test` | Run pytest |
+| Run linter | `make lint` | Run ruff linter |
+| Format code | `make format` | Auto-format with ruff |
+| Full check | `make check` | Run lint + coverage |
+| Coverage report | `make coverage` | Run pytest with coverage |
+| Build frontend | `make build` | TypeScript typecheck + bundle |
+| Quick frontend build | `make build-fast` | Bundle without typecheck |
+| Watch mode | `make bundle-watch` | Frontend dev with auto-rebuild |
+| Install dev deps | `make install-dev` | Install package + dev dependencies |
+| Clean build | `make build-all` | Full reproducible build from scratch |
+| See all targets | `make help` | Show all available commands |
+
+### Development Workflow
+
+1. **Before making changes**: Run `make check` to establish baseline
+2. **After making changes**: Run `make check` to verify no regressions
+3. **Frontend changes**: Use `make build` or `make build-fast`
+4. **Full rebuild**: Use `make build-all` (cleans everything first)
+
+### Clean Targets
+
+- `make clean` - Remove Python cache files only
+- `make bundle-clean` - Remove frontend build artifacts (node_modules, etc.)
+- `make clean-all` - Remove everything
+
+### Version Management
+
+- `make bump-patch` - Bump patch version and create git tag
+
+## Testing Guidelines
+
+- Aim for good test coverage
+- Review tests periodically to consolidate/parameterize and remove redundancy
+- Use fixtures from `tests/conftest.py` instead of duplicating setup code
+- Prefer parameterized tests for similar test cases
+
+## Code Style
+
+- Do not use heredocs or random shell commands
+- Prefer `make` and ecosystem tools (pip, bun) over manual operations
+- Debug issues systematically - search for and review documentation as needed
diff --git a/Makefile b/Makefile
index 3a68a30..d13539e 100644
--- a/Makefile
+++ b/Makefile
@@ -1,4 +1,4 @@
-.PHONY: help install install-dev lint format test coverage check clean clean-all build build-all bundle bundle-watch bundle-clean typecheck bump-patch
+.PHONY: help install install-dev lint format test coverage check clean clean-all build build-all build-fast bundle bundle-watch bundle-clean typecheck bump-patch push
PYTHON ?= python3
PIP ?= $(PYTHON) -m pip
@@ -6,92 +6,63 @@ PIP ?= $(PYTHON) -m pip
# Static assets
STATIC_JS_DIR = src/webterm/static/js
TERMINAL_JS = $(STATIC_JS_DIR)/terminal.js
-TERMINAL_TS = $(STATIC_JS_DIR)/terminal.ts
GHOSTTY_WASM = $(STATIC_JS_DIR)/ghostty-vt.wasm
-help:
- @echo "Build targets:"
- @echo " build-all - Full reproducible build (clean + deps + bundle + install)"
- @echo " build - Build frontend (typecheck + bundle)"
- @echo " build-fast - Build frontend without typecheck"
- @echo " bundle - Alias for build"
- @echo " bundle-watch - Watch mode for development"
- @echo " typecheck - Run TypeScript type checking"
- @echo ""
- @echo "Python targets:"
- @echo " install - Install package in editable mode"
- @echo " install-dev - Install with dev dependencies"
- @echo " lint - Run ruff linter"
- @echo " format - Format code with ruff"
- @echo " test - Run pytest"
- @echo " coverage - Run pytest with coverage"
- @echo " check - Run lint + coverage"
- @echo ""
- @echo "Clean targets:"
- @echo " clean - Remove Python cache files"
- @echo " bundle-clean - Remove frontend build artifacts"
- @echo " clean-all - Remove everything (clean + bundle-clean)"
+help: ## Show this help
+ @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-14s\033[0m %s\n", $$1, $$2}'
# =============================================================================
# Full reproducible build
# =============================================================================
-build-all: clean-all node_modules build install-dev check
+build-all: clean-all node_modules build install-dev check ## Full reproducible build (clean + deps + bundle + install)
@echo "Build complete!"
# =============================================================================
# Python targets
# =============================================================================
-install:
+install: ## Install package in editable mode
$(PIP) install -e .
-install-dev:
- $(PIP) install -e .
+install-dev: install ## Install with dev dependencies
$(PIP) install pytest pytest-asyncio pytest-cov pytest-timeout ruff
-lint:
+lint: ## Run ruff linter
ruff check src tests
-format:
+format: ## Format code with ruff
ruff format src tests
-test:
+test: ## Run pytest
pytest
-coverage:
+coverage: ## Run pytest with coverage
pytest --cov=src/webterm --cov-report=term-missing
-check: lint coverage
+check: lint coverage ## Run lint + coverage
# =============================================================================
# Frontend build targets (requires Bun: https://bun.sh)
-# All frontend commands MUST go through bun run to ensure consistency
# =============================================================================
-# Install node dependencies (creates bun.lock if missing)
node_modules: package.json
bun install
@touch node_modules
-# TypeScript type checking
-typecheck: node_modules
+typecheck: node_modules ## Run TypeScript type checking
bun run typecheck
-# Main build target - typecheck + bundle + copy WASM
-build: node_modules
+build: node_modules ## Build frontend (typecheck + bundle)
bun run build
-# Fast build without typecheck (for rapid iteration)
-build-fast: node_modules
+build-fast: node_modules ## Build frontend without typecheck
bun run build:fast
@test -f $(GHOSTTY_WASM) || bun run copy-wasm
-# Alias for build
-bundle: build
+bundle: build ## Alias for build
-# Watch mode for development
-bundle-watch: node_modules
+bundle-watch: node_modules ## Watch mode for frontend development
@test -f $(GHOSTTY_WASM) || bun run copy-wasm
bun run watch
@@ -99,24 +70,29 @@ bundle-watch: node_modules
# Clean targets
# =============================================================================
-clean:
+clean: ## Remove Python cache files
rm -rf .pytest_cache .coverage htmlcov .ruff_cache __pycache__ src/**/__pycache__
-bundle-clean:
+bundle-clean: ## Remove frontend build artifacts
rm -rf node_modules bun.lock $(TERMINAL_JS) $(GHOSTTY_WASM)
-clean-all: clean bundle-clean
+clean-all: clean bundle-clean ## Remove everything (clean + bundle-clean)
# =============================================================================
# Version management
# =============================================================================
-# Bump patch version (e.g., 0.5.3 -> 0.5.4)
-bump-patch:
+bump-patch: ## Bump patch version and create git tag
@OLD=$$(grep -Po '(?<=^version = ")[^"]+' pyproject.toml); \
MAJOR=$$(echo $$OLD | cut -d. -f1); \
MINOR=$$(echo $$OLD | cut -d. -f2); \
PATCH=$$(echo $$OLD | cut -d. -f3); \
NEW="$$MAJOR.$$MINOR.$$((PATCH + 1))"; \
sed -i "s/^version = \"$$OLD\"/version = \"$$NEW\"/" pyproject.toml; \
- echo "Bumped version: $$OLD -> $$NEW"
+ git add pyproject.toml; \
+ git commit -m "Bump version to $$NEW"; \
+ git tag "v$$NEW"; \
+ echo "Bumped version: $$OLD -> $$NEW (tagged v$$NEW)"
+
+push: ## Push commits and tags to origin
+ git push origin main --tags
diff --git a/src/webterm/local_server.py b/src/webterm/local_server.py
index de41ef4..abc9ae3 100644
--- a/src/webterm/local_server.py
+++ b/src/webterm/local_server.py
@@ -554,18 +554,18 @@ class LocalServer:
if cached_response is not None:
return cached_response
- has_changes = await session_process.get_screen_has_changes() # type: ignore[union-attr]
- if not has_changes and cached is not None:
- cached_response = self._get_cached_screenshot_response(request, route_key)
- if cached_response is not None:
- return cached_response
-
+ # Use non-mutating snapshot method to avoid affecting terminal state
(
screen_width,
screen_height,
screen_buffer,
- _,
- ) = await session_process.get_screen_state() # type: ignore[union-attr]
+ has_changes,
+ ) = await session_process.get_screen_snapshot() # type: ignore[union-attr]
+
+ if not has_changes and cached is not None:
+ cached_response = self._get_cached_screenshot_response(request, route_key)
+ if cached_response is not None:
+ return cached_response
def _render_svg() -> str:
# Use custom SVG exporter - simpler and more reliable than Rich
diff --git a/src/webterm/terminal_session.py b/src/webterm/terminal_session.py
index fef6020..fd950a9 100644
--- a/src/webterm/terminal_session.py
+++ b/src/webterm/terminal_session.py
@@ -57,6 +57,9 @@ class TerminalSession(Session):
# Track last known terminal size for reconnection
self._last_width = DEFAULT_SCREEN_WIDTH
self._last_height = DEFAULT_SCREEN_HEIGHT
+ # Change counter for reliable activity detection (monotonically increasing)
+ self._change_counter = 0
+ self._last_snapshot_counter = 0
super().__init__()
def __repr__(self) -> str:
@@ -146,6 +149,8 @@ class TerminalSession(Session):
await loop.run_in_executor(None, self._set_terminal_size, width, height)
# Resize pyte screen to match
self._screen.resize(height, width)
+ # Increment change counter since resize changes screen content
+ self._change_counter += 1
async def force_redraw(self) -> None:
"""Force a terminal redraw by re-sending current size."""
@@ -169,6 +174,9 @@ class TerminalSession(Session):
try:
text = data.decode("utf-8", errors="replace")
self._stream.feed(text)
+ # Increment change counter when screen is modified
+ if self._screen.dirty:
+ self._change_counter += 1
except Exception:
# Don't let pyte errors crash the session
pass
@@ -188,14 +196,60 @@ class TerminalSession(Session):
return [line.rstrip() for line in self._screen.display]
async def get_screen_has_changes(self) -> bool:
- """Check if the screen has changed since the last snapshot."""
- await self._sync_pyte_to_pty()
+ """Check if the screen has changed since the last snapshot.
+
+ This is a non-mutating read-only check that compares the change counter.
+ """
async with self._screen_lock:
- return len(self._screen.dirty) > 0
+ return self._change_counter > self._last_snapshot_counter
+
+ async def get_screen_snapshot(self) -> tuple[int, int, list, bool]:
+ """Get a read-only snapshot of the current screen state for screenshots.
+
+ This method does NOT mutate terminal state - safe for dashboard screenshots.
+
+ Returns:
+ Tuple of (width, height, buffer, has_changes) where:
+ - width: screen width in columns
+ - height: screen height in rows
+ - buffer: list of rows, each containing character data with styling
+ - has_changes: True if screen has changed since last snapshot
+ """
+ async with self._screen_lock:
+ width = self._screen.columns
+ height = self._screen.lines
+ has_changes = self._change_counter > self._last_snapshot_counter
+ # Update the snapshot counter to track what we've seen
+ self._last_snapshot_counter = self._change_counter
+ # Snapshot buffer cells quickly to minimize lock hold time
+ snapshot = [
+ [self._screen.buffer[row][col] for col in range(width)] for row in range(height)
+ ]
+
+ buffer = []
+ for row_data in snapshot:
+ row_chars = []
+ for char in row_data:
+ row_chars.append(
+ {
+ "data": char.data if char.data else " ",
+ "fg": char.fg,
+ "bg": char.bg,
+ "bold": char.bold,
+ "italics": char.italics,
+ "underscore": char.underscore,
+ "reverse": char.reverse,
+ }
+ )
+ buffer.append(row_chars)
+ return (width, height, buffer, has_changes)
async def get_screen_state(self) -> tuple[int, int, list, bool]:
"""Get the current screen state including dimensions and character buffer.
+ Note: This method syncs pyte to PTY size and clears dirty flags.
+ For read-only screenshot access, use get_screen_snapshot() instead.
+
Returns:
Tuple of (width, height, buffer, has_changes) where:
- width: screen width in columns
diff --git a/tests/conftest.py b/tests/conftest.py
index f721668..40b6b67 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -86,6 +86,7 @@ def mock_session():
session = MagicMock()
session.get_screen_has_changes = AsyncMock(return_value=False)
session.get_screen_state = AsyncMock(return_value=(80, 24, [], True))
+ session.get_screen_snapshot = AsyncMock(return_value=(80, 24, [], True))
return session
@@ -95,6 +96,54 @@ def poller() -> Poller:
return Poller()
+@pytest.fixture
+def mock_poller() -> MagicMock:
+ """Create a mock Poller for unit tests."""
+ return MagicMock()
+
+
+class DummyAsyncLock:
+ """A dummy async context manager for replacing locks in tests."""
+
+ async def __aenter__(self):
+ return None
+
+ async def __aexit__(self, exc_type, exc, tb):
+ return False
+
+
+@pytest.fixture
+def dummy_lock() -> DummyAsyncLock:
+ """Create a dummy async lock for tests."""
+ return DummyAsyncLock()
+
+
+@pytest.fixture
+def mock_screen_char():
+ """Factory for creating mock pyte screen characters."""
+
+ def _make(
+ data: str = " ",
+ fg: int = 0,
+ bg: int = 0,
+ bold: bool = False,
+ italics: bool = False,
+ underscore: bool = False,
+ reverse: bool = False,
+ ) -> MagicMock:
+ char = MagicMock()
+ char.data = data
+ char.fg = fg
+ char.bg = bg
+ char.bold = bold
+ char.italics = italics
+ char.underscore = underscore
+ char.reverse = reverse
+ return char
+
+ return _make
+
+
@pytest.fixture
def session_manager(poller: Poller, tmp_path: Path, sample_terminal_app: App) -> SessionManager:
"""Create a SessionManager instance."""
diff --git a/tests/test_local_server_unit.py b/tests/test_local_server_unit.py
index d661b0c..95a68a8 100644
--- a/tests/test_local_server_unit.py
+++ b/tests/test_local_server_unit.py
@@ -181,7 +181,7 @@ class TestLocalServerHelpers:
request.query = {"route_key": "rk"}
screen_buffer = screen_buffer_factory(["hello", ""])
- mock_session.get_screen_state = AsyncMock(return_value=(80, 2, screen_buffer, True))
+ mock_session.get_screen_snapshot = AsyncMock(return_value=(80, 2, screen_buffer, True))
monkeypatch.setattr(
server.session_manager, "get_session_by_route_key", lambda _rk: mock_session
@@ -203,7 +203,7 @@ class TestLocalServerHelpers:
request.query = {"route_key": "known"}
screen_buffer = screen_buffer_factory(["world", ""])
- mock_session.get_screen_state = AsyncMock(return_value=(80, 2, screen_buffer, True))
+ mock_session.get_screen_snapshot = AsyncMock(return_value=(80, 2, screen_buffer, True))
# Pretend app exists for slug "known"
server.session_manager.apps_by_slug["known"] = App(
@@ -639,7 +639,7 @@ class TestLocalServerMoreCoverage:
async def test_handle_screenshot_uses_cached_when_no_changes(
self, server_with_no_apps, monkeypatch, mock_request, mock_session
):
- mock_session.get_screen_state = AsyncMock(return_value=(80, 24, [], False))
+ mock_session.get_screen_snapshot = AsyncMock(return_value=(80, 24, [], False))
monkeypatch.setattr(
server_with_no_apps.session_manager,
"get_session_by_route_key",
@@ -655,18 +655,17 @@ class TestLocalServerMoreCoverage:
resp = await server_with_no_apps._handle_screenshot(request)
assert resp.text == ""
- mock_session.get_screen_state.assert_not_awaited()
@pytest.mark.asyncio
async def test_handle_screenshot_uses_screen_state(
self, server_with_no_apps, monkeypatch, screen_buffer_factory, mock_request, mock_session
):
- """Test that screenshot uses get_screen_state for rendering."""
+ """Test that screenshot uses get_screen_snapshot for rendering."""
request = mock_request
request.query = {"route_key": "rk"}
screen_buffer = screen_buffer_factory(["line1", "line2"])
- mock_session.get_screen_state = AsyncMock(return_value=(80, 2, screen_buffer, True))
+ mock_session.get_screen_snapshot = AsyncMock(return_value=(80, 2, screen_buffer, True))
monkeypatch.setattr(
server_with_no_apps.session_manager,
"get_session_by_route_key",
@@ -678,7 +677,7 @@ class TestLocalServerMoreCoverage:
resp = await server_with_no_apps._handle_screenshot(request)
assert resp.content_type == "image/svg+xml"
assert "