diff --git a/src/renderer/src/components/terminal-pane/TerminalPane.tsx b/src/renderer/src/components/terminal-pane/TerminalPane.tsx index b8e8cf1ab84..0726005e6ac 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPane.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPane.tsx @@ -115,7 +115,7 @@ import { } from '@/components/terminal-quick-commands/TerminalQuickCommandDialog' import { keybindingMatchesAction } from '../../../../shared/keybindings' import { pasteTerminalClipboard } from './terminal-clipboard-paste' -import { scheduleImagePasteWebglAtlasRecovery } from './terminal-webgl-paste-recovery' +import { maybeScheduleWebglAtlasRecoveryForPaste } from './terminal-webgl-paste-recovery' import { restoreTerminalFitToDesktop, restoreTerminalFitsToDesktop } from './terminal-fit-restore' import { useVisibleTerminalTabClaim } from './use-visible-terminal-tab-claim' @@ -1635,9 +1635,7 @@ export default function TerminalPane({ if (text) { recordTerminalUserInputForLeaf(tabId, pane.leafId) } - if (options?.recoverImagePasteWebglAtlas) { - scheduleImagePasteWebglAtlasRecovery() - } + maybeScheduleWebglAtlasRecoveryForPaste(plan) } const pasteFromClipboard = ( @@ -2408,6 +2406,9 @@ export default function TerminalPane({ return } recordTerminalUserInputForLeaf(tabId, clickedPane.leafId) + // Why: a middle-click URL paste into a bracketed-paste-mode TUI can + // corrupt the shared WebGL glyph atlas just like menu/keyboard pastes. + maybeScheduleWebglAtlasRecoveryForPaste(plan) }) }, [getPrimarySelectionMiddleClickPane, tabId, worktreeId] diff --git a/src/renderer/src/components/terminal-pane/terminal-clipboard-paste.test.ts b/src/renderer/src/components/terminal-pane/terminal-clipboard-paste.test.ts index 5e08bc002bb..b7e178636c1 100644 --- a/src/renderer/src/components/terminal-pane/terminal-clipboard-paste.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-clipboard-paste.test.ts @@ -22,7 +22,7 @@ describe('terminal clipboard paste', () => { expect(pasteText).toHaveBeenCalledWith( '/var/folders/3l/b7w02vh17tg5r5s3nhhdf3kh0000gn/T/orca-paste-1760000000000-id.png', - { forceBracketedPaste: true, recoverImagePasteWebglAtlas: true } + { forceBracketedPaste: true } ) }) @@ -103,8 +103,7 @@ describe('terminal clipboard paste', () => { runtimeEnvironmentId: undefined }) expect(pasteText).toHaveBeenCalledWith('/var/tmp/orca-paste-1760000000000-id.png', { - forceBracketedPaste: true, - recoverImagePasteWebglAtlas: true + forceBracketedPaste: true }) }) @@ -126,8 +125,7 @@ describe('terminal clipboard paste', () => { runtimeEnvironmentId: 'remote-host-1' }) expect(pasteText).toHaveBeenCalledWith('/tmp/orca-paste-1760000000000-runtime.png', { - forceBracketedPaste: true, - recoverImagePasteWebglAtlas: true + forceBracketedPaste: true }) }) @@ -143,8 +141,7 @@ describe('terminal clipboard paste', () => { }) expect(pasteText).toHaveBeenCalledWith('/tmp/orca-paste-1760000000000-id.png', { - forceBracketedPaste: true, - recoverImagePasteWebglAtlas: true + forceBracketedPaste: true }) }) @@ -165,8 +162,7 @@ describe('terminal clipboard paste', () => { runtimeEnvironmentId: undefined }) expect(pasteText).toHaveBeenCalledWith('/tmp/orca-paste-1760000000000-id.png', { - forceBracketedPaste: true, - recoverImagePasteWebglAtlas: true + forceBracketedPaste: true }) }) diff --git a/src/renderer/src/components/terminal-pane/terminal-clipboard-paste.ts b/src/renderer/src/components/terminal-pane/terminal-clipboard-paste.ts index 2c889502918..6bfaefb864f 100644 --- a/src/renderer/src/components/terminal-pane/terminal-clipboard-paste.ts +++ b/src/renderer/src/components/terminal-pane/terminal-clipboard-paste.ts @@ -83,8 +83,10 @@ export async function pasteTerminalClipboard({ const result = await pasteText(filePath, { // Why: a generated clipboard-image path is terminal image injection, not // ordinary one-line text. Keep it off the Ctrl+C stale-text paste path. - forceBracketedPaste: true, - recoverImagePasteWebglAtlas: true + // WebGL atlas recovery now keys off the resulting bracketed paste, so it + // covers this force-bracketed image path and corrupting text/URL pastes + // (issue #5960) alike. + forceBracketedPaste: true }) if (result === false) { return { status: 'skipped', reason: 'image-paste-rejected' } diff --git a/src/renderer/src/components/terminal-pane/terminal-paste-coordinator.test.ts b/src/renderer/src/components/terminal-pane/terminal-paste-coordinator.test.ts index 7a7e9494782..502f02b1260 100644 --- a/src/renderer/src/components/terminal-pane/terminal-paste-coordinator.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-paste-coordinator.test.ts @@ -136,6 +136,49 @@ describe('terminal paste coordinator', () => { expect(writePty).not.toHaveBeenCalled() }) + it('marks a single-line URL bracketed-to-terminal when bracketed-paste mode is on (issue #5960)', () => { + // Why: a TUI like Claude Code wraps even a direct paste in bracketed-paste + // escapes when bracketed-paste mode is on, so the corrupting redraw — and + // thus WebGL atlas recovery — must trigger though `bracketed` stays false. + const plan = planTerminalPaste({ + text: 'https://github.com/stablyai/orca/pull/6025', + source: 'context-menu', + target: terminalTarget(), + terminalBracketedPasteMode: true + }) + + expect(plan.mode).toBe('direct') + expect(plan.bracketed).toBe(false) + expect(plan.bracketedToTerminal).toBe(true) + }) + + it('does not mark a single-line paste bracketed-to-terminal without bracketed-paste mode', () => { + const plan = planTerminalPaste({ + text: 'https://github.com/stablyai/orca/pull/6025', + source: 'context-menu', + target: terminalTarget(), + terminalBracketedPasteMode: false + }) + + expect(plan.mode).toBe('direct') + expect(plan.bracketed).toBe(false) + expect(plan.bracketedToTerminal).toBe(false) + }) + + it('marks a force-bracketed image paste bracketed-to-terminal even with mode off', () => { + const plan = planTerminalPaste({ + text: '/tmp/orca-paste-1760000000000-id.png', + source: 'context-menu', + target: terminalTarget(), + forceBracketedPaste: true, + terminalBracketedPasteMode: false + }) + + expect(plan.mode).toBe('bracketed-terminal') + expect(plan.bracketed).toBe(true) + expect(plan.bracketedToTerminal).toBe(true) + }) + it('forces small Windows multiline paste through bracketed terminal input', async () => { const pasteText = vi.fn() const plan = planTerminalPaste({ diff --git a/src/renderer/src/components/terminal-pane/terminal-paste-coordinator.ts b/src/renderer/src/components/terminal-pane/terminal-paste-coordinator.ts index e7e58b7179b..68237f2cd84 100644 --- a/src/renderer/src/components/terminal-pane/terminal-paste-coordinator.ts +++ b/src/renderer/src/components/terminal-pane/terminal-paste-coordinator.ts @@ -169,6 +169,7 @@ function buildTerminalPastePlan({ shouldChunk, maxBytes }) + const bracketed = mode === 'bracketed-terminal' || (mode === 'chunked' && shouldBracketChunk) const plan: TerminalPastePlan = { target, payload, @@ -176,7 +177,11 @@ function buildTerminalPastePlan({ newlinePolicy: 'preserve', runtimeKey: target.runtime.runtimeKey, ...(shouldChunk ? { maxChunkBytes } : {}), - bracketed: mode === 'bracketed-terminal' || (mode === 'chunked' && shouldBracketChunk), + bracketed, + // Why: a `direct` paste still reaches the TUI bracketed when the terminal + // has bracketed-paste mode on, and that wrap drives the corrupting redraw + // (issue #5960) the same as an Orca-bracketed paste. + bracketedToTerminal: bracketed || (mode === 'direct' && terminalBracketedPasteMode), redactedDiagnostic: '', ...(mode === 'reject' ? { rejectReason: 'payload-too-large' } : {}) } diff --git a/src/renderer/src/components/terminal-pane/terminal-paste-model.ts b/src/renderer/src/components/terminal-pane/terminal-paste-model.ts index 257a23b7897..c77845f5399 100644 --- a/src/renderer/src/components/terminal-pane/terminal-paste-model.ts +++ b/src/renderer/src/components/terminal-pane/terminal-paste-model.ts @@ -39,6 +39,11 @@ export type TerminalPastePlan = { runtimeKey: string maxChunkBytes?: number bracketed: boolean + // Whether the bytes reach the TUI wrapped in bracketed-paste escapes — true + // either because Orca brackets them (`bracketed`) or because the terminal has + // bracketed-paste mode on and wraps a direct paste itself. The corrupting TUI + // redraw (issue #5960) keys off this, not `bracketed` alone. + bracketedToTerminal: boolean redactedDiagnostic: string rejectReason?: TerminalPasteExecutionReason } @@ -46,7 +51,6 @@ export type TerminalPastePlan = { export type TerminalPasteTextOptions = { forceBracketedPaste?: boolean forceBracketedPasteForMultiline?: boolean - recoverImagePasteWebglAtlas?: boolean } export type TerminalPasteExecutionReason = diff --git a/src/renderer/src/components/terminal-pane/terminal-webgl-paste-recovery.test.ts b/src/renderer/src/components/terminal-pane/terminal-webgl-paste-recovery.test.ts index 61b93ed1e08..22e4f867da8 100644 --- a/src/renderer/src/components/terminal-pane/terminal-webgl-paste-recovery.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-webgl-paste-recovery.test.ts @@ -3,9 +3,12 @@ import { registerLivePaneManager, unregisterLivePaneManager } from '@/lib/pane-manager/pane-manager-registry' -import { scheduleImagePasteWebglAtlasRecovery } from './terminal-webgl-paste-recovery' +import { + maybeScheduleWebglAtlasRecoveryForPaste, + schedulePasteWebglAtlasRecovery +} from './terminal-webgl-paste-recovery' -describe('terminal image paste WebGL recovery', () => { +describe('terminal paste WebGL recovery', () => { const registeredManagers: { resetWebglTextureAtlases(): void }[] = [] function registerManager(): { resetWebglTextureAtlases: Mock<() => void> } { @@ -38,7 +41,7 @@ describe('terminal image paste WebGL recovery', () => { const manager = registerManager() const otherManager = registerManager() - scheduleImagePasteWebglAtlasRecovery() + schedulePasteWebglAtlasRecovery() expect(manager.resetWebglTextureAtlases).not.toHaveBeenCalled() rafCallbacks[0]?.(0) @@ -56,7 +59,7 @@ describe('terminal image paste WebGL recovery', () => { vi.stubGlobal('requestAnimationFrame', undefined) const manager = registerManager() - scheduleImagePasteWebglAtlasRecovery() + schedulePasteWebglAtlasRecovery() expect(manager.resetWebglTextureAtlases).not.toHaveBeenCalled() vi.advanceTimersByTime(0) @@ -80,7 +83,31 @@ describe('terminal image paste WebGL recovery', () => { registerLivePaneManager(manager) registeredManagers.push(manager) - expect(() => scheduleImagePasteWebglAtlasRecovery()).not.toThrow() + expect(() => schedulePasteWebglAtlasRecovery()).not.toThrow() expect(() => vi.runAllTimers()).not.toThrow() }) + + describe('maybeScheduleWebglAtlasRecoveryForPaste', () => { + it('recovers the atlas when the paste reaches the TUI bracketed', () => { + vi.useFakeTimers() + vi.stubGlobal('requestAnimationFrame', undefined) + const manager = registerManager() + + maybeScheduleWebglAtlasRecoveryForPaste({ bracketedToTerminal: true }) + + vi.advanceTimersByTime(500) + expect(manager.resetWebglTextureAtlases).toHaveBeenCalled() + }) + + it('skips recovery when the paste is not delivered bracketed', () => { + vi.useFakeTimers() + vi.stubGlobal('requestAnimationFrame', undefined) + const manager = registerManager() + + maybeScheduleWebglAtlasRecoveryForPaste({ bracketedToTerminal: false }) + + vi.advanceTimersByTime(500) + expect(manager.resetWebglTextureAtlases).not.toHaveBeenCalled() + }) + }) }) diff --git a/src/renderer/src/components/terminal-pane/terminal-webgl-paste-recovery.ts b/src/renderer/src/components/terminal-pane/terminal-webgl-paste-recovery.ts index 8975913aed1..62a79585d0c 100644 --- a/src/renderer/src/components/terminal-pane/terminal-webgl-paste-recovery.ts +++ b/src/renderer/src/components/terminal-pane/terminal-webgl-paste-recovery.ts @@ -1,6 +1,6 @@ import { resetAllTerminalWebglAtlases } from '@/lib/pane-manager/pane-manager-registry' -const IMAGE_PASTE_ATLAS_RECOVERY_DELAYS_MS = [120, 500] +const PASTE_ATLAS_RECOVERY_DELAYS_MS = [120, 500] function scheduleNextFrame(callback: () => void): void { if (typeof globalThis.requestAnimationFrame === 'function') { @@ -21,12 +21,24 @@ function resetAtlases(): void { } } -export function scheduleImagePasteWebglAtlasRecovery(): void { - // Why: Claude Code redraws its image chip immediately after bracketed paste, - // and xterm WebGL atlas corruption can appear after that redraw without a - // context-loss event. A few cheap resets cover the post-paste paint window. +export function schedulePasteWebglAtlasRecovery(): void { + // Why: a TUI (e.g. Claude Code) redraws immediately after a bracketed paste — + // an image chip, or a pasted URL/text — and xterm WebGL atlas corruption can + // appear after that redraw without a context-loss event. A few cheap resets + // cover the post-paste paint window. scheduleNextFrame(() => resetAtlases()) - for (const delayMs of IMAGE_PASTE_ATLAS_RECOVERY_DELAYS_MS) { + for (const delayMs of PASTE_ATLAS_RECOVERY_DELAYS_MS) { globalThis.setTimeout(() => resetAtlases(), delayMs) } } + +// Recover the atlas only when the paste reaches the TUI bracketed — that wrap +// triggers the redraw that can corrupt it (image paste, or a text/URL paste +// into a bracketed-paste-mode TUI, issue #5960). Plain direct pastes don't. +export function maybeScheduleWebglAtlasRecoveryForPaste(plan: { + bracketedToTerminal: boolean +}): void { + if (plan.bracketedToTerminal) { + schedulePasteWebglAtlasRecovery() + } +} 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 61c41e79313..ca24cf2d11b 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 @@ -23,7 +23,7 @@ import { resolveTerminalPasteRuntime } from './terminal-paste-runtime' import { getTerminalPasteSshRemotePlatform } from './terminal-paste-ssh-platform' import { isTerminalPanePasteTargetCurrent } from './terminal-paste-target-state' import { writeTerminalPastePtyInput } from './terminal-pty-paste-writer' -import { scheduleImagePasteWebglAtlasRecovery } from './terminal-webgl-paste-recovery' +import { maybeScheduleWebglAtlasRecoveryForPaste } from './terminal-webgl-paste-recovery' import { REQUEST_ACTIVE_TERMINAL_PANE_SPLIT_EVENT, type RequestActiveTerminalPaneSplitDetail @@ -243,9 +243,7 @@ export function useTerminalPaneContextMenu({ if (text) { recordTerminalUserInputForLeaf(tabId, pane.leafId) } - if (options?.recoverImagePasteWebglAtlas) { - scheduleImagePasteWebglAtlasRecovery() - } + maybeScheduleWebglAtlasRecoveryForPaste(plan) return true }