From ccef7fedbdf2a4fa51bd1db56386312e2fd7585a Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 23 May 2026 17:34:00 -0700 Subject: [PATCH] Fix stale bracketed paste after interrupt Fix stale bracketed paste state after terminal interrupts. --- .../components/terminal-pane/TerminalPane.tsx | 11 +- .../terminal-pane/pty-connection.test.ts | 36 ++++ .../terminal-pane/pty-connection.ts | 19 +- .../terminal-bracketed-paste.test.ts | 163 ++++++++++++++++++ .../terminal-pane/terminal-bracketed-paste.ts | 78 +++++++++ .../use-terminal-pane-context-menu.ts | 5 +- .../use-terminal-pane-global-effects.ts | 3 +- 7 files changed, 306 insertions(+), 9 deletions(-) create mode 100644 src/renderer/src/components/terminal-pane/terminal-bracketed-paste.test.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-bracketed-paste.ts diff --git a/src/renderer/src/components/terminal-pane/TerminalPane.tsx b/src/renderer/src/components/terminal-pane/TerminalPane.tsx index d8a07be77..4071bee48 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPane.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPane.tsx @@ -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) } }) }, diff --git a/src/renderer/src/components/terminal-pane/pty-connection.test.ts b/src/renderer/src/components/terminal-pane/pty-connection.test.ts index 2366d2754..11205a614 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -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() diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index 4d9c2efcf..f64788a30 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -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>() 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 | null = null const setPendingTerminalInputWrite = (promise: Promise): 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 diff --git a/src/renderer/src/components/terminal-pane/terminal-bracketed-paste.test.ts b/src/renderer/src/components/terminal-pane/terminal-bracketed-paste.test.ts new file mode 100644 index 000000000..2144d0e52 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-bracketed-paste.test.ts @@ -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') + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-bracketed-paste.ts b/src/renderer/src/components/terminal-pane/terminal-bracketed-paste.ts new file mode 100644 index 000000000..44dbe930a --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-bracketed-paste.ts @@ -0,0 +1,78 @@ +import type { Terminal } from '@xterm/xterm' + +type BracketedPasteTerminal = { + modes: { + bracketedPasteMode: boolean + } +} + +type PasteTerminal = BracketedPasteTerminal & { + options: Pick + paste: (text: string) => void +} + +const interruptedBracketedPasteTerminals = new WeakSet() +const bracketedPasteModeOutputTail = new WeakMap() +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 + } +} diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-context-menu.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-context-menu.ts index 1c0200aec..260d1bbc6 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-context-menu.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-context-menu.ts @@ -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) diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.ts index af84cc9bb..0dd284152 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.ts @@ -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)