Reliably deliver the issue prompt into opencode's composer (#6798)
This commit is contained in:
parent
f6db7897d7
commit
973360cfce
|
|
@ -184,6 +184,7 @@ import {
|
|||
resolveTuiAgentLaunchEnv
|
||||
} from '../../shared/tui-agent-launch-defaults'
|
||||
import { isTuiAgent, TUI_AGENT_CONFIG } from '../../shared/tui-agent-config'
|
||||
import { createDraftPasteReadyScanner } from '../../shared/draft-paste-ready-scanner'
|
||||
import { detectInstalledAgentsWithShellPathHydration, detectRemoteAgents } from '../ipc/preflight'
|
||||
import {
|
||||
markCodexProjectTrusted,
|
||||
|
|
@ -1072,8 +1073,6 @@ function getAgentLaunchPlatformForRepo(
|
|||
|
||||
const FOREGROUND_AGENT_WRAPPER_RETRY_INTERVAL_MS = 150
|
||||
const FOREGROUND_AGENT_WRAPPER_RETRY_TIMEOUT_MS = 6_500
|
||||
const DECSET_BRACKETED_PASTE = '\x1b[?2004h'
|
||||
const CODEX_COMPOSER_PROMPT = '›'
|
||||
const BRACKETED_PASTE_BEGIN = '\x1b[200~'
|
||||
const BRACKETED_PASTE_END = '\x1b[201~'
|
||||
const BRACKETED_PASTE_QUIET_MS = 1500
|
||||
|
|
@ -12303,9 +12302,7 @@ export class OrcaRuntimeService {
|
|||
TUI_AGENT_CONFIG[agent].draftPasteReadySignal ?? 'render-quiet-after-bracketed-paste'
|
||||
return new Promise<string | null>((resolve) => {
|
||||
let settled = false
|
||||
let recent = ''
|
||||
let postHandshakeRecent = ''
|
||||
let saw2004 = false
|
||||
const scanner = createDraftPasteReadyScanner(readySignal)
|
||||
let quietTimer: NodeJS.Timeout | null = null
|
||||
let hardTimer: NodeJS.Timeout | null = null
|
||||
let unsubscribe: (() => void) | null = null
|
||||
|
|
@ -12333,36 +12330,12 @@ export class OrcaRuntimeService {
|
|||
}
|
||||
|
||||
const observeData = (data: string): void => {
|
||||
const combined = recent + data
|
||||
recent = combined.slice(-512)
|
||||
if (!saw2004) {
|
||||
const markerIndex = combined.indexOf(DECSET_BRACKETED_PASTE)
|
||||
if (markerIndex === -1) {
|
||||
return
|
||||
}
|
||||
saw2004 = true
|
||||
const postHandshakeChunk = combined.slice(markerIndex + DECSET_BRACKETED_PASTE.length)
|
||||
if (readySignal === 'codex-composer-prompt') {
|
||||
if (postHandshakeChunk.includes(CODEX_COMPOSER_PROMPT)) {
|
||||
finish(ptyId)
|
||||
return
|
||||
}
|
||||
postHandshakeRecent = postHandshakeChunk.slice(-512)
|
||||
return
|
||||
}
|
||||
postHandshakeRecent = postHandshakeChunk.slice(-512)
|
||||
} else {
|
||||
if (
|
||||
readySignal === 'codex-composer-prompt' &&
|
||||
(data.includes(CODEX_COMPOSER_PROMPT) ||
|
||||
(postHandshakeRecent + data).includes(CODEX_COMPOSER_PROMPT))
|
||||
) {
|
||||
finish(ptyId)
|
||||
return
|
||||
}
|
||||
postHandshakeRecent = (postHandshakeRecent + data).slice(-512)
|
||||
const { ready, armQuietTimer: shouldArm } = scanner.observe(data)
|
||||
if (ready) {
|
||||
finish(ptyId)
|
||||
return
|
||||
}
|
||||
if (readySignal !== 'codex-composer-prompt' && saw2004) {
|
||||
if (shouldArm) {
|
||||
armQuietTimer()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,9 +3,8 @@ import type { GlobalSettings } from '../../../shared/types'
|
|||
import { subscribeToPtyData } from '@/components/terminal-pane/pty-data-sidecar-subscriptions'
|
||||
import { isRemoteRuntimePtyId } from '@/runtime/runtime-terminal-inspection'
|
||||
import { subscribeToRuntimeTerminalData } from '@/runtime/runtime-terminal-stream'
|
||||
import { createDraftPasteReadyScanner } from '../../../shared/draft-paste-ready-scanner'
|
||||
|
||||
const DECSET_BRACKETED_PASTE = '\x1b[?2004h'
|
||||
const CODEX_COMPOSER_PROMPT = '›'
|
||||
const BRACKETED_PASTE_QUIET_MS = 1500
|
||||
|
||||
/**
|
||||
|
|
@ -26,9 +25,7 @@ export function waitForAgentDraftInputReady(
|
|||
): Promise<boolean> {
|
||||
return new Promise<boolean>((resolve) => {
|
||||
let settled = false
|
||||
let recent = ''
|
||||
let postHandshakeRecent = ''
|
||||
let saw2004 = false
|
||||
const scanner = createDraftPasteReadyScanner(readySignal)
|
||||
let quietTimer: number | null = null
|
||||
let hardTimer: number | null = null
|
||||
let unsubscribe: (() => void) | null = null
|
||||
|
|
@ -56,41 +53,12 @@ export function waitForAgentDraftInputReady(
|
|||
}
|
||||
|
||||
const observeData = (data: string): void => {
|
||||
// Why: 512 bytes covers split escape sequences and Codex's styled prompt
|
||||
// without retaining a large terminal scrollback copy.
|
||||
const combined = recent + data
|
||||
recent = combined.slice(-512)
|
||||
if (!saw2004) {
|
||||
const markerIndex = combined.indexOf(DECSET_BRACKETED_PASTE)
|
||||
if (markerIndex === -1) {
|
||||
return
|
||||
}
|
||||
saw2004 = true
|
||||
const postHandshakeChunk = combined.slice(markerIndex + DECSET_BRACKETED_PASTE.length)
|
||||
if (readySignal === 'codex-composer-prompt') {
|
||||
if (postHandshakeChunk.includes(CODEX_COMPOSER_PROMPT)) {
|
||||
finish(true)
|
||||
return
|
||||
}
|
||||
postHandshakeRecent = postHandshakeChunk.slice(-512)
|
||||
return
|
||||
}
|
||||
postHandshakeRecent = postHandshakeChunk.slice(-512)
|
||||
} else {
|
||||
if (
|
||||
readySignal === 'codex-composer-prompt' &&
|
||||
(data.includes(CODEX_COMPOSER_PROMPT) ||
|
||||
(postHandshakeRecent + data).includes(CODEX_COMPOSER_PROMPT))
|
||||
) {
|
||||
finish(true)
|
||||
return
|
||||
}
|
||||
postHandshakeRecent = (postHandshakeRecent + data).slice(-512)
|
||||
}
|
||||
if (readySignal === 'codex-composer-prompt') {
|
||||
const { ready, armQuietTimer: shouldArm } = scanner.observe(data)
|
||||
if (ready) {
|
||||
finish(true)
|
||||
return
|
||||
}
|
||||
if (saw2004) {
|
||||
if (shouldArm) {
|
||||
armQuietTimer()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -52,6 +52,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 ISSUE_URL = 'https://github.com/stablyai/orca/issues/123'
|
||||
const PASTED_ISSUE_URL = `\x1b[200~${ISSUE_URL}\x1b[201~`
|
||||
|
|
@ -146,7 +147,7 @@ describe('pasteDraftWhenAgentReady', () => {
|
|||
const promise = pasteDraftWhenAgentReady({
|
||||
tabId: 'tab-1',
|
||||
content: ISSUE_URL,
|
||||
agent: 'opencode'
|
||||
agent: 'gemini'
|
||||
})
|
||||
await flushMicrotasks()
|
||||
|
||||
|
|
@ -167,6 +168,156 @@ describe('pasteDraftWhenAgentReady', () => {
|
|||
)
|
||||
})
|
||||
|
||||
it('pastes into opencode as soon as show-cursor renders after bracketed paste is enabled', async () => {
|
||||
const promise = pasteDraftWhenAgentReady({
|
||||
tabId: 'tab-1',
|
||||
content: ISSUE_URL,
|
||||
agent: 'opencode'
|
||||
})
|
||||
await flushMicrotasks()
|
||||
|
||||
testState.ptyObserver?.(DECSET_BRACKETED_PASTE)
|
||||
await flushMicrotasks()
|
||||
expect(testState.sendRuntimePtyInputVerified).not.toHaveBeenCalled()
|
||||
|
||||
testState.ptyObserver?.(SHOW_CURSOR)
|
||||
|
||||
await expect(promise).resolves.toBe(true)
|
||||
expect(testState.sendRuntimePtyInputVerified).toHaveBeenCalledWith(
|
||||
{},
|
||||
'pty-1',
|
||||
PASTED_ISSUE_URL
|
||||
)
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('detects opencode show-cursor inside a large first render chunk', async () => {
|
||||
const promise = pasteDraftWhenAgentReady({
|
||||
tabId: 'tab-1',
|
||||
content: ISSUE_URL,
|
||||
agent: 'opencode'
|
||||
})
|
||||
await flushMicrotasks()
|
||||
|
||||
testState.ptyObserver?.(`${DECSET_BRACKETED_PASTE}${SHOW_CURSOR}${'x'.repeat(900)}`)
|
||||
|
||||
await expect(promise).resolves.toBe(true)
|
||||
expect(testState.sendRuntimePtyInputVerified).toHaveBeenCalledWith(
|
||||
{},
|
||||
'pty-1',
|
||||
PASTED_ISSUE_URL
|
||||
)
|
||||
})
|
||||
|
||||
it('detects opencode show-cursor split across a later chunk', async () => {
|
||||
const promise = pasteDraftWhenAgentReady({
|
||||
tabId: 'tab-1',
|
||||
content: ISSUE_URL,
|
||||
agent: 'opencode'
|
||||
})
|
||||
await flushMicrotasks()
|
||||
|
||||
testState.ptyObserver?.(DECSET_BRACKETED_PASTE)
|
||||
await flushMicrotasks()
|
||||
testState.ptyObserver?.('render noise \x1b[?')
|
||||
await flushMicrotasks()
|
||||
expect(testState.sendRuntimePtyInputVerified).not.toHaveBeenCalled()
|
||||
|
||||
testState.ptyObserver?.('25h')
|
||||
|
||||
await expect(promise).resolves.toBe(true)
|
||||
expect(testState.sendRuntimePtyInputVerified).toHaveBeenCalledWith(
|
||||
{},
|
||||
'pty-1',
|
||||
PASTED_ISSUE_URL
|
||||
)
|
||||
})
|
||||
|
||||
it('rescues opencode delivery under never-settling output churn', async () => {
|
||||
const promise = pasteDraftWhenAgentReady({
|
||||
tabId: 'tab-1',
|
||||
content: ISSUE_URL,
|
||||
agent: 'opencode'
|
||||
})
|
||||
await flushMicrotasks()
|
||||
|
||||
testState.ptyObserver?.(DECSET_BRACKETED_PASTE)
|
||||
await flushMicrotasks()
|
||||
for (let index = 0; index < 5; index += 1) {
|
||||
await vi.advanceTimersByTimeAsync(1499)
|
||||
testState.ptyObserver?.(`setup output ${index}`)
|
||||
await flushMicrotasks()
|
||||
expect(testState.sendRuntimePtyInputVerified).not.toHaveBeenCalled()
|
||||
}
|
||||
|
||||
testState.ptyObserver?.(SHOW_CURSOR)
|
||||
|
||||
await expect(promise).resolves.toBe(true)
|
||||
expect(testState.sendRuntimePtyInputVerified).toHaveBeenCalledWith(
|
||||
{},
|
||||
'pty-1',
|
||||
PASTED_ISSUE_URL
|
||||
)
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('does not paste on the quiet window for opencode (it never arms one)', async () => {
|
||||
// Why: opencode is silent for ~1.5-2s between enabling bracketed paste and
|
||||
// mounting its composer. A quiet window would fire during that gap and paste
|
||||
// before the composer exists (the original bug), so the cursor signal must
|
||||
// not arm one. With process inspection failing, delivery times out instead.
|
||||
testState.inspectRuntimeTerminalProcess.mockResolvedValue(null)
|
||||
const onTimeout = vi.fn()
|
||||
const promise = pasteDraftWhenAgentReady({
|
||||
tabId: 'tab-1',
|
||||
content: ISSUE_URL,
|
||||
agent: 'opencode',
|
||||
onTimeout
|
||||
})
|
||||
await flushMicrotasks()
|
||||
|
||||
testState.ptyObserver?.(DECSET_BRACKETED_PASTE)
|
||||
await flushMicrotasks()
|
||||
|
||||
// Quiet-window duration elapses with no show-cursor: must NOT paste.
|
||||
await vi.advanceTimersByTimeAsync(1500)
|
||||
await flushMicrotasks()
|
||||
expect(testState.sendRuntimePtyInputVerified).not.toHaveBeenCalled()
|
||||
|
||||
// Only the hard timeout (and failed process check) resolves it — to false.
|
||||
await vi.advanceTimersByTimeAsync(8000)
|
||||
await flushMicrotasks(5)
|
||||
await vi.advanceTimersByTimeAsync(1000)
|
||||
await expect(promise).resolves.toBe(false)
|
||||
expect(testState.sendRuntimePtyInputVerified).not.toHaveBeenCalled()
|
||||
expect(onTimeout).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('best-effort pastes for opencode at the hard timeout when its process is running', async () => {
|
||||
// Why: with no quiet window, the hard-timeout process-ownership check is the
|
||||
// backstop if show-cursor is somehow missed — same model as Codex.
|
||||
testState.inspectRuntimeTerminalProcess.mockResolvedValue({
|
||||
foregroundProcess: 'opencode',
|
||||
hasChildProcesses: false
|
||||
})
|
||||
const promise = pasteDraftWhenAgentReady({
|
||||
tabId: 'tab-1',
|
||||
content: ISSUE_URL,
|
||||
agent: 'opencode'
|
||||
})
|
||||
await flushMicrotasks()
|
||||
|
||||
testState.ptyObserver?.(DECSET_BRACKETED_PASTE)
|
||||
await vi.advanceTimersByTimeAsync(8000)
|
||||
|
||||
await expect(promise).resolves.toBe(true)
|
||||
expect(testState.sendRuntimePtyInputVerified).toHaveBeenCalledWith(
|
||||
{},
|
||||
'pty-1',
|
||||
PASTED_ISSUE_URL
|
||||
)
|
||||
})
|
||||
|
||||
it('does not paste for agents that already use native draft prefill', async () => {
|
||||
await expect(
|
||||
pasteDraftWhenAgentReady({
|
||||
|
|
|
|||
|
|
@ -0,0 +1,138 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import { createDraftPasteReadyScanner } from './draft-paste-ready-scanner'
|
||||
|
||||
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'
|
||||
|
||||
describe('createDraftPasteReadyScanner', () => {
|
||||
describe('render-cursor-after-bracketed-paste (opencode / mimo-code)', () => {
|
||||
it('is ready when show-cursor renders after bracketed paste in one chunk', () => {
|
||||
const scanner = createDraftPasteReadyScanner('render-cursor-after-bracketed-paste')
|
||||
expect(scanner.observe(`${DECSET_BRACKETED_PASTE}${SHOW_CURSOR}`)).toEqual({
|
||||
ready: true,
|
||||
armQuietTimer: false
|
||||
})
|
||||
})
|
||||
|
||||
it('does not fire on bracketed paste alone, then fires once show-cursor arrives', () => {
|
||||
const scanner = createDraftPasteReadyScanner('render-cursor-after-bracketed-paste')
|
||||
// Why: opencode enables bracketed paste ~1.5-2s before its composer mounts
|
||||
// and stays SILENT in between. The cursor gates delivery and must NOT arm
|
||||
// the quiet window, which would otherwise fire during that silent gap and
|
||||
// paste before the composer exists.
|
||||
expect(scanner.observe(DECSET_BRACKETED_PASTE)).toEqual({
|
||||
ready: false,
|
||||
armQuietTimer: false
|
||||
})
|
||||
expect(scanner.observe('startup banner output')).toEqual({
|
||||
ready: false,
|
||||
armQuietTimer: false
|
||||
})
|
||||
expect(scanner.observe(SHOW_CURSOR)).toEqual({ ready: true, armQuietTimer: false })
|
||||
})
|
||||
|
||||
it('resolves from a single replayed buffer holding both markers (SSH/remote replay path)', () => {
|
||||
// Why: the runtime waiter feeds recentPtyOutputById as one observe() call
|
||||
// when the agent emitted 2004 + show-cursor before the subscription
|
||||
// attached; a single combined buffer must still resolve.
|
||||
const scanner = createDraftPasteReadyScanner('render-cursor-after-bracketed-paste')
|
||||
expect(
|
||||
scanner.observe(`banner\n${DECSET_BRACKETED_PASTE}composer\n${SHOW_CURSOR}rest`)
|
||||
).toEqual({ ready: true, armQuietTimer: false })
|
||||
})
|
||||
|
||||
it('detects a bracketed-paste handshake split across a chunk boundary', () => {
|
||||
// Why: the pre-handshake `recent` ring must reassemble a \x1b[?2004h that
|
||||
// straddles two PTY packets, or cursor-gated readiness breaks for
|
||||
// fragmented startup output.
|
||||
const scanner = createDraftPasteReadyScanner('render-cursor-after-bracketed-paste')
|
||||
expect(scanner.observe('\x1b[?20')).toEqual({ ready: false, armQuietTimer: false })
|
||||
expect(scanner.observe('04h')).toEqual({ ready: false, armQuietTimer: false })
|
||||
expect(scanner.observe(SHOW_CURSOR)).toEqual({ ready: true, armQuietTimer: false })
|
||||
})
|
||||
|
||||
it('detects show-cursor split across a later chunk boundary', () => {
|
||||
const scanner = createDraftPasteReadyScanner('render-cursor-after-bracketed-paste')
|
||||
scanner.observe(DECSET_BRACKETED_PASTE)
|
||||
// The escape sequence is split mid-bytes across two separate chunks.
|
||||
expect(scanner.observe('render noise \x1b[?')).toEqual({ ready: false, armQuietTimer: false })
|
||||
expect(scanner.observe('25h')).toEqual({ ready: true, armQuietTimer: false })
|
||||
})
|
||||
|
||||
it('never arms the quiet window during the silent pre-composer gap', () => {
|
||||
const scanner = createDraftPasteReadyScanner('render-cursor-after-bracketed-paste')
|
||||
scanner.observe(DECSET_BRACKETED_PASTE)
|
||||
// Why: opencode is silent here; arming the quiet window would fire before
|
||||
// the composer mounts and pre-empt the cursor signal (the original bug).
|
||||
// Delivery waits for show-cursor, bounded by the caller's hard timeout.
|
||||
for (let i = 0; i < 5; i += 1) {
|
||||
expect(scanner.observe(`setup output ${i}`)).toEqual({ ready: false, armQuietTimer: false })
|
||||
}
|
||||
})
|
||||
|
||||
it('does not treat hide-cursor as the ready signal', () => {
|
||||
const scanner = createDraftPasteReadyScanner('render-cursor-after-bracketed-paste')
|
||||
// \x1b[?25l (hide) must not be mistaken for \x1b[?25h (show).
|
||||
expect(scanner.observe(`${DECSET_BRACKETED_PASTE}${HIDE_CURSOR}`)).toEqual({
|
||||
ready: false,
|
||||
armQuietTimer: false
|
||||
})
|
||||
})
|
||||
|
||||
it('ignores show-cursor that appears before bracketed paste is enabled', () => {
|
||||
const scanner = createDraftPasteReadyScanner('render-cursor-after-bracketed-paste')
|
||||
// A pre-handshake cursor toggle must not trip readiness.
|
||||
expect(scanner.observe(SHOW_CURSOR)).toEqual({ ready: false, armQuietTimer: false })
|
||||
expect(scanner.observe(DECSET_BRACKETED_PASTE)).toEqual({
|
||||
ready: false,
|
||||
armQuietTimer: false
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('codex-composer-prompt (unchanged behavior)', () => {
|
||||
it('is ready on the composer glyph after bracketed paste and never arms the quiet timer', () => {
|
||||
const scanner = createDraftPasteReadyScanner('codex-composer-prompt')
|
||||
expect(scanner.observe(DECSET_BRACKETED_PASTE)).toEqual({
|
||||
ready: false,
|
||||
armQuietTimer: false
|
||||
})
|
||||
expect(scanner.observe(CODEX_PROMPT)).toEqual({ ready: true, armQuietTimer: false })
|
||||
})
|
||||
|
||||
it('detects the composer glyph inside a large first render chunk', () => {
|
||||
const scanner = createDraftPasteReadyScanner('codex-composer-prompt')
|
||||
expect(scanner.observe(`${DECSET_BRACKETED_PASTE}${CODEX_PROMPT}${'x'.repeat(900)}`)).toEqual(
|
||||
{ ready: true, armQuietTimer: false }
|
||||
)
|
||||
})
|
||||
|
||||
it('never arms the quiet-window fallback', () => {
|
||||
const scanner = createDraftPasteReadyScanner('codex-composer-prompt')
|
||||
expect(scanner.observe(DECSET_BRACKETED_PASTE)).toEqual({
|
||||
ready: false,
|
||||
armQuietTimer: false
|
||||
})
|
||||
expect(scanner.observe('noise')).toEqual({ ready: false, armQuietTimer: false })
|
||||
})
|
||||
})
|
||||
|
||||
describe('render-quiet-after-bracketed-paste (default)', () => {
|
||||
it('arms the quiet timer after bracketed paste and never reports a signal', () => {
|
||||
const scanner = createDraftPasteReadyScanner('render-quiet-after-bracketed-paste')
|
||||
expect(scanner.observe(DECSET_BRACKETED_PASTE)).toEqual({ ready: false, armQuietTimer: true })
|
||||
// Show-cursor is not a signal for the default path; it just keeps arming.
|
||||
expect(scanner.observe(SHOW_CURSOR)).toEqual({ ready: false, armQuietTimer: true })
|
||||
})
|
||||
|
||||
it('does nothing until bracketed paste is enabled', () => {
|
||||
const scanner = createDraftPasteReadyScanner('render-quiet-after-bracketed-paste')
|
||||
expect(scanner.observe('pre-handshake output')).toEqual({
|
||||
ready: false,
|
||||
armQuietTimer: false
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,94 @@
|
|||
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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -8,7 +8,10 @@ export type AgentPromptInjectionMode =
|
|||
| 'flag-interactive'
|
||||
| 'stdin-after-start'
|
||||
|
||||
export type DraftPasteReadySignal = 'render-quiet-after-bracketed-paste' | 'codex-composer-prompt'
|
||||
export type DraftPasteReadySignal =
|
||||
| 'render-quiet-after-bracketed-paste'
|
||||
| 'codex-composer-prompt'
|
||||
| 'render-cursor-after-bracketed-paste'
|
||||
|
||||
export type TuiAgentConfig = {
|
||||
detectCmd: string
|
||||
|
|
@ -114,13 +117,20 @@ export const TUI_AGENT_CONFIG: Record<TuiAgent, TuiAgentConfig> = {
|
|||
detectCmd: 'opencode',
|
||||
launchCmd: 'opencode',
|
||||
expectedProcess: 'opencode',
|
||||
promptInjectionMode: 'flag-prompt'
|
||||
promptInjectionMode: 'flag-prompt',
|
||||
// Why: opencode enables bracketed paste before its composer mounts; wait
|
||||
// for post-\x1b[?2004h show-cursor (\x1b[?25h) so paste hits mounted input.
|
||||
draftPasteReadySignal: 'render-cursor-after-bracketed-paste'
|
||||
},
|
||||
'mimo-code': {
|
||||
detectCmd: 'mimo',
|
||||
launchCmd: 'mimo',
|
||||
expectedProcess: 'mimo',
|
||||
promptInjectionMode: 'flag-prompt'
|
||||
promptInjectionMode: 'flag-prompt',
|
||||
// Why: mimo-code shares opencode's flag-prompt paste route, so it gets the
|
||||
// same cursor-gated signal by parity (its startup stream is not separately
|
||||
// validated); the quiet-window fallback bounds the risk if it differs.
|
||||
draftPasteReadySignal: 'render-cursor-after-bracketed-paste'
|
||||
},
|
||||
pi: {
|
||||
detectCmd: 'pi',
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import {
|
|||
buildAgentStartupPlan,
|
||||
buildShellCommandFromArgv
|
||||
} from './tui-agent-startup'
|
||||
import { TUI_AGENT_CONFIG } from './tui-agent-config'
|
||||
import { normalizeTuiAgentArgsRecord, resolveTuiAgentLaunchArgs } from './tui-agent-launch-defaults'
|
||||
|
||||
describe('tui agent startup plans', () => {
|
||||
|
|
@ -336,6 +337,37 @@ describe('tui agent startup plans', () => {
|
|||
expect(plan?.launchCommand).toBe("opencode --prompt 'fix it'")
|
||||
})
|
||||
|
||||
it('keeps opencode and mimo-code on the cursor-gated paste draft route', () => {
|
||||
expect(TUI_AGENT_CONFIG.opencode.draftPasteReadySignal).toBe(
|
||||
'render-cursor-after-bracketed-paste'
|
||||
)
|
||||
expect(TUI_AGENT_CONFIG.opencode.draftPromptFlag).toBeUndefined()
|
||||
expect(TUI_AGENT_CONFIG.opencode.draftPromptEnvVar).toBeUndefined()
|
||||
expect(TUI_AGENT_CONFIG['mimo-code'].draftPasteReadySignal).toBe(
|
||||
'render-cursor-after-bracketed-paste'
|
||||
)
|
||||
expect(TUI_AGENT_CONFIG['mimo-code'].draftPromptFlag).toBeUndefined()
|
||||
expect(TUI_AGENT_CONFIG['mimo-code'].draftPromptEnvVar).toBeUndefined()
|
||||
// Why: no native draft launch plan means both agents fall through to the
|
||||
// cursor-gated paste-after-ready route, where the new signal applies.
|
||||
expect(
|
||||
buildAgentDraftLaunchPlan({
|
||||
agent: 'opencode',
|
||||
draft: 'x',
|
||||
cmdOverrides: {},
|
||||
platform: 'darwin'
|
||||
})
|
||||
).toBeNull()
|
||||
expect(
|
||||
buildAgentDraftLaunchPlan({
|
||||
agent: 'mimo-code',
|
||||
draft: 'x',
|
||||
cmdOverrides: {},
|
||||
platform: 'darwin'
|
||||
})
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('appends Kiro trust defaults to the chat subcommand that accepts them', () => {
|
||||
const plan = buildAgentStartupPlan({
|
||||
agent: 'kiro',
|
||||
|
|
|
|||
Loading…
Reference in New Issue