fix: enable multiline paste in web terminal

Multiline pastes into the web terminal (especially into pi) were split
into one prompt per line because bracketed-paste markers were not
reaching the foreground app intact.

- Put the host PTY into raw mode (tty.setraw) after openpty() so it acts
  as a pass-through pipe. The default canonical line discipline was
  line-buffering input, splitting multiline pastes at newlines, and
  mangling bracketed-paste markers before docker exec / pi could see
  them. The in-container PTY (docker exec -t) provides real discipline.
- Route the mobile Paste button through xterm.js (term.paste) instead of
  sending raw clipboard text to the WebSocket, so content is wrapped in
  bracketed-paste markers when the app has enabled BPM.
- Treat a text frame as a control message only when it is a JSON object
  with a known type (resize/ack/reset); otherwise forward as raw input
  so JSON-shaped pastes are no longer silently dropped.

Quality gates: ruff, mypy (changed files), pytest unit (227 passed),
tsc, eslint
This commit is contained in:
Developer
2026-07-11 11:27:24 +00:00
parent 3c96c7b153
commit a1a77c99a6
4 changed files with 96 additions and 71 deletions
+65 -56
View File
@@ -334,66 +334,75 @@ 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")
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
except json.JSONDecodeError: except json.JSONDecodeError:
# Not a valid JSON control message, treat as regular input parsed = None
await session.write_input(text.encode("utf-8")) if isinstance(parsed, dict) and parsed.get("type") in (
else: "resize",
"ack",
"reset",
):
ctrl = parsed
if ctrl is None:
await session.write_input(text.encode("utf-8")) 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": 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(
+14 -12
View File
@@ -23,12 +23,13 @@ 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( with patch("src.services.terminal.terminal_session.tty.setraw"):
"src.services.terminal.terminal_session.asyncio.create_subprocess_exec", with patch(
new=AsyncMock(), "src.services.terminal.terminal_session.asyncio.create_subprocess_exec",
) as mock_exec: new=AsyncMock(),
with patch("src.services.terminal.terminal_session.os.close"): ) as mock_exec:
await session.start() with patch("src.services.terminal.terminal_session.os.close"):
await session.start()
args, _kwargs = mock_exec.call_args args, _kwargs = mock_exec.call_args
assert "docker" in 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", "src.services.terminal.terminal_session.pty.openpty",
return_value=(1, 2), return_value=(1, 2),
): ):
with patch( with patch("src.services.terminal.terminal_session.tty.setraw"):
"src.services.terminal.terminal_session.asyncio.create_subprocess_exec", with patch(
new=AsyncMock(), "src.services.terminal.terminal_session.asyncio.create_subprocess_exec",
) as mock_exec: new=AsyncMock(),
with patch("src.services.terminal.terminal_session.os.close"): ) as mock_exec:
await session.start() with patch("src.services.terminal.terminal_session.os.close"):
await session.start()
args, _kwargs = mock_exec.call_args args, _kwargs = mock_exec.call_args
assert "--user" not in args assert "--user" not in args
@@ -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
} }