fix: paste through xterm so large multi-line blocks insert instead of executing

confirmPaste wrote the raw text to the pty, so a large paste bypassed
bracketed paste and PowerShell ran it line by line; it also skipped
onData, which is why history/cwd tracking needed a separate mirror. Now the
text goes through term.paste(): xterm adds the \x1b[200~ markers when the
shell enabled bracketed paste (one edit, no execution) and the normal
onData path restores tracking for free.

The risky-paste bar becomes a proper dialog (确认粘贴 / 本次会话不再提示 /
关闭后续粘贴检测), matching the behaviour users expect from other
terminals.
This commit is contained in:
Bill committed 2026-09-15 00:27:19 +08:00
1 parent cbf4c224fb
commit d7abbaad9c
2 files changed
+70 -83

No files matched your search

+58 -40
View File
@@ -7,6 +7,7 @@ import { WebglAddon } from '@xterm/addon-webgl'
import { SearchAddon } from '@xterm/addon-search' import { SearchAddon } from '@xterm/addon-search'
import '@xterm/xterm/css/xterm.css' import '@xterm/xterm/css/xterm.css'
import { ClearOutlined, CopyOutlined, FolderOpenOutlined, PauseOutlined, SearchOutlined, SelectOutlined, SnippetsOutlined, SoundOutlined } from '@ant-design/icons' import { ClearOutlined, CopyOutlined, FolderOpenOutlined, PauseOutlined, SearchOutlined, SelectOutlined, SnippetsOutlined, SoundOutlined } from '@ant-design/icons'
import { Checkbox, Modal } from 'antd'
import { getThemeById } from '@shared/theme' import { getThemeById } from '@shared/theme'
import type { CommandItem } from '@shared/commands' import type { CommandItem } from '@shared/commands'
import type { TerminalSettings } from '@shared/settings' import type { TerminalSettings } from '@shared/settings'
@@ -37,6 +38,13 @@ function isRiskyPaste(text: string): boolean {
return text.length > 100 return text.length > 100
} }
/**
* "本次会话不再提示" from the paste confirmation dialog. Deliberately in memory
* only: it lasts until the app exits and never becomes a stored preference
* (that is what 关闭后续粘贴检测 in the dialog is for).
*/
let pasteConfirmedForSession = false
const SEARCH_DECORATIONS: NonNullable<NonNullable<Parameters<SearchAddon['findNext']>[1]>['decorations']> = { const SEARCH_DECORATIONS: NonNullable<NonNullable<Parameters<SearchAddon['findNext']>[1]>['decorations']> = {
matchBackground: '#3b8eea', matchBackground: '#3b8eea',
matchBorder: '#2d2d2d', matchBorder: '#2d2d2d',
@@ -306,7 +314,7 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
// time (decides if 复制 is enabled). // time (decides if 复制 is enabled).
const [menuPos, setMenuPos] = useState<{ x: number; y: number } | null>(null) const [menuPos, setMenuPos] = useState<{ x: number; y: number } | null>(null)
const [menuHasSel, setMenuHasSel] = useState(false) const [menuHasSel, setMenuHasSel] = useState(false)
const [paste, setPaste] = useState<string | null>(null) const [paste, setPaste] = useState<{ noPrompt: boolean; disableDetection: boolean } | null>(null)
const [dead, setDead] = useState(false) const [dead, setDead] = useState(false)
const [exitCode, setExitCode] = useState(0) const [exitCode, setExitCode] = useState(0)
const [recording, setRecording] = useState(false) const [recording, setRecording] = useState(false)
@@ -457,40 +465,20 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
[sessionId] [sessionId]
) )
/**
* Pasted text is written straight to the pty (confirmPaste → writeBroadcast),
* bypassing xterm's onData, so the line buffer / history / cwd tracker would
* never see it — a pasted `cd X` used to be recorded as the bare prefix typed
* before it. Mirror the shell: each completed line is a submitted command,
* the trailing partial line stays pending in the buffer.
*/
const trackPastedText = useCallback(
(data: string): void => {
if (termRef.current?.buffer.active.type === 'alternate') return
const parts = data.replace(/\r\n/g, '\n').split('\n')
const tail = parts.pop() ?? ''
for (const line of parts) {
const t = line.trim()
lineBufRef.current = ''
if (t) trackSubmittedLine(t)
}
if (tail) lineBufRef.current += tail
},
[trackSubmittedLine]
)
const confirmPaste = useCallback( const confirmPaste = useCallback(
(text: string) => { (text: string) => {
const data = nativePasteRef.current ?? text const data = nativePasteRef.current ?? text
nativePasteRef.current = null nativePasteRef.current = null
setPaste(null) setPaste(null)
if (!deadRef.current) { // Route through xterm's own paste: when the shell asked for bracketed
writeBroadcast(sessionId, data) // paste (PSReadLine does), the text is wrapped in \x1b[200~ / \x1b[201~
trackPastedText(data) // and inserted as ONE edit instead of executing line by line — which is
} // what a large multi-line paste needs. It also feeds onData, so the line
// buffer, command history and cwd memory see the pasted text.
if (!deadRef.current) termRef.current?.paste(data)
termRef.current?.focus() termRef.current?.focus()
}, },
[sessionId, trackPastedText] []
) )
const cancelPaste = useCallback(() => { const cancelPaste = useCallback(() => {
@@ -523,9 +511,14 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
const beginPaste = useCallback( const beginPaste = useCallback(
(text: string) => { (text: string) => {
if (!deadRef.current && settings.terminal.pasteRiskConfirm && isRiskyPaste(text)) { if (
!deadRef.current &&
settings.terminal.pasteRiskConfirm &&
!pasteConfirmedForSession &&
isRiskyPaste(text)
) {
nativePasteRef.current = text nativePasteRef.current = text
setPaste(text) setPaste({ noPrompt: false, disableDetection: false })
return return
} }
confirmPaste(text) confirmPaste(text)
@@ -533,6 +526,15 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
[settings.terminal.pasteRiskConfirm, confirmPaste] [settings.terminal.pasteRiskConfirm, confirmPaste]
) )
/** Confirm the risky-paste dialog: apply its choices, then paste. */
const acceptPaste = useCallback((): void => {
if (paste?.noPrompt) pasteConfirmedForSession = true
if (paste?.disableDetection) {
void useSettingsStore.getState().updateTerminal({ pasteRiskConfirm: false })
}
confirmPaste(nativePasteRef.current ?? '')
}, [paste, confirmPaste])
const copySelection = useCallback(async () => { const copySelection = useCallback(async () => {
const term = termRef.current const term = termRef.current
if (!term) return if (!term) return
@@ -1103,17 +1105,33 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
<FolderOpenOutlined /> <FolderOpenOutlined />
</button> </button>
</div> </div>
{paste !== null && !dead && ( <Modal
<div className="term-pastebar"> open={paste !== null}
<span className="term-pastebar-text">检测到多行/长文本粘贴</span> title="确认粘贴"
<button type="button" className="term-pastebar-btn term-pastebar-btn-ok" onClick={() => confirmPaste(paste)}> okText="粘贴"
粘贴 cancelText="取消"
</button> width={420}
<button type="button" className="term-pastebar-btn term-pastebar-btn-cancel" onClick={cancelPaste}> maskClosable={false}
取消 onOk={acceptPaste}
</button> onCancel={cancelPaste}
>
<div className="term-paste-confirm">
<p>检测到多行或潜在危险命令,确定要粘贴到终端吗?</p>
<Checkbox
checked={paste?.noPrompt ?? false}
onChange={(e) => setPaste((prev) => (prev ? { ...prev, noPrompt: e.target.checked } : prev))}
>
本次会话不再提示
</Checkbox>
<Checkbox
checked={paste?.disableDetection ?? false}
onChange={(e) => setPaste((prev) => (prev ? { ...prev, disableDetection: e.target.checked } : prev))}
>
关闭后续粘贴检测
</Checkbox>
<p className="term-paste-confirm-hint">可在通用终端设置中重新开启或切换检测策略。</p>
</div> </div>
)} </Modal>
{/* Relative-positioned wrapper around the xterm surface: the suggestion {/* Relative-positioned wrapper around the xterm surface: the suggestion
popup anchors to this box, so its coordinates start at the terminal popup anchors to this box, so its coordinates start at the terminal
content rows (the search bar above never offsets the top). */} content rows (the search bar above never offsets the top). */}
+12 -43
View File
@@ -328,51 +328,20 @@
} }
/* ---- paste risk confirm bar ---- */ /* ---- paste risk confirm bar ---- */
.term-pastebar { /* ---- risky-paste confirmation dialog body ---- */
.term-paste-confirm p {
margin: 0 0 12px;
}
.term-paste-confirm .ant-checkbox-wrapper {
display: flex; display: flex;
align-items: center; margin-bottom: 8px;
gap: 8px; }
flex: none;
padding: 4px 8px; .term-paste-confirm-hint {
background: #3a2e1a; margin: 12px 0 0 !important;
border-bottom: 1px solid #5c4a26;
font-size: 12px; font-size: 12px;
color: #e8d9a8; opacity: 0.65;
}
.term-pastebar-text {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.term-pastebar-btn {
flex: none;
padding: 3px 12px;
border: none;
border-radius: 4px;
cursor: pointer;
font: inherit;
}
.term-pastebar-btn-ok {
background: var(--chrome-accent);
color: #fff;
}
.term-pastebar-btn-ok:hover {
filter: brightness(1.1);
}
.term-pastebar-btn-cancel {
background: transparent;
color: var(--chrome-fg);
}
.term-pastebar-btn-cancel:hover {
background: rgba(255, 255, 255, 0.1);
} }
/* ---- terminal docked area (flex:1) ---- */ /* ---- terminal docked area (flex:1) ---- */