fix(terminal): prevent focus prefix in startup drafts (#8433)
* fix(terminal): order startup drafts after focus input * fix(terminal): preserve startup draft activity
This commit is contained in:
parent
fcd60a03f8
commit
d5627638ab
|
|
@ -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<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 () => {
|
||||
|
|
|
|||
|
|
@ -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<typeof setTimeout> | null = null
|
||||
let startupDraftHardTimer: ReturnType<typeof setTimeout> | 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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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<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.
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue