mirror of
https://github.com/stablyai/orca.git
synced 2026-10-22 00:03:47 +00:00
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:
@@ -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.'),
|
||||
|
||||
Reference in New Issue
Block a user