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:
@@ -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(
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user