fix(terminal): bracket agent-pane pastes so a pasted newline can't submit

A paste of <=64KB is handed to xterm's term.paste(), which rewrites newlines to
CR and wraps in ESC[200~/ESC[201~ only when its parser observed DECSET 2004.
Windows ConPTY never forwards that mode, so an unbracketed pasted newline
reaches a TUI agent as Enter and submits the draft parked in its composer.

The existing force-bracket guard keyed on isWindowsUserAgent() - the client's
platform - but the ConPTY can be on a remote host, so the guard was off in
exactly the configuration that needs it. Gate on the pane's own TUI agent
instead, applied to all four paste entry points; middle-click primary selection
had no force flag at all.

The agent status row is retained deliberately and is not lifecycle-driven, so
it can outlive the agent. Veto on shell-confirmed foreground (OSC 133;D) and on
a row rehydrated across an app restart. The freshness TTL and a state check are
both unusable here - an idle-but-live agent sits at done and still needs
bracketing.

Refs STA-4294
This commit is contained in:
Brennan Benson
2026-08-13 22:18:53 -07:00
parent 5a6837fce4
commit 0d8259e2af
4 changed files with 246 additions and 4 deletions
@@ -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<TerminalPasteSource, 'keyboard' | 'paste-event'>,
@@ -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(
@@ -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> = {}): AgentStatusEntry {
return {
state: 'waiting',
prompt: '',
updatedAt: 0,
stateStartedAt: 0,
stateHistory: [],
agentType: 'codex',
paneKey: AGENT_PANE_KEY,
...overrides
}
}
function foregroundEntry(
overrides: Partial<PaneForegroundAgentEntry> = {}
): PaneForegroundAgentEntry {
return { agent: null, shellForeground: false, ...overrides }
}
const CODEX_ON_AGENT_LEAF = { [AGENT_PANE_KEY]: agentEntry() }
function decide(
args: Partial<Parameters<typeof shouldForceBracketedMultilinePasteForPane>[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')
})
})
@@ -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<string, AgentStatusEntry>
paneForegroundAgentByPaneKey: Record<string, PaneForegroundAgentEntry>
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)
}
@@ -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.'),