diff --git a/docs/solutions/ui-bugs/xterm-async-font-remeasure-paste-dedupe.md b/docs/solutions/ui-bugs/xterm-async-font-remeasure-paste-dedupe.md index aafcc1cb4e..d5ec71934c 100644 --- a/docs/solutions/ui-bugs/xterm-async-font-remeasure-paste-dedupe.md +++ b/docs/solutions/ui-bugs/xterm-async-font-remeasure-paste-dedupe.md @@ -45,11 +45,25 @@ The recurrence path was stricter real-iOS font/text measurement behavior. A long A second pitfall is custom paste handling. If an `attachCustomKeyEventHandler` Cmd/Ctrl+V branch reads `navigator.clipboard.readText()` and forwards that text to the PTY while the browser also performs the native paste into xterm's helper textarea, the same payload reaches `terminal.onData` and is sent twice. +**Paste history (superseded twice — read this before touching the paste branch):** + +1. This doc originally prescribed "prefer native paste, return `true`, never read the clipboard manually." +2. GitHub #1902 (2026-07-04) reversed that: relying only on helper-textarea paste swallowed physical Ctrl/Cmd+V in some environments, so TerminalModal switched to a custom `navigator.clipboard.readText()` path returning `false`. +3. GitHub #2121/#2307 review (2026-07-23) found the custom path double-delivered and hardened it. The CURRENT contract is below; following the original prescription verbatim would reintroduce the #1902 swallow, and following #1902's version verbatim reintroduces the double paste. + +Hard-won xterm 5.5.0 facts (verified against `@xterm/xterm` source during the 2026-07-23 fix): + +- Returning `false` from `attachCustomKeyEventHandler` skips xterm's key handling but does NOT cancel the browser's default action — the default paste still fires xterm's helper-textarea `paste` listener. +- Returning `true` for Ctrl+V on non-mac lets xterm's `_keyDown` convert it into a `\x16` (SYN) data event sent to the PTY AND `cancel(event)` the browser paste — never return `true` for paste. +- xterm's `handlePasteEvent` calls `stopPropagation()` but NOT `preventDefault()`, and the same paste handler is registered on both the helper textarea and the root element. + ## Solution -Keep one canonical paste path and remeasure after font resolution. +Current Cmd/Ctrl+V contract in `TerminalModal.tsx` (single delivery on every path): -- Prefer xterm's native helper-textarea paste for Cmd/Ctrl+V; return `true` from the custom key handler so the browser/xterm path runs, and do not read/send clipboard text manually. +- **Async clipboard available (secure context):** call `event.preventDefault()` (otherwise the browser's default paste double-delivers via the helper textarea), read via `navigator.clipboard.readText()`, and deliver through `terminal.paste(text)` — never raw `sendInput` — so bracketed-paste wrapping and `\n`→`\r` normalization apply. Return `false`. +- **Async clipboard missing (non-HTTPS remote, older Firefox) or a prior read was permission-denied (sticky `clipboardReadBlockedRef`):** return `false` WITHOUT `preventDefault()` — xterm skips its key handling and the un-prevented native paste delivers exactly once through the helper textarea. +- **`readText()` rejection** sets the sticky blocked ref so every subsequent Ctrl/Cmd+V uses the native path; at most one paste (at denial time) is lost. - Preserve custom copy behavior only for selected text, where suppressing terminal input is intentional. - After `terminal.open()`, treat FontFaceSet loading as best-effort: try the full stack, fall back to concrete individual families only if the full shorthand rejects, await `document.fonts.ready`, and never let an iOS shorthand rejection skip the later remeasure. - Guard async remeasure work with the expected session id and current terminal/addon refs so stale font-load promises cannot mutate a disposed or switched terminal. @@ -62,7 +76,9 @@ Keep one canonical paste path and remeasure after font resolution. Cover the invariant across terminal surfaces and input paths: -- Keyboard paste on macOS (`metaKey`) and non-mac (`ctrlKey`) returns `true`, does not call `clipboard.readText()`, and sends exactly one PTY input frame via xterm `onData`. +- Keyboard paste on macOS (`metaKey`) and non-mac (`ctrlKey`) with clipboard available: returns `false`, preventDefaults, calls `clipboard.readText()` once, and delivers exactly once via `terminal.paste()` (no direct `sendInput`). +- Clipboard API missing (undefined `navigator.clipboard` or no `readText`): returns `false` with `defaultPrevented === false` so the native helper-textarea paste is the single delivery path. +- After a `readText()` permission denial, the NEXT Ctrl/Cmd+V returns `false` with no `preventDefault` and no further `readText()` call (sticky denial → native path). - Native helper-textarea paste without the shortcut handler sends exactly once, covering mobile/iOS context-menu paste. - A controlled `document.fonts.load()` promise resolving after `terminal.open()` triggers a post-font-load fit, resize, and refresh. - A controlled `document.fonts.load()` rejection (the real-iOS shorthand failure mode) still triggers font option reapply, fit/resize, and refresh for both `TerminalModal` and `SessionTerminal`. diff --git a/packages/dashboard/app/components/TerminalModal.tsx b/packages/dashboard/app/components/TerminalModal.tsx index 96fd34007c..404d157bb5 100644 --- a/packages/dashboard/app/components/TerminalModal.tsx +++ b/packages/dashboard/app/components/TerminalModal.tsx @@ -551,6 +551,8 @@ interface TerminalModalProps { export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandGeneration = 0, projectId, embedded = false, defaultCwd, scopeId, footerVisible = false }: TerminalModalProps) { const { t } = useTranslation("app"); const [error, setError] = useState(null); + // FNXC:Terminal 2026-07-23-20:10: In-flight guard for the manual "Start terminal" action (GitHub #2121/#2307 review): rapid clicks must not create duplicate PTY sessions, and the Windows bootstrap-failure cohort this button serves must SEE createTab failures instead of a silently dead button. + const [isStartingTerminal, setIsStartingTerminal] = useState(false); const [exitCode, setExitCode] = useState(null); const [xtermReady, setXtermReady] = useState(false); const [xtermInitError, setXtermInitError] = useState(null); @@ -619,6 +621,8 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG // (which under StrictMode/Vite Fast Refresh could leak a stale listener // on the same xterm instance and cause per-character input doubling). const sendInputRef = useRef<(data: string) => void>(() => {}); + // FNXC:Terminal 2026-07-23-20:10: Sticky marker set when navigator.clipboard.readText rejects (permission denied). Once set, Ctrl/Cmd+V routes through the browser's native paste into xterm's helper textarea instead of retrying a read that will keep rejecting — at most one paste is lost, at denial time. + const clipboardReadBlockedRef = useRef(false); // Window resize listener tied to the live xterm instance — tracked here so // it can be removed in step with xterm disposal (modal close, tab switch). const windowResizeListenerRef = useRef<(() => void) | null>(null); @@ -1767,15 +1771,17 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG if (key === "v") { /* FNXC:Terminal 2026-07-04-10:24: - GitHub #1902 showed that relying only on xterm's helper-textarea paste can swallow physical Ctrl/Cmd+V before clipboard text reaches the PTY. Own platform paste here, then return false so xterm's own key handling cannot also emit input. + GitHub #1902 showed that relying only on xterm's helper-textarea paste can swallow physical Ctrl/Cmd+V before clipboard text reaches the PTY. Own platform paste here when the async clipboard API is available. - FNXC:Terminal 2026-07-23-14:30: - Returning false only skips xterm's key handling — it does NOT cancel the browser's default paste, which fires xterm's helper-textarea `paste` listener and delivered every Ctrl/Cmd+V payload to the PTY twice. Call event.preventDefault() so the custom clipboard read is the single delivery path. - When the async clipboard API is unavailable (non-HTTPS remote access, older Firefox), return true instead of swallowing the shortcut: the browser's native paste into xterm's helper textarea is then the only working paste path. + FNXC:Terminal 2026-07-23-20:10: + Paste contract (GitHub #2121/#2307 review), verified against xterm 5.5.0 source: + - returning false from attachCustomKeyEventHandler skips xterm's key handling but does NOT cancel the browser's default paste — that default fires xterm's helper-textarea `paste` listener (single delivery). Never return true for paste: on non-mac, xterm's own _keyDown turns Ctrl+V into a \x16 data event and cancels the browser paste. + - When readText is available: call event.preventDefault() so the custom clipboard read is the SINGLE delivery path (without it the payload reached the PTY twice), and deliver via terminal.paste() so bracketed-paste wrapping and \n→\r normalization apply. + - When readText is missing (non-HTTPS remote, older Firefox) or a prior read was denied: return false with NO preventDefault so the native helper-textarea paste delivers exactly once. */ const readText = navigator.clipboard?.readText; - if (!readText) { - return true; + if (!readText || clipboardReadBlockedRef.current) { + return false; } event.preventDefault(); readText.call(navigator.clipboard) @@ -1783,10 +1789,12 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG if (!text || xtermInitializedRef.current !== currentSessionId) { return; } - sendInputRef.current(text); + terminal.paste(text); }) .catch(() => { - // Ignore clipboard permission/errors so terminal input stays responsive. + // Permission denied (or transient failure): stop preventDefaulting future + // Ctrl/Cmd+V so the native paste path stays functional. + clipboardReadBlockedRef.current = true; }); return false; } @@ -2915,7 +2923,20 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG