fix(antigravity): wait for agy's composer before pasting continuation context (#38939)

* fix(antigravity): wait for agy's composer before pasting continuation context

The renderer's paste gate armed its 1.5s quiet window on any bracketed-paste enable, including the launching shell's own prompt, so a slow agy start received the handoff in cooked mode before its TUI ran. Wait for agy's '? for shortcuts' composer row while agy holds bracketed paste instead.

* fix(antigravity): accept agy's composer rows when a custom statusLine hides the hint

A command statusLine replaces agy's '? for shortcuts' row, so Continue in New Session waited the full 20s and pasted blind, and a CLI launch prompt burned 20s of its 60s. Also accept the rule / '>' / rule composer rows (the layout #38930 reads), still anchored to agy's own 2004h and revoked by the shell's 2004l. Checked against agy 1.5.2 captures: custom statusLine and default layouts fire; the trust dialog and signed-out startup do not.

* fix(antigravity): offer back pre-ready typed text the composer wait drops; move scanner tests

The agy composer wait has no quiet fallback, so a slow start now hits the
20s timeout and drops held type-ahead. The drop notice now carries a Copy
action with the typed text, and still shows when the user switched
worktrees, so it is never lost silently.

Move the composer-rows scanner cases into draft-paste-ready-scanner.test.ts
since main removed antigravity-readiness-transcripts.test.ts (#38973).

* chore(plugins): regenerate declarative testing bundle for antigravity config change
This commit is contained in:
Neil
2026-11-06 12:54:24 -08:00
committed by GitHub
parent a20e577dc9
commit c8e06e5e30
8 changed files with 82 additions and 12 deletions
File diff suppressed because one or more lines are too long
@@ -32,11 +32,13 @@ const {
toastInfo,
waitForAgentDraftInputReady,
sendAgentDraftPasteContent,
onPasteTimeout
onPasteTimeout,
pasteTimeoutNotice
} = vi.hoisted(() => ({
waitForAgentDraftInputReady: vi.fn(),
sendAgentDraftPasteContent: vi.fn(async () => true),
onPasteTimeout: vi.fn(),
pasteTimeoutNotice: vi.fn(),
notifyCodexPaneBoundForStaleSweep: vi.fn(),
scheduleRuntimeGraphSync: vi.fn(),
shouldSeedCacheTimerOnInitialTitle: vi.fn(() => false),
@@ -103,7 +105,10 @@ vi.mock('./pty-transport/remote-runtime/remote-runtime-pty-transport', () => ({
vi.mock('@/lib/agent-draft-paste-content', () => ({ sendAgentDraftPasteContent }))
vi.mock('@/lib/agent-session/launch/prompt/launch-agent-paste-timeout-notice', () => ({
createPasteReadinessTimeoutNotice: () => ({ onTimeout: onPasteTimeout, wasNotified: () => false })
createPasteReadinessTimeoutNotice: (args: unknown) => {
pasteTimeoutNotice(args)
return { onTimeout: onPasteTimeout, wasNotified: () => false }
}
}))
vi.mock('@/store', () => ({
@@ -303,6 +308,10 @@ describe('agent pane keys typed before its PTY binds', () => {
expect(sendAgentDraftPasteContent).not.toHaveBeenCalled()
expect(onPasteTimeout).toHaveBeenCalledOnce()
// The typed text exists nowhere else, so the notice must carry it back to the user.
expect(pasteTimeoutNotice).toHaveBeenCalledWith(
expect.objectContaining({ droppedText: 'hello' })
)
pane.dispose()
})
@@ -108,15 +108,16 @@ export function installAgentPreReadyTypedDraft(session: ConnectPanePtySession):
}
const dropDraft = (draftAgent: TuiAgent): void => {
const dropped = draft !== ''
const droppedText = draft
draft = ''
phase = 'closed'
if (dropped && !session.disposed) {
if (droppedText !== '' && !session.disposed) {
createPasteReadinessTimeoutNotice({
worktreeId: session.deps.worktreeId,
tabId: session.deps.tabId,
agent: draftAgent,
submitted: false
submitted: false,
droppedText
}).onTimeout()
}
}
@@ -11,6 +11,8 @@ export function createPasteReadinessTimeoutNotice(args: {
tabId: string
agent: TuiAgent
submitted: boolean
/** Text the user typed that only lives here; the toast offers it back instead of losing it. */
droppedText?: string
}): { onTimeout: () => void; wasNotified: () => boolean } {
let notified = false
return {
@@ -24,7 +26,7 @@ export function createPasteReadinessTimeoutNotice(args: {
// Why: PTY never spawned = genuine launch failure; stay silent so the caller emits the sole notice.
return
}
if (!currentTab || state.activeWorktreeId !== args.worktreeId) {
if (!currentTab || (state.activeWorktreeId !== args.worktreeId && !args.droppedText)) {
// Why: user cancelled (closed tab / switched worktrees); mark notified so the deferred caller suppresses its toast too.
notified = true
return
@@ -34,7 +36,8 @@ export function createPasteReadinessTimeoutNotice(args: {
'auto.lib.launch.agent.in.new.tab.a5a1f7033f',
"Your {{value0}} wasn't sent — paste it once the agent is ready.",
{ value0: args.submitted ? 'prompt' : 'notes' }
)
),
args.droppedText ? copyDroppedTextAction(args.droppedText) : undefined
)
notified = true
track('agent_error', {
@@ -44,3 +47,14 @@ export function createPasteReadinessTimeoutNotice(args: {
}
}
}
function copyDroppedTextAction(text: string): { action: { label: string; onClick: () => void } } {
return {
action: {
label: translate('menu.copy', 'Copy'),
onClick: () => {
void window.api.ui.writeClipboardText(text)
}
}
}
}
@@ -15,6 +15,7 @@ export type DraftPasteMarkerSignal =
| 'dsh-composer-prompt'
| 'claude-composer-prompt'
| 'zcode-composer-prompt'
| 'antigravity-composer-prompt'
/** Submit signals may also ask for a grace (`readyAfterMs`); only the paste waiters honour it. */
export type DraftPasteReadySignal = DraftPasteMarkerSignal | 'opencode-agent-row'
@@ -166,6 +166,9 @@ const TUI_AGENT_CONFIG_SOURCE: Record<TuiAgent, TuiAgentConfigSource> = {
promptInjectionMode: 'flag-prompt-interactive',
// Antigravity trust is exact-path, so every child workspace can show a menu that consumes paste; pre-write trust (agent-trust-presets.ts).
preflightTrust: 'antigravity',
// The shell's own 2004h armed the quiet window, so a slow agy start got the paste in cooked mode (#18088).
draftPasteReadySignal: 'antigravity-composer-prompt',
draftPasteReadyTimeoutMs: 20_000,
// agy 1.2.x expands collapsed multiline pastes over seconds; byte ingest finishing does not prove submit readiness.
submitLineSettleMsPerLine: 45
},
@@ -52,4 +52,29 @@ describe('createDraftPasteReadyScanner', () => {
expect(graced.observe(DECRST_BRACKETED_PASTE).readyAfterMs).toBeNull()
})
})
describe('antigravity-composer-prompt', () => {
it('fires on the composer rows when a custom statusLine replaces the hint row', () => {
const rule = `\x1b[38;2;65;72;104m${'─'.repeat(80)}\x1b[m\r\n`
// agy 1.5.2 with a two-line command statusLine, after its startup splash.
const startup = `${DECSET_BRACKETED_PASTE}${ALT_SCREEN_ENTER} ⣾ Signing in...\r\ncustom-status-one\x1b[?1049l\r\n\n${rule}\x1b[38;2;122;162;247m>\x1b[m\r\n${rule}custom-status-one\r\ncustom-status-two\r\x1b[3A\x1b[2C${SHOW_CURSOR}`
expect(
createDraftPasteReadyScanner('antigravity-composer-prompt').observe(startup).ready
).toBe(true)
expect(
createDraftPasteReadyScanner('antigravity-composer-prompt').observe(
startup.replace(DECSET_BRACKETED_PASTE, '')
).ready
).toBe(false)
})
it('never arms the quiet window from the shell prompt that launches agy (#18088)', () => {
const scanner = createDraftPasteReadyScanner('antigravity-composer-prompt')
// zsh enables paste at its prompt, echoes the command, then disables paste before exec.
expect(
scanner.observe(`${DECSET_BRACKETED_PASTE}% agy '--dangerously-skip-permissions'`)
).toEqual({ ready: false, armQuietTimer: false })
expect(scanner.observe(`${DECRST_BRACKETED_PASTE}\r\r\n? for shortcuts`).ready).toBe(false)
})
})
})
+21 -4
View File
@@ -22,6 +22,11 @@ const CLAUDE_COMPOSER_PROMPT = '❯\u00a0'
// only while idle at its prompt, where a `❯` + NBSP glyph is unlikely.
const DECSET_COLOR_PALETTE_UPDATES = '\x1b[?2031h'
const DECRST_COLOR_PALETTE_UPDATES = '\x1b[?2031l'
const SGR_RUN = '(?:\\x1b\\[[0-9;:]*m)*'
// agy's hint row, or its rule / `>` / rule composer rows, which a custom statusLine keeps after replacing the hint (#38930).
const ANTIGRAVITY_COMPOSER_READY = new RegExp(
`\\? for shortcuts|─{8}${SGR_RUN}\\r?\\n${SGR_RUN}>(?:\\s+[A-Za-z][A-Za-z-]*\\s+mode:[^\\n]*)?${SGR_RUN}\\r?\\n${SGR_RUN}─{8}`
)
const DECSET_ALT_SCREEN = '\x1b[?1049h'
const DECRST_ALT_SCREEN = '\x1b[?1049l'
@@ -31,7 +36,7 @@ type DraftPasteReadySignalSpec = {
/** Bytes that revoke `markerAnchor` again, for anchors that describe a mode the agent can leave. */
markerAnchorEnd: string | null
/** Composer-ready marker, or null for signals that only use the quiet window. */
marker: string | null
marker: string | RegExp | null
/** Bytes that arm the quiet-window fallback, or null when the signal has none. */
quietAnchor: string | null
}
@@ -86,6 +91,14 @@ const DRAFT_PASTE_READY_SIGNALS: Record<SingleSignal, DraftPasteReadySignalSpec>
// ZCode banner animates indefinitely; keep quiet fallback for inline builds whose alt-screen marker never arms.
quietAnchor: DECSET_BRACKETED_PASTE
},
// agy's composer; its trust dialog, sign-in menu and a shell prompt never draw it. No quiet fallback:
// the shell prompt's 2004h would arm it before agy starts. Evidence: agy 1.2.14 and 1.5.2 startups.
'antigravity-composer-prompt': {
markerAnchor: DECSET_BRACKETED_PASTE,
markerAnchorEnd: DECRST_BRACKETED_PASTE,
marker: ANTIGRAVITY_COMPOSER_READY,
quietAnchor: null
},
'render-quiet-after-bracketed-paste': {
markerAnchor: null,
markerAnchorEnd: null,
@@ -182,7 +195,7 @@ function createSingleSignalScanner(readySignal: SingleSignal): {
const segmentStart = Math.max(cursor, carriedLength)
const segment =
leaveIndex === -1 ? window.slice(segmentStart) : window.slice(segmentStart, leaveIndex)
if ((postAnchorRecent + segment).includes(signalMarker ?? '')) {
if (signalMarker !== null && containsMarker(postAnchorRecent + segment, signalMarker)) {
return true
}
if (leaveIndex === -1) {
@@ -217,13 +230,13 @@ function createSingleSignalScanner(readySignal: SingleSignal): {
if (anchorIndex !== -1) {
sawMarkerAnchor = true
const postAnchorChunk = combined.slice(anchorIndex + markerAnchor.length)
if (postAnchorChunk.includes(signalMarker)) {
if (containsMarker(postAnchorChunk, signalMarker)) {
return { ready: true, armQuietTimer: false }
}
postAnchorRecent = postAnchorChunk.slice(-512)
}
} else {
if (data.includes(signalMarker) || (postAnchorRecent + data).includes(signalMarker)) {
if (containsMarker(postAnchorRecent + data, signalMarker)) {
return { ready: true, armQuietTimer: false }
}
postAnchorRecent = (postAnchorRecent + data).slice(-512)
@@ -234,3 +247,7 @@ function createSingleSignalScanner(readySignal: SingleSignal): {
}
}
}
function containsMarker(text: string, marker: string | RegExp): boolean {
return typeof marker === 'string' ? text.includes(marker) : marker.test(text)
}