diff --git a/.changeset/fn-7262-terminal-shortcuts.md b/.changeset/fn-7262-terminal-shortcuts.md new file mode 100644 index 0000000000..240a11b1ff --- /dev/null +++ b/.changeset/fn-7262-terminal-shortcuts.md @@ -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. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index a46fca49fd..caa420fdff 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -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) diff --git a/packages/dashboard/app/components/SessionTerminal.tsx b/packages/dashboard/app/components/SessionTerminal.tsx index 97b64a306e..9a1e62b548 100644 --- a/packages/dashboard/app/components/SessionTerminal.tsx +++ b/packages/dashboard/app/components/SessionTerminal.tsx @@ -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(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 | 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({ )} )} - {readOnly && ( + {effectiveReadOnly && (