From 4163cb9a05e22615de79a71cd884b8420d7cfeb0 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Thu, 30 Jul 2026 21:29:17 -0700 Subject: [PATCH] fix(terminal): serialize bracketed paste operations --- .../terminal-pane/terminal-paste-executor.ts | 10 ++++ .../terminal-paste-operation-order.test.ts | 57 +++++++++++++++++++ .../terminal-pty-input-transaction.ts | 27 +++++++++ .../src/lib/agent-draft-paste-content.ts | 13 +++++ 4 files changed, 107 insertions(+) create mode 100644 src/renderer/src/components/terminal-pane/terminal-paste-operation-order.test.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-pty-input-transaction.ts diff --git a/src/renderer/src/components/terminal-pane/terminal-paste-executor.ts b/src/renderer/src/components/terminal-pane/terminal-paste-executor.ts index ce3d14c9cf8..f194f71f171 100644 --- a/src/renderer/src/components/terminal-pane/terminal-paste-executor.ts +++ b/src/renderer/src/components/terminal-pane/terminal-paste-executor.ts @@ -6,6 +6,7 @@ import { TERMINAL_REMOTE_PASTE_OPERATION_TIMEOUT_MS } from './terminal-paste-limits' import { runTerminalPasteOperationWithTimeout } from './terminal-paste-operation-timeout' +import { runTerminalPtyInputTransaction } from './terminal-pty-input-transaction' import type { TerminalPasteExecutionReason, TerminalPasteExecutionResult, @@ -24,6 +25,15 @@ type ExecuteTerminalPastePlanArgs = { } export async function executeTerminalPastePlan( + plan: TerminalPastePlan, + args: ExecuteTerminalPastePlanArgs +): Promise { + return await runTerminalPtyInputTransaction(plan.target.ptyId, () => + executeTerminalPastePlanNow(plan, args) + ) +} + +async function executeTerminalPastePlanNow( plan: TerminalPastePlan, { pasteText, diff --git a/src/renderer/src/components/terminal-pane/terminal-paste-operation-order.test.ts b/src/renderer/src/components/terminal-pane/terminal-paste-operation-order.test.ts new file mode 100644 index 00000000000..cbc23e4b2eb --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-paste-operation-order.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from 'vitest' + +import { sendAgentDraftPasteContent } from '@/lib/agent-draft-paste-content' +import { + BRACKETED_PASTE_END, + BRACKETED_PASTE_START, + wrapTerminalBracketedPasteText +} from './terminal-bracketed-paste' +import { executeTerminalPastePlan, planTerminalPaste } from './terminal-paste-coordinator' + +describe('terminal paste operation ordering', () => { + it('keeps startup context outside an active chunked user paste frame', async () => { + const writes: string[] = [] + let startupDraft: Promise | null = null + const writePty = async (data: string): Promise => { + writes.push(data) + return true + } + const userPaste = planTerminalPaste({ + text: 'user-line-1\nuser-line-2', + source: 'keyboard', + target: { + kind: 'terminal', + paneId: 1, + leafId: 'leaf-1', + ptyId: 'pty-1', + runtime: { + platform: 'win32', + runtimeKey: 'local:win32', + kind: 'local', + isWindowsConpty: true + } + }, + terminalBracketedPasteMode: true, + maxDirectBytes: 4, + maxChunkBytes: 12 + }) + + const userPasteResult = await executeTerminalPastePlan(userPaste, { + pasteText: () => {}, + writePty, + yieldToEventLoop: async () => { + startupDraft ??= sendAgentDraftPasteContent(null, 'pty-1', 'GENERATED_CONTEXT', writePty) + } + }) + await startupDraft + + expect(userPasteResult.status).toBe('pasted') + expect(writes).toEqual([ + BRACKETED_PASTE_START, + 'user-line-1\r', + 'user-line-2', + BRACKETED_PASTE_END, + wrapTerminalBracketedPasteText('GENERATED_CONTEXT') + ]) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-pty-input-transaction.ts b/src/renderer/src/components/terminal-pane/terminal-pty-input-transaction.ts new file mode 100644 index 00000000000..8a4303bcc5f --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-pty-input-transaction.ts @@ -0,0 +1,27 @@ +const transactionTails = new Map>() + +export async function runTerminalPtyInputTransaction( + ptyId: string | null | undefined, + operation: () => Promise +): Promise { + if (!ptyId) { + return await operation() + } + + const previous = transactionTails.get(ptyId) ?? Promise.resolve() + let release!: () => void + const current = new Promise((resolve) => { + release = resolve + }) + transactionTails.set(ptyId, current) + + await previous + try { + return await operation() + } finally { + release() + if (transactionTails.get(ptyId) === current) { + transactionTails.delete(ptyId) + } + } +} diff --git a/src/renderer/src/lib/agent-draft-paste-content.ts b/src/renderer/src/lib/agent-draft-paste-content.ts index 6418038e1fe..525743897cb 100644 --- a/src/renderer/src/lib/agent-draft-paste-content.ts +++ b/src/renderer/src/lib/agent-draft-paste-content.ts @@ -6,6 +6,7 @@ import { normalizeTerminalPasteLineEndings, wrapTerminalBracketedPasteText } from '@/components/terminal-pane/terminal-bracketed-paste' +import { runTerminalPtyInputTransaction } from '@/components/terminal-pane/terminal-pty-input-transaction' import { sendRuntimePtyInputVerified } from '@/runtime/runtime-terminal-inspection' // Why: bracketed paste markers let supported TUIs treat generated prompt text @@ -26,6 +27,18 @@ export async function sendAgentDraftPasteContent( content: string, writePty?: AgentDraftPtyInputWriter, canContinue?: () => boolean +): Promise { + return await runTerminalPtyInputTransaction(ptyId, () => + sendAgentDraftPasteContentNow(settings, ptyId, content, writePty, canContinue) + ) +} + +async function sendAgentDraftPasteContentNow( + settings: Pick | null | undefined, + ptyId: string, + content: string, + writePty?: AgentDraftPtyInputWriter, + canContinue?: () => boolean ): Promise { if (content.length > AGENT_DRAFT_PASTE_MAX_BYTES || canContinue?.() === false) { return false