FN-7262: restore terminal shortcut handling
Restore reliable copy, paste, and input gating for embedded session terminals. - Add platform-aware copy handling so selected text copies while no-selection Ctrl+C reaches the shell. - Gate xterm stdin, mobile controls, cursor blink, and input sends on authoritative attach-ticket writability. - Keep mobile send submission on one form path and cover shortcut/input behavior across desktop, modal, and mobile tests. - Document embedded terminal shortcut semantics and add a patch changeset for @runfusion/fusion. Files changed: .changeset/fn-7262-terminal-shortcuts.md | 7 ++ docs/dashboard-guide.md | 2 +- .../dashboard/app/components/SessionTerminal.tsx | 76 +++++++++++++---- .../__tests__/SessionTerminal.mobile.test.tsx | 34 ++++++-- .../components/__tests__/SessionTerminal.test.tsx | 96 +++++++++++++++++++++- .../components/__tests__/TerminalModal.test.tsx | 33 ++++++++ 6 files changed, 221 insertions(+), 27 deletions(-) Fusion-Task-Id: FN-7262 Fusion-Task-Lineage: 8a7af0a4-0b52-44fe-b949-63486068fa97 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-7262-terminal-shortcuts.md
Normal file
7
.changeset/fn-7262-terminal-shortcuts.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
summary: Restore reliable terminal keyboard shortcuts in embedded CLI session terminals.
|
||||
category: fix
|
||||
dev: SessionTerminal now mirrors TerminalModal copy/paste filtering, suppresses prop/read-only-ticket replay input, and keeps mobile composer submit on one path.
|
||||
@@ -521,7 +521,7 @@ Features:
|
||||
- Shortcuts panel buttons preserve terminal focus on the active terminal session during pointer, mouse, and touch activation, so Ctrl combinations reliably emit control bytes to the shell
|
||||
- The Preferences panel customizes font family, font size, cursor style, cursor blink, and renderer; changes persist in browser `localStorage` under `kb-terminal-preferences`, with the legacy `kb-terminal-font-size` value migrated automatically
|
||||
- Font and cursor preferences apply live to the active xterm instance; renderer changes apply the next time the terminal opens, and mobile devices keep the WebGL renderer disabled to avoid glyph artifacts
|
||||
- Embedded CLI session terminals honor the same saved preferences for live, idle, ended, read-only, and interactive session views. Cursor blink still stays disabled for read-only/replay sessions, renderer changes apply on the next session mount, and WebGL never loads on mobile viewports.
|
||||
- Embedded CLI session terminals honor the same saved preferences and physical copy/paste semantics for live interactive session views: selected text copies with the platform copy modifier, no-selection Ctrl+C stays available to the shell, and paste travels once through xterm's native input path. Idle, ended, and read-only replay views suppress input handlers and mobile accessory controls. Cursor blink still stays disabled for read-only/replay sessions, renderer changes apply on the next session mount, and WebGL never loads on mobile viewports.
|
||||
- Mobile-aware virtual keyboard handling and auto-refit behavior
|
||||
- Reopen/reconnect/session-recovery flows preserve single-keystroke input forwarding (no duplicate characters, no page refresh required)
|
||||
|
||||
|
||||
@@ -147,6 +147,10 @@ function buildCliWsUrl(sessionId: string, ticket: string): string {
|
||||
return appendTokenQuery(base);
|
||||
}
|
||||
|
||||
function isMacPlatform(): boolean {
|
||||
return /Mac|iPhone|iPad|iPod/i.test(navigator.platform);
|
||||
}
|
||||
|
||||
function decodeBase64ToString(b64: string): string {
|
||||
if (typeof window !== "undefined" && typeof window.atob === "function") {
|
||||
// atob → binary string → UTF-8 decode.
|
||||
@@ -187,15 +191,18 @@ export function SessionTerminal({
|
||||
// Sticky Ctrl: tap Ctrl, then the next tapped key combines into a control
|
||||
// sequence (Ctrl-C → 0x03, Ctrl-D → 0x04, Ctrl-Z → 0x1A).
|
||||
const [ctrlSticky, setCtrlSticky] = useState(false);
|
||||
const [ticketReadOnly, setTicketReadOnly] = useState<boolean | null>(null);
|
||||
const effectiveReadOnly = readOnly || ticketReadOnly === true;
|
||||
const canAcceptInput = !readOnly && ticketReadOnly === false && mode === "live";
|
||||
|
||||
/** Write raw bytes to the session input path (mobile bar + submit). */
|
||||
const sendInput = useCallback((data: string) => {
|
||||
if (!data) return;
|
||||
if (!data || !canAcceptInput) return;
|
||||
const ws = wsRef.current;
|
||||
if (ws?.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: "input", data }));
|
||||
}
|
||||
}, []);
|
||||
}, [canAcceptInput]);
|
||||
|
||||
/**
|
||||
* Emit one accessory-bar key. If sticky Ctrl is active and the key has a
|
||||
@@ -277,14 +284,14 @@ export function SessionTerminal({
|
||||
);
|
||||
terminal.options.fontSize = terminalPreferences.fontSize;
|
||||
terminal.options.cursorStyle = terminalPreferences.cursorStyle;
|
||||
terminal.options.cursorBlink = terminalPreferences.cursorBlink && !readOnly && mode === "live";
|
||||
terminal.options.cursorBlink = terminalPreferences.cursorBlink && canAcceptInput;
|
||||
|
||||
try {
|
||||
(fitAddonRef.current as { fit?: () => void } | null)?.fit?.();
|
||||
} catch {
|
||||
/* ignore transient measure failures */
|
||||
}
|
||||
}, [mode, readOnly]);
|
||||
}, [canAcceptInput]);
|
||||
|
||||
/*
|
||||
FNXC:Terminal 2026-06-17-01:05:
|
||||
@@ -313,6 +320,7 @@ export function SessionTerminal({
|
||||
let resizeObserver: ResizeObserver | null = null;
|
||||
let resizeTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let unackedBytes = 0;
|
||||
setTicketReadOnly(null);
|
||||
|
||||
const sendResize = (cols: number, rows: number) => {
|
||||
const ws = wsRef.current;
|
||||
@@ -343,6 +351,8 @@ export function SessionTerminal({
|
||||
return; // surfaced via the "disconnected" state header below
|
||||
}
|
||||
if (disposed) return;
|
||||
setTicketReadOnly(ticketRes.readOnly);
|
||||
const ticketCanAcceptInput = !readOnly && !ticketRes.readOnly && mode === "live";
|
||||
|
||||
// 2. Lazy-load xterm + addons (out of the main bundle).
|
||||
const [{ Terminal }, { FitAddon }, { Unicode11Addon }] = await Promise.all([
|
||||
@@ -364,13 +374,16 @@ export function SessionTerminal({
|
||||
SessionTerminal shares TerminalModal's recurrence #5 root cause: FN-6638's 66.76px diagnostic compared only symbols-inclusive stacks, so real iOS Safari still let the loaded symbols @font-face pollute xterm's ASCII measurement. Pass only the symbols-free resolved family to xterm on this attach surface too; DOM glyph fallback is scoped to the viewport CSS variable and never to the xterm font option used by DOM/canvas measurement or desktop WebGL.
|
||||
|
||||
FNXC:Terminal 2026-06-17-00:50:
|
||||
SessionTerminal consumes the shared localStorage terminal preferences for parity with TerminalModal, but replay safety still owns input posture: cursor blink is the user preference AND-gated by !readOnly && mode === "live" so read-only, idle, and ended sessions never blink.
|
||||
SessionTerminal consumes the shared localStorage terminal preferences for parity with TerminalModal, but replay safety still owns input posture: cursor blink is the user preference AND-gated by effective write permission so read-only, idle, ended, and server-downgraded attach sessions never blink.
|
||||
|
||||
FNXC:Terminal 2026-06-30-21:24:
|
||||
Attach tickets are authoritative for replay/permission downgrades. Derive xterm stdin, keyboard handlers, and mobile affordance rendering from both the caller props and ticketRes.readOnly so a server read-only attach cannot accept input even when the mount props still say live+writable.
|
||||
*/
|
||||
const term = new Terminal({
|
||||
convertEol: false,
|
||||
cursorBlink: terminalPreferences.cursorBlink && !readOnly && mode === "live",
|
||||
cursorBlink: terminalPreferences.cursorBlink && ticketCanAcceptInput,
|
||||
cursorStyle: terminalPreferences.cursorStyle,
|
||||
disableStdin: readOnly,
|
||||
disableStdin: !ticketCanAcceptInput,
|
||||
scrollback: 10000,
|
||||
// Defensive: do NOT register an OSC 52 (clipboard-write) handler. The
|
||||
// server-side neutralizer (U10) strips it; we add no client handling.
|
||||
@@ -446,14 +459,46 @@ export function SessionTerminal({
|
||||
}
|
||||
})();
|
||||
|
||||
// term.onData → input frames (skip entirely when read-only).
|
||||
if (!readOnly) {
|
||||
/*
|
||||
FNXC:Terminal 2026-06-30-00:10:
|
||||
FN-7262 root cause: the embedded SessionTerminal attach surface forwarded raw xterm data but never installed the copy/paste key filter already used by TerminalModal, so physical Ctrl/Cmd+C with a selection could be swallowed by xterm/browser routing inconsistently while replay states still accepted input. Register exactly one handler with the xterm instance for live writable sessions: platform copy+C copies selected text, copy+C without selection stays on the PTY/SIGINT path, and paste is left to xterm's native onData flow so it is delivered once.
|
||||
*/
|
||||
if (ticketCanAcceptInput) {
|
||||
term.onData((data: string) => {
|
||||
const ws = wsRef.current;
|
||||
if (ws?.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: "input", data }));
|
||||
}
|
||||
});
|
||||
|
||||
term.attachCustomKeyEventHandler((event: KeyboardEvent) => {
|
||||
if (event.type !== "keydown") {
|
||||
return true;
|
||||
}
|
||||
|
||||
const isCopyPasteModifier = isMacPlatform() ? event.metaKey : event.ctrlKey;
|
||||
if (!isCopyPasteModifier || event.altKey || event.shiftKey) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const key = event.key.toLowerCase();
|
||||
if (key === "c") {
|
||||
const selection = term.hasSelection() ? term.getSelection() : "";
|
||||
if (!selection) {
|
||||
return true;
|
||||
}
|
||||
navigator.clipboard?.writeText(selection).catch(() => {
|
||||
// Ignore clipboard permission/errors so terminal input stays responsive.
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
if (key === "v") {
|
||||
return true;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
// Debounced ResizeObserver → resize frames.
|
||||
@@ -574,7 +619,7 @@ export function SessionTerminal({
|
||||
isMobile && keyboardOpen ? " cli-session-terminal--keyboard-open" : ""
|
||||
}`}
|
||||
data-mode={mode}
|
||||
data-read-only={readOnly}
|
||||
data-read-only={effectiveReadOnly}
|
||||
data-mobile={isMobile}
|
||||
data-keyboard-open={isMobile && keyboardOpen}
|
||||
>
|
||||
@@ -631,7 +676,7 @@ export function SessionTerminal({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{readOnly && (
|
||||
{effectiveReadOnly && (
|
||||
<span className="cli-session-terminal__readonly-badge">
|
||||
<Eye size={12} aria-hidden="true" />
|
||||
{t("cliTerminal.readOnly", "Read-only")}
|
||||
@@ -680,7 +725,7 @@ export function SessionTerminal({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isMobile && !readOnly && (
|
||||
{isMobile && canAcceptInput && (
|
||||
<div
|
||||
className={`cli-session-terminal__mobile-bar${
|
||||
keyboardOpen ? " cli-session-terminal__mobile-bar--keyboard-open" : ""
|
||||
@@ -816,11 +861,12 @@ export function SessionTerminal({
|
||||
className="cli-session-terminal__mobile-send"
|
||||
data-testid="cli-terminal-mobile-send"
|
||||
aria-label={t("cliTerminal.mobileSend", "Send")}
|
||||
// iOS pattern: act on click, preventDefault on pointer/mouse down
|
||||
// so the input doesn't blur (which dismisses the keyboard).
|
||||
/*
|
||||
FNXC:Terminal 2026-06-30-22:10:
|
||||
The mobile send affordance must submit through exactly one path. Keep the form submit handler so keyboard Enter and touch activation share one input sequence, while pointer/mouse down still prevents blur on iOS.
|
||||
*/
|
||||
onPointerDown={keepFocus}
|
||||
onMouseDown={keepFocus}
|
||||
onClick={() => handleMobileSubmit()}
|
||||
>
|
||||
{t("cliTerminal.mobileSend", "Send")}
|
||||
</button>
|
||||
|
||||
@@ -9,6 +9,9 @@ const mockTerm = {
|
||||
loadAddon: vi.fn(),
|
||||
open: vi.fn(),
|
||||
onData: vi.fn(),
|
||||
attachCustomKeyEventHandler: vi.fn(),
|
||||
hasSelection: vi.fn(() => false),
|
||||
getSelection: vi.fn(() => ""),
|
||||
write: vi.fn((_data: string, cb?: () => void) => cb?.()),
|
||||
dispose: vi.fn(),
|
||||
unicode: { activeVersion: "6" },
|
||||
@@ -135,6 +138,9 @@ beforeEach(() => {
|
||||
mockTerm.loadAddon.mockClear();
|
||||
mockTerm.open.mockClear();
|
||||
mockTerm.onData.mockReset();
|
||||
mockTerm.attachCustomKeyEventHandler.mockClear();
|
||||
mockTerm.hasSelection.mockReturnValue(false);
|
||||
mockTerm.getSelection.mockReturnValue("");
|
||||
mockTerm.write.mockClear();
|
||||
mockTerm.dispose.mockClear();
|
||||
mockTerm.options = {};
|
||||
@@ -175,12 +181,28 @@ describe("SessionTerminal (mobile)", () => {
|
||||
expect(screen.queryByTestId("cli-terminal-mobile-bar")).toBeNull();
|
||||
});
|
||||
|
||||
it("does not render the mobile bar when read-only", async () => {
|
||||
apiMock.mockResolvedValue({ ticket: "tkt-1", expiresAt: "", readOnly: true });
|
||||
await renderMobile({ readOnly: true });
|
||||
it.each([
|
||||
["read-only", { readOnly: true }],
|
||||
["idle", { mode: "idle" as const }],
|
||||
["ended", { mode: "ended" as const }],
|
||||
])("does not render the mobile bar when %s", async (_label, props) => {
|
||||
if (props.readOnly) {
|
||||
apiMock.mockResolvedValue({ ticket: "tkt-1", expiresAt: "", readOnly: true });
|
||||
}
|
||||
await renderMobile(props);
|
||||
expect(screen.queryByTestId("cli-terminal-mobile-bar")).toBeNull();
|
||||
});
|
||||
|
||||
it("does not render mobile controls when the attach ticket is read-only", async () => {
|
||||
apiMock.mockResolvedValue({ ticket: "tkt-ro", expiresAt: "", readOnly: true });
|
||||
|
||||
await renderMobile();
|
||||
|
||||
expect(screen.queryByTestId("cli-terminal-mobile-bar")).toBeNull();
|
||||
expect(mockTerm.options.disableStdin).toBe(true);
|
||||
expect(mockTerm.onData).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// ── Accessory bar control sequences ───────────────────────────────────────
|
||||
it("Esc key emits 0x1b as an input frame", async () => {
|
||||
const { ws } = await renderMobile();
|
||||
@@ -271,15 +293,13 @@ describe("SessionTerminal (mobile)", () => {
|
||||
});
|
||||
|
||||
// ── Input field submit ────────────────────────────────────────────────────
|
||||
it("submitting the input field sends the text then \\r", async () => {
|
||||
it("submitting the input field sends the text then exactly one \\r", async () => {
|
||||
const { ws } = await renderMobile();
|
||||
const input = screen.getByTestId("cli-terminal-mobile-input") as HTMLInputElement;
|
||||
fireEvent.change(input, { target: { value: "ls -la" } });
|
||||
fireEvent.click(screen.getByTestId("cli-terminal-mobile-send"));
|
||||
const frames = inputFrames(ws);
|
||||
const idx = frames.indexOf("ls -la");
|
||||
expect(idx).toBeGreaterThanOrEqual(0);
|
||||
expect(frames[idx + 1]).toBe("\r");
|
||||
expect(frames).toEqual(["ls -la", "\r"]);
|
||||
// Field is cleared after submit.
|
||||
expect(input.value).toBe("");
|
||||
});
|
||||
|
||||
@@ -3,11 +3,16 @@ import { act, render, screen, fireEvent, waitFor } from "@testing-library/react"
|
||||
|
||||
// ── Mock xterm + addon dynamic imports (jsdom has no canvas/WebGL) ──────────
|
||||
const mockFitAddon = { fit: vi.fn() };
|
||||
let sessionKeyEventHandler: ((event: KeyboardEvent) => boolean) | null = null;
|
||||
const mockTerm = {
|
||||
loadAddon: vi.fn(),
|
||||
open: vi.fn(),
|
||||
onData: vi.fn(),
|
||||
attachCustomKeyEventHandler: vi.fn(),
|
||||
attachCustomKeyEventHandler: vi.fn((handler: (event: KeyboardEvent) => boolean) => {
|
||||
sessionKeyEventHandler = handler;
|
||||
}),
|
||||
hasSelection: vi.fn(() => false),
|
||||
getSelection: vi.fn(() => ""),
|
||||
write: vi.fn((_data: string, cb?: () => void) => cb?.()),
|
||||
refresh: vi.fn(),
|
||||
dispose: vi.fn(),
|
||||
@@ -82,8 +87,19 @@ beforeEach(() => {
|
||||
mockTerm.loadAddon.mockClear();
|
||||
mockTerm.open.mockClear();
|
||||
mockTerm.onData.mockReset();
|
||||
sessionKeyEventHandler = null;
|
||||
mockTerm.attachCustomKeyEventHandler.mockClear();
|
||||
mockTerm.hasSelection.mockReturnValue(false);
|
||||
mockTerm.getSelection.mockReturnValue("");
|
||||
mockTerm.write.mockClear();
|
||||
Object.defineProperty(navigator, "platform", {
|
||||
value: "Win32",
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(navigator, "clipboard", {
|
||||
value: undefined,
|
||||
configurable: true,
|
||||
});
|
||||
mockTerm.refresh.mockClear();
|
||||
mockTerm.dispose.mockClear();
|
||||
mockTerm.options = {};
|
||||
@@ -124,10 +140,34 @@ describe("SessionTerminal", () => {
|
||||
await waitFor(() => expect(mockTerm.write).toHaveBeenCalledWith("hello", expect.any(Function)));
|
||||
});
|
||||
|
||||
it("read-only: never registers term.onData (input suppressed)", async () => {
|
||||
render(<SessionTerminal sessionId="s1" readOnly />);
|
||||
it.each([
|
||||
["read-only", { readOnly: true }],
|
||||
["idle", { mode: "idle" as const }],
|
||||
["ended", { mode: "ended" as const }],
|
||||
])("%s: never registers input handlers", async (_label, props) => {
|
||||
render(<SessionTerminal sessionId="s1" {...props} />);
|
||||
await waitFor(() => expect(FakeWS.instances.length).toBe(1));
|
||||
expect(mockTerm.onData).not.toHaveBeenCalled();
|
||||
expect(mockTerm.attachCustomKeyEventHandler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("honors server read-only attach tickets when props are live+writable", async () => {
|
||||
const { Terminal } = await import("@xterm/xterm");
|
||||
apiMock.mockResolvedValue({ ticket: "tkt-ro", expiresAt: "", readOnly: true });
|
||||
|
||||
render(<SessionTerminal sessionId="s1" />);
|
||||
|
||||
await waitFor(() => expect(FakeWS.instances.length).toBe(1));
|
||||
expect(FakeWS.instances[0].url).toContain("ticket=tkt-ro");
|
||||
expect(Terminal).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
cursorBlink: false,
|
||||
disableStdin: true,
|
||||
}),
|
||||
);
|
||||
expect(mockTerm.onData).not.toHaveBeenCalled();
|
||||
expect(mockTerm.attachCustomKeyEventHandler).not.toHaveBeenCalled();
|
||||
expect(await screen.findByText("Read-only")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("relies on native xterm paste while applying the default terminal font preference", async () => {
|
||||
@@ -145,7 +185,6 @@ describe("SessionTerminal", () => {
|
||||
}),
|
||||
);
|
||||
expectMeasurementSafeFontStack(mockTerm.options.fontFamily as string);
|
||||
expect(mockTerm.attachCustomKeyEventHandler).not.toHaveBeenCalled();
|
||||
|
||||
const inputHandler = mockTerm.onData.mock.calls[0]?.[0] as
|
||||
| ((data: string) => void)
|
||||
@@ -158,6 +197,55 @@ describe("SessionTerminal", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("drops physical input frames when the attach WebSocket is not open", async () => {
|
||||
render(<SessionTerminal sessionId="s1" />);
|
||||
await waitFor(() => expect(FakeWS.instances.length).toBe(1));
|
||||
expect(mockTerm.onData).toHaveBeenCalledTimes(1);
|
||||
|
||||
const inputHandler = mockTerm.onData.mock.calls[0]?.[0] as ((data: string) => void) | undefined;
|
||||
FakeWS.instances[0].readyState = 3;
|
||||
inputHandler?.("dropped");
|
||||
|
||||
expect(FakeWS.instances[0].sent).toEqual([]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["mac", "MacIntel", { metaKey: true }],
|
||||
["non-mac", "Win32", { ctrlKey: true }],
|
||||
] as const)("preserves physical copy/paste terminal semantics on %s", async (_name, platform, modifier) => {
|
||||
const writeText = vi.fn().mockResolvedValue(undefined);
|
||||
const readText = vi.fn().mockResolvedValue("ignored because xterm handles paste");
|
||||
Object.defineProperty(navigator, "platform", {
|
||||
value: platform,
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(navigator, "clipboard", {
|
||||
value: { writeText, readText },
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
render(<SessionTerminal sessionId="s1" />);
|
||||
await waitFor(() => expect(FakeWS.instances.length).toBe(1));
|
||||
await waitFor(() => expect(sessionKeyEventHandler).not.toBeNull());
|
||||
|
||||
mockTerm.hasSelection.mockReturnValue(true);
|
||||
mockTerm.getSelection.mockReturnValue("selected cli output");
|
||||
expect(sessionKeyEventHandler?.(new KeyboardEvent("keydown", { key: "c", ...modifier }))).toBe(false);
|
||||
await waitFor(() => expect(writeText).toHaveBeenCalledWith("selected cli output"));
|
||||
|
||||
mockTerm.hasSelection.mockReturnValue(false);
|
||||
expect(sessionKeyEventHandler?.(new KeyboardEvent("keydown", { key: "c", ...modifier }))).toBe(true);
|
||||
|
||||
const beforePasteFrames = FakeWS.instances[0].sent.length;
|
||||
expect(sessionKeyEventHandler?.(new KeyboardEvent("keydown", { key: "v", ...modifier }))).toBe(true);
|
||||
expect(readText).not.toHaveBeenCalled();
|
||||
const inputHandler = mockTerm.onData.mock.calls[0]?.[0] as ((data: string) => void) | undefined;
|
||||
inputHandler?.("pasted once");
|
||||
expect(FakeWS.instances[0].sent.slice(beforePasteFrames)).toEqual([
|
||||
JSON.stringify({ type: "input", data: "pasted once" }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("refits after font settlement even when iOS rejects the font-load shorthand", async () => {
|
||||
const load = vi.fn(() => Promise.reject(new DOMException("Invalid font shorthand")));
|
||||
Object.defineProperty(document, "fonts", {
|
||||
|
||||
@@ -1217,6 +1217,39 @@ describe("TerminalModal", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("sends sticky Ctrl shortcut bytes and clears the modifier after each delivery", async () => {
|
||||
const terminalDiv = document.createElement("div");
|
||||
terminalDiv.setAttribute("data-testid", "terminal");
|
||||
const helperTextarea = document.createElement("textarea");
|
||||
helperTextarea.className = "xterm-helper-textarea";
|
||||
terminalDiv.appendChild(helperTextarea);
|
||||
document.body.appendChild(terminalDiv);
|
||||
|
||||
try {
|
||||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||||
helperTextarea.focus();
|
||||
|
||||
fireEvent.click(screen.getByTestId("terminal-shortcut-toggle"));
|
||||
const ctrlButton = screen.getByTestId("terminal-modifier-ctrl");
|
||||
|
||||
for (const [label, expected] of [
|
||||
["C", "\x03"],
|
||||
["D", "\x04"],
|
||||
["L", "\x0c"],
|
||||
] as const) {
|
||||
fireEvent.click(ctrlButton);
|
||||
fireEvent.click(screen.getByRole("button", { name: label }));
|
||||
expect(mockSendInput).toHaveBeenLastCalledWith(expected);
|
||||
expect(ctrlButton.getAttribute("aria-pressed")).toBe("false");
|
||||
}
|
||||
|
||||
expect(mockSendInput.mock.calls.map(([value]) => value)).toEqual(["\x03", "\x04", "\x0c"]);
|
||||
expect(document.activeElement).toBe(helperTextarea);
|
||||
} finally {
|
||||
document.body.removeChild(terminalDiv);
|
||||
}
|
||||
});
|
||||
|
||||
it("sends literal ANSI arrow sequences independent of sticky modifiers", async () => {
|
||||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user