Merge branch 'fix/web-terminal-multiline-paste' into dev

This commit is contained in:
Developer
2026-07-11 11:32:49 +00:00
4 changed files with 96 additions and 71 deletions
+21 -12
View File
@@ -334,12 +334,29 @@ async def _write_loop(session_ref: SessionRef, websocket, instance_id: str) -> N
await session.write_input(message["bytes"]) await session.write_input(message["bytes"])
elif "text" in message: elif "text" in message:
text = message["text"] 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("{"): if text.startswith("{"):
# Control message (JSON)
try: try:
ctrl = json.loads(text) parsed = json.loads(text)
msg_type = ctrl.get("type") except json.JSONDecodeError:
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": if msg_type == "resize":
cols = ctrl.get("cols", 80) cols = ctrl.get("cols", 80)
rows = ctrl.get("rows", 24) rows = ctrl.get("rows", 24)
@@ -379,21 +396,13 @@ async def _write_loop(session_ref: SessionRef, websocket, instance_id: str) -> N
session_ref.session = new_session session_ref.session = new_session
# Attach to new session # Attach to new session
await terminal_manager.attach_websocket( await terminal_manager.attach_websocket(new_session, websocket)
new_session, websocket
)
await websocket.send_json( await websocket.send_json(
{"type": "status", "status": "connected"} {"type": "status", "status": "connected"}
) )
# Continue the loop with the new session # Continue the loop with the new session
continue continue
except json.JSONDecodeError:
# 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": elif message["type"] == "websocket.disconnect":
break break
except Exception: except Exception:
@@ -12,6 +12,7 @@ import signal
import struct import struct
import fcntl import fcntl
import time import time
import tty
import uuid import uuid
from collections import deque from collections import deque
from typing import Any from typing import Any
@@ -123,6 +124,15 @@ class TerminalSession:
# Create a pseudo-terminal on the host # Create a pseudo-terminal on the host
self._master_fd, slave_fd = pty.openpty() 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 # Set the terminal size initially
self._set_terminal_size(self._cols, self._rows) self._set_terminal_size(self._cols, self._rows)
logger.debug( logger.debug(
@@ -23,6 +23,7 @@ async def test_start_passes_container_user_to_docker_exec() -> None:
"src.services.terminal.terminal_session.pty.openpty", "src.services.terminal.terminal_session.pty.openpty",
return_value=(1, 2), return_value=(1, 2),
): ):
with patch("src.services.terminal.terminal_session.tty.setraw"):
with patch( with patch(
"src.services.terminal.terminal_session.asyncio.create_subprocess_exec", "src.services.terminal.terminal_session.asyncio.create_subprocess_exec",
new=AsyncMock(), new=AsyncMock(),
@@ -53,6 +54,7 @@ async def test_start_omits_user_when_not_configured() -> None:
"src.services.terminal.terminal_session.pty.openpty", "src.services.terminal.terminal_session.pty.openpty",
return_value=(1, 2), return_value=(1, 2),
): ):
with patch("src.services.terminal.terminal_session.tty.setraw"):
with patch( with patch(
"src.services.terminal.terminal_session.asyncio.create_subprocess_exec", "src.services.terminal.terminal_session.asyncio.create_subprocess_exec",
new=AsyncMock(), new=AsyncMock(),
@@ -722,9 +722,13 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
const handlePaste = async () => { const handlePaste = async () => {
try { try {
const text = await navigator.clipboard.readText(); const text = await navigator.clipboard.readText();
if (wsRef.current?.readyState === WebSocket.OPEN) { // Route through xterm.js instead of sending raw text directly.
wsRef.current.send(text); // 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 { } catch {
// Clipboard API not available // Clipboard API not available
} }