diff --git a/apps/api/src/api/system/terminal.py b/apps/api/src/api/system/terminal.py index 2a02521..4622aae 100644 --- a/apps/api/src/api/system/terminal.py +++ b/apps/api/src/api/system/terminal.py @@ -334,66 +334,75 @@ async def _write_loop(session_ref: SessionRef, websocket, instance_id: str) -> N await session.write_input(message["bytes"]) elif "text" in message: text = message["text"] + # Treat a text frame as a control message only when it + # is a JSON object carrying a known "type". Anything + # else — including JSON-shaped pastes — is forwarded as + # raw terminal input so multiline and bracketed-paste + # content is never silently swallowed or misrouted. + ctrl = None if text.startswith("{"): - # Control message (JSON) try: - ctrl = json.loads(text) - msg_type = ctrl.get("type") - - if msg_type == "resize": - cols = ctrl.get("cols", 80) - rows = ctrl.get("rows", 24) - logger.debug( - "Received resize message for instance %s: %sx%s", - instance_id, - cols, - rows, - ) - await session.resize(cols, rows) - elif msg_type == "ack": - char_count = ctrl.get("chars", 0) - if char_count > 0: - session.acknowledge_data(char_count) - elif msg_type == "reset": - # Reset terminal session (scoped to current slot) - logger.debug( - "Resetting terminal session for instance %s (slot=%s)", - session.instance_id, - session_ref.slot_session_id, - ) - await websocket.send_json( - {"type": "status", "status": "resetting"} - ) - - # Reset the session scoped to its slot - new_session = await terminal_manager.reset_session( - session.instance_id, - session.container_id, - startup_command=session.startup_command, - session_id=session_ref.slot_session_id, - name=session.name, - container_user=session.container_user, - ) - - # Update the mutable session reference - 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"} - ) - - # Continue the loop with the new session - continue - + parsed = json.loads(text) except json.JSONDecodeError: - # Not a valid JSON control message, treat as regular input - await session.write_input(text.encode("utf-8")) - else: + parsed = None + if isinstance(parsed, dict) and parsed.get("type") in ( + "resize", + "ack", + "reset", + ): + ctrl = parsed + + if ctrl is None: await session.write_input(text.encode("utf-8")) + continue + + msg_type = ctrl["type"] + if msg_type == "resize": + cols = ctrl.get("cols", 80) + rows = ctrl.get("rows", 24) + logger.debug( + "Received resize message for instance %s: %sx%s", + instance_id, + cols, + rows, + ) + await session.resize(cols, rows) + elif msg_type == "ack": + char_count = ctrl.get("chars", 0) + if char_count > 0: + session.acknowledge_data(char_count) + elif msg_type == "reset": + # Reset terminal session (scoped to current slot) + logger.debug( + "Resetting terminal session for instance %s (slot=%s)", + session.instance_id, + session_ref.slot_session_id, + ) + await websocket.send_json( + {"type": "status", "status": "resetting"} + ) + + # Reset the session scoped to its slot + new_session = await terminal_manager.reset_session( + session.instance_id, + session.container_id, + startup_command=session.startup_command, + session_id=session_ref.slot_session_id, + name=session.name, + container_user=session.container_user, + ) + + # Update the mutable session reference + 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"} + ) + + # Continue the loop with the new session + continue elif message["type"] == "websocket.disconnect": break except Exception: diff --git a/apps/api/src/services/terminal/terminal_session.py b/apps/api/src/services/terminal/terminal_session.py index 6804398..4d796b2 100644 --- a/apps/api/src/services/terminal/terminal_session.py +++ b/apps/api/src/services/terminal/terminal_session.py @@ -12,6 +12,7 @@ import signal import struct import fcntl import time +import tty import uuid from collections import deque from typing import Any @@ -123,6 +124,15 @@ class TerminalSession: # Create a pseudo-terminal on the host self._master_fd, slave_fd = pty.openpty() + # Put the host PTY into raw mode so it behaves as a pass-through + # pipe. openpty() leaves the slave in canonical mode by default, + # which line-buffers input, splits multiline pastes at newlines, + # and mangles bracketed-paste markers before docker exec / the + # foreground app (e.g. pi) ever see them. Real terminal discipline + # (echo, canonical editing for readline) is provided by the + # in-container PTY that `docker exec -t` allocates. + tty.setraw(slave_fd) + # Set the terminal size initially self._set_terminal_size(self._cols, self._rows) logger.debug( diff --git a/apps/api/tests/unit/test_terminal_session.py b/apps/api/tests/unit/test_terminal_session.py index 1bb663d..38da8a4 100644 --- a/apps/api/tests/unit/test_terminal_session.py +++ b/apps/api/tests/unit/test_terminal_session.py @@ -23,12 +23,13 @@ async def test_start_passes_container_user_to_docker_exec() -> None: "src.services.terminal.terminal_session.pty.openpty", return_value=(1, 2), ): - with patch( - "src.services.terminal.terminal_session.asyncio.create_subprocess_exec", - new=AsyncMock(), - ) as mock_exec: - with patch("src.services.terminal.terminal_session.os.close"): - await session.start() + with patch("src.services.terminal.terminal_session.tty.setraw"): + with patch( + "src.services.terminal.terminal_session.asyncio.create_subprocess_exec", + new=AsyncMock(), + ) as mock_exec: + with patch("src.services.terminal.terminal_session.os.close"): + await session.start() args, _kwargs = mock_exec.call_args assert "docker" in args @@ -53,12 +54,13 @@ async def test_start_omits_user_when_not_configured() -> None: "src.services.terminal.terminal_session.pty.openpty", return_value=(1, 2), ): - with patch( - "src.services.terminal.terminal_session.asyncio.create_subprocess_exec", - new=AsyncMock(), - ) as mock_exec: - with patch("src.services.terminal.terminal_session.os.close"): - await session.start() + with patch("src.services.terminal.terminal_session.tty.setraw"): + with patch( + "src.services.terminal.terminal_session.asyncio.create_subprocess_exec", + new=AsyncMock(), + ) as mock_exec: + with patch("src.services.terminal.terminal_session.os.close"): + await session.start() args, _kwargs = mock_exec.call_args assert "--user" not in args diff --git a/apps/web/src/components/features/terminal/terminal.tsx b/apps/web/src/components/features/terminal/terminal.tsx index 077664a..cb763a9 100644 --- a/apps/web/src/components/features/terminal/terminal.tsx +++ b/apps/web/src/components/features/terminal/terminal.tsx @@ -722,9 +722,13 @@ export const TerminalComponent = React.forwardRef( const handlePaste = async () => { try { const text = await navigator.clipboard.readText(); - if (wsRef.current?.readyState === WebSocket.OPEN) { - wsRef.current.send(text); - } + // Route through xterm.js instead of sending raw text directly. + // term.paste() wraps the content in bracketed-paste markers + // (\e[200~...\e[201~) when the app has enabled BPM, so multiline + // pastes arrive as a single input rather than one prompt per line. + // It emits via onData, which the existing handler forwards to the + // WebSocket, so the readyState check happens there. + termRef.current?.paste(text); } catch { // Clipboard API not available }