feat: responsive web terminal with auto-reconnect, heartbeat, and local echo

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/
This commit is contained in:
2026-05-27 21:27:49 +02:00
parent 48fa858090
commit 6c8cfe9157
18 changed files with 2776 additions and 350 deletions
+52 -18
View File
@@ -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
+118 -21
View File
@@ -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()
+76 -31
View File
@@ -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:
@@ -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()
@@ -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
+11
View File
@@ -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",
+1
View File
@@ -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": {
+265 -149
View File
@@ -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<TerminalProps> = ({ instanceId, onClose }) => {
const terminalRef = useRef<HTMLDivElement>(null);
const wsRef = useRef<WebSocket | null>(null);
const [status, setStatus] = useState<"connecting" | "connected" | "disconnected" | "error">(
"connecting",
);
const [error, setError] = useState<string | null>(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 (
<div className="terminal-wrapper">
<div className="terminal-header">
<div className="terminal-status">
<span
className={`status-dot ${status}`}
aria-label={`Terminal status: ${status}`}
/>
<span className="status-text">{status}</span>
</div>
{onClose && (
<button className="terminal-close" onClick={onClose} type="button">
Close
</button>
)}
</div>
{error && <div className="terminal-error">{error}</div>}
<div ref={terminalRef} className="terminal-container" />
</div>
);
const STATUS_DOT_COLORS: Record<TerminalConnectionState["status"], string> = {
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<TerminalProps> = ({
instanceId,
onClose,
}) => {
const terminalRef = useRef<HTMLDivElement>(null);
const xtermRef = useRef<Terminal | null>(null);
const fitAddonRef = useRef<FitAddon | null>(null);
const serializeAddonRef = useRef<SerializeAddon | null>(null);
const resizeObserverRef = useRef<ResizeObserver | null>(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<string, string> = {
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 (
<div className="terminal-wrapper">
<div className="terminal-header">
<div className="terminal-status">
<span
className="status-dot"
style={{
backgroundColor: STATUS_DOT_COLORS[state.status],
}}
aria-label={`Terminal status: ${state.status}`}
title={
state.latency !== null
? `Latency: ${state.latency}ms`
: getStatusText(state)
}
/>
<span className="status-text">{getStatusText(state)}</span>
</div>
<div className="terminal-actions">
{state.status === "disconnected" && (
<button
className="secondary-button small"
onClick={reconnect}
type="button"
>
Reconnect
</button>
)}
{onClose && (
<button className="terminal-close" onClick={onClose} type="button">
Close
</button>
)}
</div>
</div>
{sessionEnded && (
<div className="terminal-overlay">
<div className="terminal-overlay-content">
<h3>Session Ended</h3>
<p>{sessionEnded.message}</p>
<div className="terminal-overlay-actions">
<button
className="primary-button small"
onClick={() => {
setSessionEnded(null);
reconnect();
}}
type="button"
>
Reconnect
</button>
{onClose && (
<button
className="secondary-button small"
onClick={onClose}
type="button"
>
Go Back
</button>
)}
</div>
</div>
</div>
)}
{state.status === "reconnecting" && (
<div className="terminal-reconnect-banner">
<span className="spinner" />
{state.error}
</div>
)}
<div ref={terminalRef} className="terminal-container" />
</div>
);
};
@@ -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");
});
});
@@ -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<TerminalConnectionState>({
status: "connecting",
attempt: 0,
latency: null,
error: null,
});
const wsRef = useRef<WebSocket | null>(null);
const reconnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const pingTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const pongTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const resizeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const lastResizeRef = useRef<number>(0);
const pendingEchoRef = useRef<string>("");
const echoEnabledRef = useRef<boolean>(true);
const pingIdRef = useRef<number>(0);
const pingSentAtRef = useRef<number>(0);
const reconnectAttemptRef = useRef<number>(0);
const isConnectingRef = useRef<boolean>(false);
const lastStatusRef = useRef<string>("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,
};
}
+164 -131
View File
@@ -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;
}
}
+82
View File
@@ -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;
}
+76
View File
@@ -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<string, unknown>;
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;
}
}
@@ -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
@@ -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
@@ -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
@@ -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.
@@ -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.