From 487a0489d46aa2fa37485b8bf34f8cb1fe09b5b9 Mon Sep 17 00:00:00 2001
From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
Date: Fri, 28 Aug 2026 05:17:06 -0700
Subject: [PATCH] fix(terminal): defer a focus-cancelled paste instead of
discarding it (STA-5272)
A paste cancelled because focus left the pane threw the clipboard payload
away. Any dictation tool, IME, overlay, or clipboard-manager popup takes
focus for a moment and gives it back, so a routine focus blip destroyed a
paragraph of dictated text with no way to get it back.
Three changes, all on the renderer paste path:
- A paste the focus guard stops is now held for the pane it was aimed at
and delivered the moment that pane has focus again, bounded at 2s. Focus
landing in a *different* pane still cancels immediately, which is the
wrong-target case the guard exists for. After the deadline the payload is
released and the user gets a persistent, platform-correct instruction for
pasting again.
- A chord that arrives while focus sits on
- exactly what a dictation
overlay leaves behind - now reaches the pane. The pane's listeners are
bound to its own container with capture, so a body-targeted keydown never
reached them: no paste, no error, nothing. A document-level fallback
recovers it, but only while the pane is the last surface to have held
focus and neither another element nor the pointer has claimed it since.
- A clipboard text read that FAILS is now reported as unavailable rather
than coerced into "empty clipboard". Image-only clipboards still fall
through to the image path unchanged; only a failure with no image behind
it now produces a message instead of silence.
Refs STA-3834
---
.../components/terminal-pane/TerminalPane.tsx | 119 ++++-
...minal-clipboard-paste-read-failure.test.ts | 90 ++++
.../terminal-pane/terminal-clipboard-paste.ts | 14 +-
.../terminal-deferred-paste.test.ts | 412 ++++++++++++++++++
.../terminal-pane/terminal-deferred-paste.ts | 187 ++++++++
...erminal-inert-focus-paste-fallback.test.ts | 230 ++++++++++
.../terminal-inert-focus-paste-fallback.ts | 85 ++++
.../terminal-pane/terminal-pane-menu-paste.ts | 8 +-
.../terminal-paste-errors.test.ts | 42 ++
.../terminal-pane/terminal-paste-errors.ts | 13 +
.../terminal-paste-target-state.ts | 2 +-
11 files changed, 1192 insertions(+), 10 deletions(-)
create mode 100644 src/renderer/src/components/terminal-pane/terminal-clipboard-paste-read-failure.test.ts
create mode 100644 src/renderer/src/components/terminal-pane/terminal-deferred-paste.test.ts
create mode 100644 src/renderer/src/components/terminal-pane/terminal-deferred-paste.ts
create mode 100644 src/renderer/src/components/terminal-pane/terminal-inert-focus-paste-fallback.test.ts
create mode 100644 src/renderer/src/components/terminal-pane/terminal-inert-focus-paste-fallback.ts
create mode 100644 src/renderer/src/components/terminal-pane/terminal-paste-errors.test.ts
diff --git a/src/renderer/src/components/terminal-pane/TerminalPane.tsx b/src/renderer/src/components/terminal-pane/TerminalPane.tsx
index 2b58732f0ea..8017a78f2fb 100644
--- a/src/renderer/src/components/terminal-pane/TerminalPane.tsx
+++ b/src/renderer/src/components/terminal-pane/TerminalPane.tsx
@@ -222,8 +222,23 @@ import {
type TerminalPasteSource,
type TerminalPasteTextOptions
} from './terminal-paste-coordinator'
+import {
+ createDeferredPasteFocusInHandler,
+ createDeferredTerminalPasteQueue,
+ isDeferrablePasteFocusCancellation,
+ isFocusInsideOtherPane,
+ type DeferredTerminalPasteQueue
+} from './terminal-deferred-paste'
import { appendTerminalErrorMessage } from './terminal-error-accumulation'
-import { formatTerminalPasteExecutionError } from './terminal-paste-errors'
+import {
+ installInertFocusPasteFallback,
+ restoreInertFocusPasteTarget
+} from './terminal-inert-focus-paste-fallback'
+import {
+ formatDeferredTerminalPasteDroppedError,
+ formatTerminalPasteExecutionError,
+ TERMINAL_CLIPBOARD_READ_UNAVAILABLE_MESSAGE
+} from './terminal-paste-errors'
import { resolveTerminalPasteRuntime } from './terminal-paste-runtime'
import { getTerminalPasteSshRemotePlatform } from './terminal-paste-ssh-platform'
import {
@@ -402,6 +417,15 @@ function TerminalPane(
const [agentSessionContinuation, setAgentSessionContinuation] =
useState(null)
const [terminalError, setTerminalError] = useState(null)
+ // Why: outlives the paste effect so a keybinding change mid-deferral does not
+ // silently drop the payload; only unmount does.
+ const deferredPasteRef = useRef(null)
+ useEffect(() => {
+ return () => {
+ deferredPasteRef.current?.dispose()
+ deferredPasteRef.current = null
+ }
+ }, [])
const [paneProcessExitsByPaneId, setPaneProcessExitsByPaneId] = useState<
Record
>({})
@@ -1949,12 +1973,25 @@ function TerminalPane(
})
}
+ const getDeferredPasteQueue = (): DeferredTerminalPasteQueue => {
+ if (!deferredPasteRef.current) {
+ deferredPasteRef.current = createDeferredTerminalPasteQueue({
+ onExpire: () =>
+ setTerminalError(formatDeferredTerminalPasteDroppedError(shortcutPlatform))
+ })
+ }
+ return deferredPasteRef.current
+ }
+
const executePanePasteText = async (
pane: ManagedPane,
source: TerminalPasteSource,
activeElementAtDispatch: Element | null,
text: string,
- options?: TerminalPasteTextOptions
+ options?: TerminalPasteTextOptions,
+ // Why: a redelivered paste never re-defers, so a pane that keeps refusing
+ // focus cannot loop the payload past its deadline.
+ allowDeferOnFocusLoss = true
): Promise => {
const connectionId = getConnectionId(worktreeId) ?? null
const transport = paneTransportsRef.current.get(pane.id)
@@ -2000,6 +2037,28 @@ function TerminalPane(
canContinue: () => isPanePasteTargetMounted(pane, transport, ptyId)
})
if (execution.status !== 'pasted') {
+ if (
+ allowDeferOnFocusLoss &&
+ isDeferrablePasteFocusCancellation({
+ status: execution.status,
+ reason: execution.reason,
+ targetMounted: isPanePasteTargetMounted(pane, transport, ptyId),
+ focusMovedToOtherPane: isFocusInsideOtherPane({
+ panes: managerRef.current?.getPanes() ?? [],
+ paneId: pane.id,
+ focusedElement: document.activeElement
+ })
+ })
+ ) {
+ getDeferredPasteQueue().defer({
+ paneId: pane.id,
+ leafId: pane.leafId,
+ source,
+ text,
+ options
+ })
+ return
+ }
setTerminalError(formatTerminalPasteExecutionError(execution.reason))
return
}
@@ -2055,7 +2114,9 @@ function TerminalPane(
executePanePasteText(pane, source, activeElementAtDispatch, text, options),
onTextPasteError: () =>
setTerminalError('Paste failed: clipboard text is too large for a safe terminal paste.'),
- onImagePasteError: (error) => setTerminalError(formatClipboardImagePasteError(error))
+ onImagePasteError: (error) => setTerminalError(formatClipboardImagePasteError(error)),
+ onClipboardReadUnavailable: () =>
+ setTerminalError(TERMINAL_CLIPBOARD_READ_UNAVAILABLE_MESSAGE)
}).catch(() => {
setTerminalError('Paste failed.')
})
@@ -2071,7 +2132,19 @@ function TerminalPane(
(!isMac && e.key === 'Insert' && e.shiftKey && !e.ctrlKey && !e.metaKey && !e.altKey)
)
}
- const onKeyPaste = (e: KeyboardEvent): void => {
+ // Why: focus sits on after a dictation/IME/overlay handoff, so the pane's
+ // own dispatch-element guard would read this paste as aimed somewhere else.
+ // Put focus back on the pane that still owns it before the shared path runs.
+ const restoreInertPaneFocus = (): ManagedPane | null => {
+ const pane = managerRef.current?.getActivePane() ?? managerRef.current?.getPanes()[0] ?? null
+ if (!pane) {
+ return null
+ }
+ restoreInertFocusPasteTarget(pane, document.activeElement)
+ return pane
+ }
+
+ const onKeyPaste = (e: KeyboardEvent, recoveredFromInertFocus = false): void => {
const target = e.target
if (
(target instanceof Element && target.closest('[data-terminal-search-root]')) ||
@@ -2100,6 +2173,9 @@ function TerminalPane(
}
return
}
+ if (recoveredFromInertFocus && !restoreInertPaneFocus()) {
+ return
+ }
if (isClipboardEventPasteRequired() && firesNativePasteEvent(e, isMac)) {
// Why: without navigator.clipboard the chord's native paste event is the
// only clipboard access — let its default fire and handle it in onPaste.
@@ -2129,7 +2205,7 @@ function TerminalPane(
}
// Fallback: paste events from non-keyboard sources (Edit > Paste menu, programmatic paste, etc.).
- const onPaste = (e: ClipboardEvent): void => {
+ const onPaste = (e: ClipboardEvent, recoveredFromInertFocus = false): void => {
const target = e.target
if (
(target instanceof Element && target.closest('[data-terminal-search-root]')) ||
@@ -2149,6 +2225,9 @@ function TerminalPane(
}
e.preventDefault()
e.stopPropagation()
+ if (recoveredFromInertFocus && !restoreInertPaneFocus()) {
+ return
+ }
const manager = managerRef.current
if (!manager) {
return
@@ -2202,7 +2281,9 @@ function TerminalPane(
executePanePasteText(pane, 'app-menu', activeElementAtDispatch, text, options),
onTextPasteError: () =>
setTerminalError('Paste failed: clipboard text is too large for a safe terminal paste.'),
- onImagePasteError: (error) => setTerminalError(formatClipboardImagePasteError(error))
+ onImagePasteError: (error) => setTerminalError(formatClipboardImagePasteError(error)),
+ onClipboardReadUnavailable: () =>
+ setTerminalError(TERMINAL_CLIPBOARD_READ_UNAVAILABLE_MESSAGE)
}).catch(() => {
setTerminalError('Paste failed.')
})
@@ -2242,8 +2323,32 @@ function TerminalPane(
}
}
+ // A deferred payload lands the moment its own pane has focus again; focus
+ // reaching a different pane is the wrong-target case the guard exists for.
+ const onDeferredPasteFocusIn = createDeferredPasteFocusInHandler({
+ queue: getDeferredPasteQueue(),
+ getPanes: () => managerRef.current?.getPanes() ?? [],
+ getFocusedElement: () => document.activeElement,
+ deliver: (pane, deferred) =>
+ void executePanePasteText(
+ pane,
+ deferred.source,
+ document.activeElement,
+ deferred.text,
+ deferred.options,
+ false
+ ),
+ onDropped: () => setTerminalError(formatDeferredTerminalPasteDroppedError(shortcutPlatform))
+ })
+
container.addEventListener('keydown', onKeyPaste, { capture: true })
container.addEventListener('paste', onPaste, { capture: true })
+ container.addEventListener('focusin', onDeferredPasteFocusIn)
+ const inertFocusPasteFallback = installInertFocusPasteFallback({
+ container,
+ onPasteKey: (event) => onKeyPaste(event, true),
+ onPasteEvent: (event) => onPaste(event, true)
+ })
window.addEventListener(APP_MENU_PASTE_EVENT, onAppMenuPaste)
window.addEventListener(APP_MENU_SELECTION_ACTION_EVENT, onAppMenuSelectionAction)
return () => {
@@ -2252,6 +2357,8 @@ function TerminalPane(
}
container.removeEventListener('keydown', onKeyPaste, { capture: true })
container.removeEventListener('paste', onPaste, { capture: true })
+ container.removeEventListener('focusin', onDeferredPasteFocusIn)
+ inertFocusPasteFallback.dispose()
window.removeEventListener(APP_MENU_PASTE_EVENT, onAppMenuPaste)
window.removeEventListener(APP_MENU_SELECTION_ACTION_EVENT, onAppMenuSelectionAction)
}
diff --git a/src/renderer/src/components/terminal-pane/terminal-clipboard-paste-read-failure.test.ts b/src/renderer/src/components/terminal-pane/terminal-clipboard-paste-read-failure.test.ts
new file mode 100644
index 00000000000..ad6e2f574a5
--- /dev/null
+++ b/src/renderer/src/components/terminal-pane/terminal-clipboard-paste-read-failure.test.ts
@@ -0,0 +1,90 @@
+import { describe, expect, it, vi } from 'vitest'
+
+import { CLIPBOARD_TEXT_TOO_LARGE_ERROR } from '../../../../shared/clipboard-text'
+import { pasteTerminalClipboard } from './terminal-clipboard-paste'
+
+// STA-5272 defect 2: a clipboard text read that FAILS is `unavailable`, never `empty`.
+// Collapsing the two makes a failed read look to the user like "you copied nothing":
+// no paste, no error, no toast.
+describe('terminal clipboard paste: failed read is not an empty clipboard', () => {
+ it('reports a failed text read as clipboard-read-failed, not empty', async () => {
+ const readError = new Error('Read permission denied')
+ const onClipboardReadUnavailable = vi.fn()
+ const onTextPasteError = vi.fn()
+ const pasteText = vi.fn()
+
+ const result = await pasteTerminalClipboard({
+ readClipboardText: vi.fn().mockRejectedValue(readError),
+ saveClipboardImageAsTempFile: vi.fn().mockResolvedValue(null),
+ pasteText,
+ onClipboardReadUnavailable,
+ onTextPasteError
+ })
+
+ expect(result).toEqual({ status: 'skipped', reason: 'clipboard-read-failed' })
+ expect(onClipboardReadUnavailable).toHaveBeenCalledTimes(1)
+ expect(onClipboardReadUnavailable).toHaveBeenCalledWith(readError)
+ // The too-large path owns onTextPasteError; a plain read failure must not borrow it.
+ expect(onTextPasteError).not.toHaveBeenCalled()
+ expect(pasteText).not.toHaveBeenCalled()
+ })
+
+ it('keeps a genuinely empty clipboard silent', async () => {
+ const onClipboardReadUnavailable = vi.fn()
+ const onTextPasteError = vi.fn()
+ const onImagePasteError = vi.fn()
+
+ const result = await pasteTerminalClipboard({
+ readClipboardText: vi.fn().mockResolvedValue(''),
+ saveClipboardImageAsTempFile: vi.fn().mockResolvedValue(null),
+ pasteText: vi.fn(),
+ onClipboardReadUnavailable,
+ onTextPasteError,
+ onImagePasteError
+ })
+
+ expect(result).toEqual({ status: 'skipped', reason: 'empty' })
+ expect(onClipboardReadUnavailable).not.toHaveBeenCalled()
+ expect(onTextPasteError).not.toHaveBeenCalled()
+ expect(onImagePasteError).not.toHaveBeenCalled()
+ })
+
+ it('still pastes an image when the text read failed on an image-only clipboard', async () => {
+ const onClipboardReadUnavailable = vi.fn()
+ const pasteText = vi.fn()
+
+ const result = await pasteTerminalClipboard({
+ readClipboardText: vi.fn().mockRejectedValue(new Error('No text on clipboard')),
+ saveClipboardImageAsTempFile: vi.fn().mockResolvedValue('/tmp/orca-paste-1.png'),
+ pasteText,
+ onClipboardReadUnavailable
+ })
+
+ expect(result).toEqual({ status: 'pasted', kind: 'image-path' })
+ expect(pasteText).toHaveBeenCalledWith('/tmp/orca-paste-1.png', {
+ forceBracketedPaste: true,
+ recoverImagePasteWebglAtlas: true
+ })
+ expect(onClipboardReadUnavailable).not.toHaveBeenCalled()
+ })
+
+ it('leaves the too-large read on its own error path', async () => {
+ const tooLarge = new Error(CLIPBOARD_TEXT_TOO_LARGE_ERROR)
+ const onClipboardReadUnavailable = vi.fn()
+ const onTextPasteError = vi.fn()
+ const saveClipboardImageAsTempFile = vi.fn()
+
+ const result = await pasteTerminalClipboard({
+ readClipboardText: vi.fn().mockRejectedValue(tooLarge),
+ saveClipboardImageAsTempFile,
+ pasteText: vi.fn(),
+ onClipboardReadUnavailable,
+ onTextPasteError
+ })
+
+ expect(result).toEqual({ status: 'skipped', reason: 'text-too-large' })
+ expect(onTextPasteError).toHaveBeenCalledWith(tooLarge)
+ expect(onClipboardReadUnavailable).not.toHaveBeenCalled()
+ expect(saveClipboardImageAsTempFile).not.toHaveBeenCalled()
+ })
+})
diff --git a/src/renderer/src/components/terminal-pane/terminal-clipboard-paste.ts b/src/renderer/src/components/terminal-pane/terminal-clipboard-paste.ts
index abd0fb95b97..03a0f76c4fa 100644
--- a/src/renderer/src/components/terminal-pane/terminal-clipboard-paste.ts
+++ b/src/renderer/src/components/terminal-pane/terminal-clipboard-paste.ts
@@ -25,6 +25,7 @@ type PasteTerminalClipboardDeps = {
protectedMultilineTextPasteOptions?: TerminalPasteTextOptions
onTextPasteError?: (error: unknown) => void
onImagePasteError?: (error: unknown) => void
+ onClipboardReadUnavailable?: (error: unknown) => void
}
export type TerminalClipboardPasteResult =
@@ -33,6 +34,7 @@ export type TerminalClipboardPasteResult =
status: 'skipped'
reason:
| 'empty'
+ | 'clipboard-read-failed'
| 'image-paste-failed'
| 'image-paste-rejected'
| 'text-paste-failed'
@@ -49,9 +51,14 @@ export async function pasteTerminalClipboard({
forceBracketedMultilineTextPaste = false,
protectedMultilineTextPasteOptions,
onTextPasteError,
- onImagePasteError
+ onImagePasteError,
+ onClipboardReadUnavailable
}: PasteTerminalClipboardDeps): Promise {
let text = ''
+ // Why: a failed read is `unavailable`, never `empty`. Holding the error keeps
+ // the image path reachable for image-only clipboards without letting a real
+ // read failure be reported to the user as "you copied nothing".
+ let readFailure: { error: unknown } | null = null
try {
text = await readClipboardText({ maxBytes: TERMINAL_PASTE_MAX_BYTES })
} catch (error) {
@@ -61,6 +68,7 @@ export async function pasteTerminalClipboard({
}
// Why: browser clipboard text reads can fail for image-only clipboards.
// Still try the image path so Cmd/Ctrl+V works for screenshots.
+ readFailure = { error }
}
if (text) {
try {
@@ -81,6 +89,10 @@ export async function pasteTerminalClipboard({
try {
const filePath = await saveClipboardImageAsTempFile({ connectionId, runtimeEnvironmentId })
if (!filePath) {
+ if (readFailure) {
+ onClipboardReadUnavailable?.(readFailure.error)
+ return { status: 'skipped', reason: 'clipboard-read-failed' }
+ }
return { status: 'skipped', reason: 'empty' }
}
const result = await pasteText(filePath, {
diff --git a/src/renderer/src/components/terminal-pane/terminal-deferred-paste.test.ts b/src/renderer/src/components/terminal-pane/terminal-deferred-paste.test.ts
new file mode 100644
index 00000000000..cc7d1f2482e
--- /dev/null
+++ b/src/renderer/src/components/terminal-pane/terminal-deferred-paste.test.ts
@@ -0,0 +1,412 @@
+// @vitest-environment happy-dom
+import { describe, expect, it, vi, type Mock } from 'vitest'
+
+import {
+ createDeferredPasteFocusInHandler,
+ createDeferredTerminalPasteQueue,
+ isDeferrablePasteFocusCancellation,
+ isFocusInsideOtherPane,
+ resolveDeferredPasteFocusIn,
+ TERMINAL_DEFERRED_PASTE_TIMEOUT_MS,
+ type DeferredTerminalPaste
+} from './terminal-deferred-paste'
+
+type Timer = { id: number; callback: () => void; ms: number }
+
+function createTimerHarness(): {
+ setTimer: (callback: () => void, ms: number) => number
+ clearTimer: (id: number) => void
+ fire: (id?: number) => void
+ live: () => Timer[]
+} {
+ const timers = new Map()
+ let nextId = 1
+ return {
+ setTimer: (callback, ms) => {
+ const id = nextId
+ nextId += 1
+ timers.set(id, { id, callback, ms })
+ return id
+ },
+ clearTimer: (id) => {
+ timers.delete(id)
+ },
+ fire: (id) => {
+ const timer = id === undefined ? [...timers.values()][0] : timers.get(id)
+ if (!timer) {
+ throw new Error(`no live timer ${id ?? '(first)'}`)
+ }
+ timers.delete(timer.id)
+ timer.callback()
+ },
+ live: () => [...timers.values()]
+ }
+}
+
+const entry = (overrides: Partial = {}): DeferredTerminalPaste => ({
+ paneId: 1,
+ leafId: 'leaf-a',
+ source: 'keyboard',
+ text: 'dictated paragraph',
+ ...overrides
+})
+
+describe('deferred terminal paste queue', () => {
+ it('hands the payload back to the pane it was aimed at', () => {
+ const timers = createTimerHarness()
+ const onExpire = vi.fn()
+ const queue = createDeferredTerminalPasteQueue({ onExpire, ...timers })
+
+ queue.defer(entry())
+
+ expect(queue.isPending()).toBe(true)
+ expect(queue.claim(1, 'leaf-a')).toEqual(entry())
+ expect(queue.isPending()).toBe(false)
+ // The deadline must be disarmed by the claim, not left to fire later.
+ expect(timers.live()).toEqual([])
+ expect(onExpire).not.toHaveBeenCalled()
+ })
+
+ it('refuses to hand the payload to a different pane or leaf', () => {
+ const timers = createTimerHarness()
+ const queue = createDeferredTerminalPasteQueue({ onExpire: vi.fn(), ...timers })
+
+ queue.defer(entry())
+
+ expect(queue.claim(2, 'leaf-a')).toBe(null)
+ expect(queue.claim(1, 'leaf-b')).toBe(null)
+ expect(queue.isPending()).toBe(true)
+ })
+
+ it('drops the payload and signals the user when focus never comes back', () => {
+ const timers = createTimerHarness()
+ const onExpire = vi.fn()
+ const queue = createDeferredTerminalPasteQueue({ onExpire, ...timers })
+
+ queue.defer(entry())
+ expect(timers.live()[0]?.ms).toBe(TERMINAL_DEFERRED_PASTE_TIMEOUT_MS)
+
+ timers.fire()
+
+ expect(onExpire).toHaveBeenCalledTimes(1)
+ expect(onExpire).toHaveBeenCalledWith(entry())
+ // Bounded: the clipboard text is not retained past the deadline, and a late
+ // focus return cannot make it land.
+ expect(queue.isPending()).toBe(false)
+ expect(queue.claim(1, 'leaf-a')).toBe(null)
+ })
+
+ it('does not retain the payload when the expiry notifier throws', () => {
+ const timers = createTimerHarness()
+ const queue = createDeferredTerminalPasteQueue({
+ onExpire: () => {
+ throw new Error('toast surface gone')
+ },
+ ...timers
+ })
+
+ queue.defer(entry())
+ expect(() => timers.fire()).toThrow('toast surface gone')
+
+ expect(queue.isPending()).toBe(false)
+ expect(queue.claim(1, 'leaf-a')).toBe(null)
+ })
+
+ it('lets a newer paste supersede the pending one without a stray expiry', () => {
+ const timers = createTimerHarness()
+ const onExpire = vi.fn()
+ const queue = createDeferredTerminalPasteQueue({ onExpire, ...timers })
+
+ queue.defer(entry({ text: 'first' }))
+ queue.defer(entry({ text: 'second' }))
+
+ expect(timers.live()).toHaveLength(1)
+ timers.fire()
+ expect(onExpire).toHaveBeenCalledTimes(1)
+ expect(onExpire).toHaveBeenCalledWith(entry({ text: 'second' }))
+ })
+
+ it('carries a payload larger than the 1024-byte typed-command cap intact', () => {
+ const timers = createTimerHarness()
+ const queue = createDeferredTerminalPasteQueue({ onExpire: vi.fn(), ...timers })
+ // Line-bounded so the payload is never at the mercy of a TTY MAX_CANON limit.
+ const large = Array.from({ length: 40 }, (_, index) => `line-${index}-${'x'.repeat(60)}`).join(
+ '\n'
+ )
+ expect(new TextEncoder().encode(large).byteLength).toBeGreaterThan(1024)
+
+ queue.defer(entry({ text: large }))
+ const claimed = queue.claim(1, 'leaf-a')
+
+ expect(claimed?.text).toBe(large)
+ expect(new TextEncoder().encode(claimed?.text ?? '').byteLength).toBe(
+ new TextEncoder().encode(large).byteLength
+ )
+ })
+
+ it('drops the payload on dispose without firing the expiry signal', () => {
+ const timers = createTimerHarness()
+ const onExpire = vi.fn()
+ const queue = createDeferredTerminalPasteQueue({ onExpire, ...timers })
+
+ queue.defer(entry())
+ queue.dispose()
+
+ expect(queue.isPending()).toBe(false)
+ expect(timers.live()).toEqual([])
+ expect(onExpire).not.toHaveBeenCalled()
+ })
+})
+
+describe('isDeferrablePasteFocusCancellation', () => {
+ const base = {
+ status: 'cancelled' as const,
+ reason: 'stale-target' as const,
+ targetMounted: true,
+ focusMovedToOtherPane: false
+ }
+
+ it('defers only a focus-loss cancellation whose pane is still the live target', () => {
+ expect(isDeferrablePasteFocusCancellation(base)).toBe(true)
+ })
+
+ it('refuses to defer when focus moved to a different pane', () => {
+ // The wrong-target case the guard actually exists for.
+ expect(isDeferrablePasteFocusCancellation({ ...base, focusMovedToOtherPane: true })).toBe(false)
+ })
+
+ it('refuses to defer when the target is gone', () => {
+ expect(isDeferrablePasteFocusCancellation({ ...base, targetMounted: false })).toBe(false)
+ })
+
+ it('refuses to defer non-focus cancellations and rejections', () => {
+ expect(isDeferrablePasteFocusCancellation({ ...base, reason: 'target-disconnected' })).toBe(
+ false
+ )
+ expect(isDeferrablePasteFocusCancellation({ ...base, reason: 'operation-timeout' })).toBe(false)
+ expect(isDeferrablePasteFocusCancellation({ ...base, reason: undefined })).toBe(false)
+ expect(
+ isDeferrablePasteFocusCancellation({
+ ...base,
+ status: 'rejected',
+ reason: 'payload-too-large'
+ })
+ ).toBe(false)
+ })
+})
+
+describe('deferred paste focus resolution', () => {
+ const buildPanes = (): {
+ panes: { id: number; leafId: string; container: HTMLElement }[]
+ focusable: (paneIndex: number) => HTMLElement
+ } => {
+ const root = document.createElement('div')
+ document.body.append(root)
+ const panes = [0, 1].map((index) => {
+ const container = document.createElement('div')
+ const helper = document.createElement('textarea')
+ helper.className = 'xterm-helper-textarea'
+ container.append(helper)
+ root.append(container)
+ return { id: index + 1, leafId: `leaf-${index}`, container }
+ })
+ return {
+ panes,
+ focusable: (paneIndex) => panes[paneIndex]!.container.firstElementChild as HTMLElement
+ }
+ }
+
+ it('delivers the payload when focus returns to its own pane', () => {
+ const { panes, focusable } = buildPanes()
+ const queue = createDeferredTerminalPasteQueue({ onExpire: vi.fn(), ...createTimerHarness() })
+ queue.defer(entry({ paneId: 1, leafId: 'leaf-0' }))
+
+ const resolution = resolveDeferredPasteFocusIn({
+ panes,
+ focusedElement: focusable(0),
+ queue
+ })
+
+ expect(resolution.action).toBe('deliver')
+ expect(resolution.action === 'deliver' && resolution.entry.text).toBe('dictated paragraph')
+ expect(queue.isPending()).toBe(false)
+ })
+
+ it('drops the payload when focus lands in a different pane', () => {
+ const { panes, focusable } = buildPanes()
+ const queue = createDeferredTerminalPasteQueue({ onExpire: vi.fn(), ...createTimerHarness() })
+ queue.defer(entry({ paneId: 1, leafId: 'leaf-0' }))
+
+ const resolution = resolveDeferredPasteFocusIn({
+ panes,
+ focusedElement: focusable(1),
+ queue
+ })
+
+ expect(resolution.action).toBe('drop')
+ expect(resolution.action === 'drop' && resolution.entry?.text).toBe('dictated paragraph')
+ expect(queue.isPending()).toBe(false)
+ })
+
+ it('ignores focus that lands outside every pane', () => {
+ const { panes } = buildPanes()
+ const outside = document.createElement('input')
+ document.body.append(outside)
+ const queue = createDeferredTerminalPasteQueue({ onExpire: vi.fn(), ...createTimerHarness() })
+ queue.defer(entry({ paneId: 1, leafId: 'leaf-0' }))
+
+ expect(resolveDeferredPasteFocusIn({ panes, focusedElement: outside, queue }).action).toBe(
+ 'ignore'
+ )
+ expect(queue.isPending()).toBe(true)
+ })
+
+ it('ignores focus movement when nothing is deferred', () => {
+ const { panes, focusable } = buildPanes()
+ const queue = createDeferredTerminalPasteQueue({ onExpire: vi.fn(), ...createTimerHarness() })
+
+ expect(resolveDeferredPasteFocusIn({ panes, focusedElement: focusable(0), queue }).action).toBe(
+ 'ignore'
+ )
+ })
+})
+
+describe('isFocusInsideOtherPane', () => {
+ it('separates the paste target from its split siblings', () => {
+ const root = document.createElement('div')
+ document.body.append(root)
+ const own = document.createElement('div')
+ const sibling = document.createElement('div')
+ const ownChild = document.createElement('textarea')
+ const siblingChild = document.createElement('textarea')
+ own.append(ownChild)
+ sibling.append(siblingChild)
+ root.append(own, sibling)
+ const panes = [
+ { id: 1, leafId: 'leaf-0', container: own },
+ { id: 2, leafId: 'leaf-1', container: sibling }
+ ]
+
+ expect(isFocusInsideOtherPane({ panes, paneId: 1, focusedElement: ownChild })).toBe(false)
+ expect(isFocusInsideOtherPane({ panes, paneId: 1, focusedElement: siblingChild })).toBe(true)
+ expect(isFocusInsideOtherPane({ panes, paneId: 1, focusedElement: document.body })).toBe(false)
+ expect(isFocusInsideOtherPane({ panes, paneId: 1, focusedElement: null })).toBe(false)
+ })
+})
+
+// The pane's real focusin handler, driven by real DOM focus moves.
+describe('deferred paste focusin handler', () => {
+ const buildScene = (): {
+ handler: () => void
+ queue: ReturnType
+ deliver: Mock<(pane: { id: number }, deferred: DeferredTerminalPaste) => void>
+ onDropped: Mock<(deferred: DeferredTerminalPaste) => void>
+ onExpire: Mock<(deferred: DeferredTerminalPaste) => void>
+ timers: ReturnType
+ focusables: HTMLElement[]
+ root: HTMLElement
+ } => {
+ document.body.innerHTML = ''
+ const root = document.createElement('div')
+ document.body.append(root)
+ const panes = [0, 1].map((index) => {
+ const container = document.createElement('div')
+ const helper = document.createElement('textarea')
+ helper.className = 'xterm-helper-textarea'
+ container.append(helper)
+ root.append(container)
+ return { id: index + 1, leafId: `leaf-${index}`, container }
+ })
+ const timers = createTimerHarness()
+ const onExpire = vi.fn<(deferred: DeferredTerminalPaste) => void>()
+ const queue = createDeferredTerminalPasteQueue({ onExpire, ...timers })
+ const deliver = vi.fn<(pane: { id: number }, deferred: DeferredTerminalPaste) => void>()
+ const onDropped = vi.fn<(deferred: DeferredTerminalPaste) => void>()
+ const handler = createDeferredPasteFocusInHandler({
+ queue,
+ getPanes: () => panes,
+ getFocusedElement: () => document.activeElement,
+ deliver,
+ onDropped
+ })
+ root.addEventListener('focusin', handler)
+ return {
+ handler,
+ queue,
+ deliver,
+ onDropped,
+ onExpire,
+ timers,
+ focusables: panes.map((pane) => pane.container.firstElementChild as HTMLElement),
+ root
+ }
+ }
+
+ it('lands the deferred payload when focus returns to the pane', () => {
+ const scene = buildScene()
+ scene.queue.defer(entry({ paneId: 1, leafId: 'leaf-0', text: 'a dictated paragraph' }))
+
+ scene.focusables[0]!.focus()
+
+ expect(scene.deliver).toHaveBeenCalledTimes(1)
+ expect(scene.deliver.mock.calls[0]?.[0]?.id).toBe(1)
+ expect(scene.deliver.mock.calls[0]?.[1]?.text).toBe('a dictated paragraph')
+ expect(scene.onDropped).not.toHaveBeenCalled()
+ expect(scene.queue.isPending()).toBe(false)
+ expect(scene.timers.live()).toEqual([])
+ })
+
+ it('lands a payload larger than the 1024-byte typed-command cap without truncating it', () => {
+ const scene = buildScene()
+ const large = Array.from({ length: 40 }, (_, index) => `line-${index}-${'x'.repeat(60)}`).join(
+ '\n'
+ )
+ expect(new TextEncoder().encode(large).byteLength).toBeGreaterThan(1024)
+ scene.queue.defer(entry({ paneId: 1, leafId: 'leaf-0', text: large }))
+
+ scene.focusables[0]!.focus()
+
+ expect(scene.deliver.mock.calls[0]?.[1]?.text).toBe(large)
+ })
+
+ it('drops the payload with a signal when a different pane takes focus', () => {
+ const scene = buildScene()
+ scene.queue.defer(entry({ paneId: 1, leafId: 'leaf-0' }))
+
+ scene.focusables[1]!.focus()
+
+ expect(scene.deliver).not.toHaveBeenCalled()
+ expect(scene.onDropped).toHaveBeenCalledTimes(1)
+ expect(scene.queue.isPending()).toBe(false)
+ })
+
+ it('keeps the payload pending while focus has not come back yet, then expires it', () => {
+ const scene = buildScene()
+ scene.queue.defer(entry({ paneId: 1, leafId: 'leaf-0' }))
+ const outside = document.createElement('input')
+ document.body.append(outside)
+
+ outside.focus()
+
+ expect(scene.deliver).not.toHaveBeenCalled()
+ expect(scene.onDropped).not.toHaveBeenCalled()
+ expect(scene.queue.isPending()).toBe(true)
+
+ scene.timers.fire()
+
+ expect(scene.onExpire).toHaveBeenCalledTimes(1)
+ // Bounded: focus coming back after the deadline must not fire a surprise paste.
+ scene.focusables[0]!.focus()
+ expect(scene.deliver).not.toHaveBeenCalled()
+ })
+
+ it('does nothing on ordinary focus moves when no paste is deferred', () => {
+ const scene = buildScene()
+
+ scene.focusables[0]!.focus()
+ scene.focusables[1]!.focus()
+
+ expect(scene.deliver).not.toHaveBeenCalled()
+ expect(scene.onDropped).not.toHaveBeenCalled()
+ })
+})
diff --git a/src/renderer/src/components/terminal-pane/terminal-deferred-paste.ts b/src/renderer/src/components/terminal-pane/terminal-deferred-paste.ts
new file mode 100644
index 00000000000..659d2fe6a9b
--- /dev/null
+++ b/src/renderer/src/components/terminal-pane/terminal-deferred-paste.ts
@@ -0,0 +1,187 @@
+import type {
+ TerminalPasteExecutionReason,
+ TerminalPasteExecutionResult,
+ TerminalPasteSource,
+ TerminalPasteTextOptions
+} from './terminal-paste-model'
+
+/** Long enough for a dictation/IME/clipboard-manager overlay to hand focus back,
+ * short enough that the payload can never land in a surprising place later. */
+export const TERMINAL_DEFERRED_PASTE_TIMEOUT_MS = 2_000
+
+export type DeferredTerminalPaste = {
+ paneId: number
+ leafId: string
+ source: TerminalPasteSource
+ text: string
+ options?: TerminalPasteTextOptions
+}
+
+export type DeferredTerminalPasteQueue = {
+ /** Holds one payload; a second defer replaces the first and restarts the deadline. */
+ defer: (entry: DeferredTerminalPaste) => void
+ /** Returns and clears the payload only when it belongs to this pane. */
+ claim: (paneId: number, leafId: string) => DeferredTerminalPaste | null
+ /** Drops the payload without firing the expiry callback; returns what was dropped. */
+ cancel: () => DeferredTerminalPaste | null
+ isPending: () => boolean
+ dispose: () => void
+}
+
+type CreateDeferredTerminalPasteQueueArgs = {
+ onExpire: (entry: DeferredTerminalPaste) => void
+ timeoutMs?: number
+ setTimer?: (callback: () => void, ms: number) => number
+ clearTimer?: (timerId: number) => void
+}
+
+export function createDeferredTerminalPasteQueue({
+ onExpire,
+ timeoutMs = TERMINAL_DEFERRED_PASTE_TIMEOUT_MS,
+ setTimer = (callback, ms) => window.setTimeout(callback, ms),
+ clearTimer = (timerId) => window.clearTimeout(timerId)
+}: CreateDeferredTerminalPasteQueueArgs): DeferredTerminalPasteQueue {
+ let pending: DeferredTerminalPaste | null = null
+ let timerId: number | null = null
+
+ const take = (): DeferredTerminalPaste | null => {
+ const taken = pending
+ pending = null
+ if (timerId !== null) {
+ clearTimer(timerId)
+ timerId = null
+ }
+ return taken
+ }
+
+ return {
+ defer: (entry) => {
+ take()
+ pending = entry
+ timerId = setTimer(() => {
+ timerId = null
+ // Why: release the clipboard text before notifying, so a throwing
+ // notifier cannot leave the payload retained past its deadline.
+ const expired = pending
+ pending = null
+ if (expired) {
+ onExpire(expired)
+ }
+ }, timeoutMs)
+ },
+ claim: (paneId, leafId) => {
+ if (!pending || pending.paneId !== paneId || pending.leafId !== leafId) {
+ return null
+ }
+ return take()
+ },
+ cancel: () => take(),
+ isPending: () => pending !== null,
+ dispose: () => {
+ take()
+ }
+ }
+}
+
+/** A paste the focus guard stopped is only deferrable while its pane is still the
+ * live target and no other pane has taken focus — the case the guard exists for. */
+export function isDeferrablePasteFocusCancellation({
+ status,
+ reason,
+ targetMounted,
+ focusMovedToOtherPane
+}: {
+ status: TerminalPasteExecutionResult['status']
+ reason: TerminalPasteExecutionReason | undefined
+ targetMounted: boolean
+ focusMovedToOtherPane: boolean
+}): boolean {
+ return (
+ status === 'cancelled' && reason === 'stale-target' && targetMounted && !focusMovedToOtherPane
+ )
+}
+
+type DeferredPasteFocusPane = {
+ id: number
+ leafId: string
+ container: { contains: (node: Node | null) => boolean }
+}
+
+export type DeferredPasteFocusResolution =
+ | { action: 'ignore' }
+ | { action: 'deliver'; pane: TPane; entry: DeferredTerminalPaste }
+ | { action: 'drop'; pane: TPane; entry: DeferredTerminalPaste | null }
+
+/** Focus landing back inside the deferred pane delivers it; focus landing in a
+ * different pane drops it, because that is the wrong-target case the guard protects. */
+export function resolveDeferredPasteFocusIn({
+ panes,
+ focusedElement,
+ queue
+}: {
+ panes: readonly TPane[]
+ focusedElement: Node | null
+ queue: DeferredTerminalPasteQueue
+}): DeferredPasteFocusResolution {
+ if (!queue.isPending() || !focusedElement) {
+ return { action: 'ignore' }
+ }
+ const pane = panes.find((candidate) => candidate.container.contains(focusedElement))
+ if (!pane) {
+ return { action: 'ignore' }
+ }
+ const entry = queue.claim(pane.id, pane.leafId)
+ if (entry) {
+ return { action: 'deliver', pane, entry }
+ }
+ return { action: 'drop', pane, entry: queue.cancel() }
+}
+
+/** True when focus currently sits in a pane other than the paste's own target. */
+export function isFocusInsideOtherPane({
+ panes,
+ paneId,
+ focusedElement
+}: {
+ panes: readonly TPane[]
+ paneId: number
+ focusedElement: Node | null
+}): boolean {
+ if (!focusedElement) {
+ return false
+ }
+ return panes.some(
+ (candidate) => candidate.id !== paneId && candidate.container.contains(focusedElement)
+ )
+}
+
+/** The pane's focusin handler: deliver the payload to its own pane, drop it when a
+ * different pane takes focus, and leave it pending for anything else. */
+export function createDeferredPasteFocusInHandler({
+ queue,
+ getPanes,
+ getFocusedElement,
+ deliver,
+ onDropped
+}: {
+ queue: DeferredTerminalPasteQueue
+ getPanes: () => readonly TPane[]
+ getFocusedElement: () => Node | null
+ deliver: (pane: TPane, entry: DeferredTerminalPaste) => void
+ onDropped: (entry: DeferredTerminalPaste) => void
+}): () => void {
+ return () => {
+ const resolution = resolveDeferredPasteFocusIn({
+ panes: getPanes(),
+ focusedElement: getFocusedElement(),
+ queue
+ })
+ if (resolution.action === 'deliver') {
+ deliver(resolution.pane, resolution.entry)
+ return
+ }
+ if (resolution.action === 'drop' && resolution.entry) {
+ onDropped(resolution.entry)
+ }
+ }
+}
diff --git a/src/renderer/src/components/terminal-pane/terminal-inert-focus-paste-fallback.test.ts b/src/renderer/src/components/terminal-pane/terminal-inert-focus-paste-fallback.test.ts
new file mode 100644
index 00000000000..2a3ab31aa6c
--- /dev/null
+++ b/src/renderer/src/components/terminal-pane/terminal-inert-focus-paste-fallback.test.ts
@@ -0,0 +1,230 @@
+// @vitest-environment happy-dom
+import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest'
+
+import {
+ installInertFocusPasteFallback,
+ restoreInertFocusPasteTarget
+} from './terminal-inert-focus-paste-fallback'
+
+// STA-5272 / STA-3834: a dictation tool, IME, or overlay hands focus back to ,
+// not to the pane. The pane's own keydown/paste listeners are bound to its root
+// container with capture, so a body-targeted chord never reaches them: no paste,
+// no error, nothing. Everything here runs on real DOM nodes and real dispatched
+// events — a `contains` double would let this pass against unreachable state.
+describe('inert-focus paste fallback', () => {
+ let container: HTMLElement
+ let inside: HTMLTextAreaElement
+ let outside: HTMLInputElement
+ let onPasteKey: Mock<(event: KeyboardEvent) => void>
+ let onPasteEvent: Mock<(event: ClipboardEvent) => void>
+ let dispose: (() => void) | null
+
+ const install = (): ReturnType => {
+ const fallback = installInertFocusPasteFallback({ container, onPasteKey, onPasteEvent })
+ dispose = fallback.dispose
+ return fallback
+ }
+ const pressPasteChordOn = (target: EventTarget): void => {
+ target.dispatchEvent(
+ new KeyboardEvent('keydown', { key: 'v', ctrlKey: true, bubbles: true, cancelable: true })
+ )
+ }
+
+ beforeEach(() => {
+ document.body.innerHTML = ''
+ container = document.createElement('div')
+ inside = document.createElement('textarea')
+ inside.className = 'xterm-helper-textarea'
+ container.append(inside)
+ outside = document.createElement('input')
+ document.body.append(container, outside)
+ onPasteKey = vi.fn()
+ onPasteEvent = vi.fn()
+ dispose = null
+ })
+
+ afterEach(() => {
+ dispose?.()
+ document.body.innerHTML = ''
+ })
+
+ it('premise: a container-bound capture listener never sees a body-targeted chord', () => {
+ const paneListener = vi.fn()
+ container.addEventListener('keydown', paneListener, { capture: true })
+ inside.focus()
+ inside.blur()
+
+ pressPasteChordOn(document.body)
+
+ // This is the whole defect: body is the container's ancestor, so the capture
+ // path document -> html -> body never reaches the container.
+ expect(paneListener).not.toHaveBeenCalled()
+ container.removeEventListener('keydown', paneListener, { capture: true })
+ })
+
+ it('recovers a paste chord that lands on body after the pane blurred to it', () => {
+ install()
+ inside.focus()
+ inside.blur()
+
+ expect(document.activeElement).toBe(document.body)
+ pressPasteChordOn(document.body)
+
+ expect(onPasteKey).toHaveBeenCalledTimes(1)
+ })
+
+ it('recovers a native paste event that lands on body the same way', () => {
+ install()
+ inside.focus()
+ inside.blur()
+
+ document.body.dispatchEvent(new Event('paste', { bubbles: true, cancelable: true }))
+
+ expect(onPasteEvent).toHaveBeenCalledTimes(1)
+ })
+
+ it('leaves events inside the pane to the pane so a paste cannot fire twice', () => {
+ install()
+ inside.focus()
+
+ pressPasteChordOn(inside)
+
+ expect(onPasteKey).not.toHaveBeenCalled()
+ })
+
+ it('leaves an in-pane event to the pane even while focus itself is inert', () => {
+ install()
+ inside.focus()
+ inside.blur()
+ expect(document.activeElement).toBe(document.body)
+
+ // A programmatic paste aimed at a pane child while focus sits on body: the
+ // pane's own capture listener does receive this one, so the fallback must not
+ // handle it as well and paste twice.
+ inside.dispatchEvent(new Event('paste', { bubbles: true, cancelable: true }))
+ pressPasteChordOn(inside)
+
+ expect(onPasteEvent).not.toHaveBeenCalled()
+ expect(onPasteKey).not.toHaveBeenCalled()
+ })
+
+ it('does not steal a paste once another surface has taken focus', () => {
+ install()
+ inside.focus()
+ outside.focus()
+ // Focus then falls to body from that other surface, not from the pane.
+ outside.blur()
+
+ expect(document.activeElement).toBe(document.body)
+ pressPasteChordOn(document.body)
+
+ expect(onPasteKey).not.toHaveBeenCalled()
+ })
+
+ it('does not steal a paste after the user clicks outside the pane', () => {
+ install()
+ inside.focus()
+ inside.blur()
+ // A click on a non-focusable region blurs to body with no focusin at all, so
+ // the pointer is the only evidence the user moved on.
+ document.body.dispatchEvent(new Event('pointerdown', { bubbles: true }))
+
+ pressPasteChordOn(document.body)
+
+ expect(onPasteKey).not.toHaveBeenCalled()
+ })
+
+ it('keeps ownership through a click inside the pane', () => {
+ install()
+ inside.focus()
+ inside.dispatchEvent(new Event('pointerdown', { bubbles: true }))
+ inside.blur()
+
+ pressPasteChordOn(document.body)
+
+ expect(onPasteKey).toHaveBeenCalledTimes(1)
+ })
+
+ it('does not fire while a real element outside the pane holds focus', () => {
+ install()
+ inside.focus()
+ outside.focus()
+
+ expect(document.activeElement).toBe(outside)
+ pressPasteChordOn(outside)
+
+ expect(onPasteKey).not.toHaveBeenCalled()
+ })
+
+ it('claims focus that already sat in the pane when the listeners were installed', () => {
+ inside.focus()
+ install()
+ inside.blur()
+
+ pressPasteChordOn(document.body)
+
+ expect(onPasteKey).toHaveBeenCalledTimes(1)
+ })
+
+ it('reports inert-focus ownership so other paste entry points can share the test', () => {
+ const fallback = install()
+
+ expect(fallback.ownsInertFocus()).toBe(false)
+ inside.focus()
+ expect(fallback.ownsInertFocus()).toBe(false)
+ inside.blur()
+ expect(fallback.ownsInertFocus()).toBe(true)
+ outside.focus()
+ expect(fallback.ownsInertFocus()).toBe(false)
+ })
+
+ it('stops recovering pastes after dispose', () => {
+ const fallback = install()
+ inside.focus()
+ inside.blur()
+ fallback.dispose()
+ dispose = null
+
+ pressPasteChordOn(document.body)
+ document.body.dispatchEvent(new Event('paste', { bubbles: true, cancelable: true }))
+
+ expect(onPasteKey).not.toHaveBeenCalled()
+ expect(onPasteEvent).not.toHaveBeenCalled()
+ })
+})
+
+describe('restoreInertFocusPasteTarget', () => {
+ const buildPane = (): { container: HTMLElement; terminal: { focus: Mock<() => void> } } => {
+ document.body.innerHTML = ''
+ const container = document.createElement('div')
+ const helper = document.createElement('textarea')
+ helper.className = 'xterm-helper-textarea'
+ container.append(helper)
+ document.body.append(container)
+ return { container, terminal: { focus: vi.fn<() => void>() } }
+ }
+
+ it('puts focus back on the pane when focus fell to body', () => {
+ const pane = buildPane()
+
+ restoreInertFocusPasteTarget(pane, document.body)
+
+ expect(pane.terminal.focus).toHaveBeenCalledTimes(1)
+ })
+
+ it('puts focus back on the pane when nothing at all has focus', () => {
+ const pane = buildPane()
+
+ restoreInertFocusPasteTarget(pane, null)
+
+ expect(pane.terminal.focus).toHaveBeenCalledTimes(1)
+ })
+
+ it('leaves focus alone when it is already inside the pane', () => {
+ const pane = buildPane()
+
+ restoreInertFocusPasteTarget(pane, pane.container.firstElementChild as Element)
+
+ expect(pane.terminal.focus).not.toHaveBeenCalled()
+ })
+})
diff --git a/src/renderer/src/components/terminal-pane/terminal-inert-focus-paste-fallback.ts b/src/renderer/src/components/terminal-pane/terminal-inert-focus-paste-fallback.ts
new file mode 100644
index 00000000000..b5f00cfe08a
--- /dev/null
+++ b/src/renderer/src/components/terminal-pane/terminal-inert-focus-paste-fallback.ts
@@ -0,0 +1,85 @@
+import { isInertDocumentFocus } from './terminal-paste-target-state'
+
+type InstallInertFocusPasteFallbackArgs = {
+ /** TerminalPane root; the pane's own listeners already cover events inside it. */
+ container: HTMLElement
+ documentTarget?: Document
+ onPasteKey: (event: KeyboardEvent) => void
+ onPasteEvent: (event: ClipboardEvent) => void
+}
+
+export type InertFocusPasteFallback = {
+ /** True while this pane is the last surface to have held focus and focus has
+ * since fallen to `` rather than moving to another surface. */
+ ownsInertFocus: () => boolean
+ dispose: () => void
+}
+
+/** A dictation tool, IME, or overlay hands focus back to ``, not to the pane.
+ * The pane's own capture listeners never see those keydown/paste events, so the
+ * chord is dropped with no paste and no error. Recover it at the document, but
+ * only while nothing else has claimed focus or the pointer. */
+export function installInertFocusPasteFallback({
+ container,
+ documentTarget = document,
+ onPasteKey,
+ onPasteEvent
+}: InstallInertFocusPasteFallbackArgs): InertFocusPasteFallback {
+ let paneOwnsFocus = containsNode(container, documentTarget.activeElement)
+
+ const onFocusIn = (event: FocusEvent): void => {
+ paneOwnsFocus = containsNode(container, event.target)
+ }
+ // Why: a click on a non-focusable region blurs to without any focusin,
+ // so the pointer is the only signal that the user moved on.
+ const onPointerDown = (event: Event): void => {
+ if (!containsNode(container, event.target)) {
+ paneOwnsFocus = false
+ }
+ }
+ const ownsInertFocus = (): boolean =>
+ paneOwnsFocus && isInertDocumentFocus(documentTarget.activeElement)
+ const shouldRecover = (event: Event): boolean =>
+ !containsNode(container, event.target) && ownsInertFocus()
+
+ const onKeyDown = (event: KeyboardEvent): void => {
+ if (shouldRecover(event)) {
+ onPasteKey(event)
+ }
+ }
+ const onPaste = (event: ClipboardEvent): void => {
+ if (shouldRecover(event)) {
+ onPasteEvent(event)
+ }
+ }
+
+ documentTarget.addEventListener('focusin', onFocusIn, { capture: true })
+ documentTarget.addEventListener('pointerdown', onPointerDown, { capture: true })
+ documentTarget.addEventListener('keydown', onKeyDown, { capture: true })
+ documentTarget.addEventListener('paste', onPaste, { capture: true })
+
+ return {
+ ownsInertFocus,
+ dispose: () => {
+ documentTarget.removeEventListener('focusin', onFocusIn, { capture: true })
+ documentTarget.removeEventListener('pointerdown', onPointerDown, { capture: true })
+ documentTarget.removeEventListener('keydown', onKeyDown, { capture: true })
+ documentTarget.removeEventListener('paste', onPaste, { capture: true })
+ }
+ }
+}
+
+function containsNode(container: HTMLElement, node: EventTarget | Node | null): boolean {
+ return node instanceof Node && container.contains(node)
+}
+
+/** Focus is on but the pane still owns it logically; putting it back before
+ * the shared paste path runs lets that path's dispatch-element guard see the real target. */
+export function restoreInertFocusPasteTarget(
+ pane: { container: HTMLElement; terminal: { focus: () => void } },
+ activeElement: Element | null
+): void {
+ if (!activeElement || !pane.container.contains(activeElement)) {
+ pane.terminal.focus()
+ }
+}
diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-menu-paste.ts b/src/renderer/src/components/terminal-pane/terminal-pane-menu-paste.ts
index 295b7e35c53..b4f441e471e 100644
--- a/src/renderer/src/components/terminal-pane/terminal-pane-menu-paste.ts
+++ b/src/renderer/src/components/terminal-pane/terminal-pane-menu-paste.ts
@@ -10,7 +10,10 @@ import {
type TerminalPasteSource,
type TerminalPasteTextOptions
} from './terminal-paste-coordinator'
-import { formatTerminalPasteExecutionError } from './terminal-paste-errors'
+import {
+ formatTerminalPasteExecutionError,
+ TERMINAL_CLIPBOARD_READ_UNAVAILABLE_MESSAGE
+} from './terminal-paste-errors'
import { resolveTerminalPasteRuntime } from './terminal-paste-runtime'
import { getTerminalPasteSshRemotePlatform } from './terminal-paste-ssh-platform'
import { isTerminalPanePasteTargetCurrent } from './terminal-paste-target-state'
@@ -144,7 +147,8 @@ export const pasteTerminalPaneMenuClipboard = async (
onImagePasteError: (error) => {
const detail = error instanceof Error ? error.message : String(error)
onPasteError(`Image paste failed: ${detail}`)
- }
+ },
+ onClipboardReadUnavailable: () => onPasteError(TERMINAL_CLIPBOARD_READ_UNAVAILABLE_MESSAGE)
})
if (result.status !== 'pasted') {
return
diff --git a/src/renderer/src/components/terminal-pane/terminal-paste-errors.test.ts b/src/renderer/src/components/terminal-pane/terminal-paste-errors.test.ts
new file mode 100644
index 00000000000..1ec7fcb37b9
--- /dev/null
+++ b/src/renderer/src/components/terminal-pane/terminal-paste-errors.test.ts
@@ -0,0 +1,42 @@
+import { describe, expect, it } from 'vitest'
+
+import {
+ formatDeferredTerminalPasteDroppedError,
+ formatTerminalPasteExecutionError,
+ TERMINAL_CLIPBOARD_READ_UNAVAILABLE_MESSAGE
+} from './terminal-paste-errors'
+
+describe('terminal paste error copy', () => {
+ it('tells the user how to recover a deferred paste that timed out, per platform', () => {
+ // The payload is already gone, so the copy has to be actionable, and the chord
+ // label has to match the platform the user is actually on (reported on Win 11).
+ expect(formatDeferredTerminalPasteDroppedError('darwin')).toBe(
+ 'Paste cancelled: terminal focus did not return in time. Click the terminal and press ⌘V to paste again.'
+ )
+ expect(formatDeferredTerminalPasteDroppedError('win32')).toBe(
+ 'Paste cancelled: terminal focus did not return in time. Click the terminal and press Ctrl+V to paste again.'
+ )
+ expect(formatDeferredTerminalPasteDroppedError('linux')).toBe(
+ 'Paste cancelled: terminal focus did not return in time. Click the terminal and press Ctrl+V to paste again.'
+ )
+ })
+
+ it('names a failed clipboard read as a failure, never as an empty clipboard', () => {
+ expect(TERMINAL_CLIPBOARD_READ_UNAVAILABLE_MESSAGE).toBe(
+ 'Paste failed: could not read the clipboard. Copy again, then retry.'
+ )
+ // Distinct from every execution-stage message, so the toast cannot be mistaken
+ // for a focus or transport problem.
+ const executionMessages = (
+ [
+ 'payload-too-large',
+ 'stale-target',
+ 'target-disconnected',
+ 'pty-writer-unavailable',
+ 'operation-timeout',
+ undefined
+ ] as const
+ ).map((reason) => formatTerminalPasteExecutionError(reason))
+ expect(executionMessages).not.toContain(TERMINAL_CLIPBOARD_READ_UNAVAILABLE_MESSAGE)
+ })
+})
diff --git a/src/renderer/src/components/terminal-pane/terminal-paste-errors.ts b/src/renderer/src/components/terminal-pane/terminal-paste-errors.ts
index 8a2114cfe93..42480feaab8 100644
--- a/src/renderer/src/components/terminal-pane/terminal-paste-errors.ts
+++ b/src/renderer/src/components/terminal-pane/terminal-paste-errors.ts
@@ -1,5 +1,9 @@
+import { getShortcutPlatform } from '@/lib/shortcut-platform'
import type { TerminalPasteExecutionReason } from './terminal-paste-model'
+export const TERMINAL_CLIPBOARD_READ_UNAVAILABLE_MESSAGE =
+ 'Paste failed: could not read the clipboard. Copy again, then retry.'
+
export function formatTerminalPasteExecutionError(
reason: TerminalPasteExecutionReason | undefined
): string {
@@ -20,3 +24,12 @@ export function formatTerminalPasteExecutionError(
}
return 'Paste failed.'
}
+
+/** The payload is gone by the time this shows, so the copy has to tell the user
+ * what to do next rather than only what went wrong. */
+export function formatDeferredTerminalPasteDroppedError(
+ platform: NodeJS.Platform = getShortcutPlatform()
+): string {
+ const shortcut = platform === 'darwin' ? '⌘V' : 'Ctrl+V'
+ return `Paste cancelled: terminal focus did not return in time. Click the terminal and press ${shortcut} to paste again.`
+}
diff --git a/src/renderer/src/components/terminal-pane/terminal-paste-target-state.ts b/src/renderer/src/components/terminal-pane/terminal-paste-target-state.ts
index 1f42adfaae9..a9671dc4996 100644
--- a/src/renderer/src/components/terminal-pane/terminal-paste-target-state.ts
+++ b/src/renderer/src/components/terminal-pane/terminal-paste-target-state.ts
@@ -71,7 +71,7 @@ export function isTerminalPanePasteFocusCurrent({
return paneContainer.contains(activeElement) && isXtermHelperTextarea(activeElement)
}
-function isInertDocumentFocus(element: Element | null): boolean {
+export function isInertDocumentFocus(element: Element | null): boolean {
if (!element) {
return true
}