orca/src/shared/draft-paste-ready-scanner.ts

95 lines
4.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import type { DraftPasteReadySignal } from './tui-agent-config'
// Why: agents enable bracketed paste (DECSET 2004) before their composer is
// actually mounted/focused. These markers let the scanner detect the real
// "input is ready" moment per agent instead of guessing from output silence.
const DECSET_BRACKETED_PASTE = '\x1b[?2004h'
const CODEX_COMPOSER_PROMPT = ''
// 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
// enabled, so gating on it (instead of a quiet window) stops the paste from
// racing the composer mount under slow/noisy startup. mimo-code uses the same
// signal by parity; the quiet-window fallback covers any agent that differs.
const DECTCEM_SHOW_CURSOR = '\x1b[?25h'
export type DraftPasteReadyScanResult = {
/** The agent-specific ready signal fired — caller should deliver the paste now. */
ready: boolean
/** Caller should (re)arm the quiet-window fallback timer for this chunk. */
armQuietTimer: boolean
}
/**
* Pure, incremental scanner shared by the renderer and main-process draft-paste
* readiness waiters so the two delivery paths (desktop-local vs runtime/SSH/
* remote) cannot drift. It only parses the PTY byte stream; timers, the PTY
* subscription, and resolution stay with each caller because their transports
* and return types differ.
*
* Per agent signal:
* - `codex-composer-prompt`: ready when the `` glyph renders after DECSET
* 2004; never arms the quiet window (`armQuietTimer` stays false).
* - `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
* bracketed paste and mounting its composer, so a quiet window would fire
* during that gap and pre-empt the marker. opencode re-emits show-cursor on
* every render frame once mounted, so the marker is effectively guaranteed;
* the caller's hard timeout is the backstop if it never appears.
* - `render-quiet-after-bracketed-paste` (default): no signal marker; arms the
* quiet window once DECSET 2004 is seen.
*
* A 512-byte ring (`recent` / `postHandshakeRecent`) covers escape sequences
* split across chunk boundaries without retaining terminal scrollback.
*/
export function createDraftPasteReadyScanner(readySignal: DraftPasteReadySignal): {
observe: (data: string) => DraftPasteReadyScanResult
} {
let recent = ''
let postHandshakeRecent = ''
let saw2004 = false
const signalMarker =
readySignal === 'codex-composer-prompt'
? CODEX_COMPOSER_PROMPT
: readySignal === 'render-cursor-after-bracketed-paste'
? DECTCEM_SHOW_CURSOR
: null
return {
observe(data: string): DraftPasteReadyScanResult {
const combined = recent + data
recent = combined.slice(-512)
if (!saw2004) {
const markerIndex = combined.indexOf(DECSET_BRACKETED_PASTE)
if (markerIndex === -1) {
return { ready: false, armQuietTimer: false }
}
saw2004 = true
const postHandshakeChunk = combined.slice(markerIndex + DECSET_BRACKETED_PASTE.length)
if (signalMarker !== null && postHandshakeChunk.includes(signalMarker)) {
return { ready: true, armQuietTimer: false }
}
postHandshakeRecent = postHandshakeChunk.slice(-512)
} else {
if (
signalMarker !== null &&
(data.includes(signalMarker) || (postHandshakeRecent + data).includes(signalMarker))
) {
return { ready: true, armQuietTimer: false }
}
postHandshakeRecent = (postHandshakeRecent + data).slice(-512)
}
// Why: marker-based signals (Codex glyph, opencode show-cursor) must NOT
// arm the quiet window. opencode goes silent for ~1.5-2s between enabling
// bracketed paste and mounting its composer, so a quiet window would fire
// during that gap — before the composer exists — and pre-empt the marker.
// These signals wait for their marker, bounded only by the caller's hard
// timeout (and the caller's best-effort process-ownership paste after it).
// Only the default signal, which has no marker, uses the quiet window.
return { ready: false, armQuietTimer: signalMarker === null && saw2004 }
}
}
}