diff --git a/src/renderer/src/lib/agent-draft-readiness.test.ts b/src/renderer/src/lib/agent-draft-readiness.test.ts index 13470e88bb1..fe0ce62c4a9 100644 --- a/src/renderer/src/lib/agent-draft-readiness.test.ts +++ b/src/renderer/src/lib/agent-draft-readiness.test.ts @@ -23,7 +23,7 @@ vi.mock('@/runtime/runtime-terminal-inspection', () => ({ })) const PTY_ID = 'pty-buffered-codex' -const CODEX_COMPOSER = '\x1b[1m›\x1b[0m Ask Codex to do anything' +const CODEX_COMPOSER = '\x1b[?1049h\x1b[1m›\x1b[0m Implement {feature}' const DECSET_BRACKETED_PASTE = '\x1b[?2004h' describe('waitForAgentDraftInputReady', () => { diff --git a/src/renderer/src/lib/agent-paste-draft.test.ts b/src/renderer/src/lib/agent-paste-draft.test.ts index ff78abd44ab..a09c88901c4 100644 --- a/src/renderer/src/lib/agent-paste-draft.test.ts +++ b/src/renderer/src/lib/agent-paste-draft.test.ts @@ -67,6 +67,7 @@ vi.mock('@/runtime/runtime-terminal-stream', () => ({ const DECSET_BRACKETED_PASTE = '\x1b[?2004h' const SHOW_CURSOR = '\x1b[?25h' const CODEX_COMPOSER_PROMPT_RENDER = '\x1b[1m›\x1b[0m Ask Codex to do anything' +const CODEX_DYNAMIC_COMPOSER_PROMPT_RENDER = '\x1b[?1049h\x1b[1m›\x1b[0m Implement {feature}' const ISSUE_URL = 'https://github.com/stablyai/orca/issues/123' const PASTED_ISSUE_URL = `\x1b[200~${ISSUE_URL}\x1b[201~` @@ -111,7 +112,7 @@ describe('pasteDraftWhenAgentReady', () => { vi.useRealTimers() }) - it('pastes into Codex once its composer and bracketed paste are both ready', async () => { + it('pastes into Codex as soon as its composer prompt renders after bracketed paste is enabled', async () => { const promise = pasteDraftWhenAgentReady({ tabId: 'tab-1', content: ISSUE_URL, @@ -124,6 +125,10 @@ describe('pasteDraftWhenAgentReady', () => { expect(testState.sendRuntimePtyInputVerified).not.toHaveBeenCalled() testState.ptyObserver?.(DECSET_BRACKETED_PASTE) + await flushMicrotasks() + expect(testState.sendRuntimePtyInputVerified).not.toHaveBeenCalled() + + testState.ptyObserver?.(CODEX_COMPOSER_PROMPT_RENDER) await expect(promise).resolves.toBe(true) expect(testState.sendRuntimePtyInputVerified).toHaveBeenCalledWith( @@ -138,7 +143,7 @@ describe('pasteDraftWhenAgentReady', () => { testState.appState.ptyIdsByTabId = {} testState.replayPreHandlerPtyData.mockImplementation( (_ptyId: string, observer: (data: string) => void) => { - observer(CODEX_COMPOSER_PROMPT_RENDER) + observer(CODEX_DYNAMIC_COMPOSER_PROMPT_RENDER) observer(DECSET_BRACKETED_PASTE) } ) diff --git a/src/shared/draft-paste-ready-scanner.test.ts b/src/shared/draft-paste-ready-scanner.test.ts index d6437869bce..5f6a3537fb0 100644 --- a/src/shared/draft-paste-ready-scanner.test.ts +++ b/src/shared/draft-paste-ready-scanner.test.ts @@ -5,6 +5,9 @@ const DECSET_BRACKETED_PASTE = '\x1b[?2004h' const SHOW_CURSOR = '\x1b[?25h' const HIDE_CURSOR = '\x1b[?25l' const CODEX_PROMPT = '\x1b[1m›\x1b[0m Ask Codex to do anything' +const CODEX_DYNAMIC_PROMPT = '\x1b[1m›\x1b[0m Implement {feature}' +const ALT_SCREEN_ENTER = '\x1b[?1049h' +const ALT_SCREEN_LEAVE = '\x1b[?1049l' const GROK_ALT_SCREEN_ENTER = '\x1b[?1049h\x1b[?2004h\x1b[?25l' const GROK_ALT_SCREEN_LEAVE = '\x1b[?1049l\x1b[?25h' const GROK_COMPOSER_FRAME = '\x1b[38;2;80;80;88m│\x1b[38;2;200;200;200m❯ \x1b[0m' @@ -114,13 +117,25 @@ describe('createDraftPasteReadyScanner', () => { it('is ready when Codex renders its composer before enabling bracketed paste', () => { const scanner = createDraftPasteReadyScanner('codex-composer-prompt') - expect(scanner.observe(CODEX_PROMPT)).toEqual({ ready: false, armQuietTimer: false }) + expect(scanner.observe(`${ALT_SCREEN_ENTER}${CODEX_DYNAMIC_PROMPT}`)).toEqual({ + ready: false, + armQuietTimer: false + }) expect(scanner.observe(DECSET_BRACKETED_PASTE)).toEqual({ ready: true, armQuietTimer: false }) }) + it('forgets a pre-anchor glyph when Codex leaves the alternate screen', () => { + const scanner = createDraftPasteReadyScanner('codex-composer-prompt') + scanner.observe(`${ALT_SCREEN_ENTER}${CODEX_DYNAMIC_PROMPT}${ALT_SCREEN_LEAVE}`) + expect(scanner.observe(DECSET_BRACKETED_PASTE)).toEqual({ + ready: false, + armQuietTimer: false + }) + }) + it('ignores a stale shell glyph before bracketed paste is enabled', () => { const scanner = createDraftPasteReadyScanner('codex-composer-prompt') expect(scanner.observe('› codex\r\nstartup output')).toEqual({ diff --git a/src/shared/draft-paste-ready-scanner.ts b/src/shared/draft-paste-ready-scanner.ts index aba9f7e80ee..0b0e104e54a 100644 --- a/src/shared/draft-paste-ready-scanner.ts +++ b/src/shared/draft-paste-ready-scanner.ts @@ -5,7 +5,6 @@ import type { DraftPasteReadySignal } from './tui-agent-config' // "input is ready" moment per agent instead of guessing from output silence. const DECSET_BRACKETED_PASTE = '\x1b[?2004h' const CODEX_COMPOSER_PROMPT = '›' -const CODEX_COMPOSER_PLACEHOLDER = 'Ask Codex to do anything' // Why: opencode emits the DECTCEM show-cursor only once the composer row is // mounted and the text cursor is placed in it — a "composer ready" signal, // analogous to Codex's prompt glyph. It fires ~2s after bracketed paste is @@ -89,8 +88,8 @@ export type DraftPasteReadyScanResult = { * * Per agent signal: * - `codex-composer-prompt`: ready when the `›` glyph renders after DECSET - * 2004, or when DECSET immediately follows Codex's composer placeholder; - * never arms the quiet window. + * 2004, or when DECSET follows a glyph rendered while Codex owns the + * alternate screen; never arms the quiet window. * - `render-cursor-after-bracketed-paste`: ready when DECTCEM show-cursor * (`\x1b[?25h`) renders after DECSET 2004. Like Codex it does NOT arm the * quiet window: opencode stays silent for ~1.5-2s between enabling @@ -124,8 +123,11 @@ export function createDraftPasteReadyScanner(readySignal: DraftPasteReadySignal) let recent = '' let postAnchorRecent = '' let anchorCarry = '' + let codexCarry = '' let sawMarkerAnchor = false let sawQuietAnchor = false + let codexAltScreen = false + let sawCodexPromptInAltScreen = false const { markerAnchor, @@ -169,6 +171,37 @@ export function createDraftPasteReadyScanner(readySignal: DraftPasteReadySignal) return false } + const scanCodexPreAnchorPrompt = (data: string): void => { + const window = codexCarry + data + codexCarry = window.slice(-ANCHOR_CARRY_CHARS) + let cursor = 0 + while (cursor < window.length) { + const enterIndex = window.indexOf(DECSET_ALT_SCREEN, cursor) + const leaveIndex = window.indexOf(DECRST_ALT_SCREEN, cursor) + const promptIndex = window.indexOf(CODEX_COMPOSER_PROMPT, cursor) + const nextIndex = Math.min( + ...[enterIndex, leaveIndex, promptIndex].filter((index) => index !== -1) + ) + if (!Number.isFinite(nextIndex)) { + return + } + if (nextIndex === enterIndex) { + codexAltScreen = true + sawCodexPromptInAltScreen = false + cursor = nextIndex + DECSET_ALT_SCREEN.length + } else if (nextIndex === leaveIndex) { + codexAltScreen = false + sawCodexPromptInAltScreen = false + cursor = nextIndex + DECRST_ALT_SCREEN.length + } else { + if (codexAltScreen) { + sawCodexPromptInAltScreen = true + } + cursor = nextIndex + CODEX_COMPOSER_PROMPT.length + } + } + } + return { observe(data: string): DraftPasteReadyScanResult { const combined = recent + data @@ -176,6 +209,9 @@ export function createDraftPasteReadyScanner(readySignal: DraftPasteReadySignal) if (!sawQuietAnchor && quietAnchor !== null && combined.includes(quietAnchor)) { sawQuietAnchor = true } + if (readySignal === 'codex-composer-prompt' && !sawMarkerAnchor) { + scanCodexPreAnchorPrompt(data) + } if (signalMarker !== null && markerAnchor !== null) { if (markerAnchorEnd !== null) { // Why: carry only the bytes an anchor could straddle, so already-scanned @@ -189,12 +225,7 @@ export function createDraftPasteReadyScanner(readySignal: DraftPasteReadySignal) const anchorIndex = combined.indexOf(markerAnchor) if (anchorIndex !== -1) { sawMarkerAnchor = true - const preAnchorChunk = combined.slice(Math.max(0, anchorIndex - 512), anchorIndex) - if ( - readySignal === 'codex-composer-prompt' && - preAnchorChunk.includes(CODEX_COMPOSER_PROMPT) && - preAnchorChunk.includes(CODEX_COMPOSER_PLACEHOLDER) - ) { + if (readySignal === 'codex-composer-prompt' && sawCodexPromptInAltScreen) { return { ready: true, armQuietTimer: false } } const postAnchorChunk = combined.slice(anchorIndex + markerAnchor.length)