95 lines
4.4 KiB
TypeScript
95 lines
4.4 KiB
TypeScript
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 }
|
||
}
|
||
}
|
||
}
|