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:
gsxdsm
2026-06-30 01:18:16 -07:00
parent 3e0391eada
commit 273553429c
6 changed files with 221 additions and 27 deletions

View 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.

View File

@@ -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)

View File

@@ -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>

View File

@@ -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("");
});

View File

@@ -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", {

View File

@@ -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} />);