From d3d029fe4f1addcc3f292b49c939e35cf197feb7 Mon Sep 17 00:00:00 2001 From: Bill Date: Tue, 15 Sep 2026 11:45:17 +0800 Subject: [PATCH] fix: wire plain Ctrl/Cmd+V to paste and split the check by line count MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plain Ctrl+V was never the terminal's: it went to the pty as a literal ^V (0x16) — the command history even recorded 'cd \u0016' — so nothing pasted and the confirm dialog never saw it. It is now handled on the terminal host in the capture phase, with preventDefault, feeding the same path as Ctrl+Shift+V. The paste check is now shape-based rather than length-based: two or more lines confirm (a stray newline executes an unreviewed command), a single line pastes straight through unless it is unusually long. --- src/renderer/src/settings/SettingsTabs.tsx | 2 +- src/renderer/src/terminal/TerminalView.tsx | 55 +++++++++++++++++----- 2 files changed, 45 insertions(+), 12 deletions(-) diff --git a/src/renderer/src/settings/SettingsTabs.tsx b/src/renderer/src/settings/SettingsTabs.tsx index 10d9a22..5753e42 100644 --- a/src/renderer/src/settings/SettingsTabs.tsx +++ b/src/renderer/src/settings/SettingsTabs.tsx @@ -270,7 +270,7 @@ export function RenderSettingsTab(): React.JSX.Element { /> 100 +/** + * Paste-risk heuristic, split by shape rather than by raw length: + * - two or more lines always confirm — a stray newline executes a command + * the user never reviewed; + * - a single line pastes straight through, unless it is unusually long + * (those are pasted scripts rather than something typed by hand). + */ +const SINGLE_LINE_CONFIRM_LENGTH = 1000 +function needsPasteConfirm(text: string): boolean { + const normalized = text.replace(/\r\n?/g, '\n').replace(/\n$/, '') + if (normalized.includes('\n')) return true + return normalized.length > SINGLE_LINE_CONFIRM_LENGTH } /** @@ -511,19 +519,17 @@ export const TerminalView: ForwardRefExoticComponent { - if ( - !deadRef.current && - settings.terminal.pasteRiskConfirm && - !pasteConfirmedForSession && - isRiskyPaste(text) - ) { + // Read the setting at call time: this runs from a long-lived keydown + // listener, so a captured value would go stale after a settings change. + const confirmOn = useSettingsStore.getState().settings.terminal.pasteRiskConfirm + if (!deadRef.current && confirmOn && !pasteConfirmedForSession && needsPasteConfirm(text)) { nativePasteRef.current = text setPaste({ noPrompt: false, disableDetection: false }) return } confirmPaste(text) }, - [settings.terminal.pasteRiskConfirm, confirmPaste] + [confirmPaste] ) /** Confirm the risky-paste dialog: apply its choices, then paste. */ @@ -873,9 +879,36 @@ export const TerminalView: ForwardRefExoticComponent { + if (e.type !== 'keydown' || e.altKey) return + const mod = navigator.platform.toLowerCase().includes('mac') ? e.metaKey : e.ctrlKey + if (!mod || e.shiftKey || e.key.toLowerCase() !== 'v') return + const target = e.target as HTMLElement | null + const isXtermHelper = + target instanceof HTMLTextAreaElement && target.classList.contains('xterm-helper-textarea') + if ( + target && + !isXtermHelper && + (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) + ) { + return // ordinary inputs keep the browser's native paste + } + e.preventDefault() + e.stopPropagation() + void pasteFromClipboard() + } + host?.addEventListener('keydown', onPasteKey, true) + return () => { if (carryTimer !== undefined) window.clearTimeout(carryTimer) if (ptyTimerRef.current) window.clearTimeout(ptyTimerRef.current) + host?.removeEventListener('keydown', onPasteKey, true) for (const unsubscribe of unsubscribes) unsubscribe() for (const disposable of disposables) disposable.dispose() observer?.disconnect()