Fix stale bracketed paste after interrupt

Fix stale bracketed paste state after terminal interrupts.
This commit is contained in:
Neil 2026-05-23 17:34:00 -07:00 committed by GitHub
parent 16a059d906
commit ccef7fedbd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 306 additions and 9 deletions

View File

@ -13,7 +13,7 @@ import {
normalizeColor,
resolveEffectiveTerminalAppearance
} from '@/lib/terminal-theme'
import type { PaneManager } from '@/lib/pane-manager/pane-manager'
import type { ManagedPane, PaneManager } from '@/lib/pane-manager/pane-manager'
import TerminalSearch from '@/components/TerminalSearch'
import type { PtyTransport } from './pty-transport'
import { fitPanes, isWindowsUserAgent, shellEscapePath } from './pane-helpers'
@ -80,6 +80,7 @@ import { keybindingMatchesAction } from '../../../../shared/keybindings'
// that otherwise leaves createTerminalSlice undefined at store-init time.
import { shutdownBufferCaptures } from './shutdown-buffer-captures'
import { mergeCapturedLeafState } from './merge-captured-leaf-state'
import { pasteTerminalText } from './terminal-bracketed-paste'
type TerminalPaneProps = {
tabId: string
@ -1065,12 +1066,12 @@ export default function TerminalPane({
// Shared helper: try text first (fast path, single IPC call for the
// common case), then check for a clipboard image only when text is empty
// — which is the image-only clipboard scenario this fix targets.
const pasteFromClipboard = (pane: { terminal: { paste: (data: string) => void } }): void => {
const pasteFromClipboard = (pane: ManagedPane): void => {
void window.api.ui
.readClipboardText()
.then((text) => {
if (text) {
pane.terminal.paste(text)
pasteTerminalText(pane.terminal, text)
return
}
// Why: clipboard has no text — check for an image. This is the
@ -1082,7 +1083,7 @@ export default function TerminalPane({
.saveClipboardImageAsTempFile({ connectionId })
.then((filePath) => {
if (filePath) {
pane.terminal.paste(filePath)
pasteTerminalText(pane.terminal, filePath)
}
})
.catch((error: unknown) => {
@ -1528,7 +1529,7 @@ export default function TerminalPane({
clickedPane.terminal.focus()
void readPrimarySelectionText().then((text) => {
if (text) {
clickedPane.terminal.paste(text)
pasteTerminalText(clickedPane.terminal, text)
}
})
},

View File

@ -218,7 +218,14 @@ function createPane(paneId: number) {
terminal: {
cols: 120,
rows: 40,
modes: {
bracketedPasteMode: false
},
options: {
ignoreBracketedPasteMode: false
},
write: vi.fn(),
paste: vi.fn(),
onData: vi.fn(() => ({ dispose: vi.fn() })),
onResize: vi.fn(() => ({ dispose: vi.fn() })),
onTitleChange: vi.fn(() => ({ dispose: vi.fn() })),
@ -779,6 +786,35 @@ describe('connectPanePty', () => {
})
})
it('marks bracketed paste as stale after acknowledged Ctrl+C input', async () => {
const { connectPanePty } = await import('./pty-connection')
const { pasteTerminalText } = await import('./terminal-bracketed-paste')
const transport = createMockTransport()
transportFactoryQueue.push(transport)
const pane = createPane(1)
pane.terminal.modes.bracketedPasteMode = true
const observedIgnoreValues: (boolean | undefined)[] = []
pane.terminal.paste.mockImplementation(() => {
observedIgnoreValues.push(pane.terminal.options.ignoreBracketedPasteMode)
})
let onDataHandler: ((data: string) => void) | null = null
pane.terminal.onData = vi.fn(((handler: (data: string) => void) => {
onDataHandler = handler
return { dispose: vi.fn() }
}) as typeof pane.terminal.onData)
connectPanePty(pane as never, createManager(1) as never, createDeps() as never)
if (!onDataHandler) {
throw new Error('expected onData handler to be registered')
}
;(onDataHandler as unknown as (data: string) => void)('\x03')
await flushAsyncTicks()
pasteTerminalText(pane.terminal as never, 'a69ce28e1d092e0c8825cd1a109ac36409962bc1')
expect(observedIgnoreValues).toEqual([true])
expect(pane.terminal.options.ignoreBracketedPasteMode).toBe(false)
})
it('infers captured Ctrl+C even when xterm emits an enhanced keyboard sequence', async () => {
const { connectPanePty } = await import('./pty-connection')
const transport = createMockTransport()

View File

@ -47,6 +47,10 @@ import {
type AgentInterruptInputIntent
} from '../../../../shared/agent-interrupt-intent'
import { createAgentCompletionCoordinator } from './agent-completion-coordinator'
import {
markTerminalBracketedPasteInterrupted,
observeTerminalBracketedPasteModeOutput
} from './terminal-bracketed-paste'
const pendingSpawnByPaneKey = new Map<string, Promise<string | null>>()
const SSH_SESSION_EXPIRED_ERROR = 'SSH_SESSION_EXPIRED'
@ -392,6 +396,14 @@ export function connectPanePty(
observeTitleOnlyInterrupt()
}
}
const observeAcceptedTerminalInput = (
data: string,
intent: AgentInterruptInputIntent | null = null
): void => {
if (intent === 'ctrl-c' || data === '\x03') {
markTerminalBracketedPasteInterrupted(pane.terminal)
}
}
let pendingTerminalInputWrite: Promise<void> | null = null
const setPendingTerminalInputWrite = (promise: Promise<void>): void => {
pendingTerminalInputWrite = promise
@ -914,6 +926,7 @@ export function connectPanePty(
.sendInputAccepted(data)
.then((accepted) => {
if (accepted) {
observeAcceptedTerminalInput(data, acknowledgedIntent)
interruptInference.observeInputIntent(acknowledgedIntent)
observeTitleOnlyInterrupt()
}
@ -925,11 +938,14 @@ export function connectPanePty(
return
}
if (intent) {
transport.sendInput(data)
if (transport.sendInput(data)) {
observeAcceptedTerminalInput(data, intent)
}
clearPendingTerminalInputIntent()
return
}
if (transport.sendInput(data)) {
observeAcceptedTerminalInput(data)
observeSentTerminalInputIntent(data)
} else {
clearPendingTerminalInputIntent()
@ -1170,6 +1186,7 @@ export function connectPanePty(
}
const dataCallback = (data: string): void => {
observeTerminalBracketedPasteModeOutput(pane.terminal, data)
commandLifecycle.handlePtyData(data)
// Why: visibility is the right gate — split-pane layouts have multiple
// visible-but-inactive panes whose output the user is watching. Only

View File

@ -0,0 +1,163 @@
import { describe, expect, it, vi } from 'vitest'
import {
markTerminalBracketedPasteInterrupted,
observeTerminalBracketedPasteModeOutput,
pasteTerminalText
} from './terminal-bracketed-paste'
function createTerminal(bracketedPasteMode = true) {
const terminal = {
modes: {
bracketedPasteMode
},
options: {
ignoreBracketedPasteMode: false as boolean | undefined
},
paste: vi.fn()
}
return terminal
}
describe('terminal bracketed paste policy', () => {
it('temporarily ignores bracketed paste wrappers for single-line paste after Ctrl+C', () => {
const terminal = createTerminal(true)
const observedIgnoreValues: (boolean | undefined)[] = []
terminal.paste.mockImplementation(() => {
observedIgnoreValues.push(terminal.options.ignoreBracketedPasteMode)
})
markTerminalBracketedPasteInterrupted(terminal)
pasteTerminalText(terminal, 'a69ce28e1d092e0c8825cd1a109ac36409962bc1')
expect(terminal.paste).toHaveBeenCalledWith('a69ce28e1d092e0c8825cd1a109ac36409962bc1')
expect(observedIgnoreValues).toEqual([true])
expect(terminal.options.ignoreBracketedPasteMode).toBe(false)
})
it('keeps bracketed paste behavior for multi-line paste after Ctrl+C', () => {
const terminal = createTerminal(true)
const observedIgnoreValues: (boolean | undefined)[] = []
terminal.paste.mockImplementation(() => {
observedIgnoreValues.push(terminal.options.ignoreBracketedPasteMode)
})
markTerminalBracketedPasteInterrupted(terminal)
pasteTerminalText(terminal, 'echo one\necho two')
expect(terminal.paste).toHaveBeenCalledWith('echo one\necho two')
expect(observedIgnoreValues).toEqual([false])
})
it('does not change paste behavior when Ctrl+C happened outside bracketed paste mode', () => {
const terminal = createTerminal(false)
const observedIgnoreValues: (boolean | undefined)[] = []
terminal.paste.mockImplementation(() => {
observedIgnoreValues.push(terminal.options.ignoreBracketedPasteMode)
})
markTerminalBracketedPasteInterrupted(terminal)
pasteTerminalText(terminal, 'commit')
expect(terminal.paste).toHaveBeenCalledWith('commit')
expect(observedIgnoreValues).toEqual([false])
})
it('clears the interrupted state when live output refreshes bracketed paste mode', () => {
const terminal = createTerminal(true)
const observedIgnoreValues: (boolean | undefined)[] = []
terminal.paste.mockImplementation(() => {
observedIgnoreValues.push(terminal.options.ignoreBracketedPasteMode)
})
markTerminalBracketedPasteInterrupted(terminal)
observeTerminalBracketedPasteModeOutput(terminal, '\x1b[?25;2004h')
pasteTerminalText(terminal, 'commit')
expect(observedIgnoreValues).toEqual([false])
})
it('clears the interrupted state when bracketed paste mode output is split', () => {
const terminal = createTerminal(true)
const observedIgnoreValues: (boolean | undefined)[] = []
terminal.paste.mockImplementation(() => {
observedIgnoreValues.push(terminal.options.ignoreBracketedPasteMode)
})
markTerminalBracketedPasteInterrupted(terminal)
observeTerminalBracketedPasteModeOutput(terminal, '\x1b[?20')
observeTerminalBracketedPasteModeOutput(terminal, '04h')
pasteTerminalText(terminal, 'commit')
expect(observedIgnoreValues).toEqual([false])
})
it('clears the interrupted state when bracketed paste disable output is split', () => {
const terminal = createTerminal(true)
const observedIgnoreValues: (boolean | undefined)[] = []
terminal.paste.mockImplementation(() => {
observedIgnoreValues.push(terminal.options.ignoreBracketedPasteMode)
})
markTerminalBracketedPasteInterrupted(terminal)
observeTerminalBracketedPasteModeOutput(terminal, '\x1b[?20')
observeTerminalBracketedPasteModeOutput(terminal, '04l')
pasteTerminalText(terminal, 'commit')
expect(observedIgnoreValues).toEqual([false])
})
it('ignores partial mode output seen before an interrupt', () => {
const terminal = createTerminal(true)
const observedIgnoreValues: (boolean | undefined)[] = []
terminal.paste.mockImplementation(() => {
observedIgnoreValues.push(terminal.options.ignoreBracketedPasteMode)
})
observeTerminalBracketedPasteModeOutput(terminal, '\x1b[?20')
markTerminalBracketedPasteInterrupted(terminal)
observeTerminalBracketedPasteModeOutput(terminal, '04h')
pasteTerminalText(terminal, 'commit')
expect(observedIgnoreValues).toEqual([true])
})
it('detects split compound bracketed paste mode output', () => {
const terminal = createTerminal(true)
const observedIgnoreValues: (boolean | undefined)[] = []
terminal.paste.mockImplementation(() => {
observedIgnoreValues.push(terminal.options.ignoreBracketedPasteMode)
})
markTerminalBracketedPasteInterrupted(terminal)
observeTerminalBracketedPasteModeOutput(terminal, '\x1b[?25;1000;1002;1003;1004;1006;20')
observeTerminalBracketedPasteModeOutput(terminal, '04h')
pasteTerminalText(terminal, 'commit')
expect(observedIgnoreValues).toEqual([false])
})
it('clears the interrupted state when the terminal mode is already disabled', () => {
const terminal = createTerminal(true)
const observedIgnoreValues: (boolean | undefined)[] = []
terminal.paste.mockImplementation(() => {
observedIgnoreValues.push(terminal.options.ignoreBracketedPasteMode)
})
markTerminalBracketedPasteInterrupted(terminal)
terminal.modes.bracketedPasteMode = false
pasteTerminalText(terminal, 'commit')
terminal.modes.bracketedPasteMode = true
pasteTerminalText(terminal, 'next')
expect(observedIgnoreValues).toEqual([false, false])
})
it('renders embedded escape bytes inert when forcing plain single-line paste', () => {
const terminal = createTerminal(true)
markTerminalBracketedPasteInterrupted(terminal)
pasteTerminalText(terminal, 'before\x1b[201~after')
expect(terminal.paste).toHaveBeenCalledWith('before\u241b[201~after')
})
})

View File

@ -0,0 +1,78 @@
import type { Terminal } from '@xterm/xterm'
type BracketedPasteTerminal = {
modes: {
bracketedPasteMode: boolean
}
}
type PasteTerminal = BracketedPasteTerminal & {
options: Pick<Terminal['options'], 'ignoreBracketedPasteMode'>
paste: (text: string) => void
}
const interruptedBracketedPasteTerminals = new WeakSet<object>()
const bracketedPasteModeOutputTail = new WeakMap<object, string>()
const ESCAPE = '\u001b'
const BRACKETED_PASTE_MODE_SEQUENCE_RE = /^\[\?(?:\d+;)*2004(?:;\d+)*[hl]/
const BRACKETED_PASTE_MODE_TAIL_MAX = 128
const LINE_BREAK_RE = /[\r\n]/
function hasBracketedPasteModeSequence(data: string): boolean {
const segments = data.split(ESCAPE)
for (let index = 1; index < segments.length; index++) {
if (BRACKETED_PASTE_MODE_SEQUENCE_RE.test(segments[index])) {
return true
}
}
return false
}
export function markTerminalBracketedPasteInterrupted(terminal: BracketedPasteTerminal): void {
if (terminal.modes.bracketedPasteMode) {
interruptedBracketedPasteTerminals.add(terminal)
}
}
export function observeTerminalBracketedPasteModeOutput(
terminal: BracketedPasteTerminal,
data: string
): void {
if (!interruptedBracketedPasteTerminals.has(terminal)) {
bracketedPasteModeOutputTail.delete(terminal)
return
}
const combined = (bracketedPasteModeOutputTail.get(terminal) ?? '') + data
bracketedPasteModeOutputTail.set(terminal, combined.slice(-BRACKETED_PASTE_MODE_TAIL_MAX))
if (hasBracketedPasteModeSequence(combined)) {
interruptedBracketedPasteTerminals.delete(terminal)
bracketedPasteModeOutputTail.delete(terminal)
}
}
export function pasteTerminalText(terminal: PasteTerminal, text: string): void {
if (!interruptedBracketedPasteTerminals.has(terminal)) {
terminal.paste(text)
return
}
if (!terminal.modes.bracketedPasteMode) {
interruptedBracketedPasteTerminals.delete(terminal)
bracketedPasteModeOutputTail.delete(terminal)
terminal.paste(text)
return
}
if (LINE_BREAK_RE.test(text)) {
terminal.paste(text)
return
}
const previousIgnoreBracketedPasteMode = terminal.options.ignoreBracketedPasteMode
// Why: Ctrl+C can leave xterm's bracketed-paste bit stale after the foreground
// process dies. Single-line paste does not need wrappers, so avoid leaking them.
terminal.options.ignoreBracketedPasteMode = true
try {
terminal.paste(text.split(ESCAPE).join('\u241b'))
} finally {
terminal.options.ignoreBracketedPasteMode = previousIgnoreBracketedPasteMode
}
}

View File

@ -6,6 +6,7 @@ import { resolveSplitCwd, type PaneCwdMap } from './resolve-split-cwd'
import type { TerminalQuickCommand } from '../../../../shared/types'
import { sendTerminalQuickCommandToPane } from './terminal-quick-command-dispatch'
import { splitWebRuntimeTerminal } from '@/runtime/web-runtime-session'
import { pasteTerminalText } from './terminal-bracketed-paste'
const CLOSE_ALL_CONTEXT_MENUS_EVENT = 'orca-close-all-context-menus'
@ -108,7 +109,7 @@ export function useTerminalPaneContextMenu({
}
const text = await window.api.ui.readClipboardText()
if (text) {
pane.terminal.paste(text)
pasteTerminalText(pane.terminal, text)
pane.terminal.focus()
return
}
@ -118,7 +119,7 @@ export function useTerminalPaneContextMenu({
const connectionId = getConnectionId(worktreeId) ?? null
const filePath = await window.api.ui.saveClipboardImageAsTempFile({ connectionId })
if (filePath) {
pane.terminal.paste(filePath)
pasteTerminalText(pane.terminal, filePath)
}
} catch (error) {
const detail = error instanceof Error ? error.message : String(error)

View File

@ -17,6 +17,7 @@ import { useAppStore } from '@/store'
import { restoreScrollStateAfterLayout } from '@/lib/pane-manager/pane-scroll'
import { useTerminalScrollVisibilityMemory } from './use-terminal-scroll-visibility-memory'
import { useTerminalContainerFitSync } from './use-terminal-container-fit-sync'
import { pasteTerminalText } from './terminal-bracketed-paste'
type UseTerminalPaneGlobalEffectsArgs = {
tabId: string
@ -178,7 +179,7 @@ export function useTerminalPaneGlobalEffects({
if (!pane) {
return
}
pane.terminal.paste(detail.text)
pasteTerminalText(pane.terminal, detail.text)
pane.terminal.focus()
}
window.addEventListener(PASTE_TERMINAL_TEXT_EVENT, onPasteText)