fix(agent-launch): anchor early Codex prompt to TUI screen

This commit is contained in:
Brennan Benson
2026-08-12 21:39:03 -07:00
parent ad3dc0247f
commit 5d84b991a0
4 changed files with 64 additions and 13 deletions
@@ -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', () => {
@@ -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)
}
)
+16 -1
View File
@@ -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({
+40 -9
View File
@@ -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)