diff --git a/apps/api/src/api/terminal.py b/apps/api/src/api/terminal.py index c582e33..38b5116 100644 --- a/apps/api/src/api/terminal.py +++ b/apps/api/src/api/terminal.py @@ -15,6 +15,13 @@ router = APIRouter() logger = logging.getLogger(__name__) +class SessionRef: + """Mutable reference to a terminal session, allowing updates during reset.""" + + def __init__(self, session): + self.session = session + + @router.websocket( "/ws/tool-instances/{instance_id}/terminal", ) @@ -86,9 +93,12 @@ async def terminal_websocket( # Send connected status await websocket.send_json({"type": "status", "status": "connected"}) + # Use mutable session reference so loops can survive reset + session_ref = SessionRef(session) + # Start I/O loops and heartbeat - read_task = asyncio.create_task(_read_loop(session, websocket)) - write_task = asyncio.create_task(_write_loop(session, websocket, instance_id)) + read_task = asyncio.create_task(_read_loop(session_ref, websocket)) + write_task = asyncio.create_task(_write_loop(session_ref, websocket, instance_id)) heartbeat_task = asyncio.create_task(_heartbeat_loop(websocket)) # Wait for either task to complete (indicating disconnect or error) @@ -114,10 +124,14 @@ async def terminal_websocket( pass -async def _read_loop(session, websocket) -> None: +async def _read_loop(session_ref: SessionRef, websocket) -> None: """Read output from the container and send to WebSocket.""" try: - while session.is_alive() and not session._closed: + while True: + session = session_ref.session + if not session.is_alive() or session._closed: + await asyncio.sleep(0.1) + continue data = await session.read_output() if data: try: @@ -130,10 +144,14 @@ async def _read_loop(session, websocket) -> None: pass -async def _write_loop(session, websocket, instance_id: str) -> None: +async def _write_loop(session_ref: SessionRef, websocket, instance_id: str) -> None: """Read input from WebSocket and send to container.""" try: - while session.is_alive() and not session._closed: + while True: + session = session_ref.session + if not session.is_alive() or session._closed: + await asyncio.sleep(0.1) + continue message = await websocket.receive() if message["type"] == "websocket.receive": if "bytes" in message: @@ -163,17 +181,19 @@ async def _write_loop(session, websocket, instance_id: str) -> None: session.container_id, ) + # Update the mutable session reference so read_loop uses the new session + session_ref.session = new_session + # Attach to new session await terminal_manager.attach_websocket(new_session, websocket) await websocket.send_json({"type": "status", "status": "connected"}) - # Update session reference and restart loops - # Note: This will cause the current loops to exit - # The WebSocket handler will create new ones - return + # Continue the loop with the new session + continue except json.JSONDecodeError: - pass + # Not a valid JSON control message, treat as regular input + await session.write_input(text.encode("utf-8")) else: await session.write_input(text.encode("utf-8")) elif message["type"] == "websocket.disconnect": diff --git a/apps/api/src/services/terminal_session.py b/apps/api/src/services/terminal_session.py index db26918..0557a8b 100644 --- a/apps/api/src/services/terminal_session.py +++ b/apps/api/src/services/terminal_session.py @@ -5,6 +5,7 @@ import logging import os import pty import select +import signal import struct import fcntl import time @@ -152,16 +153,19 @@ class TerminalSession: logger.info(f"resize() called for session {self.session_id}: {cols}x{rows}") self._set_terminal_size(cols, rows) - # Docker exec doesn't forward PTY resize to the container process, - # so we need to explicitly set the size inside the container shell. - # Send on every resize so the container shell always matches the frontend. - # Use stty -echo to prevent the command from being visible, then clear the line. - stty_cmd = ( - f"stty -echo; stty cols {cols} rows {rows}; stty echo\n" - f"\x1b[A\x1b[M" # Move up 1 line and delete it (clears the stty command) - ).encode() - await self.write_input(stty_cmd) - logger.debug(f"Sent stty resize to container for session {self.session_id}: {cols}x{rows}") + # Docker exec -it creates its own PTY inside the container, + # so host PTY resize doesn't propagate to the container shell. + # Send SIGWINCH to the docker exec process on the host. + # Docker exec forwards signals to the container process, which should + # cause the container's shell to re-read its terminal size. + if self.process and self.process.pid: + try: + os.kill(self.process.pid, signal.SIGWINCH) + logger.debug(f"Sent SIGWINCH to docker exec process {self.process.pid} for session {self.session_id}") + except ProcessLookupError: + logger.warning(f"docker exec process {self.process.pid} not found for session {self.session_id}") + except Exception as e: + logger.warning(f"Failed to send SIGWINCH: {e}") async def reset(self) -> None: """Reset the session by killing the process and clearing state.""" diff --git a/apps/web/src/components/terminal.tsx b/apps/web/src/components/terminal.tsx index dc441aa..fe8e969 100644 --- a/apps/web/src/components/terminal.tsx +++ b/apps/web/src/components/terminal.tsx @@ -115,6 +115,20 @@ export const TerminalComponent: React.FC = ({ if (msg.status === "connected") { setStatus("connected"); setError(null); + // Clear terminal and refit after reset/reconnect + if (termRef.current) { + termRef.current.clear(); + requestAnimationFrame(() => { + if (fitAddonRef.current && termRef.current) { + fitAddonRef.current.fit(); + const { cols, rows } = termRef.current; + const currentWs = wsRef.current; + if (currentWs?.readyState === WebSocket.OPEN) { + currentWs.send(JSON.stringify({ type: "resize", cols, rows })); + } + } + }); + } } else if (msg.status === "resetting") { setStatus("resetting"); } @@ -210,11 +224,7 @@ export const TerminalComponent: React.FC = ({ const container = terminalRef.current; let ws: WebSocket; - // Open xterm immediately - term.open(container); - ws = connectWebSocket(); - - // Fit terminal and notify backend + // Define fitTerminal before connectWebSocket so it's available in onmessage const fitTerminal = () => { if (!fitAddonRef.current || !termRef.current) return; const oldCols = termRef.current.cols; @@ -225,8 +235,9 @@ export const TerminalComponent: React.FC = ({ if (cols !== oldCols || rows !== oldRows) { termRef.current.refresh(0, rows - 1); } - if (ws.readyState === WebSocket.OPEN) { - ws.send(JSON.stringify({ type: "resize", cols, rows })); + const currentWs = wsRef.current; + if (currentWs?.readyState === WebSocket.OPEN) { + currentWs.send(JSON.stringify({ type: "resize", cols, rows })); } }; @@ -237,6 +248,10 @@ export const TerminalComponent: React.FC = ({ }); }); + // Open xterm immediately + term.open(container); + ws = connectWebSocket(); + // Refit after font load (metrics may change) document.fonts.ready.then(() => { requestAnimationFrame(() => fitTerminal()); @@ -244,20 +259,21 @@ export const TerminalComponent: React.FC = ({ // Handle terminal input term.onData((data) => { - if (ws.readyState !== WebSocket.OPEN) return; + const currentWs = wsRef.current; + if (currentWs?.readyState !== WebSocket.OPEN) return; // Apply active modifier to single-character input const modifier = activeModifierRef.current; if (modifier && data.length === 1) { const modified = applyModifierToChar(data, modifier); if (modified) { - ws.send(modified); + currentWs.send(modified); onModifierChange?.(null); return; } } - ws.send(data); + currentWs.send(data); }); // Handle container resize with ResizeObserver for accurate dimension tracking @@ -302,8 +318,9 @@ export const TerminalComponent: React.FC = ({ // Notify parent about terminal readiness if (onTerminalReadyRef.current) { const sendData = (data: string) => { - if (ws.readyState === WebSocket.OPEN) { - ws.send(data); + const currentWs = wsRef.current; + if (currentWs?.readyState === WebSocket.OPEN) { + currentWs.send(data); } }; const focusInput = () => {