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"])
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")
parsed = json.loads(text)
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":
cols = ctrl.get("cols", 80)
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
# Attach to new session
await terminal_manager.attach_websocket(
new_session, websocket
)
await terminal_manager.attach_websocket(new_session, websocket)
await websocket.send_json(
{"type": "status", "status": "connected"}
)
# Continue the loop with the new session
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":
break
except Exception:
@@ -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(
@@ -23,6 +23,7 @@ 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.tty.setraw"):
with patch(
"src.services.terminal.terminal_session.asyncio.create_subprocess_exec",
new=AsyncMock(),
@@ -53,6 +54,7 @@ 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.tty.setraw"):
with patch(
"src.services.terminal.terminal_session.asyncio.create_subprocess_exec",
new=AsyncMock(),
@@ -722,9 +722,13 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
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
}