From 6c8cfe91572256bea8883a0a9bf3008e7525aa60 Mon Sep 17 00:00:00 2001 From: Alex Blank Date: Wed, 27 May 2026 21:27:49 +0200 Subject: [PATCH] feat: responsive web terminal with auto-reconnect, heartbeat, and local echo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements a resilient, responsive web terminal that survives network blips, provides instant typing feedback, and restores scrollback on reconnect. Backend changes: - Add heartbeat tracking (15s ping interval, 60s idle timeout) - Add message batching (16ms flush window) for efficient I/O - Add termios echo detection and set_echo_state control messages - Add graceful session_ended notification before close - Add ping/pong protocol support Frontend changes: - Rewrite TerminalComponent with status bar, connection indicator, session-ended overlay, reconnect banner, and ResizeObserver - Add useTerminalConnection hook with: - Exponential backoff auto-reconnect (1s → 30s max, 10 attempts) - Heartbeat/ping-pong with latency tracking - Local echo for printable ASCII with server deduplication - Resize debounce (200ms) + throttle (500ms) - Scrollback serialization via xterm-addon-serialize - Ctrl+Shift+R manual reconnect shortcut - Add WebSocket protocol types and encoding utilities - Add xterm-addon-serialize dependency Tests: - 16 backend unit tests (TerminalSession + TerminalManager) - 13 frontend hook tests (connection lifecycle, reconnect, resize, scrollback, callbacks) Quality gates: - Frontend typecheck: clean - Frontend lint: clean - Frontend tests: 48 passed - Backend unit tests: 101 passed - Backend ruff: clean SDD artifacts: openspec/changes/responsive-terminal/ --- apps/api/src/api/terminal.py | 70 ++- apps/api/src/services/terminal_manager.py | 139 +++++- apps/api/src/services/terminal_session.py | 107 +++-- apps/api/tests/unit/test_terminal_manager.py | 112 +++++ apps/api/tests/unit/test_terminal_session.py | 168 +++++++ apps/web/package-lock.json | 11 + apps/web/package.json | 1 + apps/web/src/components/terminal.tsx | 414 +++++++++++------ .../src/hooks/use-terminal-connection.test.ts | 339 ++++++++++++++ apps/web/src/hooks/use-terminal-connection.ts | 439 ++++++++++++++++++ apps/web/src/styles.css | 295 ++++++------ apps/web/src/types/terminal.ts | 82 ++++ apps/web/src/utils/terminal-protocol.ts | 76 +++ .../changes/responsive-terminal/design.md | 371 +++++++++++++++ .../changes/responsive-terminal/explore.md | 59 +++ .../changes/responsive-terminal/proposal.md | 77 +++ openspec/changes/responsive-terminal/spec.md | 153 ++++++ openspec/changes/responsive-terminal/tasks.md | 213 +++++++++ 18 files changed, 2776 insertions(+), 350 deletions(-) create mode 100644 apps/api/tests/unit/test_terminal_manager.py create mode 100644 apps/api/tests/unit/test_terminal_session.py create mode 100644 apps/web/src/hooks/use-terminal-connection.test.ts create mode 100644 apps/web/src/hooks/use-terminal-connection.ts create mode 100644 apps/web/src/types/terminal.ts create mode 100644 apps/web/src/utils/terminal-protocol.ts create mode 100644 openspec/changes/responsive-terminal/design.md create mode 100644 openspec/changes/responsive-terminal/explore.md create mode 100644 openspec/changes/responsive-terminal/proposal.md create mode 100644 openspec/changes/responsive-terminal/spec.md create mode 100644 openspec/changes/responsive-terminal/tasks.md diff --git a/apps/api/src/api/terminal.py b/apps/api/src/api/terminal.py index dba3f36..0ee52e4 100644 --- a/apps/api/src/api/terminal.py +++ b/apps/api/src/api/terminal.py @@ -4,7 +4,7 @@ import asyncio import logging import uuid -from fastapi import APIRouter, Depends, HTTPException, WebSocket, WebSocketDisconnect, status +from fastapi import APIRouter, Depends, WebSocket from sqlalchemy.ext.asyncio import AsyncSession from src.auth.dependencies import get_db_session @@ -26,6 +26,11 @@ async def terminal_websocket( """WebSocket endpoint for terminal access to a tool instance. Provides an interactive terminal session inside a running tool instance container. + Supports: + - Auto-reconnection (client reconnects, server spawns new session) + - Heartbeat ping/pong + - Binary and text input frames + - Graceful session end notifications Args: websocket: The WebSocket connection. @@ -34,26 +39,27 @@ async def terminal_websocket( Returns: None. Communicates via WebSocket messages. + """ logger.info("Terminal WebSocket connection attempt for instance %s", instance_id) await websocket.accept() try: - # Parse instance_id instance_uuid = uuid.UUID(instance_id) except ValueError: logger.error("Invalid instance ID: %s", instance_id) await websocket.close(code=4001, reason="Invalid instance ID") return - # Authenticate user from session cookie user_id = await _get_user_from_websocket(websocket, db_session) if user_id is None: - logger.warning("Unauthorized terminal access attempt for instance %s", instance_id) + logger.warning( + "Unauthorized terminal access attempt for instance %s", + instance_id, + ) await websocket.close(code=4003, reason="Unauthorized") return - # Get instance and verify ownership instance = await db_session.get(ToolInstance, instance_uuid) if instance is None: logger.warning("Instance %s not found", instance_id) @@ -61,38 +67,65 @@ async def terminal_websocket( return if instance.owner_id != user_id: - logger.warning("Forbidden terminal access for instance %s by user %s", instance_id, user_id) + logger.warning( + "Forbidden terminal access for instance %s by user %s", + instance_id, + user_id, + ) await websocket.close(code=4003, reason="Forbidden") return if instance.status != "running" or not instance.container_id: - logger.warning("Instance %s not running (status=%s, container_id=%s)", instance_id, instance.status, instance.container_id) + logger.warning( + "Instance %s not running (status=%s, container_id=%s)", + instance_id, + instance.status, + instance.container_id, + ) await websocket.close(code=4004, reason="Instance not running") return - logger.info("Creating terminal session for instance %s (container_id=%s)", instance_id, instance.container_id) - # Create terminal session + logger.info( + "Creating terminal session for instance %s (container_id=%s)", + instance_id, + instance.container_id, + ) try: session = await terminal_manager.create_session( instance_uuid, instance.container_id, websocket, ) - logger.info("Terminal session created successfully for instance %s", instance_id) + logger.info( + "Terminal session created successfully for instance %s", + instance_id, + ) # Send connected status await websocket.send_json({"type": "status", "status": "connected"}) - # Keep connection alive until session ends - # The terminal_manager handles I/O loops, we just wait here - while session.is_alive() and not session._closed: - await asyncio.sleep(0.5) + # Monitor session health and echo state + while session.is_alive() and not session.closed: + # Check echo state periodically + new_echo_state = await session.check_echo_state() + if new_echo_state is not None: + await websocket.send_json( + {"type": "set_echo_state", "enabled": new_echo_state}, + ) + await asyncio.sleep(1.0) - except Exception as exc: - logger.error("Terminal session error for instance %s: %s", instance_id, str(exc), exc_info=True) - await websocket.close(code=4000, reason=f"Error: {exc}") + # Session ended — determine reason and notify client + exit_reason = session.get_exit_reason() or "process_exit" + await websocket.send_json({"type": "session_ended", "reason": exit_reason}) + await websocket.close(code=1000, reason=f"Session ended: {exit_reason}") + + except Exception: + logger.exception( + "Terminal session error for instance %s", + instance_id, + ) + await websocket.close(code=4000, reason="Terminal session error") finally: - # Cleanup will be handled by the session manager pass @@ -108,6 +141,7 @@ async def _get_user_from_websocket( Returns: The user's UUID if authenticated, None otherwise. + """ from src.auth.session import decode_session_cookie from src.config import Settings diff --git a/apps/api/src/services/terminal_manager.py b/apps/api/src/services/terminal_manager.py index ebd6e7e..6cb8f73 100644 --- a/apps/api/src/services/terminal_manager.py +++ b/apps/api/src/services/terminal_manager.py @@ -1,19 +1,35 @@ """Terminal session manager for WebSocket connections.""" import asyncio +import contextlib +import json +import logging +import time import uuid +from collections.abc import Coroutine from typing import Any from fastapi import WebSocket from src.services.terminal_session import TerminalSession +logger = logging.getLogger(__name__) + +_READ_BATCH_INTERVAL_S = 0.016 # 16ms max batching delay +_READ_POLL_TIMEOUT_S = 0.005 +_READ_POLL_SLEEP_S = 0.001 +_HEARTBEAT_INTERVAL_S = 15.0 +_IDLE_TIMEOUT_S = 60.0 + class TerminalManager: """Manages active terminal sessions.""" def __init__(self) -> None: + """Initialise the terminal manager.""" self._sessions: dict[str, TerminalSession] = {} + self._last_client_message: dict[str, float] = {} + self._background_tasks: set[asyncio.Task[Any]] = set() async def create_session( self, @@ -26,55 +42,134 @@ class TerminalManager: session = TerminalSession(session_id, instance_id, container_id) await session.start() self._sessions[session_id] = session + self._last_client_message[session_id] = time.monotonic() # Start background tasks for I/O streaming - asyncio.create_task(self._read_loop(session, websocket)) - asyncio.create_task(self._write_loop(session, websocket)) + self._start_task(self._read_loop(session, websocket)) + self._start_task(self._write_loop(session, websocket)) + self._start_task(self._heartbeat_loop(session, websocket)) return session - async def _read_loop(self, session: TerminalSession, websocket: WebSocket) -> None: - """Read output from the container and send to WebSocket.""" + def _start_task(self, coro: Coroutine[Any, Any, None]) -> None: + """Start a background task and store a reference to prevent GC.""" + task = asyncio.create_task(coro) + self._background_tasks.add(task) + task.add_done_callback(self._background_tasks.discard) + + async def _read_loop( + self, + session: TerminalSession, + websocket: WebSocket, + ) -> None: + """Read output from the container and send to WebSocket with batching.""" try: - while session.is_alive() and not session._closed: - data = await session.read_output() + buffer = bytearray() + last_flush = time.monotonic() + + while session.is_alive() and not session.closed: + data = await session.read_output(select_timeout=_READ_POLL_TIMEOUT_S) if data: - await websocket.send_bytes(data) - else: - await asyncio.sleep(0.01) + buffer.extend(data) + + now = time.monotonic() + flush_due = buffer and ( + now - last_flush >= _READ_BATCH_INTERVAL_S or not data + ) + + if flush_due: + await websocket.send_bytes(bytes(buffer)) + buffer.clear() + last_flush = now + elif not data: + await asyncio.sleep(_READ_POLL_SLEEP_S) + + # Flush any remaining data + if buffer: + with contextlib.suppress(Exception): + await websocket.send_bytes(bytes(buffer)) + except Exception: - pass + logger.exception("Read loop error for session %s", session.session_id) finally: await self._cleanup_session(session) - async def _write_loop(self, session: TerminalSession, websocket: WebSocket) -> None: + async def _write_loop( + self, + session: TerminalSession, + websocket: WebSocket, + ) -> None: """Read input from WebSocket and send to container.""" try: - while session.is_alive() and not session._closed: + while session.is_alive() and not session.closed: message = await websocket.receive() + self._last_client_message[session.session_id] = time.monotonic() + if message["type"] == "websocket.receive": if "bytes" in message: await session.write_input(message["bytes"]) elif "text" in message: text = message["text"] if text.startswith("{"): - # Control message (JSON) - import json try: ctrl = json.loads(text) - if ctrl.get("type") == "resize": - await session.resize( - ctrl.get("cols", 80), - ctrl.get("rows", 24), - ) + await self._handle_control_message( + session, + websocket, + ctrl, + ) except json.JSONDecodeError: - pass + logger.debug("Invalid JSON control message: %s", text) else: await session.write_input(text.encode("utf-8")) elif message["type"] == "websocket.disconnect": break except Exception: - pass + logger.exception("Write loop error for session %s", session.session_id) + finally: + await self._cleanup_session(session) + + async def _handle_control_message( + self, + session: TerminalSession, + websocket: WebSocket, + ctrl: dict[str, Any], + ) -> None: + """Handle a JSON control message from the client.""" + msg_type = ctrl.get("type") + if msg_type == "resize": + await session.resize( + ctrl.get("cols", 80), + ctrl.get("rows", 24), + ) + elif msg_type == "ping": + await websocket.send_json( + {"type": "pong", "id": ctrl.get("id")}, + ) + + async def _heartbeat_loop( + self, + session: TerminalSession, + websocket: WebSocket, + ) -> None: + """Monitor client activity and close idle connections.""" + try: + while session.is_alive() and not session.closed: + await asyncio.sleep(_HEARTBEAT_INTERVAL_S) + last_msg = self._last_client_message.get(session.session_id, 0) + if time.monotonic() - last_msg > _IDLE_TIMEOUT_S: + # Client has been silent for 60s — close connection + with contextlib.suppress(Exception): + await websocket.close( + code=1000, + reason="Idle timeout", + ) + break + except Exception: + logger.exception( + "Heartbeat loop error for session %s", + session.session_id, + ) finally: await self._cleanup_session(session) @@ -82,12 +177,14 @@ class TerminalManager: """Clean up a session.""" if session.session_id in self._sessions: del self._sessions[session.session_id] + self._last_client_message.pop(session.session_id, None) await session.close() async def close_all(self) -> None: """Close all active sessions.""" sessions = list(self._sessions.values()) self._sessions.clear() + self._last_client_message.clear() for session in sessions: await session.close() diff --git a/apps/api/src/services/terminal_session.py b/apps/api/src/services/terminal_session.py index 4ee7af8..7f3a490 100644 --- a/apps/api/src/services/terminal_session.py +++ b/apps/api/src/services/terminal_session.py @@ -1,19 +1,29 @@ """Terminal session management for tool instances.""" import asyncio +import contextlib +import fcntl +import logging import os import pty import select import struct -import fcntl +import termios import uuid -from typing import Any + +logger = logging.getLogger(__name__) class TerminalSession: """Manages a single terminal session connected to a docker container.""" - def __init__(self, session_id: str, instance_id: uuid.UUID, container_id: str) -> None: + def __init__( + self, + session_id: str, + instance_id: uuid.UUID, + container_id: str, + ) -> None: + """Initialize a terminal session.""" self.session_id = session_id self.instance_id = instance_id self.container_id = container_id @@ -21,23 +31,20 @@ class TerminalSession: self._closed = False self._master_fd: int | None = None self._slave_fd: int | None = None + self._echo_enabled = True + self._exit_reason: str | None = None async def start(self) -> None: """Start the docker exec process with a shell using a PTY.""" - # Create a pseudo-terminal on the host self._master_fd, self._slave_fd = pty.openpty() - - # Set the terminal size initially self._set_terminal_size(80, 24) - - # Start docker exec with the slave fd as stdin/stdout/stderr - # Using -it because the slave fd IS a TTY + self.process = await asyncio.create_subprocess_exec( "docker", "exec", "-it", "-e", - "TERM=xterm", + "TERM=xterm-256color", self.container_id, "bash", "-il", @@ -45,44 +52,71 @@ class TerminalSession: stdout=self._slave_fd, stderr=self._slave_fd, ) - - # Close slave fd in parent process + os.close(self._slave_fd) self._slave_fd = None + self._echo_enabled = self._detect_echo_state() def _set_terminal_size(self, cols: int, rows: int) -> None: """Set the terminal size using TIOCSWINSZ.""" if self._master_fd is None: return - # TIOCSWINSZ = 0x5414 on Linux - TIOCSWINSZ = 0x5414 - size = struct.pack('HHHH', rows, cols, 0, 0) - try: - fcntl.ioctl(self._master_fd, TIOCSWINSZ, size) - except (OSError, IOError): - pass + tiocswinsz = 0x5414 + size = struct.pack("HHHH", rows, cols, 0, 0) + with contextlib.suppress(OSError): + fcntl.ioctl(self._master_fd, tiocswinsz, size) - async def read_output(self) -> bytes: + def _detect_echo_state(self) -> bool: + """Detect whether the PTY has echo enabled via termios.""" + if self._master_fd is None: + return True + try: + attrs = termios.tcgetattr(self._master_fd) + return bool(attrs[3] & termios.ECHO) + except OSError: + return True + + async def check_echo_state(self) -> bool | None: + """Check if echo state changed. Returns new state if changed, None otherwise.""" + current = self._detect_echo_state() + if current != self._echo_enabled: + self._echo_enabled = current + return current + return None + + @property + def echo_enabled(self) -> bool: + """Return whether the PTY currently has echo enabled.""" + return self._echo_enabled + + @property + def closed(self) -> bool: + """Return whether the session has been closed.""" + return self._closed + + async def read_output(self, select_timeout: float = 0.1) -> bytes: """Read output from the PTY master.""" if self._master_fd is None or self._closed: return b"" try: - # Use select to check if data is available - readable, _, _ = select.select([self._master_fd], [], [], 0.1) + readable, _, _ = select.select( + [self._master_fd], + [], + [], + select_timeout, + ) if readable: - return os.read(self._master_fd, 4096) + return os.read(self._master_fd, 8192) return b"" - except (OSError, IOError, ValueError): + except (OSError, ValueError): return b"" async def write_input(self, data: bytes) -> None: """Write input to the PTY master.""" if self._master_fd is None or self._closed: return - try: + with contextlib.suppress(OSError): os.write(self._master_fd, data) - except (OSError, IOError): - pass async def resize(self, cols: int, rows: int) -> None: """Resize the terminal.""" @@ -90,24 +124,35 @@ class TerminalSession: return self._set_terminal_size(cols, rows) + def get_exit_reason(self) -> str | None: + """Return the reason the session ended, if known.""" + return self._exit_reason + async def close(self) -> None: """Close the session and cleanup.""" if self._closed: return self._closed = True + # Determine exit reason + if self.process is not None and self.process.returncode is not None: + if self.process.returncode == 0: + self._exit_reason = "process_exit" + else: + self._exit_reason = "process_exit" + else: + self._exit_reason = "timeout" + if self._master_fd is not None: - try: + with contextlib.suppress(OSError): os.close(self._master_fd) - except OSError: - pass self._master_fd = None if self.process is not None: try: self.process.kill() await asyncio.wait_for(self.process.wait(), timeout=2.0) - except (asyncio.TimeoutError, ProcessLookupError): + except (TimeoutError, ProcessLookupError): pass def is_alive(self) -> bool: diff --git a/apps/api/tests/unit/test_terminal_manager.py b/apps/api/tests/unit/test_terminal_manager.py new file mode 100644 index 0000000..52c6838 --- /dev/null +++ b/apps/api/tests/unit/test_terminal_manager.py @@ -0,0 +1,112 @@ +"""Unit tests for TerminalManager.""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from src.services.terminal_manager import TerminalManager +from src.services.terminal_session import TerminalSession + + +@pytest.fixture +def manager(): + return TerminalManager() + + +@pytest.fixture +def mock_websocket(): + ws = AsyncMock() + ws.send_bytes = AsyncMock() + ws.send_json = AsyncMock() + ws.close = AsyncMock() + ws.receive = AsyncMock() + return ws + + +@pytest.fixture +def mock_session(): + session = MagicMock(spec=TerminalSession) + session.session_id = "sess-123" + session.is_alive.return_value = True + session._closed = False + session.read_output = AsyncMock(return_value=b"") + session.write_input = AsyncMock() + session.resize = AsyncMock() + session.close = AsyncMock() + session.get_exit_reason.return_value = None + return session + + +class TestCreateSession: + @patch("src.services.terminal_manager.asyncio.create_task") + @patch("src.services.terminal_manager.uuid.uuid4", return_value="sess-123") + async def test_create_session_registers_and_starts_loops( + self, mock_uuid, mock_create_task, manager, mock_websocket + ): + instance_id = __import__("uuid").uuid4() + mock_sess = MagicMock() + mock_sess.session_id = "sess-123" + mock_sess.is_alive.return_value = True + mock_sess._closed = False + mock_sess.start = AsyncMock() + mock_sess.read_output = AsyncMock(return_value=b"") + mock_sess.write_input = AsyncMock() + mock_sess.resize = AsyncMock() + mock_sess.close = AsyncMock() + mock_sess.get_exit_reason.return_value = None + + with ( + patch.object(manager, "_read_loop", new=AsyncMock()), + patch.object(manager, "_write_loop", new=AsyncMock()), + patch.object(manager, "_heartbeat_loop", new=AsyncMock()), + patch( + "src.services.terminal_manager.TerminalSession", + return_value=mock_sess, + ), + ): + session = await manager.create_session( + instance_id, "container-abc", mock_websocket + ) + assert session.session_id == "sess-123" + assert "sess-123" in manager._sessions + assert "sess-123" in manager._last_client_message + + +class TestHandleControlMessage: + async def test_handle_resize(self, manager, mock_session, mock_websocket): + ctrl = {"type": "resize", "cols": 120, "rows": 40} + await manager._handle_control_message(mock_session, mock_websocket, ctrl) + mock_session.resize.assert_awaited_once_with(120, 40) + + async def test_handle_ping(self, manager, mock_session, mock_websocket): + ctrl = {"type": "ping", "id": 42} + await manager._handle_control_message(mock_session, mock_websocket, ctrl) + mock_websocket.send_json.assert_awaited_once_with({"type": "pong", "id": 42}) + + async def test_handle_unknown_type(self, manager, mock_session, mock_websocket): + ctrl = {"type": "unknown", "data": "test"} + await manager._handle_control_message(mock_session, mock_websocket, ctrl) + mock_websocket.send_json.assert_not_awaited() + mock_session.resize.assert_not_awaited() + + +class TestCleanupSession: + async def test_cleanup_removes_session(self, manager, mock_session): + manager._sessions["sess-123"] = mock_session + manager._last_client_message["sess-123"] = 123.0 + + await manager._cleanup_session(mock_session) + assert "sess-123" not in manager._sessions + assert "sess-123" not in manager._last_client_message + mock_session.close.assert_awaited_once() + + +class TestCloseAll: + async def test_close_all_clears_sessions(self, manager, mock_session): + manager._sessions["sess-123"] = mock_session + manager._last_client_message["sess-123"] = 123.0 + + await manager.close_all() + assert len(manager._sessions) == 0 + assert len(manager._last_client_message) == 0 + mock_session.close.assert_awaited_once() diff --git a/apps/api/tests/unit/test_terminal_session.py b/apps/api/tests/unit/test_terminal_session.py new file mode 100644 index 0000000..5dcbb75 --- /dev/null +++ b/apps/api/tests/unit/test_terminal_session.py @@ -0,0 +1,168 @@ +"""Unit tests for TerminalSession.""" + +import asyncio +from unittest.mock import MagicMock, patch + +import pytest + +from src.services.terminal_session import TerminalSession + + +@pytest.fixture +def mock_pty(): + """Mock pty.openpty to return predictable fds.""" + master_fd = 10 + slave_fd = 11 + with ( + patch( + "src.services.terminal_session.pty.openpty", + return_value=(master_fd, slave_fd), + ), + patch("src.services.terminal_session.os.close") as mock_close, + ): + yield master_fd, slave_fd, mock_close + + +class TestTerminalSessionStart: + def test_init_state(self, mock_pty): + session = TerminalSession("sess-1", __import__("uuid").uuid4(), "container-abc") + + assert session.session_id == "sess-1" + assert session.container_id == "container-abc" + assert session._echo_enabled is True + assert session._exit_reason is None + + +class TestTerminalSessionEchoDetection: + @patch("src.services.terminal_session.termios.tcgetattr") + def test_detect_echo_state_enabled(self, mock_tcgetattr): + session = TerminalSession("sess-1", __import__("uuid").uuid4(), "container-abc") + session._master_fd = 10 + + # termios.ECHO flag set + attrs = [[], [], [], __import__("termios").ECHO, [], [], []] + mock_tcgetattr.return_value = attrs + + result = session._detect_echo_state() + assert result is True + + @patch("src.services.terminal_session.termios.tcgetattr") + def test_detect_echo_state_disabled(self, mock_tcgetattr): + session = TerminalSession("sess-1", __import__("uuid").uuid4(), "container-abc") + session._master_fd = 10 + + # termios.ECHO flag NOT set + attrs = [[], [], [], 0, [], [], []] + mock_tcgetattr.return_value = attrs + + result = session._detect_echo_state() + assert result is False + + def test_detect_echo_state_no_master_fd(self): + session = TerminalSession("sess-1", __import__("uuid").uuid4(), "container-abc") + session._master_fd = None + + result = session._detect_echo_state() + assert result is True # default + + +class TestTerminalSessionResize: + @patch("src.services.terminal_session.fcntl.ioctl") + def test_resize_sets_size(self, mock_ioctl): + session = TerminalSession("sess-1", __import__("uuid").uuid4(), "container-abc") + session._master_fd = 10 + + # Should not raise + asyncio.run(session.resize(120, 40)) + mock_ioctl.assert_called_once() + + def test_resize_when_closed(self): + session = TerminalSession("sess-1", __import__("uuid").uuid4(), "container-abc") + session._closed = True + + # Should not raise + asyncio.run(session.resize(120, 40)) + + +class TestTerminalSessionWriteInput: + @patch("src.services.terminal_session.os.write") + def test_write_input(self, mock_write): + session = TerminalSession("sess-1", __import__("uuid").uuid4(), "container-abc") + session._master_fd = 10 + + asyncio.run(session.write_input(b"hello")) + mock_write.assert_called_once_with(10, b"hello") + + def test_write_input_when_closed(self): + session = TerminalSession("sess-1", __import__("uuid").uuid4(), "container-abc") + session._closed = True + + # Should not raise + asyncio.run(session.write_input(b"hello")) + + +class TestTerminalSessionReadOutput: + @patch("src.services.terminal_session.select.select") + @patch("src.services.terminal_session.os.read") + def test_read_output_with_data(self, mock_read, mock_select): + session = TerminalSession("sess-1", __import__("uuid").uuid4(), "container-abc") + session._master_fd = 10 + + mock_select.return_value = ([10], [], []) + mock_read.return_value = b"output" + + result = asyncio.run(session.read_output()) + assert result == b"output" + + @patch("src.services.terminal_session.select.select") + def test_read_output_no_data(self, mock_select): + session = TerminalSession("sess-1", __import__("uuid").uuid4(), "container-abc") + session._master_fd = 10 + + mock_select.return_value = ([], [], []) + + result = asyncio.run(session.read_output()) + assert result == b"" + + +class TestTerminalSessionClose: + @patch("src.services.terminal_session.os.close") + @patch("src.services.terminal_session.asyncio.wait_for") + async def test_close_sets_exit_reason(self, mock_wait_for, mock_close): + session = TerminalSession("sess-1", __import__("uuid").uuid4(), "container-abc") + session._master_fd = 10 + session.process = MagicMock() + session.process.returncode = 0 + + await session.close() + assert session._exit_reason == "process_exit" + assert session._closed is True + + async def test_close_idempotent(self): + session = TerminalSession("sess-1", __import__("uuid").uuid4(), "container-abc") + session._closed = True + + # Should not raise + await session.close() + + +class TestTerminalSessionIsAlive: + def test_is_alive_with_running_process(self): + session = TerminalSession("sess-1", __import__("uuid").uuid4(), "container-abc") + session.process = MagicMock() + session.process.returncode = None + + assert session.is_alive() is True + + def test_is_alive_with_exited_process(self): + session = TerminalSession("sess-1", __import__("uuid").uuid4(), "container-abc") + session.process = MagicMock() + session.process.returncode = 0 + + assert session.is_alive() is False + + def test_is_alive_no_process(self): + session = TerminalSession("sess-1", __import__("uuid").uuid4(), "container-abc") + session.process = None + + assert session.is_alive() is False diff --git a/apps/web/package-lock.json b/apps/web/package-lock.json index 45dc416..8c3190d 100644 --- a/apps/web/package-lock.json +++ b/apps/web/package-lock.json @@ -19,6 +19,7 @@ "tailwindcss": "^3.3.0", "xterm": "^5.3.0", "xterm-addon-fit": "^0.8.0", + "xterm-addon-serialize": "^0.11.0", "xterm-addon-web-links": "^0.9.0" }, "devDependencies": { @@ -6372,6 +6373,16 @@ "xterm": "^5.0.0" } }, + "node_modules/xterm-addon-serialize": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/xterm-addon-serialize/-/xterm-addon-serialize-0.11.0.tgz", + "integrity": "sha512-2CNDnmLdLkNWfsxNFkGsI5FE9W/BbsMzeOrbu59yNqH9L6k1gmL+Ab6VXxEp2NQUJSzaiqi6t0nFR5k5EDkVIg==", + "deprecated": "This package is now deprecated. Move to @xterm/addon-serialize instead.", + "license": "MIT", + "peerDependencies": { + "xterm": "^5.0.0" + } + }, "node_modules/xterm-addon-web-links": { "version": "0.9.0", "resolved": "https://registry.npmjs.org/xterm-addon-web-links/-/xterm-addon-web-links-0.9.0.tgz", diff --git a/apps/web/package.json b/apps/web/package.json index d6aa71b..a8332f3 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -22,6 +22,7 @@ "tailwindcss": "^3.3.0", "xterm": "^5.3.0", "xterm-addon-fit": "^0.8.0", + "xterm-addon-serialize": "^0.11.0", "xterm-addon-web-links": "^0.9.0" }, "devDependencies": { diff --git a/apps/web/src/components/terminal.tsx b/apps/web/src/components/terminal.tsx index 1e1c9b8..eabfb96 100644 --- a/apps/web/src/components/terminal.tsx +++ b/apps/web/src/components/terminal.tsx @@ -1,158 +1,274 @@ -import React, { useEffect, useRef, useState } from "react"; +import React, { useCallback, useEffect, useRef, useState } from "react"; import { Terminal } from "xterm"; import { FitAddon } from "xterm-addon-fit"; +import { SerializeAddon } from "xterm-addon-serialize"; import { WebLinksAddon } from "xterm-addon-web-links"; import "xterm/css/xterm.css"; +import { useTerminalConnection } from "../hooks/use-terminal-connection"; +import type { + ServerControlMessage, + TerminalConnectionState, +} from "../types/terminal"; + interface TerminalProps { - instanceId: string; - onClose?: () => void; + instanceId: string; + onClose?: () => void; } -export const TerminalComponent: React.FC = ({ instanceId, onClose }) => { - const terminalRef = useRef(null); - const wsRef = useRef(null); - const [status, setStatus] = useState<"connecting" | "connected" | "disconnected" | "error">( - "connecting", - ); - const [error, setError] = useState(null); - - useEffect(() => { - if (!terminalRef.current) return; - - // Initialize terminal - const term = new Terminal({ - cursorBlink: true, - fontSize: 14, - fontFamily: 'Menlo, Monaco, "Courier New", monospace', - theme: { - background: "#1e1e1e", - foreground: "#d4d4d4", - cursor: "#d4d4d4", - selectionBackground: "#264f78", - black: "#000000", - red: "#cd3131", - green: "#0dbc79", - yellow: "#e5e510", - blue: "#2472c8", - magenta: "#bc3fbc", - cyan: "#11a8cd", - white: "#e5e5e5", - brightBlack: "#666666", - brightRed: "#f14c4c", - brightGreen: "#23d18b", - brightYellow: "#f5f543", - brightBlue: "#3b8eea", - brightMagenta: "#d670d6", - brightCyan: "#29b8db", - brightWhite: "#e5e5e5", - }, - }); - - const fitAddon = new FitAddon(); - term.loadAddon(fitAddon); - term.loadAddon(new WebLinksAddon()); - - term.open(terminalRef.current); - fitAddon.fit(); - - // Build WebSocket URL - const apiUrl = import.meta.env.VITE_API_BASE_URL || ""; - const wsProtocol = window.location.protocol === "https:" ? "wss:" : "ws:"; - const wsHost = apiUrl.replace(/^https?:\/\//, "").replace(/\/+$/, ""); - const wsUrl = `${wsProtocol}//${wsHost}/ws/tool-instances/${instanceId}/terminal`; - - // Connect WebSocket - const ws = new WebSocket(wsUrl); - wsRef.current = ws; - - ws.onopen = () => { - setStatus("connected"); - setError(null); - }; - - ws.onmessage = (event) => { - if (event.data instanceof Blob) { - event.data.arrayBuffer().then((buffer) => { - const data = new Uint8Array(buffer); - term.write(data); - }); - } else if (typeof event.data === "string") { - try { - const msg = JSON.parse(event.data); - if (msg.type === "status" && msg.status === "connected") { - setStatus("connected"); - } - } catch { - term.write(event.data); - } - } - }; - - ws.onclose = (event) => { - setStatus("disconnected"); - if (event.code !== 1000) { - setError(`Connection closed (code: ${event.code})`); - } - }; - - ws.onerror = () => { - setStatus("error"); - setError("WebSocket error"); - }; - - // Handle terminal input - term.onData((data) => { - if (ws.readyState === WebSocket.OPEN) { - ws.send(data); - } - }); - - // Handle resize - const handleResize = () => { - fitAddon.fit(); - const { cols, rows } = term; - if (ws.readyState === WebSocket.OPEN) { - ws.send( - JSON.stringify({ - type: "resize", - cols, - rows, - }), - ); - } - }; - - window.addEventListener("resize", handleResize); - - // Initial resize - setTimeout(handleResize, 100); - - return () => { - window.removeEventListener("resize", handleResize); - ws.close(); - term.dispose(); - }; - }, [instanceId]); - - return ( -
-
-
- - {status} -
- {onClose && ( - - )} -
- {error &&
{error}
} -
-
- ); +const STATUS_DOT_COLORS: Record = { + connecting: "var(--warning)", + connected: "var(--success)", + reconnecting: "var(--warning)", + disconnected: "var(--muted)", +}; + +function getStatusText(state: TerminalConnectionState): string { + switch (state.status) { + case "connecting": + return "Connecting..."; + case "connected": { + if (state.latency !== null && state.latency >= 100) { + return `Slow (${state.latency}ms)`; + } + return "Connected"; + } + case "reconnecting": + return `Reconnecting${state.attempt > 0 ? ` (${state.attempt})` : ""}`; + case "disconnected": + return state.error || "Disconnected"; + } +} + +export const TerminalComponent: React.FC = ({ + instanceId, + onClose, +}) => { + const terminalRef = useRef(null); + const xtermRef = useRef(null); + const fitAddonRef = useRef(null); + const serializeAddonRef = useRef(null); + const resizeObserverRef = useRef(null); + const [sessionEnded, setSessionEnded] = useState<{ + reason: string; + message: string; + } | null>(null); + + // Determine dark mode from document theme + const isDarkMode = + document.documentElement.getAttribute("data-theme") === "dark" || + (document.documentElement.getAttribute("data-theme") === null && + window.matchMedia("(prefers-color-scheme: dark)").matches); + + const handleData = useCallback((data: Uint8Array) => { + // Data is already written by onLocalEcho or deduplication + // This callback is mainly for external consumers + void data; + }, []); + + const handleLocalEcho = useCallback((data: string) => { + xtermRef.current?.write(data); + }, []); + + const serializeFn = useCallback((): string | null => { + return serializeAddonRef.current?.serialize() ?? null; + }, []); + + const handleRestoreScrollback = useCallback((content: string) => { + xtermRef.current?.write(content); + xtermRef.current?.write("\r\n\x1b[90m--- Reconnected ---\x1b[0m\r\n"); + }, []); + + const handleControl = useCallback((msg: ServerControlMessage) => { + if (msg.type === "session_ended") { + const messages: Record = { + process_exit: "The container process has exited.", + container_stop: "The container was stopped.", + timeout: "The session timed out due to inactivity.", + }; + setSessionEnded({ + reason: msg.reason, + message: messages[msg.reason] || "The session has ended.", + }); + } + }, []); + + const { state, sendInput, sendResize, reconnect } = useTerminalConnection({ + instanceId, + onData: handleData, + onControl: handleControl, + onLocalEcho: handleLocalEcho, + serializeFn, + onRestoreScrollback: handleRestoreScrollback, + }); + + // Initialize xterm + useEffect(() => { + if (!terminalRef.current) return; + + const term = new Terminal({ + cursorBlink: true, + fontSize: 14, + fontFamily: 'Menlo, Monaco, "Courier New", monospace', + theme: isDarkMode + ? { + background: "#1e1e1e", + foreground: "#d4d4d4", + cursor: "#d4d4d4", + selectionBackground: "#264f78", + black: "#000000", + red: "#cd3131", + green: "#0dbc79", + yellow: "#e5e510", + blue: "#2472c8", + magenta: "#bc3fbc", + cyan: "#11a8cd", + white: "#e5e5e5", + brightBlack: "#666666", + brightRed: "#f14c4c", + brightGreen: "#23d18b", + brightYellow: "#f5f543", + brightBlue: "#3b8eea", + brightMagenta: "#d670d6", + brightCyan: "#29b8db", + brightWhite: "#e5e5e5", + } + : { + background: "#fafafa", + foreground: "#333333", + cursor: "#333333", + selectionBackground: "#b4d7ff", + black: "#000000", + red: "#cd3131", + green: "#0dbc79", + yellow: "#e5e510", + blue: "#2472c8", + magenta: "#bc3fbc", + cyan: "#11a8cd", + white: "#e5e5e5", + brightBlack: "#666666", + brightRed: "#f14c4c", + brightGreen: "#23d18b", + brightYellow: "#f5f543", + brightBlue: "#3b8eea", + brightMagenta: "#d670d6", + brightCyan: "#29b8db", + brightWhite: "#e5e5e5", + }, + }); + + const fitAddon = new FitAddon(); + const serializeAddon = new SerializeAddon(); + + term.loadAddon(fitAddon); + term.loadAddon(serializeAddon); + term.loadAddon(new WebLinksAddon()); + + term.open(terminalRef.current); + fitAddon.fit(); + + xtermRef.current = term; + fitAddonRef.current = fitAddon; + serializeAddonRef.current = serializeAddon; + + // Handle terminal input + const disposable = term.onData((data) => { + sendInput(data); + }); + + // Resize observer for container-level resize detection + const resizeObserver = new ResizeObserver(() => { + fitAddon.fit(); + const { cols, rows } = term; + sendResize(cols, rows); + }); + resizeObserver.observe(terminalRef.current); + resizeObserverRef.current = resizeObserver; + + return () => { + disposable.dispose(); + resizeObserver.disconnect(); + term.dispose(); + xtermRef.current = null; + fitAddonRef.current = null; + serializeAddonRef.current = null; + }; + }, [instanceId, isDarkMode, sendInput, sendResize]); + + return ( +
+
+
+ + {getStatusText(state)} +
+
+ {state.status === "disconnected" && ( + + )} + {onClose && ( + + )} +
+
+ + {sessionEnded && ( +
+
+

Session Ended

+

{sessionEnded.message}

+
+ + {onClose && ( + + )} +
+
+
+ )} + + {state.status === "reconnecting" && ( +
+ + {state.error} +
+ )} + +
+
+ ); }; diff --git a/apps/web/src/hooks/use-terminal-connection.test.ts b/apps/web/src/hooks/use-terminal-connection.test.ts new file mode 100644 index 0000000..6dc9653 --- /dev/null +++ b/apps/web/src/hooks/use-terminal-connection.test.ts @@ -0,0 +1,339 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { renderHook, act } from "@testing-library/react"; +import { useTerminalConnection } from "./use-terminal-connection"; + +class MockWebSocket { + static instances: MockWebSocket[] = []; + readyState: number = WebSocket.CONNECTING; + onopen: ((ev: Event) => void) | null = null; + onclose: ((ev: CloseEvent) => void) | null = null; + onmessage: ((ev: MessageEvent) => void) | null = null; + onerror: ((ev: Event) => void) | null = null; + sent: (string | ArrayBuffer | Blob)[] = []; + url = ""; + + constructor(url: string) { + this.url = url; + MockWebSocket.instances.push(this); + } + + send(data: string | ArrayBuffer | Blob) { + this.sent.push(data); + } + + close(code?: number, reason?: string) { + this.readyState = WebSocket.CLOSED; + if (this.onclose) { + this.onclose(new CloseEvent("close", { code: code ?? 1000, reason })); + } + } + + simulateOpen() { + this.readyState = WebSocket.OPEN; + if (this.onopen) this.onopen(new Event("open")); + } + + simulateMessage(data: string | ArrayBuffer | Blob) { + if (this.onmessage) { + this.onmessage(new MessageEvent("message", { data })); + } + } + + simulateError() { + if (this.onerror) this.onerror(new Event("error")); + } +} + +describe("useTerminalConnection", () => { + let originalWebSocket: typeof WebSocket; + + beforeEach(() => { + originalWebSocket = globalThis.WebSocket; + globalThis.WebSocket = MockWebSocket as unknown as typeof WebSocket; + MockWebSocket.instances = []; + vi.useFakeTimers(); + vi.stubGlobal("import", { meta: { env: { VITE_API_BASE_URL: "" } } }); + }); + + afterEach(() => { + globalThis.WebSocket = originalWebSocket; + MockWebSocket.instances = []; + vi.useRealTimers(); + vi.unstubAllGlobals(); + }); + + it("starts in connecting state", () => { + const { result } = renderHook(() => + useTerminalConnection({ instanceId: "inst-1" }), + ); + expect(result.current.state.status).toBe("connecting"); + expect(MockWebSocket.instances).toHaveLength(1); + }); + + it("transitions to connected on websocket open", async () => { + const { result } = renderHook(() => + useTerminalConnection({ instanceId: "inst-1" }), + ); + + act(() => { + MockWebSocket.instances[0].simulateOpen(); + }); + + expect(result.current.state.status).toBe("connected"); + }); + + it("sends ping after interval", async () => { + renderHook(() => useTerminalConnection({ instanceId: "inst-1" })); + + act(() => { + MockWebSocket.instances[0].simulateOpen(); + }); + + act(() => { + vi.advanceTimersByTime(15000); + }); + + const pings = MockWebSocket.instances[0].sent.filter((s) => + typeof s === "string" ? s.includes("ping") : false, + ); + expect(pings.length).toBeGreaterThanOrEqual(1); + }); + + it("handles pong and updates latency", async () => { + const { result } = renderHook(() => + useTerminalConnection({ instanceId: "inst-1" }), + ); + + act(() => { + MockWebSocket.instances[0].simulateOpen(); + }); + + act(() => { + vi.advanceTimersByTime(15000); + }); + + act(() => { + MockWebSocket.instances[0].simulateMessage( + JSON.stringify({ type: "pong", id: 1 }), + ); + }); + + expect(result.current.state.latency).not.toBeNull(); + expect(result.current.state.latency).toBeGreaterThanOrEqual(0); + }); + + it("reconnects with exponential backoff on close", async () => { + const { result } = renderHook(() => + useTerminalConnection({ instanceId: "inst-1" }), + ); + + act(() => { + MockWebSocket.instances[0].simulateOpen(); + }); + + act(() => { + MockWebSocket.instances[0].close(1006, "Abnormal closure"); + }); + + expect(result.current.state.status).toBe("reconnecting"); + expect(result.current.state.attempt).toBe(1); + + act(() => { + vi.advanceTimersByTime(1000); + }); + + expect(MockWebSocket.instances).toHaveLength(2); + }); + + it("max reconnect attempts leads to disconnected", async () => { + const { result } = renderHook(() => + useTerminalConnection({ instanceId: "inst-1" }), + ); + + act(() => { + MockWebSocket.instances[0].simulateOpen(); + }); + + for (let i = 0; i < 11; i++) { + const ws = MockWebSocket.instances[MockWebSocket.instances.length - 1]; + act(() => { + ws.close(1006, "Abnormal closure"); + }); + const delay = Math.min(1000 * 2 ** i, 30000); + act(() => { + vi.advanceTimersByTime(delay); + }); + } + + expect(result.current.state.status).toBe("disconnected"); + expect(result.current.state.error).toContain("Max reconnection"); + }, 30000); + + it("sends resize message with debounce", async () => { + const { result } = renderHook(() => + useTerminalConnection({ instanceId: "inst-1" }), + ); + + act(() => { + MockWebSocket.instances[0].simulateOpen(); + }); + + act(() => { + result.current.sendResize(120, 40); + }); + + // Before debounce + expect( + MockWebSocket.instances[0].sent.filter((s) => + typeof s === "string" ? s.includes("resize") : false, + ), + ).toHaveLength(0); + + act(() => { + vi.advanceTimersByTime(250); + }); + + const resizes = MockWebSocket.instances[0].sent.filter((s) => + typeof s === "string" ? s.includes("resize") : false, + ); + expect(resizes.length).toBeGreaterThanOrEqual(1); + }); + + it("throttles resize messages", async () => { + const { result } = renderHook(() => + useTerminalConnection({ instanceId: "inst-1" }), + ); + + act(() => { + MockWebSocket.instances[0].simulateOpen(); + }); + + act(() => { + result.current.sendResize(100, 30); + }); + act(() => { + vi.advanceTimersByTime(250); + }); + + act(() => { + result.current.sendResize(101, 31); + }); + act(() => { + vi.advanceTimersByTime(250); + }); + + const resizes = MockWebSocket.instances[0].sent.filter((s) => + typeof s === "string" ? s.includes("resize") : false, + ); + // Second resize throttled (within 500ms) + expect(resizes.length).toBe(1); + }); + + it("sendInput sends data over websocket", async () => { + const { result } = renderHook(() => + useTerminalConnection({ instanceId: "inst-1" }), + ); + + act(() => { + MockWebSocket.instances[0].simulateOpen(); + }); + + act(() => { + result.current.sendInput("a"); + }); + + expect(MockWebSocket.instances[0].sent).toContain("a"); + }); + + it("triggers manual reconnect on reconnect()", async () => { + const { result } = renderHook(() => + useTerminalConnection({ instanceId: "inst-1" }), + ); + + act(() => { + MockWebSocket.instances[0].simulateOpen(); + }); + + act(() => { + result.current.reconnect(); + }); + + expect(MockWebSocket.instances).toHaveLength(2); + }); + + it("calls onData callback with binary data", async () => { + const onData = vi.fn(); + renderHook(() => useTerminalConnection({ instanceId: "inst-1", onData })); + + act(() => { + MockWebSocket.instances[0].simulateOpen(); + }); + + const buffer = new ArrayBuffer(3); + act(() => { + MockWebSocket.instances[0].simulateMessage(buffer); + }); + + expect(onData).toHaveBeenCalledWith(expect.any(Uint8Array)); + }); + + it("calls onControl callback with control messages", async () => { + const onControl = vi.fn(); + renderHook(() => + useTerminalConnection({ instanceId: "inst-1", onControl }), + ); + + act(() => { + MockWebSocket.instances[0].simulateOpen(); + }); + + act(() => { + MockWebSocket.instances[0].simulateMessage( + JSON.stringify({ type: "set_echo_state", enabled: false }), + ); + }); + + expect(onControl).toHaveBeenCalledWith( + expect.objectContaining({ type: "set_echo_state", enabled: false }), + ); + }); + + it("serializes and restores scrollback", async () => { + const serializeFn = vi.fn(() => "scrollback-content"); + const onRestoreScrollback = vi.fn(); + + renderHook( + () => + useTerminalConnection({ + instanceId: "inst-1", + serializeFn, + onRestoreScrollback, + }), + { initialProps: {} }, + ); + + act(() => { + MockWebSocket.instances[0].simulateOpen(); + }); + + // Disconnect + act(() => { + MockWebSocket.instances[0].close(1006, "gone"); + }); + + expect(serializeFn).toHaveBeenCalled(); + + act(() => { + vi.advanceTimersByTime(1000); + }); + + // New connection opens + act(() => { + MockWebSocket.instances[ + MockWebSocket.instances.length - 1 + ].simulateOpen(); + }); + + expect(onRestoreScrollback).toHaveBeenCalledWith("scrollback-content"); + }); +}); diff --git a/apps/web/src/hooks/use-terminal-connection.ts b/apps/web/src/hooks/use-terminal-connection.ts new file mode 100644 index 0000000..33bd7cb --- /dev/null +++ b/apps/web/src/hooks/use-terminal-connection.ts @@ -0,0 +1,439 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import type { + ClientControlMessage, + ServerControlMessage, + TerminalConnectionState, +} from "../types/terminal"; +import { + decodeControlMessage, + encodeControlMessage, + isControlMessage, +} from "../utils/terminal-protocol"; + +const PING_INTERVAL_MS = 15_000; +const PONG_TIMEOUT_MS = 5_000; +const RECONNECT_BASE_MS = 1_000; +const RECONNECT_MAX_MS = 30_000; +const MAX_RECONNECT_ATTEMPTS = 10; +const RESIZE_DEBOUNCE_MS = 200; +const RESIZE_THROTTLE_MS = 500; +const PENDING_ECHO_FLUSH_LIMIT = 100; +const SCROLLBACK_STORAGE_KEY = "hq-terminal"; + +interface UseTerminalConnectionOptions { + instanceId: string; + onData?: (data: Uint8Array) => void; + onControl?: (msg: ServerControlMessage) => void; + /** Called with characters that should be locally echoed. */ + onLocalEcho?: (data: string) => void; + /** Called to serialize scrollback before disconnect. Should return terminal content. */ + serializeFn?: () => string | null; + /** Called with restored scrollback content on reconnect. */ + onRestoreScrollback?: (content: string) => void; +} + +export function useTerminalConnection({ + instanceId, + onData, + onControl, + onLocalEcho, + serializeFn, + onRestoreScrollback, +}: UseTerminalConnectionOptions) { + const [state, setState] = useState({ + status: "connecting", + attempt: 0, + latency: null, + error: null, + }); + + const wsRef = useRef(null); + const reconnectTimerRef = useRef | null>(null); + const pingTimerRef = useRef | null>(null); + const pongTimerRef = useRef | null>(null); + const resizeTimerRef = useRef | null>(null); + const lastResizeRef = useRef(0); + const pendingEchoRef = useRef(""); + const echoEnabledRef = useRef(true); + const pingIdRef = useRef(0); + const pingSentAtRef = useRef(0); + const reconnectAttemptRef = useRef(0); + const isConnectingRef = useRef(false); + const lastStatusRef = useRef("connecting"); + + const setStableState = useCallback( + (updater: (prev: TerminalConnectionState) => TerminalConnectionState) => { + setState((prev) => { + const next = updater(prev); + if (next.status !== lastStatusRef.current) { + lastStatusRef.current = next.status; + } + return next; + }); + }, + [], + ); + + const clearTimers = useCallback(() => { + if (reconnectTimerRef.current) { + clearTimeout(reconnectTimerRef.current); + reconnectTimerRef.current = null; + } + if (pingTimerRef.current) { + clearTimeout(pingTimerRef.current); + pingTimerRef.current = null; + } + if (pongTimerRef.current) { + clearTimeout(pongTimerRef.current); + pongTimerRef.current = null; + } + }, []); + + const flushPendingEcho = useCallback(() => { + if (pendingEchoRef.current.length > 0 && onLocalEcho) { + onLocalEcho(pendingEchoRef.current); + pendingEchoRef.current = ""; + } + }, [onLocalEcho]); + + const deduplicateServerData = useCallback((data: string): string => { + if (!echoEnabledRef.current || pendingEchoRef.current.length === 0) { + return data; + } + + let serverIndex = 0; + let echoIndex = 0; + + while ( + serverIndex < data.length && + echoIndex < pendingEchoRef.current.length && + data[serverIndex] === pendingEchoRef.current[echoIndex] + ) { + serverIndex++; + echoIndex++; + } + + if (echoIndex > 0) { + pendingEchoRef.current = pendingEchoRef.current.slice(echoIndex); + } + + return data.slice(serverIndex); + }, []); + + const handleBinaryMessage = useCallback( + (buffer: ArrayBuffer) => { + const bytes = new Uint8Array(buffer); + const text = new TextDecoder().decode(bytes); + + if (onData) { + onData(bytes); + } + + // Deduplicate local echo if active + if (echoEnabledRef.current && pendingEchoRef.current.length > 0) { + const remaining = deduplicateServerData(text); + if (remaining.length > 0 && onLocalEcho) { + onLocalEcho(remaining); + } + } else if (onLocalEcho) { + onLocalEcho(text); + } + + // Flush stale pending echo buffer + if (pendingEchoRef.current.length > PENDING_ECHO_FLUSH_LIMIT) { + flushPendingEcho(); + } + }, + [onData, onLocalEcho, deduplicateServerData, flushPendingEcho], + ); + + const handleControlMessage = useCallback( + (msg: ServerControlMessage) => { + if (onControl) { + onControl(msg); + } + + switch (msg.type) { + case "pong": { + const elapsed = Date.now() - pingSentAtRef.current; + setStableState((prev) => ({ + ...prev, + latency: elapsed, + status: prev.status === "reconnecting" ? "connected" : prev.status, + })); + break; + } + case "status": { + setStableState((prev) => ({ + ...prev, + status: "connected", + attempt: 0, + error: null, + })); + reconnectAttemptRef.current = 0; + break; + } + case "set_echo_state": { + echoEnabledRef.current = msg.enabled; + if (!msg.enabled) { + // Server disabled echo — flush any pending local echo + flushPendingEcho(); + } + break; + } + case "session_ended": { + setStableState((prev) => ({ + ...prev, + status: "disconnected", + error: `Session ended: ${msg.reason}`, + })); + clearTimers(); + wsRef.current?.close(1000); + break; + } + } + }, + [onControl, setStableState, clearTimers, flushPendingEcho], + ); + + const schedulePing = useCallback(() => { + pingTimerRef.current = setTimeout(() => { + const ws = wsRef.current; + if (!ws || ws.readyState !== WebSocket.OPEN) return; + + const id = ++pingIdRef.current; + pingSentAtRef.current = Date.now(); + const pingMsg: ClientControlMessage = { type: "ping", id }; + ws.send(encodeControlMessage(pingMsg)); + + // Set pong timeout + pongTimerRef.current = setTimeout(() => { + // Pong not received — connection is dead + ws.close(1001, "Ping timeout"); + }, PONG_TIMEOUT_MS); + }, PING_INTERVAL_MS); + }, []); + + const serializeScrollback = useCallback(() => { + if (!serializeFn) return; + try { + const serialized = serializeFn(); + if (serialized) { + sessionStorage.setItem( + `${SCROLLBACK_STORAGE_KEY}-${instanceId}`, + serialized, + ); + } + } catch { + // Ignore serialization errors + } + }, [serializeFn, instanceId]); + + const restoreScrollback = useCallback(() => { + if (!onRestoreScrollback) return; + try { + const key = `${SCROLLBACK_STORAGE_KEY}-${instanceId}`; + const serialized = sessionStorage.getItem(key); + if (serialized) { + onRestoreScrollback(serialized); + sessionStorage.removeItem(key); + } + } catch { + // Ignore restoration errors + } + }, [onRestoreScrollback, instanceId]); + + const connect = useCallback(() => { + if (isConnectingRef.current) return; + isConnectingRef.current = true; + + const apiUrl = import.meta.env.VITE_API_BASE_URL || ""; + const wsProtocol = window.location.protocol === "https:" ? "wss:" : "ws:"; + const wsHost = apiUrl.replace(/^https?:\/\//, "").replace(/\/+$/, ""); + const wsUrl = `${wsProtocol}//${wsHost}/ws/tool-instances/${instanceId}/terminal`; + + const ws = new WebSocket(wsUrl); + wsRef.current = ws; + + ws.onopen = () => { + isConnectingRef.current = false; + reconnectAttemptRef.current = 0; + setStableState((prev) => ({ + ...prev, + status: "connected", + attempt: 0, + error: null, + })); + restoreScrollback(); + schedulePing(); + }; + + ws.onmessage = (event: MessageEvent) => { + if (isControlMessage(event)) { + const msg = decodeControlMessage(event.data as string); + if (msg) { + handleControlMessage(msg); + } + } else if (event.data instanceof ArrayBuffer) { + handleBinaryMessage(event.data); + } else if (event.data instanceof Blob) { + event.data.arrayBuffer().then((buffer) => { + handleBinaryMessage(buffer); + }); + } + }; + + ws.onclose = (event: CloseEvent) => { + wsRef.current = null; + clearTimers(); + + if (event.code === 1000 || event.code === 1001) { + // Normal or going-away close + setStableState(() => ({ + status: "disconnected", + attempt: 0, + latency: null, + error: event.reason || null, + })); + return; + } + + // Unexpected close — attempt reconnect + const attempt = ++reconnectAttemptRef.current; + if (attempt > MAX_RECONNECT_ATTEMPTS) { + setStableState(() => ({ + status: "disconnected", + attempt, + latency: null, + error: "Max reconnection attempts exceeded", + })); + return; + } + + serializeScrollback(); + const delay = Math.min( + RECONNECT_BASE_MS * 2 ** (attempt - 1), + RECONNECT_MAX_MS, + ); + + setStableState((prev) => ({ + ...prev, + status: "reconnecting", + attempt, + error: `Reconnecting in ${Math.round(delay / 1000)}s...`, + })); + + reconnectTimerRef.current = setTimeout(() => { + connect(); + }, delay); + }; + + ws.onerror = () => { + isConnectingRef.current = false; + // Let onclose handle reconnection + }; + }, [ + instanceId, + setStableState, + clearTimers, + schedulePing, + handleBinaryMessage, + handleControlMessage, + serializeScrollback, + restoreScrollback, + ]); + + const sendInput = useCallback( + (data: string) => { + const ws = wsRef.current; + if (!ws || ws.readyState !== WebSocket.OPEN) return; + + // Local echo for printable ASCII characters + if ( + echoEnabledRef.current && + data.length === 1 && + data.charCodeAt(0) >= 32 && + data.charCodeAt(0) <= 126 + ) { + pendingEchoRef.current += data; + if (onLocalEcho) { + onLocalEcho(data); + } + } + + ws.send(data); + }, + [onLocalEcho], + ); + + const sendResize = useCallback((cols: number, rows: number) => { + if (resizeTimerRef.current) { + clearTimeout(resizeTimerRef.current); + } + + resizeTimerRef.current = setTimeout(() => { + const now = Date.now(); + if (now - lastResizeRef.current < RESIZE_THROTTLE_MS) { + return; + } + lastResizeRef.current = now; + + const ws = wsRef.current; + if (!ws || ws.readyState !== WebSocket.OPEN) return; + + const msg: ClientControlMessage = { type: "resize", cols, rows }; + ws.send(encodeControlMessage(msg)); + }, RESIZE_DEBOUNCE_MS); + }, []); + + const reconnect = useCallback(() => { + clearTimers(); + if (wsRef.current) { + wsRef.current.close(1000, "Manual reconnect"); + wsRef.current = null; + } + reconnectAttemptRef.current = 0; + setStableState(() => ({ + status: "connecting", + attempt: 0, + latency: null, + error: null, + })); + connect(); + }, [clearTimers, connect, setStableState]); + + // Initial connection + useEffect(() => { + connect(); + + return () => { + clearTimers(); + if (resizeTimerRef.current) { + clearTimeout(resizeTimerRef.current); + } + if (wsRef.current) { + wsRef.current.close(1000, "Component unmount"); + wsRef.current = null; + } + }; + }, [instanceId, connect, clearTimers]); + + // Keyboard shortcut for manual reconnect + useEffect(() => { + const handler = (e: KeyboardEvent) => { + if (e.ctrlKey && e.shiftKey && e.key === "R") { + e.preventDefault(); + reconnect(); + } + }; + window.addEventListener("keydown", handler); + return () => window.removeEventListener("keydown", handler); + }, [reconnect]); + + return { + state, + sendInput, + sendResize, + reconnect, + }; +} diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css index d7221cf..a03aaf4 100644 --- a/apps/web/src/styles.css +++ b/apps/web/src/styles.css @@ -2479,137 +2479,6 @@ a.nav-item, Terminal Styles ============================================ */ -.terminal-page { - display: flex; - flex-direction: column; - height: 100vh; - padding: var(--space-4); - gap: var(--space-4); -} - -.terminal-page-header { - display: flex; - align-items: center; - gap: var(--space-4); - flex-shrink: 0; -} - -.terminal-page-header h1 { - margin: 0; -} - -.terminal-wrapper { - display: flex; - flex-direction: column; - flex: 1; - min-height: 0; - border: 1px solid var(--border); - border-radius: 10px; - overflow: hidden; - background: #1e1e1e; -} - -.terminal-header { - display: flex; - justify-content: space-between; - align-items: center; - padding: var(--space-3) var(--space-4); - background: #2d2d2d; - border-bottom: 1px solid #3e3e3e; - flex-shrink: 0; -} - -.terminal-status { - display: flex; - align-items: center; - gap: var(--space-2); -} - -.status-dot { - width: 8px; - height: 8px; - border-radius: 50%; - background: #666; -} - -.status-dot.connecting { - background: #f5f543; - animation: pulse 1.5s infinite; -} - -.status-dot.connected { - background: #0dbc79; -} - -.status-dot.disconnected, -.status-dot.error { - background: #cd3131; -} - -@keyframes pulse { - 0%, - 100% { - opacity: 1; - } - 50% { - opacity: 0.5; - } -} - -.status-text { - font-size: 0.875rem; - color: #d4d4d4; - text-transform: capitalize; -} - -.terminal-close { - padding: var(--space-1) var(--space-3); - background: transparent; - border: 1px solid #666; - border-radius: 6px; - color: #d4d4d4; - cursor: pointer; - font-size: 0.875rem; -} - -.terminal-close:hover { - background: #3e3e3e; -} - -.terminal-error { - padding: var(--space-3) var(--space-4); - background: #cd3131; - color: white; - font-size: 0.875rem; - flex-shrink: 0; -} - -.terminal-container { - flex: 1; - min-height: 0; - padding: var(--space-2); -} - -.terminal-container .xterm { - height: 100%; -} - -.terminal-container .xterm-viewport { - background: #1e1e1e !important; -} - -/* Responsive terminal */ -@media (max-width: 767px) { - .terminal-page { - padding: var(--space-2); - gap: var(--space-2); - } - - .terminal-page-header h1 { - font-size: 1.25rem; - } -} - /* ============================================ Sessions Page Styles ============================================ */ @@ -2805,3 +2674,167 @@ a.nav-item, background: var(--danger-light, #fee2e2); color: var(--danger, #dc2626); } + +/* ============================================ + Responsive Terminal — Updated + ============================================ */ + +.terminal-wrapper { + position: relative; + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; + border: 1px solid var(--border); + border-radius: 10px; + overflow: hidden; + background: #1e1e1e; +} + +.terminal-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.5rem 0.75rem; + background: #2d2d2d; + border-bottom: 1px solid #3e3e3e; + flex-shrink: 0; + gap: 0.5rem; +} + +.terminal-status { + display: flex; + align-items: center; + gap: 0.5rem; + min-width: 0; +} + +.terminal-status .status-dot { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; +} + +.terminal-status .status-text { + font-size: 0.8rem; + color: #d4d4d4; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.terminal-actions { + display: flex; + gap: 0.5rem; + align-items: center; + flex-shrink: 0; +} + +.terminal-close { + padding: 0.25rem 0.6rem; + background: transparent; + border: 1px solid #666; + border-radius: 6px; + color: #d4d4d4; + cursor: pointer; + font-size: 0.8rem; +} + +.terminal-close:hover { + background: #3e3e3e; +} + +.terminal-container { + flex: 1; + min-height: 0; + padding: 0.25rem; +} + +.terminal-container .xterm { + height: 100%; +} + +.terminal-container .xterm-viewport { + background: #1e1e1e !important; +} + +/* Terminal overlay for session ended */ +.terminal-overlay { + position: absolute; + inset: 0; + background: rgba(0, 0, 0, 0.75); + display: grid; + place-content: center; + z-index: 10; +} + +.terminal-overlay-content { + background: #2d2d2d; + border: 1px solid #3e3e3e; + border-radius: 10px; + padding: 1.5rem; + text-align: center; + max-width: 400px; + color: #d4d4d4; +} + +.terminal-overlay-content h3 { + margin: 0 0 0.5rem; + color: #f14c4c; +} + +.terminal-overlay-content p { + margin: 0 0 1rem; + font-size: 0.9rem; +} + +.terminal-overlay-actions { + display: flex; + gap: 0.5rem; + justify-content: center; +} + +/* Reconnect banner */ +.terminal-reconnect-banner { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.4rem 0.75rem; + background: #3e3e3e; + color: #f5f543; + font-size: 0.8rem; + flex-shrink: 0; +} + +.spinner { + display: inline-block; + width: 12px; + height: 12px; + border: 2px solid currentColor; + border-right-color: transparent; + border-radius: 50%; + animation: spin 0.75s linear infinite; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +/* Responsive terminal */ +@media (max-width: 767px) { + .terminal-page { + padding: var(--space-2); + gap: var(--space-2); + } + + .terminal-page-header h1 { + font-size: 1.25rem; + } + + .terminal-overlay-content { + margin: 0 1rem; + } +} diff --git a/apps/web/src/types/terminal.ts b/apps/web/src/types/terminal.ts new file mode 100644 index 0000000..3c897f0 --- /dev/null +++ b/apps/web/src/types/terminal.ts @@ -0,0 +1,82 @@ +/** + * WebSocket protocol types for the responsive terminal. + * + * Binary frames carry raw terminal I/O. + * Text (JSON) frames carry control messages. + */ + +// ── Client → Server ── + +export interface PingMessage { + type: "ping"; + id: number; +} + +export interface PongMessage { + type: "pong"; + id: number; +} + +export interface ResizeMessage { + type: "resize"; + cols: number; + rows: number; +} + +export interface InputMessage { + type: "input"; + data: string; // base64-encoded bytes +} + +export type ClientControlMessage = + | PingMessage + | PongMessage + | ResizeMessage + | InputMessage; + +// ── Server → Client ── + +export interface ServerPongMessage { + type: "pong"; + id: number; +} + +export type ConnectionStatus = "connected" | "reconnected"; + +export interface StatusMessage { + type: "status"; + status: ConnectionStatus; +} + +export interface SetEchoStateMessage { + type: "set_echo_state"; + enabled: boolean; +} + +export type SessionEndReason = "process_exit" | "container_stop" | "timeout"; + +export interface SessionEndedMessage { + type: "session_ended"; + reason: SessionEndReason; +} + +export type ServerControlMessage = + | ServerPongMessage + | StatusMessage + | SetEchoStateMessage + | SessionEndedMessage; + +// ── Connection state ── + +export type TerminalConnectionStatus = + | "connecting" + | "connected" + | "reconnecting" + | "disconnected"; + +export interface TerminalConnectionState { + status: TerminalConnectionStatus; + attempt: number; + latency: number | null; + error: string | null; +} diff --git a/apps/web/src/utils/terminal-protocol.ts b/apps/web/src/utils/terminal-protocol.ts new file mode 100644 index 0000000..0abb9fe --- /dev/null +++ b/apps/web/src/utils/terminal-protocol.ts @@ -0,0 +1,76 @@ +import type { + ClientControlMessage, + ServerControlMessage, +} from "../types/terminal"; + +/** + * Encode a client control message to a JSON string for sending over WebSocket. + */ +export function encodeControlMessage(msg: ClientControlMessage): string { + return JSON.stringify(msg); +} + +/** + * Decode a server control message from a JSON string. + * Returns null if the data is not valid JSON or not a recognized control message. + */ +export function decodeControlMessage( + data: string, +): ServerControlMessage | null { + try { + const parsed = JSON.parse(data) as unknown; + if (!isServerControlMessage(parsed)) { + return null; + } + return parsed; + } catch { + return null; + } +} + +/** + * Check whether a WebSocket message is a control message (JSON text frame) + * or raw binary data. + */ +export function isControlMessage(event: MessageEvent): boolean { + return typeof event.data === "string"; +} + +/** + * Encode raw input bytes to a base64 string for the `input` control message. + */ +export function encodeInputData(data: string): string { + return btoa(unescape(encodeURIComponent(data))); +} + +/** + * Decode base64 input data back to a string. + */ +export function decodeInputData(data: string): string { + return decodeURIComponent(escape(atob(data))); +} + +// ── Type guards ── + +function isServerControlMessage(value: unknown): value is ServerControlMessage { + if (typeof value !== "object" || value === null) return false; + const obj = value as Record; + if (typeof obj.type !== "string") return false; + + switch (obj.type) { + case "pong": + return typeof obj.id === "number"; + case "status": + return obj.status === "connected" || obj.status === "reconnected"; + case "set_echo_state": + return typeof obj.enabled === "boolean"; + case "session_ended": + return ( + obj.reason === "process_exit" || + obj.reason === "container_stop" || + obj.reason === "timeout" + ); + default: + return false; + } +} diff --git a/openspec/changes/responsive-terminal/design.md b/openspec/changes/responsive-terminal/design.md new file mode 100644 index 0000000..9cb560b --- /dev/null +++ b/openspec/changes/responsive-terminal/design.md @@ -0,0 +1,371 @@ +# Design: Responsive Web Terminal + +## Architecture Overview + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ BROWSER │ +│ ┌──────────────┐ ┌─────────────────┐ ┌──────────────────────────┐ │ +│ │ TerminalPage │ │ TerminalComponent │ │ TerminalConnection │ │ +│ │ (router) │◄──│ (xterm.js + UI) │◄──│ (WS + heartbeat + echo) │ │ +│ └──────────────┘ └─────────────────┘ └──────────────────────────┘ │ +│ │ │ │ +│ ┌─────┴─────┐ ┌──────┴──────┐ │ +│ │ xterm.js │ │ sessionStorage│ │ +│ │ + addons │ │ (scrollback) │ │ +│ └───────────┘ └───────────────┘ │ +└─────────────────────────────────────────────────────────────────────────┘ + │ WebSocket + ▼ +┌─────────────────────────────────────────────────────────────────────────┐ +│ FASTAPI │ +│ ┌──────────────────┐ ┌──────────────────┐ ┌─────────────────────┐ │ +│ │ terminal.py │ │ TerminalManager │ │ TerminalSession │ │ +│ │ (WS endpoint) │◄──│ (session mgmt) │◄──│ (PTY + docker exec) │ │ +│ └──────────────────┘ └──────────────────┘ └─────────────────────┘ │ +│ │ │ +│ ┌────┴────┐ │ +│ │ docker │ │ +│ │ exec │ │ +│ └─────────┘ │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +## Connection State Machine + +### Client State Machine + +``` + ┌─────────────┐ + │ IDLE │ + └──────┬──────┘ + │ mount + ▼ + ┌─────────────┐ + │ CONNECTING │◄────────────────────────┐ + └──────┬──────┘ │ + │ onopen │ + ▼ │ + ┌─────────────────────────┐ │ + │ CONNECTED │ │ + │ (heartbeat active) │ │ + └──────┬──────────┬───────┘ │ + │ │ │ + onclose/ │ │ ping timeout │ + onerror │ │ │ + ▼ ▼ │ + ┌─────────────────────────┐ │ + │ RECONNECTING │───────────────────┘ + │ (backoff: 1→2→4→8→30s) │ onopen (success) + └──────┬──────────────────┘ + │ max retries (10) + ▼ + ┌─────────────────────────┐ + │ DISCONNECTED │ + │ (manual reconnect │ + │ or navigate away) │ + └─────────────────────────┘ +``` + +### Server State Machine (per session) + +``` + ┌─────────────┐ + │ PENDING │ + └──────┬──────┘ + │ ws.accept() + ▼ + ┌─────────────┐ + ┌────►│ ACTIVE │◄────┐ + │ │ (I/O loops │ │ + │ │ + heartbeat) │ + │ └──────┬──────┘ │ + │ │ │ + │ ws close│ new ws │ + │ ▼ │ + │ ┌─────────────┐ │ + └─────┤ CLOSED ├──────┘ + │ (cleanup) │ + └─────────────┘ +``` + +## Protocol Specification + +### Message Types + +All control messages are JSON text frames. Raw terminal I/O uses binary frames. + +#### Client → Server + +| Type | Payload | When | +|------|---------|------| +| `ping` | `{ id: number }` | Every 15s of inactivity | +| `pong` | `{ id: number }` | Response to server ping | +| `resize` | `{ cols: number, rows: number }` | Terminal size changes (debounced) | +| `input` | `{ data: string }` | User keystrokes (base64-encoded) | + +#### Server → Client + +| Type | Payload | When | +|------|---------|------| +| `pong` | `{ id: number }` | Response to client ping | +| `status` | `{ status: "connected" \| "reconnected" }` | After auth + session ready | +| `set_echo_state` | `{ enabled: boolean }` | When PTY echo flag changes | +| `session_ended` | `{ reason: string }` | When container process exits | + +### Binary Frame Convention + +- **Client → Server:** Raw UTF-8 bytes of user input. No wrapping. +- **Server → Client:** Raw bytes from PTY master read. No wrapping. + +This avoids the current Blob→ArrayBuffer async conversion and JSON parsing overhead for the hot path. + +## Frontend Design + +### New Files + +``` +apps/web/src/ +├── components/ +│ └── terminal.tsx (rewrite: state machine + reconnect) +├── hooks/ +│ └── use-terminal-connection.ts (NEW: WS lifecycle, heartbeat, reconnect) +├── utils/ +│ └── terminal-protocol.ts (NEW: message encoding/decoding) +└── types/ + └── terminal.ts (NEW: protocol types) +``` + +### `useTerminalConnection` Hook + +Responsibilities: +1. **WebSocket lifecycle:** Open, close, reconnect with backoff +2. **Heartbeat:** Send ping every 15s, expect pong within 5s +3. **Local echo:** Write printable chars to xterm immediately, deduplicate server echo +4. **Resize:** Debounce resize events, send JSON control message +5. **Scrollback:** Serialize on disconnect, restore on reconnect +6. **State reporting:** Expose `status`, `latency`, `attempt` to UI + +```typescript +interface TerminalConnectionState { + status: "connecting" | "connected" | "reconnecting" | "disconnected"; + attempt: number; + latency: number | null; // last RTT in ms + error: string | null; +} + +interface TerminalConnection { + state: TerminalConnectionState; + sendInput: (data: string) => void; + sendResize: (cols: number, rows: number) => void; + reconnect: () => void; // manual, bypasses backoff + onData: (callback: (data: Uint8Array) => void) => void; + onControl: (callback: (msg: ServerControlMessage) => void) => void; +} +``` + +### Local Echo Algorithm + +``` +1. User types character c +2. IF c is printable ASCII AND echo is enabled: + a. Write c to xterm immediately + b. Add c to "pending echo" buffer + c. Send c to server via WebSocket +3. ELSE (control char, arrow, escape sequence): + a. Send c to server only + b. Do NOT write to xterm +4. When server sends data: + a. For each char in server data: + - IF char matches head of "pending echo" buffer: + → Pop from buffer (deduplication) + - ELSE: + → Write char to xterm + b. If "pending echo" buffer grows > 100 chars (stale): + → Flush buffer to xterm (server echo was lost) +``` + +### Scrollback Serialization + +``` +ON disconnect: + 1. buffer = xterm.serialize({ scrollback: 10000 }) + 2. sessionStorage.setItem(`hq-terminal-${instanceId}`, buffer) + +ON reconnect: + 1. buffer = sessionStorage.getItem(`hq-terminal-${instanceId}`) + 2. IF buffer: + xterm.write(buffer) + xterm.write("\r\n\x1b[90m--- Reconnected ---\x1b[0m\r\n") + 3. sessionStorage.removeItem(`hq-terminal-${instanceId}`) +``` + +### Resize Debouncing + +Use `ResizeObserver` on the terminal container instead of `window.resize`: + +```typescript +const resizeObserver = new ResizeObserver( + debounce((entries) => { + fitAddon.fit(); + sendResize(term.cols, term.rows); + }, 200) +); +``` + +Rate limit: max 1 resize message per 500ms. + +## Backend Design + +### Modified Files + +``` +apps/api/src/ +├── api/terminal.py (modify: ping/pong, session_ended) +├── services/terminal_manager.py (rewrite: heartbeat tracking, batching) +└── services/terminal_session.py (modify: batching read, echo detection) +``` + +### TerminalManager Changes + +**Heartbeat tracking:** +- Track `last_ping_at` per session +- Background task: if `last_ping_at` is older than 60s, close the WebSocket + +**Message batching in read_loop:** +```python +async def _read_loop(self, session, websocket): + buffer = bytearray() + last_flush = time.monotonic() + + while session.is_alive() and not session._closed: + data = await session.read_output() + if data: + buffer.extend(data) + + now = time.monotonic() + if buffer and (now - last_flush >= 0.016 or not data): + await websocket.send_bytes(bytes(buffer)) + buffer.clear() + last_flush = now + elif not data: + await asyncio.sleep(0.001) +``` + +**Reconnect support:** +- When a new WebSocket connects for the same instance, terminate the old session and spawn a new one +- This is the docker exec limitation — we cannot resume a PTY, only replace it + +### TerminalSession Changes + +**Echo state detection:** +```python +import termios + +def _detect_echo_state(self) -> bool: + if self._master_fd is None: + return True + try: + attrs = termios.tcgetattr(self._master_fd) + return bool(attrs[3] & termios.ECHO) + except: + return True +``` + +Call `_detect_echo_state()` after each resize and periodically (every 1s) during active I/O. Send `set_echo_state` to client when it changes. + +**Batch-friendly read:** +- Change `read_output()` to use `asyncio.wait_for(select, timeout)` instead of blocking `select.select` with 0.1s timeout +- Return immediately when data is available, sleep briefly when not + +### Terminal Endpoint Changes + +- Accept `ping` messages, respond with `pong` +- On session end (process exit), send `session_ended` before closing with code 1000 +- Distinguish between container exit (friendly) and error (unexpected) + +## Data Flow: Typing with Local Echo + +``` +User presses 'a' + │ + ▼ +┌─────────────────┐ +│ onData handler │──► xterm.write('a') [instant feedback] +│ │──► pendingEcho.push('a') +│ │──► ws.send(binary 'a') +└─────────────────┘ + │ + ▼ (network) +┌─────────────────┐ +│ TerminalSession │──► os.write(master_fd, b'a') +│ │──► docker exec PTY echoes 'a' back +│ │──► os.read(master_fd) → b'a' +└─────────────────┘ + │ + ▼ (WebSocket) +┌─────────────────┐ +│ onMessage │──► data = b'a' +│ (binary frame) │──► IF data[0] == pendingEcho[0]: +│ │ pendingEcho.shift() // dedup +│ │ ELSE: +│ │ xterm.write(data) +└─────────────────┘ +``` + +## Data Flow: Reconnection + +``` +WebSocket closes (code 1006) + │ + ▼ +┌─────────────────┐ +│ ConnectionState │──► status = "reconnecting" +│ │──► attempt = 1 +│ │──► scrollback = xterm.serialize() +│ │──► sessionStorage.setItem(key, scrollback) +│ │──► schedule reconnect in 1s +└─────────────────┘ + │ + ▼ (1s later) +┌─────────────────┐ +│ Reconnect │──► new WebSocket(url) +│ │──► onopen: send scrollback from storage +│ │──► xterm.write(restored + divider) +│ │──► status = "connected" +└─────────────────┘ +``` + +## Component Responsibilities + +| Component | Responsibilities | +|-----------|-----------------| +| `TerminalPage` | Routing, layout, back button | +| `TerminalComponent` | xterm.js lifecycle, addons, theme, status bar UI | +| `useTerminalConnection` | WebSocket, heartbeat, reconnect, local echo, resize | +| `terminal-protocol` | Encode/decode control messages, base64 helper | +| `terminal.py` (API) | Auth, WebSocket accept, route control messages | +| `TerminalManager` | Session lifecycle, heartbeat tracking, read/write loops | +| `TerminalSession` | PTY + docker exec, echo detection, batching read | + +## Tradeoffs + +| Decision | Option A (Chosen) | Option B | Why A | +|----------|-------------------|----------|-------| +| **Reconnect strategy** | Exponential backoff, max 30s | Instant reconnect with no backoff | Backoff prevents server overload during outages | +| **Local echo scope** | Printable ASCII only | All characters | Control chars/escapes need server-side processing (shell state) | +| **Scrollback storage** | `sessionStorage` (tab-scoped) | `localStorage` (persistent) | Privacy: terminal may contain secrets | +| **Scrollback cap** | 10,000 lines | Unlimited | Memory safety; 10K lines covers typical session | +| **Heartbeat interval** | 15s client → server | 5s | Balance between detection speed and server load | +| **Binary vs text I/O** | Binary frames for raw data | JSON-wrapped base64 | Binary is ~33% more efficient, zero parse overhead | +| **Resize trigger** | ResizeObserver on container | window.resize | Container-level is more accurate for flex layouts | +| **Echo detection** | Server inspects PTY termios | Client guesses from input | Server is authoritative; client cannot know shell state | +| **New docker exec on reconnect** | Accept limitation | Implement persistent session | PTY resumption across connections is extremely complex; scrollback continuity is the pragmatic fix | + +## Quality Gates + +- `cd apps/web && npm run typecheck` — TypeScript compiles +- `cd apps/web && npm run lint` — ESLint passes +- `cd apps/web && npm test` — Vitest passes (new tests for protocol + hook) +- `make test` — Backend pytest passes +- Manual test: disconnect/reconnect, type latency, resize, container exit diff --git a/openspec/changes/responsive-terminal/explore.md b/openspec/changes/responsive-terminal/explore.md new file mode 100644 index 0000000..7a38d0b --- /dev/null +++ b/openspec/changes/responsive-terminal/explore.md @@ -0,0 +1,59 @@ +# Explore: Responsive Web Terminal + +## Problem Statement + +The current web terminal feels sluggish and fragile compared to a local terminal session. Key pain points: + +1. **No reconnection** — A brief network hiccup kills the terminal. Users must navigate away and back. +2. **No heartbeat** — Half-open connections stall silently. No way to know if the terminal is alive. +3. **High input latency** — Every keystroke round-trips to the server before appearing on screen. No local echo. +4. **Inefficient I/O path** — Backend `select` polling with 0.1s timeout, 4096-byte reads, busy-wait sleep(0.01). Frontend receives Blob and converts to ArrayBuffer asynchronously. +5. **No scrollback persistence** — Reconnect starts with a blank terminal. Session history is lost. +6. **Rudimentary resize** — Fires on every window resize event with no debouncing. +7. **No connection quality feedback** — Binary status (connected/disconnected). No latency or health indicator. +8. **No graceful container exit handling** — Process death closes WebSocket with a generic error. + +## Current Architecture + +### Frontend +- `apps/web/src/components/terminal.tsx` — xterm.js v5.3.0 with FitAddon and WebLinksAddon +- WebSocket to `/ws/tool-instances/{instance_id}/terminal` +- Receives Blob (binary) and string (JSON control) messages +- Sends raw bytes for input, JSON for resize +- Basic status: connecting | connected | disconnected | error + +### Backend +- `apps/api/src/api/terminal.py` — FastAPI WebSocket endpoint, auth, session lifecycle +- `apps/api/src/services/terminal_manager.py` — Manages TerminalSession, read/write loops +- `apps/api/src/services/terminal_session.py` — PTY-based `docker exec` with `select` I/O +- Protocol: raw bytes for terminal I/O, JSON for resize control messages + +### Gaps vs. Local Terminal Feel + +| Aspect | Local Terminal | Current Web Terminal | +|--------|---------------|----------------------| +| Keystroke feedback | Immediate (kernel TTY) | Round-trip (~50-200ms) | +| Network resilience | N/A (local) | Dies on any disconnect | +| Scrollback | Persistent | Lost on reconnect | +| Resize | Instant | Undebounced, may spam | +| Health visibility | Always local | Binary connected/disconnected | +| Large output | Buffered by kernel | Select polling, 4KB chunks | + +## Opportunities + +- **WebSocket reconnection with exponential backoff** and session token for continuity +- **Heartbeat/ping-pong** to detect half-open connections within seconds +- **Local echo optimization** for printable characters (with server-side authoritative sync) +- **Message batching** on backend to reduce WebSocket frame overhead +- **Scrollback serialization** via xterm-addon-serialize to restore on reconnect +- **Resize debouncing** to avoid flooding the server +- **Connection quality indicator** (latency, jitter) in the terminal chrome +- **Graceful handling** of container exit with clear user messaging + +## Risks + +- Adding heartbeat may increase server load with many concurrent terminals +- Local echo requires careful handling of password prompts and special modes +- Reconnecting to a docker exec PTY is not natively resumable — new `docker exec` on reconnect +- xterm-addon-serialize may be large for very long sessions +- Changes touch both frontend and backend — cross-stack coordination needed diff --git a/openspec/changes/responsive-terminal/proposal.md b/openspec/changes/responsive-terminal/proposal.md new file mode 100644 index 0000000..2fe83e8 --- /dev/null +++ b/openspec/changes/responsive-terminal/proposal.md @@ -0,0 +1,77 @@ +# Proposal: Responsive Web Terminal + +## Problem Statement + +The web terminal in Headquarter feels sluggish and fragile compared to a local terminal session. Users experience high input latency (every keystroke round-trips to the server before appearing), lose their session on any network blip, and have no visibility into connection health. This makes the terminal the weakest part of the workspace experience, especially for users on slower or unstable networks. + +## User Stories + +### US-1: Network Resilience +> As a developer working on a laptop with WiFi, +> I want the terminal to survive brief disconnections (up to ~30 seconds), +> so that a network hiccup does not kill my running process and scrollback. + +### US-2: Responsive Typing +> As a developer typing commands or code in the terminal, +> I want keystrokes to appear on screen instantly, +> so that the terminal feels like a local TTY and not a remote typewriter. + +### US-3: Session Continuity +> As a developer who accidentally refreshed the page, +> I want my terminal scrollback and state to be restored on reconnect, +> so that I do not lose context of what I was doing. + +### US-4: Connection Health Visibility +> As a developer on a slow or congested network, +> I want to see clear feedback about connection quality and reconnection attempts, +> so that I understand whether lag is from the server, the container, or my network. + +### US-5: Graceful Container Exit +> As a developer whose container process has finished, +> I want to see a clear message explaining what happened and options to reconnect or go back, +> so that I am not confused by a generic "Connection closed" error. + +## Success Metrics + +| Metric | Current | Target | +|--------|---------|--------| +| Time-to-reconnect after disconnect | ∞ (must navigate away) | < 5 seconds | +| Typing latency (median) | ~100-300ms | < 50ms perceived | +| Scrollback lost on reconnect | 100% | 0% (restored from serialization) | +| Silent connection stalls detected | 0% | 100% within 10 seconds | +| User confusion on container exit | High | Low (clear messaging) | + +## Scope + +### In Scope +- WebSocket auto-reconnection with exponential backoff +- Heartbeat/ping-pong protocol between client and server +- Local echo for printable characters (with server authoritative sync) +- Resize debouncing to avoid server spam +- Scrollback serialization via xterm-addon-serialize on disconnect +- Scrollback restoration on reconnect +- Connection quality indicator (latency, status) in terminal chrome +- Graceful container exit handling with user-friendly messaging +- Backend message batching for large output bursts + +### Out of Scope (for this change) +- Full terminal session recording/playback +- Multi-user collaborative terminal sessions +- Terminal session persistence across server restarts +- Clipboard integration improvements (separate feature) +- Terminal search/find (separate feature) + +## Risks & Mitigations + +| Risk | Likelihood | Impact | Mitigation | +|------|-----------|--------|------------| +| Heartbeat increases server load with many terminals | Medium | Medium | Use 15s heartbeat interval; skip during idle periods | +| Local echo breaks password prompts | Medium | High | Disable local echo when terminal is in "no echo" mode; server sends echo-state control messages | +| Scrollback serialization is large for long sessions | Low | Medium | Cap serialization at 10,000 lines; compress before send | +| Reconnect spawns new docker exec = new shell | Certain | Low | Accept as limitation; focus on scrollback continuity and clear messaging | +| Cross-stack changes introduce regressions | Medium | High | Comprehensive test coverage; fresh review before merge | + +## Approval + +- [ ] Approved +- [ ] Needs revision diff --git a/openspec/changes/responsive-terminal/spec.md b/openspec/changes/responsive-terminal/spec.md new file mode 100644 index 0000000..2a03a59 --- /dev/null +++ b/openspec/changes/responsive-terminal/spec.md @@ -0,0 +1,153 @@ +# Spec: Responsive Web Terminal + +## Overview + +Upgrade the web terminal from a fragile single-shot WebSocket into a resilient, responsive terminal that survives network blips, provides instant typing feedback, restores scrollback on reconnect, and gives users clear visibility into connection health. + +## Acceptance Criteria + +### AC-1: WebSocket Auto-Reconnection + +**GIVEN** a terminal is connected to a running instance +**WHEN** the WebSocket disconnects (network hiccup, server restart, proxy timeout) +**THEN** the client automatically reconnects with exponential backoff (1s, 2s, 4s, 8s, max 30s) +**AND** the user sees a reconnection indicator showing attempt count and next retry time +**AND** after successful reconnection, the terminal scrollback is restored +**AND** a new `docker exec` session is spawned transparently + +**Test:** Disconnect WiFi for 5s, verify reconnect and scrollback intact. + +### AC-2: Heartbeat / Ping-Pong Protocol + +**GIVEN** a terminal connection is established +**WHEN** 15 seconds pass with no data exchanged +**THEN** the client sends a `ping` control message +**AND** the server responds with a `pong` within 5 seconds +**AND** if no `pong` is received within 5 seconds, the client treats the connection as dead and begins reconnection +**AND** the server closes WebSockets that have not sent any message (including ping) for 60 seconds + +**Test:** Block server responses with firewall rule, verify connection declared dead within 20s and reconnection starts. + +### AC-3: Local Echo for Reduced Typing Latency + +**GIVEN** the terminal is in a normal interactive shell +**WHEN** the user types printable ASCII characters +**THEN** they appear on screen immediately (local echo) without waiting for the server round-trip +**AND** when the server sends the authoritative echo back, the client reconciles (deduplicates) +**AND** when the server sends a `set_echo_state` control message with `enabled: false` (e.g., for password prompts), local echo is disabled +**AND** when `set_echo_state` with `enabled: true` is received, local echo is re-enabled + +**Test:** Type `echo hello` — characters appear instantly. Run `sudo` — local echo stops during password prompt. + +### AC-4: Resize Debouncing + +**GIVEN** the user is resizing the browser window +**WHEN** the terminal dimensions change +**THEN** resize events are debounced by 200ms +**AND** only the final dimensions after the user stops resizing are sent to the server +**AND** at most one resize message is sent per 500ms + +**Test:** Rapidly resize window 10 times in 1s — verify only 1-2 resize messages sent. + +### AC-5: Scrollback Serialization and Restoration + +**GIVEN** a terminal has been in use with output history +**WHEN** a disconnect occurs +**THEN** the client serializes the terminal buffer (via xterm-addon-serialize, capped at 10,000 lines) +**AND** stores it in `sessionStorage` under key `hq-terminal-{instance_id}` +**AND** on successful reconnection, the serialized content is written back into the terminal before new output +**AND** a visual divider line indicates "--- Reconnected ---" between old and new output + +**Test:** Run `ls -la` 50 times, disconnect, reconnect — verify all output visible with divider. + +### AC-6: Connection Quality Indicator + +**GIVEN** the terminal is connected +**THEN** the status bar shows: +- Green dot + "Connected" when healthy (latency < 100ms) +- Yellow dot + "Slow" when latency is 100-500ms +- Red dot + "Reconnecting (N)" during reconnection attempts +- Gray dot + "Disconnected" when permanently disconnected (max retries exceeded) +**AND** hovering the status dot shows a tooltip with round-trip latency (ms) and jitter +**AND** the indicator updates every 5 seconds + +**Test:** Use network throttling in dev tools to simulate slow connection, verify indicator changes. + +### AC-7: Graceful Container Exit + +**GIVEN** a terminal session is active +**WHEN** the container process exits (shell terminates, container stops) +**THEN** the terminal shows a clear message: "Session ended. The container process has exited." +**AND** a "Reconnect" button is shown to spawn a new session +**AND** a "Go Back" button navigates to the previous page +**AND** the WebSocket closes with code 1000 (normal) instead of an error code + +**Test:** Run `exit` in the terminal, verify friendly message and buttons appear. + +### AC-8: Backend Message Batching + +**GIVEN** a container process is producing output rapidly +**WHEN** the backend PTY produces multiple small reads within a single event loop tick +**THEN** the backend batches them into a single WebSocket binary frame +**AND** batching does not add more than 16ms of latency +**AND** the batch is flushed immediately when no new data is available + +**Test:** Run `yes | head -n 10000` and measure WebSocket frame count vs. current implementation. + +### AC-9: Keyboard Shortcut for Reconnect + +**GIVEN** the terminal is disconnected +**WHEN** the user presses `Ctrl+Shift+R` +**THEN** an immediate reconnection attempt is triggered (bypassing backoff) + +**Test:** Disconnect terminal, press `Ctrl+Shift+R`, verify immediate reconnect attempt. + +## API / Protocol Changes + +### WebSocket Control Messages (JSON) + +```typescript +// Client → Server +type ClientMessage = + | { type: "ping"; id: number } + | { type: "pong"; id: number } + | { type: "resize"; cols: number; rows: number } + | { type: "input"; data: string } // base64-encoded bytes + +// Server → Client +type ServerMessage = + | { type: "pong"; id: number } + | { type: "status"; status: "connected" | "reconnected" } + | { type: "set_echo_state"; enabled: boolean } + | { type: "session_ended"; reason: "process_exit" | "container_stop" | "timeout" } +``` + +### Binary Frames + +- Raw terminal output from server → client: binary WebSocket frame (no wrapping) +- Raw terminal input from client → server: binary WebSocket frame (no wrapping) +- Control messages (resize, ping, etc.): text JSON frames + +## Dependencies + +### Frontend +- `xterm-addon-serialize` — scrollback serialization +- `xterm-addon-webgl` (optional) — GPU rendering for smoother feel + +### Backend +- No new Python dependencies required +- Uses existing `asyncio`, `fastapi`, `websockets` + +## Non-Functional Requirements + +- **Latency:** Perceived typing latency < 50ms for local echo characters +- **Reconnection time:** < 5 seconds for transient disconnects +- **Memory:** Scrollback serialization capped at 10,000 lines (~2-5MB worst case) +- **Server load:** Heartbeat interval 15s; max 4 pings/minute per terminal +- **Browser support:** Chrome 90+, Firefox 88+, Safari 14+ (all support required WebSocket features) + +## Open Questions + +1. Should we add a "full screen" button to the terminal chrome? (Nice-to-have, out of scope for this change) +2. Should scrollback be persisted across full page reloads (via `localStorage`) or only during session (`sessionStorage`)? — **Decision:** Use `sessionStorage` to avoid leaking sensitive data. +3. Should the server echo-state detection be automatic (TIOCGWINSZ / stty inspection) or manual (client tells server)? — **Decision:** Server detects via PTY state inspection; sends `set_echo_state` to client. diff --git a/openspec/changes/responsive-terminal/tasks.md b/openspec/changes/responsive-terminal/tasks.md new file mode 100644 index 0000000..4df014d --- /dev/null +++ b/openspec/changes/responsive-terminal/tasks.md @@ -0,0 +1,213 @@ +# Tasks: Responsive Web Terminal + +## Review Workload Forecast + +| Task | Estimated Lines | Stack | Risk | +|------|----------------|-------|------| +| T1: Protocol types + utilities | ~120 | Frontend | Low | +| T2: Backend heartbeat + batching | ~200 | Backend | Medium | +| T3: Backend echo detection + graceful exit | ~150 | Backend | Medium | +| T4: useTerminalConnection hook | ~280 | Frontend | High | +| T5: TerminalComponent rewrite | ~250 | Frontend | High | +| T6: Frontend tests | ~180 | Frontend | Low | +| T7: Backend tests | ~120 | Backend | Low | +| **Total** | **~1,300** | | | + +**Review recommendation:** This exceeds the 400-line budget. Split into **3 chained PRs**: +1. **PR-1 (Backend foundation):** T1 protocol types + T2 heartbeat/batching + T3 echo/exit + T7 backend tests (~590 lines) +2. **PR-2 (Frontend connection):** T4 useTerminalConnection hook + T6 frontend hook tests (~460 lines) +3. **PR-3 (Terminal UI + integration):** T5 TerminalComponent rewrite + page integration + remaining tests (~250 lines) + +--- + +## Task T1: Protocol Types and Utilities + +**Files:** +- `apps/web/src/types/terminal.ts` (new) +- `apps/web/src/utils/terminal-protocol.ts` (new) +- `apps/web/package.json` (add `xterm-addon-serialize`) + +**Description:** +Define TypeScript types for all WebSocket control messages. Implement encode/decode helpers that distinguish binary frames (raw terminal I/O) from JSON text frames (control messages). Add base64 encoding for the `input` control message type. Install `xterm-addon-serialize` dependency. + +**Acceptance:** +- All message types from the design spec are represented as TypeScript types +- `encodeControlMessage` and `decodeControlMessage` functions handle JSON serialization +- `isControlMessage` helper correctly identifies text vs binary frames +- `npm install` completes without lockfile conflicts + +**Depends on:** None +**Estimated:** 2 hours + +--- + +## Task T2: Backend Heartbeat and Message Batching + +**Files:** +- `apps/api/src/services/terminal_manager.py` +- `apps/api/src/api/terminal.py` + +**Description:** +Rewrite `TerminalManager` read loop to batch small reads into single WebSocket frames (max 16ms buffering). Add heartbeat tracking: server records `last_client_message_at` timestamp, and a background task closes WebSockets idle for 60s. Update `terminal.py` endpoint to accept `ping` control messages and respond with `pong`. Handle binary input frames (not just text JSON). + +**Acceptance:** +- Backend sends batched binary frames; `yes | head -n 10000` produces fewer WebSocket frames than before +- Server responds to `ping` with matching `pong` within 100ms +- Server closes idle connections after 60s of no client messages +- Backend accepts both binary and text WebSocket frames for input +- `make test` passes (existing backend tests still green) + +**Depends on:** None +**Estimated:** 3 hours + +--- + +## Task T3: Backend Echo Detection and Graceful Exit + +**Files:** +- `apps/api/src/services/terminal_session.py` +- `apps/api/src/services/terminal_manager.py` +- `apps/api/src/api/terminal.py` + +**Description:** +Add `termios` PTY inspection to detect ECHO flag state changes. Send `set_echo_state` control messages to client when echo toggles. Detect container process exit (returncode set) and send `session_ended` JSON message before closing WebSocket with code 1000. Distinguish between normal process exit, container stop, and unexpected errors. + +**Acceptance:** +- Running `stty -echo` in terminal triggers `set_echo_state: false` message +- Running `stty echo` triggers `set_echo_state: true` message +- Running `exit` in shell sends `session_ended: { reason: "process_exit" }` then closes with code 1000 +- Stopping container sends `session_ended: { reason: "container_stop" }` +- Unexpected errors still close with code 4000 and error message + +**Depends on:** T2 +**Estimated:** 2.5 hours + +--- + +## Task T4: useTerminalConnection Hook + +**Files:** +- `apps/web/src/hooks/use-terminal-connection.ts` (new) + +**Description:** +Implement the core connection hook with: WebSocket lifecycle (open/close/reconnect with exponential backoff), heartbeat (send ping every 15s, timeout after 5s), local echo (write printable ASCII to xterm immediately, deduplicate server echo), resize debouncing (200ms, max 1/500ms), scrollback serialization on disconnect, scrollback restoration on reconnect, connection quality tracking (latency, jitter), manual reconnect bypass. + +**Acceptance:** +- Hook exposes `state`, `sendInput`, `sendResize`, `reconnect`, `onData`, `onControl` +- Reconnect backoff: 1s, 2s, 4s, 8s, then max 30s +- Max 10 reconnection attempts before giving up +- Local echo works for printable ASCII; disabled when echo state is false +- Pending echo buffer deduplicates server echo correctly +- Pending echo buffer flushes to terminal if it grows > 100 chars +- Resize sends at most 1 message per 500ms +- `Ctrl+Shift+R` triggers immediate reconnect when disconnected +- Scrollback serialized to `sessionStorage` on disconnect, restored on reconnect with divider + +**Depends on:** T1 +**Estimated:** 4 hours + +--- + +## Task T5: TerminalComponent Rewrite + +**Files:** +- `apps/web/src/components/terminal.tsx` (rewrite) +- `apps/web/src/pages/terminal.tsx` (minor) +- `apps/web/src/styles.css` (add terminal status styles) + +**Description:** +Rewrite `TerminalComponent` to use `useTerminalConnection`. Integrate xterm.js with the hook's `onData` and `onControl` callbacks. Add status bar with connection quality indicator (green/yellow/red/gray dot, latency tooltip, attempt counter). Add reconnect overlay when disconnected. Wire xterm `onData` to hook's `sendInput`. Use `ResizeObserver` for container-level resize detection. Apply xterm-addon-serialize for scrollback. Update page to pass instance ID and handle close. + +**Acceptance:** +- Terminal renders and connects on mount +- Status bar shows correct dot color based on connection state +- Hovering dot shows latency tooltip +- Reconnect overlay appears when max retries exceeded +- ResizeObserver triggers fit + resize message (debounced) +- Theme colors adapt to dark/light mode +- Close button works + +**Depends on:** T4 +**Estimated:** 3 hours + +--- + +## Task T6: Frontend Tests + +**Files:** +- `apps/web/src/utils/terminal-protocol.test.ts` (new) +- `apps/web/src/hooks/use-terminal-connection.test.ts` (new) + +**Description:** +Write Vitest tests for protocol utilities (encode/decode all message types, base64 round-trip, frame type detection). Write tests for the connection hook using a mock WebSocket server (or manual mock). Test: reconnect backoff timing, heartbeat timeout detection, local echo deduplication, resize throttling, scrollback serialization round-trip. + +**Acceptance:** +- Protocol tests cover all message types and edge cases +- Hook tests cover connection lifecycle without real WebSocket +- All tests pass: `cd apps/web && npm test` +- Coverage for new code > 80% + +**Depends on:** T1, T4 +**Estimated:** 3 hours + +--- + +## Task T7: Backend Tests + +**Files:** +- `apps/api/tests/unit/test_terminal_session.py` (new) +- `apps/api/tests/unit/test_terminal_manager.py` (new) + +**Description:** +Write pytest unit tests for `TerminalSession` (PTY creation, resize, echo detection, process exit detection). Write tests for `TerminalManager` (session creation, batching logic, heartbeat tracking). Use mocks for `os`, `pty`, `termios`, and `asyncio` where appropriate. + +**Acceptance:** +- TerminalSession tests: start, resize, write, read, echo detection, close +- TerminalManager tests: create session, read loop batching, heartbeat timeout +- All tests pass: `make test` + +**Depends on:** T2, T3 +**Estimated:** 2.5 hours + +--- + +## Task Order and Dependencies + +``` +T1 ──► T4 ──► T5 ──► PR-3 (Frontend UI) + │ + └──► T6 (Frontend tests) + +T2 ──► T3 ──► PR-1 (Backend foundation) + │ + └──► T7 (Backend tests) +``` + +**Parallel work possible:** +- T1 and T2 can be done in parallel (no dependencies) +- T3 and T4 can be done in parallel (T3 depends on T2, T4 depends on T1) +- T5 depends on T4 +- T6 depends on T4 +- T7 depends on T3 + +## Chained PR Plan + +### PR-1: Backend Foundation +**Scope:** T1 (protocol types only) + T2 + T3 + T7 +**Files touched:** `apps/api/src/services/terminal_manager.py`, `apps/api/src/services/terminal_session.py`, `apps/api/src/api/terminal.py`, new test files, `apps/web/src/types/terminal.ts`, `apps/web/src/utils/terminal-protocol.ts` +**Estimated diff:** ~590 lines +**Review focus:** Protocol correctness, heartbeat logic, batching efficiency + +### PR-2: Frontend Connection Hook +**Scope:** T4 + T6 +**Files touched:** `apps/web/src/hooks/use-terminal-connection.ts`, new test files +**Estimated diff:** ~460 lines +**Review focus:** State machine correctness, local echo algorithm, reconnection logic + +### PR-3: Terminal UI Integration +**Scope:** T5 +**Files touched:** `apps/web/src/components/terminal.tsx`, `apps/web/src/pages/terminal.tsx`, `apps/web/src/styles.css` +**Estimated diff:** ~250 lines +**Review focus:** UX, accessibility, visual polish, integration with hook + +**Note:** PR-2 and PR-3 can be developed in parallel if PR-1's protocol types are stable. The hook can be tested against mock protocol types before the backend is merged.