feat(terminal): improve mobile controls and paste

This commit is contained in:
naiba
2026-08-12 02:26:58 +00:00
parent d4f3f998a3
commit ab8c803607
7 changed files with 559 additions and 117 deletions
-5
View File
@@ -27,7 +27,6 @@
"@tanstack/react-table": "^8.21.3",
"@trivago/prettier-plugin-sort-imports": "^6.0.2",
"@types/luxon": "^3.7.3",
"@xterm/addon-attach": "^0.12.0",
"@xterm/addon-fit": "^0.11.0",
"@xterm/xterm": "^6.0.0",
"class-variance-authority": "^0.7.1",
@@ -3257,10 +3256,6 @@
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@xterm/addon-attach": {
"version": "0.12.0",
"license": "MIT"
},
"node_modules/@xterm/addon-fit": {
"version": "0.11.0",
"license": "MIT"
-1
View File
@@ -35,7 +35,6 @@
"@tanstack/react-table": "^8.21.3",
"@trivago/prettier-plugin-sort-imports": "^6.0.2",
"@types/luxon": "^3.7.3",
"@xterm/addon-attach": "^0.12.0",
"@xterm/addon-fit": "^0.11.0",
"@xterm/xterm": "^6.0.0",
"class-variance-authority": "^0.7.1",
+256 -69
View File
@@ -8,8 +8,7 @@ import {
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import useTerminal from "@/hooks/useTerminal"
import { sleep } from "@/lib/utils"
import { AttachAddon } from "@xterm/addon-attach"
import { type TerminalKey, controlSequenceForInput, terminalKeySequence } from "@/lib/terminal-keys"
import { FitAddon } from "@xterm/addon-fit"
import { Terminal } from "@xterm/xterm"
import "@xterm/xterm/css/xterm.css"
@@ -36,111 +35,299 @@ interface XtermProps {
setClose: React.Dispatch<React.SetStateAction<boolean>>
}
type ConnectionState = "connecting" | "connected" | "disconnected"
const terminalKeys: Array<{ key: TerminalKey; label: string; ariaLabel: string }> = [
{ key: "escape", label: "Esc", ariaLabel: "Escape" },
{ key: "tab", label: "Tab", ariaLabel: "Tab" },
{ key: "arrowLeft", label: "←", ariaLabel: "Left arrow" },
{ key: "arrowUp", label: "↑", ariaLabel: "Up arrow" },
{ key: "arrowDown", label: "↓", ariaLabel: "Down arrow" },
{ key: "arrowRight", label: "→", ariaLabel: "Right arrow" },
{ key: "home", label: "Home", ariaLabel: "Home" },
{ key: "end", label: "End", ariaLabel: "End" },
{ key: "pageUp", label: "PgUp", ariaLabel: "Page up" },
{ key: "pageDown", label: "PgDn", ariaLabel: "Page down" },
]
const maxClipboardBytes = 512 * 1024
export const XtermComponent = forwardRef<HTMLDivElement, XtermProps & JSX.IntrinsicElements["div"]>(
({ wsUrl, setClose, ...props }, ref) => {
const terminalIdRef = useRef<HTMLDivElement>(null)
({ wsUrl, setClose, className = "", ...props }, ref) => {
const shellRef = useRef<HTMLDivElement>(null)
const screenRef = useRef<HTMLDivElement>(null)
const terminalRef = useRef<Terminal | null>(null)
const wsRef = useRef<WebSocket | null>(null)
const fitAddonRef = useRef<FitAddon | null>(null)
const fitFrameRef = useRef(0)
const lastSizeRef = useRef("")
const controlActiveRef = useRef(false)
const pasteInProgressRef = useRef(false)
const pasteRequestRef = useRef(0)
const [controlActive, setControlActive] = useState(false)
const [pasteBusy, setPasteBusy] = useState(false)
const [connectionState, setConnectionState] = useState<ConnectionState>("connecting")
useImperativeHandle(ref, () => {
return {
...terminalIdRef.current!,
async requestFullscreen() {
await terminalIdRef.current?.requestFullscreen()
},
}
useImperativeHandle(ref, () => shellRef.current!, [])
const updateControl = useCallback((active: boolean) => {
controlActiveRef.current = active
setControlActive(active)
}, [])
const fitAddon = useRef(new FitAddon()).current
const sendResize = useRef(false)
const sendResize = useCallback(() => {
const terminal = terminalRef.current
const ws = wsRef.current
if (!terminal || !ws || ws.readyState !== WebSocket.OPEN) return
if (terminal.cols < 2 || terminal.rows < 2) return
const doResize = useCallback(() => {
if (!terminalIdRef.current) return
fitAddon.fit()
const dimensions = fitAddon.proposeDimensions()
if (dimensions) {
const prefix = new Int8Array([1])
const sizeKey = `${terminal.cols}x${terminal.rows}`
if (lastSizeRef.current === sizeKey) return
lastSizeRef.current = sizeKey
const resizeMessage = new TextEncoder().encode(
JSON.stringify({
Rows: dimensions.rows,
Cols: dimensions.cols,
}),
JSON.stringify({ Rows: terminal.rows, Cols: terminal.cols }),
)
const message = new Uint8Array(resizeMessage.length + 1)
message[0] = 1
message.set(resizeMessage, 1)
ws.send(message)
}, [])
const msg = new Int8Array(prefix.length + resizeMessage.length)
msg.set(prefix)
msg.set(resizeMessage, prefix.length)
wsRef.current?.send(msg)
}
}, [fitAddon])
const onResize = useCallback(async () => {
if (sendResize.current) return
sendResize.current = true
const fit = useCallback(() => {
window.cancelAnimationFrame(fitFrameRef.current)
fitFrameRef.current = window.requestAnimationFrame(() => {
const screen = screenRef.current
if (!screen || screen.clientWidth === 0 || screen.clientHeight === 0) return
try {
await sleep(1500)
doResize()
fitAddonRef.current?.fit()
sendResize()
} catch (error) {
console.error("resize error", error)
} finally {
sendResize.current = false
}
}, [doResize])
})
}, [sendResize])
useEffect(() => {
const container = terminalIdRef.current
if (!container) return
const shell = shellRef.current
const screen = screenRef.current
if (!shell || !screen) return
let active = true
const terminal = new Terminal({
cursorBlink: true,
fontSize: 16,
fontSize: window.innerWidth <= 640 ? 13 : 16,
scrollback: 10000,
scrollOnUserInput: true,
})
const fitAddon = new FitAddon()
fitAddonRef.current = fitAddon
terminal.loadAddon(fitAddon)
terminal.open(screen)
terminal.element?.setAttribute("aria-label", "Interactive terminal")
terminalRef.current = terminal
const url = new URL(wsUrl, window.location.origin)
url.protocol = url.protocol.replace("http", "ws")
const ws = new WebSocket(url)
ws.binaryType = "arraybuffer"
terminalRef.current = terminal
wsRef.current = ws
pasteInProgressRef.current = false
setPasteBusy(false)
updateControl(false)
setConnectionState("connecting")
const attachAddon = new AttachAddon(ws)
terminal.loadAddon(attachAddon)
terminal.loadAddon(fitAddon)
terminal.open(container)
window.addEventListener("resize", onResize)
const sendText = (data: string, applyControl: boolean) => {
if (ws.readyState !== WebSocket.OPEN) return
let outgoing = data
if (applyControl && controlActiveRef.current) {
outgoing = controlSequenceForInput(data) ?? data
updateControl(false)
}
ws.send(outgoing)
terminal.focus()
}
const dataSubscription = terminal.onData((data) => {
sendText(data, !pasteInProgressRef.current)
})
const binarySubscription = terminal.onBinary((data) => {
if (ws.readyState !== WebSocket.OPEN) return
const message = new Uint8Array(data.length + 1)
for (let index = 0; index < data.length; index += 1) {
message[index + 1] = data.charCodeAt(index) & 0xff
}
ws.send(message)
})
ws.onopen = () => {
onResize()
if (!active) return
lastSizeRef.current = ""
setConnectionState("connected")
fit()
terminal.focus()
}
ws.onmessage = (event) => {
if (!active) return
if (event.data instanceof ArrayBuffer) {
terminal.write(new Uint8Array(event.data))
} else if (typeof event.data === "string") {
terminal.write(event.data)
}
}
ws.onclose = () => {
terminal.dispose()
if (!active) return
pasteRequestRef.current += 1
setPasteBusy(false)
setConnectionState("disconnected")
setClose(true)
}
ws.onerror = (e) => {
console.error(e)
toast("Websocket error", {
description: "View console for details.",
})
ws.onerror = (event) => {
console.error(event)
toast("Websocket error", { description: "View console for details." })
}
const updateViewport = () => {
const viewport = window.visualViewport
const viewportTop = viewport?.offsetTop ?? 0
const viewportBottom = viewportTop + (viewport?.height ?? window.innerHeight)
const shellTop = Math.max(shell.getBoundingClientRect().top, viewportTop)
shell.style.setProperty(
"--terminal-available-height",
`${Math.max(220, Math.floor(viewportBottom - shellTop - 8))}px`,
)
fit()
}
const observer = new ResizeObserver(fit)
observer.observe(screen)
window.addEventListener("resize", updateViewport)
window.addEventListener("orientationchange", updateViewport)
window.visualViewport?.addEventListener("resize", updateViewport)
window.visualViewport?.addEventListener("scroll", updateViewport)
updateViewport()
return () => {
window.removeEventListener("resize", onResize)
active = false
pasteRequestRef.current += 1
window.cancelAnimationFrame(fitFrameRef.current)
observer.disconnect()
window.removeEventListener("resize", updateViewport)
window.removeEventListener("orientationchange", updateViewport)
window.visualViewport?.removeEventListener("resize", updateViewport)
window.visualViewport?.removeEventListener("scroll", updateViewport)
dataSubscription.dispose()
binarySubscription.dispose()
ws.onopen = null
ws.onmessage = null
ws.onclose = null
ws.onerror = null
ws.close()
terminal.dispose()
if (wsRef.current === ws) wsRef.current = null
if (terminalRef.current === terminal) terminalRef.current = null
if (fitAddonRef.current === fitAddon) fitAddonRef.current = null
}
}, [fitAddon, onResize, setClose, wsUrl])
}, [fit, setClose, updateControl, wsUrl])
return <div ref={terminalIdRef} {...props} />
const pasteClipboard = async () => {
if (pasteBusy || connectionState !== "connected") return
if (!navigator.clipboard?.readText) {
toast("Clipboard unavailable", {
description: "Use HTTPS and allow clipboard permission in your browser.",
})
terminalRef.current?.focus()
return
}
setPasteBusy(true)
const requestID = ++pasteRequestRef.current
try {
const text = await navigator.clipboard.readText()
if (pasteRequestRef.current !== requestID) return
if (!text) {
toast("Clipboard is empty")
return
}
if (new TextEncoder().encode(text).length > maxClipboardBytes) {
toast("Clipboard is too large", {
description: "Terminal paste is limited to 512 KiB per action.",
})
return
}
const terminal = terminalRef.current
if (!terminal) return
pasteInProgressRef.current = true
try {
terminal.paste(text)
} finally {
pasteInProgressRef.current = false
}
terminal.focus()
} catch {
if (pasteRequestRef.current === requestID) {
toast("Could not read clipboard", {
description: "Allow clipboard access in your browser and try again.",
})
}
} finally {
if (pasteRequestRef.current === requestID) setPasteBusy(false)
}
}
const sendKey = (key: TerminalKey) => {
const ws = wsRef.current
if (!ws || ws.readyState !== WebSocket.OPEN) return
const withControl = controlActiveRef.current
updateControl(false)
ws.send(terminalKeySequence(key, withControl))
terminalRef.current?.focus()
}
return (
<div
ref={shellRef}
className={`terminal-shell ${className}`}
data-connection-state={connectionState}
{...props}
>
<div ref={screenRef} className="terminal-screen" />
<div className="terminal-keyboard" role="toolbar" aria-label="Terminal controls">
<button
type="button"
className="terminal-key terminal-key-paste"
disabled={connectionState !== "connected" || pasteBusy}
aria-label="Paste clipboard"
onClick={() => void pasteClipboard()}
>
{pasteBusy ? "Pasting…" : "Paste"}
</button>
<button
type="button"
className={`terminal-key ${controlActive ? "is-active" : ""}`}
aria-label="Control modifier for next key"
aria-pressed={controlActive}
disabled={connectionState !== "connected"}
onClick={() => {
updateControl(!controlActiveRef.current)
terminalRef.current?.focus()
}}
>
Ctrl
</button>
{terminalKeys.map(({ key, label, ariaLabel }) => (
<button
key={key}
type="button"
className={`terminal-key ${key.startsWith("arrow") ? "terminal-key-arrow" : ""}`}
aria-label={ariaLabel}
disabled={connectionState !== "connected"}
onClick={() => sendKey(key)}
>
{label}
</button>
))}
</div>
</div>
)
},
)
@@ -150,10 +337,10 @@ export const TerminalPage = () => {
const terminal = useTerminal(id ? parseInt(id) : undefined)
const terminalIdRef = useRef<HTMLDivElement>(null)
return (
<div className="px-8">
<div className="flex mt-6 mb-4">
<h1 className="flex-1 text-3xl font-bold tracking-tight">{`Terminal (${id})`}</h1>
<div className="flex ml-auto self-end sm:self-auto gap-2 flex-wrap shrink-0">
<div className="terminal-page px-3 sm:px-8">
<div className="flex mt-3 sm:mt-6 mb-3 sm:mb-4 items-center gap-2">
<h1 className="flex-1 text-xl sm:text-3xl font-bold tracking-tight">{`Terminal (${id})`}</h1>
<div className="flex ml-auto gap-2 shrink-0">
<IconButton
icon="expand"
onClick={async () => {
@@ -166,8 +353,8 @@ export const TerminalPage = () => {
{terminal?.session_id ? (
<XtermComponent
ref={terminalIdRef}
className="max-h-[60%] mb-5 overflow-auto"
wsUrl={`/api/v1/ws/terminal/${terminal?.session_id}`}
className="mb-3 sm:mb-5"
wsUrl={`/api/v1/ws/terminal/${terminal.session_id}`}
setClose={setOpen}
/>
) : (
+116
View File
@@ -119,3 +119,119 @@ body,
::-webkit-scrollbar-thumb {
@apply bg-border rounded-full border-[1px] border-solid border-transparent bg-clip-padding;
}
.terminal-shell {
--terminal-available-height: 70vh;
display: flex;
height: min(70vh, var(--terminal-available-height));
min-height: 360px;
flex-direction: column;
overflow: hidden;
border: 1px solid hsl(var(--border));
border-radius: var(--radius);
background: #09090b;
}
.terminal-screen {
min-height: 0;
flex: 1 1 auto;
padding: 6px 3px 2px 6px;
touch-action: pan-y;
}
.terminal-screen .xterm {
height: 100%;
}
.terminal-screen .xterm-viewport {
overscroll-behavior: contain;
-webkit-overflow-scrolling: touch;
}
.terminal-keyboard {
display: flex;
flex: 0 0 auto;
gap: 4px;
overflow-x: auto;
padding: 6px 6px calc(6px + env(safe-area-inset-bottom));
border-top: 1px solid hsl(var(--border));
background: hsl(var(--background));
touch-action: pan-x;
-webkit-overflow-scrolling: touch;
}
.terminal-key {
display: inline-flex;
min-width: 46px;
height: 42px;
flex: 0 0 auto;
align-items: center;
justify-content: center;
border: 1px solid hsl(var(--border));
border-radius: calc(var(--radius) - 2px);
background: hsl(var(--secondary));
padding: 0 9px;
color: hsl(var(--secondary-foreground));
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 0.75rem;
font-weight: 500;
user-select: none;
-webkit-user-select: none;
}
.terminal-key:hover:not(:disabled),
.terminal-key:focus-visible,
.terminal-key.is-active {
border-color: hsl(var(--ring));
background: hsl(var(--accent));
outline: none;
}
.terminal-key:disabled {
cursor: not-allowed;
opacity: 0.5;
}
.terminal-key-arrow {
min-width: 40px;
padding-inline: 5px;
}
.terminal-key-paste {
min-width: 58px;
}
.terminal-shell:fullscreen {
width: 100%;
height: 100%;
min-height: 0;
border: 0;
border-radius: 0;
}
@media (max-width: 640px) {
.terminal-shell {
height: var(--terminal-available-height);
min-height: 220px;
}
.terminal-keyboard {
gap: 2px;
padding-inline: 4px;
}
.terminal-key {
min-width: 43px;
height: 44px;
padding-inline: 5px;
}
.terminal-key-arrow {
min-width: 36px;
padding-inline: 2px;
}
.terminal-key-paste {
min-width: 49px;
}
}
+66
View File
@@ -0,0 +1,66 @@
export type TerminalKey =
| "escape"
| "tab"
| "arrowUp"
| "arrowDown"
| "arrowLeft"
| "arrowRight"
| "home"
| "end"
| "pageUp"
| "pageDown"
const keySequences: Record<TerminalKey, string> = {
escape: "\x1b",
tab: "\t",
arrowUp: "\x1b[A",
arrowDown: "\x1b[B",
arrowRight: "\x1b[C",
arrowLeft: "\x1b[D",
home: "\x1b[H",
end: "\x1b[F",
pageUp: "\x1b[5~",
pageDown: "\x1b[6~",
}
const controlKeySequences: Partial<Record<TerminalKey, string>> = {
arrowUp: "\x1b[1;5A",
arrowDown: "\x1b[1;5B",
arrowRight: "\x1b[1;5C",
arrowLeft: "\x1b[1;5D",
home: "\x1b[1;5H",
end: "\x1b[1;5F",
pageUp: "\x1b[5;5~",
pageDown: "\x1b[6;5~",
}
export function terminalKeySequence(key: TerminalKey, control = false): string {
return (control && controlKeySequences[key]) || keySequences[key]
}
export function controlSequenceForInput(data: string): string | null {
if (data.length !== 1) return null
const code = data.toUpperCase().charCodeAt(0)
if (code >= 65 && code <= 90) return String.fromCharCode(code - 64)
const punctuation: Record<string, number> = {
" ": 0,
"@": 0,
"[": 27,
"\\": 28,
"]": 29,
"^": 30,
_: 31,
"?": 127,
"2": 0,
"3": 27,
"4": 28,
"5": 29,
"6": 30,
"7": 31,
"8": 127,
}
const mapped = punctuation[data]
return mapped === undefined ? null : String.fromCharCode(mapped)
}
+18
View File
@@ -0,0 +1,18 @@
import { controlSequenceForInput, terminalKeySequence } from "@/lib/terminal-keys"
import { expect, test } from "vitest"
test("terminal mobile keys use xterm-compatible control sequences", () => {
expect(terminalKeySequence("escape")).toBe("\x1b")
expect(terminalKeySequence("tab")).toBe("\t")
expect(terminalKeySequence("arrowUp")).toBe("\x1b[A")
expect(terminalKeySequence("arrowLeft", true)).toBe("\x1b[1;5D")
expect(terminalKeySequence("pageDown", true)).toBe("\x1b[6;5~")
})
test("one-shot Ctrl converts printable input without corrupting paste chunks", () => {
expect(controlSequenceForInput("c")).toBe("\x03")
expect(controlSequenceForInput("D")).toBe("\x04")
expect(controlSequenceForInput("[")).toBe("\x1b")
expect(controlSequenceForInput("codex")).toBeNull()
expect(controlSequenceForInput("中文")).toBeNull()
})
+97 -36
View File
@@ -1,46 +1,61 @@
import { render } from "@testing-library/react"
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"
import { afterEach, beforeEach, expect, test, vi } from "vitest"
vi.mock("sonner", () => ({ toast: () => undefined }))
vi.mock("@/lib/utils", () => ({
sleep: () => Promise.resolve(),
cn: (...args: unknown[]) => args.filter(Boolean).join(" "),
const terminalMocks = vi.hoisted(() => ({
instances: [] as Array<{
write: ReturnType<typeof vi.fn>
paste: ReturnType<typeof vi.fn>
focus: ReturnType<typeof vi.fn>
dataHandler?: (data: string) => void
binaryHandler?: (data: string) => void
}>,
}))
const attachAddonInstances: { ws: WebSocket }[] = []
vi.mock("@xterm/addon-attach", () => ({
AttachAddon: class {
ws: WebSocket
constructor(ws: WebSocket) {
this.ws = ws
attachAddonInstances.push(this)
}
activate() {}
dispose() {}
},
}))
const toastMock = vi.hoisted(() => vi.fn())
vi.mock("sonner", () => ({ toast: toastMock }))
vi.mock("@xterm/addon-fit", () => ({
FitAddon: class {
activate() {}
dispose() {}
fit() {}
proposeDimensions() {
return { rows: 24, cols: 80 }
}
},
}))
vi.mock("@xterm/xterm", () => ({
Terminal: class {
cols = 80
rows = 24
element: HTMLElement | null = null
write = vi.fn()
focus = vi.fn()
dataHandler?: (data: string) => void
binaryHandler?: (data: string) => void
paste = vi.fn((data: string) => this.dataHandler?.(data))
constructor() {
terminalMocks.instances.push(this)
}
loadAddon() {}
open() {}
open(container: HTMLElement) {
this.element = document.createElement("div")
container.appendChild(this.element)
}
dispose() {}
onData(handler: (data: string) => void) {
this.dataHandler = handler
return { dispose() {} }
}
onBinary(handler: (data: string) => void) {
this.binaryHandler = handler
return { dispose() {} }
}
},
}))
class FakeWebSocket {
static readonly OPEN = 1
static instances: FakeWebSocket[] = []
url: string
binaryType = "arraybuffer"
@@ -50,37 +65,42 @@ class FakeWebSocket {
onmessage: ((ev: MessageEvent) => unknown) | null = null
readyState = 0
closeCalls = 0
send = vi.fn()
constructor(url: string | URL) {
this.url = url.toString()
FakeWebSocket.instances.push(this)
}
open() {
this.readyState = FakeWebSocket.OPEN
this.onopen?.(new Event("open"))
}
close() {
this.closeCalls += 1
this.readyState = 3
}
send() {}
addEventListener() {}
removeEventListener() {}
dispatchEvent() {
return true
}
}
beforeEach(() => {
FakeWebSocket.instances = []
attachAddonInstances.length = 0
terminalMocks.instances = []
toastMock.mockReset()
;(globalThis as { WebSocket: typeof WebSocket }).WebSocket =
FakeWebSocket as unknown as typeof WebSocket
vi.stubGlobal(
"requestAnimationFrame",
vi.fn(() => 1),
)
vi.stubGlobal("cancelAnimationFrame", vi.fn())
})
afterEach(() => {
vi.clearAllMocks()
})
test("XtermComponent closes the previous WebSocket and re-attaches xterm when wsUrl changes", async () => {
test("XtermComponent closes the previous WebSocket and recreates xterm when wsUrl changes", async () => {
const { XtermComponent } = await import("../components/terminal")
const noop = () => undefined
@@ -90,14 +110,55 @@ test("XtermComponent closes the previous WebSocket and re-attaches xterm when ws
expect(FakeWebSocket.instances).toHaveLength(1)
const firstSocket = FakeWebSocket.instances[0]
expect(attachAddonInstances).toHaveLength(1)
expect(attachAddonInstances[0].ws).toBe(firstSocket as unknown as WebSocket)
expect(terminalMocks.instances).toHaveLength(1)
rerender(<XtermComponent wsUrl="/api/v1/ws/terminal/session-2" setClose={noop} />)
expect(FakeWebSocket.instances).toHaveLength(2)
const secondSocket = FakeWebSocket.instances[1]
expect(firstSocket.closeCalls).toBeGreaterThanOrEqual(1)
expect(attachAddonInstances).toHaveLength(2)
expect(attachAddonInstances[1].ws).toBe(secondSocket as unknown as WebSocket)
expect(terminalMocks.instances).toHaveLength(2)
})
test("mobile controls send text keys and preserve one-shot Ctrl across xterm paste", async () => {
const { XtermComponent } = await import("../components/terminal")
Object.defineProperty(navigator, "clipboard", {
configurable: true,
value: { readText: vi.fn().mockResolvedValue("first line\n第二行") },
})
render(<XtermComponent wsUrl="/api/v1/ws/terminal/mobile" setClose={() => undefined} />)
const socket = FakeWebSocket.instances[0]
act(() => socket.open())
const control = screen.getByRole("button", { name: "Control modifier for next key" })
fireEvent.click(control)
fireEvent.click(screen.getByRole("button", { name: "Paste clipboard" }))
const terminal = terminalMocks.instances[0]
await waitFor(() => expect(terminal.paste).toHaveBeenCalledWith("first line\n第二行"))
expect(control.getAttribute("aria-pressed")).toBe("true")
expect(socket.send).toHaveBeenCalledWith("first line\n第二行")
fireEvent.click(screen.getByRole("button", { name: "Left arrow" }))
expect(socket.send).toHaveBeenLastCalledWith("\x1b[1;5D")
expect(control.getAttribute("aria-pressed")).toBe("false")
})
test("xterm output and binary input remain byte-safe without AttachAddon", async () => {
const { XtermComponent } = await import("../components/terminal")
render(<XtermComponent wsUrl="/api/v1/ws/terminal/bytes" setClose={() => undefined} />)
const socket = FakeWebSocket.instances[0]
act(() => socket.open())
const terminal = terminalMocks.instances[0]
const output = new window.Uint8Array(new window.ArrayBuffer(16))
output.set(new TextEncoder().encode("stream 中文"))
act(() => socket.onmessage?.({ data: output.buffer } as MessageEvent))
expect(terminal.write).toHaveBeenCalledWith(expect.any(Uint8Array))
terminal.binaryHandler?.("\x00\xff")
const binaryCall = socket.send.mock.calls.find(([data]) => ArrayBuffer.isView(data))?.[0]
expect(Array.from(binaryCall as Uint8Array)).toEqual([0, 0, 255])
fireEvent.click(screen.getByRole("button", { name: "Escape" }))
expect(socket.send).toHaveBeenLastCalledWith("\x1b")
})