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 <body> - 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
This commit is contained in:
Brennan Benson
2026-08-28 05:17:06 -07:00
parent 94e7586665
commit 487a0489d4
11 changed files with 1192 additions and 10 deletions
@@ -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<AgentSessionContinuationRequest | null>(null)
const [terminalError, setTerminalError] = useState<string | null>(null)
// Why: outlives the paste effect so a keybinding change mid-deferral does not
// silently drop the payload; only unmount does.
const deferredPasteRef = useRef<DeferredTerminalPasteQueue | null>(null)
useEffect(() => {
return () => {
deferredPasteRef.current?.dispose()
deferredPasteRef.current = null
}
}, [])
const [paneProcessExitsByPaneId, setPaneProcessExitsByPaneId] = useState<
Record<number, PaneProcessExit>
>({})
@@ -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<void> => {
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 <body> 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<ManagedPane>({
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)
}
@@ -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()
})
})
@@ -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<TerminalClipboardPasteResult> {
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, {
@@ -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<number, Timer>()
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> = {}): 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<typeof createDeferredTerminalPasteQueue>
deliver: Mock<(pane: { id: number }, deferred: DeferredTerminalPaste) => void>
onDropped: Mock<(deferred: DeferredTerminalPaste) => void>
onExpire: Mock<(deferred: DeferredTerminalPaste) => void>
timers: ReturnType<typeof createTimerHarness>
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()
})
})
@@ -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<TPane extends DeferredPasteFocusPane> =
| { 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<TPane extends DeferredPasteFocusPane>({
panes,
focusedElement,
queue
}: {
panes: readonly TPane[]
focusedElement: Node | null
queue: DeferredTerminalPasteQueue
}): DeferredPasteFocusResolution<TPane> {
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<TPane extends DeferredPasteFocusPane>({
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<TPane extends DeferredPasteFocusPane>({
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)
}
}
}
@@ -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 <body>,
// 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<typeof installInertFocusPasteFallback> => {
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()
})
})
@@ -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 `<body>` rather than moving to another surface. */
ownsInertFocus: () => boolean
dispose: () => void
}
/** A dictation tool, IME, or overlay hands focus back to `<body>`, 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 <body> 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 <body> 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()
}
}
@@ -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
@@ -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)
})
})
@@ -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.`
}
@@ -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
}