Fix stale bracketed paste after interrupt
Fix stale bracketed paste state after terminal interrupts.
This commit is contained in:
parent
16a059d906
commit
ccef7fedbd
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in New Issue