diff --git a/src/renderer/src/components/terminal-pane/pty-connection.test.ts b/src/renderer/src/components/terminal-pane/pty-connection.test.ts index 0e4bcdded..6c72e259c 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -4593,7 +4593,7 @@ describe('connectPanePty', () => { } }) - it('pastes a startup draft when Codex renders its composer in the first observed output', async () => { + it('orders a startup draft behind xterm focus input when Codex renders its composer', async () => { const { connectPanePty } = await import('./pty-connection') const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } @@ -4626,13 +4626,27 @@ describe('connectPanePty', () => { await flushAsyncTicks() expect(capturedDataCallback.current).not.toBeNull() + // A focused xterm emits CSI I after Codex enables focus reporting. The + // startup draft must use the same transport instead of racing a direct IPC. + ;( + pane.terminal.onData as unknown as { + mock: { calls: [(data: string) => void][] } + } + ).mock.calls[0]?.[0]('\x1b[I') + ;(mockStoreState.recordTerminalInput as ReturnType).mockClear() capturedDataCallback.current?.('\x1b[?2004h\x1b[2K› ') await flushAsyncTicks() - expect(window.api.pty.writeAccepted).toHaveBeenCalledWith( - 'pty-codex', + expect(transport.sendInputAccepted).toHaveBeenCalledWith( '\x1b[200~https://github.com/stablyai/orca/issues/42\x1b[201~' ) + expect(transport.sendInput.mock.calls.map(([data]) => data)).toEqual([ + '\x1b[I', + '\x1b[200~https://github.com/stablyai/orca/issues/42\x1b[201~' + ]) + expect(window.api.pty.writeAccepted).not.toHaveBeenCalled() + expect(mockStoreState.recordTerminalInput).toHaveBeenCalledOnce() + expect(mockStoreState.recordTerminalInput).toHaveBeenCalledWith(makePaneKey('tab-1', LEAF_1)) }) it('does not consume startup draft delivery before deferred connect starts', async () => { diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index ae7781a9f..ae752d77d 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -219,6 +219,7 @@ import { isWslUncPath } from '../../../../shared/wsl-paths' import { isTuiAgent, TUI_AGENT_CONFIG } from '../../../../shared/tui-agent-config' import { createDraftPasteReadyScanner } from '../../../../shared/draft-paste-ready-scanner' import { sendAgentDraftPasteContent } from '@/lib/agent-draft-paste-content' +import { writeTerminalPastePtyInput } from './terminal-pty-paste-writer' import { beginAgentStartupDeliveryAttempt, releaseAgentStartupDeliveryAttempt @@ -3938,6 +3939,7 @@ export function connectPanePty( let startupDraftReadinessArmed = false let startupDraftPasteSettled = !ownsStartupDraftPaste let startupDraftPasteInFlight = false + let startupDraftInputRecorded = false let startupDraftQuietTimer: ReturnType | null = null let startupDraftHardTimer: ReturnType | null = null const clearStartupDraftPasteTimers = (): void => { @@ -3981,7 +3983,18 @@ export function connectPanePty( startupDraftPasteAttempted = true cleanupStartupDraftPasteTimers() const settings = getSettingsForWorktreeRuntimeOwner(useAppStore.getState(), deps.worktreeId) - void sendAgentDraftPasteContent(settings, ptyId, startupDraftPrompt) + // Why: xterm focus reports share this transport queue. Bypassing it can + // race CSI I against the draft on ConPTY and expose a literal `[I` prefix. + void sendAgentDraftPasteContent(settings, ptyId, startupDraftPrompt, async (data) => { + const accepted = await writeTerminalPastePtyInput(transport, data) + if (accepted && !startupDraftInputRecorded) { + // Why: this transport write bypasses xterm's user-input signal; keep + // the composed draft from being discarded by later hibernation. + startupDraftInputRecorded = true + recordTerminalInputForHibernation() + } + return accepted + }) .catch(() => false) .finally(() => { startupDraftPasteInFlight = false diff --git a/src/renderer/src/lib/agent-draft-paste-content.ts b/src/renderer/src/lib/agent-draft-paste-content.ts index b1019b7bc..5bfe4bc5a 100644 --- a/src/renderer/src/lib/agent-draft-paste-content.ts +++ b/src/renderer/src/lib/agent-draft-paste-content.ts @@ -16,10 +16,13 @@ const AGENT_DRAFT_PASTE_ESCAPE_CODE_POINT = 0x1b const AGENT_DRAFT_PASTE_INERT_ESCAPE_CODE_POINT = 0x241b const AGENT_DRAFT_PASTE_INERT_ESCAPE = '\u241b' +export type AgentDraftPtyInputWriter = (data: string) => boolean | Promise + export async function sendAgentDraftPasteContent( settings: Pick | null | undefined, ptyId: string, - content: string + content: string, + writePty?: AgentDraftPtyInputWriter ): Promise { if (content.length > AGENT_DRAFT_PASTE_MAX_BYTES) { return false @@ -29,10 +32,11 @@ export async function sendAgentDraftPasteContent( stopAfterBytes: AGENT_DRAFT_PASTE_DIRECT_MAX_BYTES }) if (!directMeasurement.exceededLimit) { - return await sendRuntimePtyInputVerified( + return await writeAgentDraftPtyInput( settings, ptyId, - [BRACKETED_PASTE_START, sanitizeTerminalPasteText(content), BRACKETED_PASTE_END].join('') + [BRACKETED_PASTE_START, sanitizeTerminalPasteText(content), BRACKETED_PASTE_END].join(''), + writePty ) } @@ -46,16 +50,16 @@ export async function sendAgentDraftPasteContent( for (const chunk of iterateAgentDraftPasteContentChunks(content)) { let accepted = false try { - accepted = await sendRuntimePtyInputVerified(settings, ptyId, chunk) + accepted = await writeAgentDraftPtyInput(settings, ptyId, chunk, writePty) } catch { if (bracketedPasteOpen && chunk !== BRACKETED_PASTE_END) { - await closeAgentDraftBracketedPaste(settings, ptyId) + await closeAgentDraftBracketedPaste(settings, ptyId, writePty) } return false } if (!accepted) { if (bracketedPasteOpen && chunk !== BRACKETED_PASTE_END) { - await closeAgentDraftBracketedPaste(settings, ptyId) + await closeAgentDraftBracketedPaste(settings, ptyId, writePty) } return false } @@ -182,14 +186,24 @@ function yieldToAgentDraftPastePreflight(): Promise { return new Promise((resolve) => globalThis.setTimeout(resolve, 0)) } +async function writeAgentDraftPtyInput( + settings: Pick | null | undefined, + ptyId: string, + data: string, + writePty?: AgentDraftPtyInputWriter +): Promise { + return writePty ? await writePty(data) : await sendRuntimePtyInputVerified(settings, ptyId, data) +} + async function closeAgentDraftBracketedPaste( settings: Pick | null | undefined, - ptyId: string + ptyId: string, + writePty?: AgentDraftPtyInputWriter ): Promise { try { // Why: once the opener reached the PTY, a failed content chunk should not // leave the target TUI in bracketed-paste mode. - await sendRuntimePtyInputVerified(settings, ptyId, BRACKETED_PASTE_END) + await writeAgentDraftPtyInput(settings, ptyId, BRACKETED_PASTE_END, writePty) } catch { // The original write already failed; callers only need the paste to fail closed. }