Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 17 additions & 3 deletions src/renderer/src/components/terminal-pane/pty-connection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down Expand Up @@ -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<typeof vi.fn>).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 () => {
Expand Down
15 changes: 14 additions & 1 deletion src/renderer/src/components/terminal-pane/pty-connection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -3938,6 +3939,7 @@ export function connectPanePty(
let startupDraftReadinessArmed = false
let startupDraftPasteSettled = !ownsStartupDraftPaste
let startupDraftPasteInFlight = false
let startupDraftInputRecorded = false
let startupDraftQuietTimer: ReturnType<typeof setTimeout> | null = null
let startupDraftHardTimer: ReturnType<typeof setTimeout> | null = null
const clearStartupDraftPasteTimers = (): void => {
Expand Down Expand Up @@ -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
Expand Down
30 changes: 22 additions & 8 deletions src/renderer/src/lib/agent-draft-paste-content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<boolean>

export async function sendAgentDraftPasteContent(
settings: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined,
ptyId: string,
content: string
content: string,
writePty?: AgentDraftPtyInputWriter
): Promise<boolean> {
if (content.length > AGENT_DRAFT_PASTE_MAX_BYTES) {
return false
Expand All @@ -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
)
}

Expand All @@ -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
}
Expand Down Expand Up @@ -182,14 +186,24 @@ function yieldToAgentDraftPastePreflight(): Promise<void> {
return new Promise((resolve) => globalThis.setTimeout(resolve, 0))
}

async function writeAgentDraftPtyInput(
settings: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined,
ptyId: string,
data: string,
writePty?: AgentDraftPtyInputWriter
): Promise<boolean> {
return writePty ? await writePty(data) : await sendRuntimePtyInputVerified(settings, ptyId, data)
}

async function closeAgentDraftBracketedPaste(
settings: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined,
ptyId: string
ptyId: string,
writePty?: AgentDraftPtyInputWriter
): Promise<void> {
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.
}
Expand Down
Loading