mirror of
https://github.com/stablyai/orca.git
synced 2026-10-22 00:03:47 +00:00
fix(agent-launch): anchor early Codex prompt to TUI screen
This commit is contained in:
@@ -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)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user