diff --git a/src/renderer/src/components/terminal-pane/TerminalPane.tsx b/src/renderer/src/components/terminal-pane/TerminalPane.tsx index 5546390b6db..9925850186c 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPane.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPane.tsx @@ -192,6 +192,7 @@ import { useVisibleTerminalTabClaim } from './use-visible-terminal-tab-claim' import { TerminalSshReconnectOverlay } from './TerminalSshReconnectOverlay' import { TerminalRemoteRuntimeReconnectBanner } from './TerminalRemoteRuntimeReconnectBanner' import { selectTerminalTabAgentTypesByLeaf } from './terminal-tab-agent-type-index' +import { shouldForceBracketedMultilinePasteForPane } from './terminal-agent-paste-bracketing' import { canContinueAgentSessionInNewSession } from './terminal-agent-session-continuation' import { updateTerminalRemoteRuntimeRecoveryUiState, @@ -1963,6 +1964,17 @@ function TerminalPane( } } + // Why: resolved per pane, not per tab — an agent composer needs bracketing even off + // Windows, because a remote ConPTY host never forwards DECSET 2004 to anyone. + const resolvePaneForceBracketedMultiline = (pane: ManagedPane): boolean => + shouldForceBracketedMultilinePasteForPane({ + isWindowsClient: forceBracketedMultilineTextPaste, + agentStatusByPaneKey: useAppStore.getState().agentStatusByPaneKey, + paneForegroundAgentByPaneKey: useAppStore.getState().paneForegroundAgentByPaneKey, + tabId, + leafId: pane.leafId + }) + const pasteFromClipboard = ( pane: ManagedPane, source: Extract, @@ -1980,7 +1992,7 @@ function TerminalPane( saveClipboardImageAsTempFile: window.api.ui.saveClipboardImageAsTempFile, connectionId, runtimeEnvironmentId, - forceBracketedMultilineTextPaste, + forceBracketedMultilineTextPaste: resolvePaneForceBracketedMultiline(pane), pasteText: (text, options) => executePanePasteText(pane, source, activeElementAtDispatch, text, options), onTextPasteError: () => @@ -2127,7 +2139,7 @@ function TerminalPane( saveClipboardImageAsTempFile: window.api.ui.saveClipboardImageAsTempFile, connectionId, runtimeEnvironmentId, - forceBracketedMultilineTextPaste, + forceBracketedMultilineTextPaste: resolvePaneForceBracketedMultiline(pane), pasteText: (text, options) => executePanePasteText(pane, 'app-menu', activeElementAtDispatch, text, options), onTextPasteError: () => @@ -2735,6 +2747,13 @@ function TerminalPane( transport }) }, + forceBracketedPasteForMultiline: shouldForceBracketedMultilinePasteForPane({ + isWindowsClient: forceBracketedMultilineTextPaste, + agentStatusByPaneKey: useAppStore.getState().agentStatusByPaneKey, + paneForegroundAgentByPaneKey: useAppStore.getState().paneForegroundAgentByPaneKey, + tabId, + leafId: clickedPane.leafId + }), terminalBracketedPasteMode: clickedPane.terminal.modes.bracketedPasteMode }) const execution = await executeTerminalPastePlan(plan, { @@ -2751,7 +2770,7 @@ function TerminalPane( recordTerminalUserInputForLeaf(tabId, clickedPane.leafId) }) }, - [getPrimarySelectionMiddleClickPane, tabId, worktreeId] + [getPrimarySelectionMiddleClickPane, forceBracketedMultilineTextPaste, tabId, worktreeId] ) const handlePrimarySelectionAuxClick = useCallback( diff --git a/src/renderer/src/components/terminal-pane/terminal-agent-paste-bracketing.test.ts b/src/renderer/src/components/terminal-pane/terminal-agent-paste-bracketing.test.ts new file mode 100644 index 00000000000..9dd82fb972b --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-agent-paste-bracketing.test.ts @@ -0,0 +1,170 @@ +import { describe, expect, it } from 'vitest' + +import type { AgentStatusEntry } from '../../../../shared/agent-status-types' +import type { PaneForegroundAgentEntry } from '../../store/slices/pane-foreground-agent' +import { shouldForceBracketedMultilinePasteForPane } from './terminal-agent-paste-bracketing' +import { planTerminalPaste, type TerminalPasteTarget } from './terminal-paste-coordinator' +import { resolveTerminalPasteRuntime } from './terminal-paste-runtime' + +const TAB_ID = 'tab-1' +const AGENT_LEAF = '11111111-1111-4111-8111-111111111111' +const SHELL_LEAF = '22222222-2222-4222-8222-222222222222' +const AGENT_PANE_KEY = `${TAB_ID}:${AGENT_LEAF}` + +function agentEntry(overrides: Partial = {}): AgentStatusEntry { + return { + state: 'waiting', + prompt: '', + updatedAt: 0, + stateStartedAt: 0, + stateHistory: [], + agentType: 'codex', + paneKey: AGENT_PANE_KEY, + ...overrides + } +} + +function foregroundEntry( + overrides: Partial = {} +): PaneForegroundAgentEntry { + return { agent: null, shellForeground: false, ...overrides } +} + +const CODEX_ON_AGENT_LEAF = { [AGENT_PANE_KEY]: agentEntry() } + +function decide( + args: Partial[0]> = {} +): boolean { + return shouldForceBracketedMultilinePasteForPane({ + isWindowsClient: false, + agentStatusByPaneKey: CODEX_ON_AGENT_LEAF, + paneForegroundAgentByPaneKey: {}, + tabId: TAB_ID, + leafId: AGENT_LEAF, + ...args + }) +} + +describe('shouldForceBracketedMultilinePasteForPane', () => { + it('brackets an agent pane on a non-Windows client (remote ConPTY host case)', () => { + expect(decide()).toBe(true) + }) + + it('leaves a plain shell pane alone so ESC[200~ never reaches a non-TUI program', () => { + expect(decide({ leafId: SHELL_LEAF })).toBe(false) + }) + + it('gates per leaf, not per tab', () => { + expect(decide({ tabId: 'other-tab' })).toBe(false) + }) + + it('keeps the existing Windows-client behaviour for non-agent panes', () => { + expect(decide({ isWindowsClient: true, agentStatusByPaneKey: {}, leafId: SHELL_LEAF })).toBe( + true + ) + }) + + it('ignores a pane whose agentType is not a TUI agent', () => { + expect( + decide({ + agentStatusByPaneKey: { + [AGENT_PANE_KEY]: agentEntry({ + agentType: 'not-an-agent' as AgentStatusEntry['agentType'] + }) + } + }) + ).toBe(false) + }) + + // Staleness: the agent row is retained on purpose and is not lifecycle-driven, so it + // can outlive the agent. Only evidence the pane is no longer the agent's may veto. + it('vetoes when OSC 133;D proved the foreground is back at the shell', () => { + expect( + decide({ + paneForegroundAgentByPaneKey: { + [AGENT_PANE_KEY]: foregroundEntry({ shellForeground: true }) + } + }) + ).toBe(false) + }) + + it('vetoes a row rehydrated from disk across an app restart', () => { + expect( + decide({ + agentStatusByPaneKey: { + [AGENT_PANE_KEY]: agentEntry({ restoredUnconfirmed: true }) + } + }) + ).toBe(false) + }) + + it('still brackets a long-idle agent parked at done', () => { + // Why: an idle-but-live agent sits at `done` with an old updatedAt. Gating on + // state or the 30-minute freshness TTL would strip bracketing from exactly the + // pane that needs it most. + expect( + decide({ + agentStatusByPaneKey: { + [AGENT_PANE_KEY]: agentEntry({ state: 'done', updatedAt: 0, stateStartedAt: 0 }) + } + }) + ).toBe(true) + }) + + it('still brackets when foreground evidence exists but has not seen a shell', () => { + expect( + decide({ + paneForegroundAgentByPaneKey: { + [AGENT_PANE_KEY]: foregroundEntry({ agent: 'codex', shellForeground: false }) + } + }) + ).toBe(true) + }) +}) + +describe('leading-newline paste into a remote agent pane', () => { + // Regression: a mac client on a remote Windows host pasted a block starting with "\n". + // ConPTY never forwarded DECSET 2004, so xterm rewrote the newline to CR and codex + // submitted the draft parked in its composer. + const PASTED = '\nRemember: At the end of the day, we want the best possible code.' + + function remoteWindowsTarget(): TerminalPasteTarget { + return { + kind: 'terminal', + paneId: 1, + leafId: AGENT_LEAF, + ptyId: 'remote:host-abc/pty-1', + runtime: resolveTerminalPasteRuntime({ + platform: 'darwin', + ptyId: 'remote:host-abc/pty-1', + isWindowsConpty: false + }) + } + } + + it('brackets the paste so the newline can never submit the draft', () => { + const plan = planTerminalPaste({ + text: PASTED, + source: 'keyboard', + target: remoteWindowsTarget(), + terminalBracketedPasteMode: false, + forceBracketedPasteForMultiline: decide() + }) + + expect(plan.payload.lineCount).toBe(2) + expect(plan.mode).toBe('bracketed-terminal') + expect(plan.bracketed).toBe(true) + }) + + it('a single-line paste stays on the direct path', () => { + const plan = planTerminalPaste({ + text: 'no newline here', + source: 'keyboard', + target: remoteWindowsTarget(), + terminalBracketedPasteMode: false, + forceBracketedPasteForMultiline: decide() + }) + + expect(plan.mode).toBe('direct') + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-agent-paste-bracketing.ts b/src/renderer/src/components/terminal-pane/terminal-agent-paste-bracketing.ts new file mode 100644 index 00000000000..48a3efea906 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-agent-paste-bracketing.ts @@ -0,0 +1,46 @@ +import type { AgentStatusEntry } from '../../../../shared/agent-status-types' +import { isTuiAgent } from '../../../../shared/tui-agent-config' +import { makePaneKey } from '../../../../shared/stable-pane-id' +import type { PaneForegroundAgentEntry } from '../../store/slices/pane-foreground-agent' + +/** + * Why: xterm brackets a paste only after its own parser saw DECSET 2004, but Windows + * ConPTY never forwards it and that ConPTY can be on a remote host — so the client's + * platform proves nothing about the PTY. Unbracketed, xterm rewrites the paste's + * newlines to CR and the agent submits whatever draft was parked in its composer. + * + * An agent row is retained on purpose (an idle agent sits at `done`), so neither the + * 30-minute freshness TTL nor `state !== 'done'` can gate this — both would strip + * bracketing from a live but quiet agent. Only evidence that the pane is *no longer* + * the agent's vetoes it. + */ +export function shouldForceBracketedMultilinePasteForPane({ + isWindowsClient, + agentStatusByPaneKey, + paneForegroundAgentByPaneKey, + tabId, + leafId +}: { + isWindowsClient: boolean + agentStatusByPaneKey: Record + paneForegroundAgentByPaneKey: Record + tabId: string + leafId: string +}): boolean { + if (isWindowsClient) { + return true + } + const paneKey = makePaneKey(tabId, leafId) + // Why: OSC 133;D proved the foreground is back at the shell — process-grade evidence + // that outranks an agent row nothing has cleared yet. + if (paneForegroundAgentByPaneKey[paneKey]?.shellForeground === true) { + return false + } + const entry = agentStatusByPaneKey[paneKey] + // Why: a row rehydrated from disk across a restart describes the previous process, + // not the shell now attached to this pane. + if (entry?.restoredUnconfirmed === true) { + return false + } + return isTuiAgent(entry?.agentType) +} 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 c8264945744..9f968ce991e 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 @@ -40,6 +40,7 @@ import type { AgentSessionContinuationRequest } from '@/lib/agent-session-contin import { recordCreatedTerminalPaneSplit } from './terminal-pane-split-completion' import { splitTerminalPaneWithInheritedCwd } from './terminal-pane-split-with-inherited-cwd' import { useAppStore } from '@/store' +import { shouldForceBracketedMultilinePasteForPane } from './terminal-agent-paste-bracketing' import { translate } from '@/i18n/i18n' import { recordTerminalUserInputForLeaf } from './terminal-input-activity' import { copyTerminalHandleForPane } from './terminal-handle-copy' @@ -331,7 +332,13 @@ export function useTerminalPaneContextMenu({ saveClipboardImageAsTempFile: window.api.ui.saveClipboardImageAsTempFile, connectionId, runtimeEnvironmentId, - forceBracketedMultilineTextPaste, + forceBracketedMultilineTextPaste: shouldForceBracketedMultilinePasteForPane({ + isWindowsClient: forceBracketedMultilineTextPaste, + agentStatusByPaneKey: useAppStore.getState().agentStatusByPaneKey, + paneForegroundAgentByPaneKey: useAppStore.getState().paneForegroundAgentByPaneKey, + tabId, + leafId: pane.leafId + }), pasteText: (text, options) => executeMenuPasteText(pane, source, text, options), onTextPasteError: () => onPasteError('Paste failed: clipboard text is too large for a safe terminal paste.'),