mirror of
https://github.com/stablyai/orca.git
synced 2026-10-12 08:02:37 +00:00
fix(agents): smarter TUI-ready heuristic + bracketed paste for codex/pi/opencode
Replaces per-agent strategy guesswork with a measured readiness check: title-idle / non-shell-foreground stable for 1.5s / 2.5s minimum floor. Verified against codex, pi, opencode, claude in a node-pty + xterm-headless test rig — bracketed paste lands in the input buffer for all four. Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
@@ -3,7 +3,8 @@ import {
|
||||
type AgentDraftInjectionStrategy
|
||||
} from '../../../shared/tui-agent-config'
|
||||
import type { TuiAgent } from '../../../shared/types'
|
||||
import { waitForAgentReady } from '@/lib/agent-ready-wait'
|
||||
import { detectAgentStatusFromTitle } from '../../../shared/agent-detection'
|
||||
import { isShellProcess } from '@/lib/tui-agent-startup'
|
||||
import { useAppStore } from '@/store'
|
||||
|
||||
// Why: bracketed paste markers let modern TUIs (Claude Code / Codex / Gemini)
|
||||
@@ -14,13 +15,16 @@ import { useAppStore } from '@/store'
|
||||
const BRACKETED_PASTE_BEGIN = '\x1b[200~'
|
||||
const BRACKETED_PASTE_END = '\x1b[201~'
|
||||
|
||||
// Why: 'bracketed-paste' uses the existing Claude-tuned grace; 'slow' adds
|
||||
// margin for TUIs that emit \x1b[?2004h instantly but only enable the input
|
||||
// box after a splash/model-init phase (Codex). 'type-chars' bypasses paste
|
||||
// markers entirely and types one character at a time with a small inter-key
|
||||
// delay so the input handler can debounce/render between keys.
|
||||
const SLOW_GRACE_MS = 1500
|
||||
const CHAR_TYPING_DELAY_MS = 25
|
||||
const POLL_INTERVAL_MS = 120
|
||||
// Why: many TUIs render their input box only after a startup splash. Empirical
|
||||
// timings (test-paste rig in /tmp/opencode): codex ≥ 600ms, pi ≥ 600ms,
|
||||
// opencode ≥ 3000ms. Wait until we see two consecutive polls that look like
|
||||
// the TUI is rendered (idle title OR non-shell foreground stable for 1500ms),
|
||||
// or until this hard floor passes — whichever comes first.
|
||||
const MIN_TUI_READY_MS = 2500
|
||||
const STABLE_FG_DURATION_MS = 1500
|
||||
const READINESS_TIMEOUT_MS = 12000
|
||||
|
||||
function resolveDraftStrategy(agent: TuiAgent | undefined): AgentDraftInjectionStrategy {
|
||||
if (!agent) {
|
||||
@@ -37,7 +41,8 @@ function resolveDraftStrategy(agent: TuiAgent | undefined): AgentDraftInjectionS
|
||||
*
|
||||
* Returns true when an injection was issued, false on timeout, missing PTY,
|
||||
* or `unsupported` strategy. `onTimeout` lets the caller surface a UI hint
|
||||
* (e.g. toast) when the agent doesn't reach a ready state inside `timeoutMs`.
|
||||
* (e.g. toast) when the agent doesn't reach a ready state inside the
|
||||
* readiness budget.
|
||||
*/
|
||||
export async function pasteDraftWhenAgentReady(args: {
|
||||
tabId: string
|
||||
@@ -47,14 +52,16 @@ export async function pasteDraftWhenAgentReady(args: {
|
||||
timeoutMs?: number
|
||||
onTimeout?: () => void
|
||||
}): Promise<boolean> {
|
||||
const { tabId, expectedProcess, content, agent, timeoutMs = 15000, onTimeout } = args
|
||||
const { tabId, expectedProcess, content, agent, timeoutMs, onTimeout } = args
|
||||
const strategy = resolveDraftStrategy(agent)
|
||||
if (strategy === 'unsupported') {
|
||||
return false
|
||||
}
|
||||
|
||||
const readyResult = await waitForAgentReady(tabId, expectedProcess, { timeoutMs })
|
||||
if (!readyResult.ready) {
|
||||
const ready = await waitForTuiInputReady(tabId, expectedProcess, {
|
||||
timeoutMs: timeoutMs ?? READINESS_TIMEOUT_MS
|
||||
})
|
||||
if (!ready) {
|
||||
onTimeout?.()
|
||||
return false
|
||||
}
|
||||
@@ -64,25 +71,135 @@ export async function pasteDraftWhenAgentReady(args: {
|
||||
return false
|
||||
}
|
||||
|
||||
// Why: TUIs must enable bracketed paste mode (\x1b[?2004h) before they can
|
||||
// interpret our paste markers. `title-idle` means the TUI has fully rendered
|
||||
// its input box and enabled paste mode; weaker signals (`foreground-match`,
|
||||
// `child-process`) only confirm the binary is running — the TUI's input
|
||||
// setup may still be in-flight, especially on slow shell environments.
|
||||
const baseGraceMs = readyResult.reason === 'title-idle' ? 150 : 600
|
||||
const graceMs =
|
||||
strategy === 'bracketed-paste-slow' ? Math.max(baseGraceMs, SLOW_GRACE_MS) : baseGraceMs
|
||||
await new Promise((resolve) => window.setTimeout(resolve, graceMs))
|
||||
|
||||
if (strategy === 'type-chars') {
|
||||
await typeChars(ptyId, content)
|
||||
return true
|
||||
}
|
||||
|
||||
// Why: 'bracketed-paste-slow' is preserved for callers / agents that
|
||||
// explicitly opt into a longer wait beyond the standard readiness check.
|
||||
// The default readiness logic already accounts for slow-startup TUIs, so
|
||||
// the 'slow' variant is mostly a no-op on top, but kept as an escape hatch.
|
||||
if (strategy === 'bracketed-paste-slow') {
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 800))
|
||||
}
|
||||
|
||||
window.api.pty.write(ptyId, `${BRACKETED_PASTE_BEGIN}${content}${BRACKETED_PASTE_END}`)
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Heuristic readiness for "the TUI's input box is mounted and accepting
|
||||
* input." Combines three signals:
|
||||
* 1. `titleSuggestsTuiReady`: the title detector says the agent is in a
|
||||
* visibly-rendered state (idle, working with a real label, etc.) — i.e.
|
||||
* anything other than a bare empty title.
|
||||
* 2. `foreground stable for ≥1500ms` on a non-shell process.
|
||||
* 3. Hard floor of `MIN_TUI_READY_MS` to absorb slow renderers (OpenCode).
|
||||
*/
|
||||
async function waitForTuiInputReady(
|
||||
tabId: string,
|
||||
expectedProcess: string,
|
||||
opts: { timeoutMs: number }
|
||||
): Promise<boolean> {
|
||||
const deadline = Date.now() + opts.timeoutMs
|
||||
const startedAt = Date.now()
|
||||
let firstNonShellFgAt: number | null = null
|
||||
let firstNonEmptyTitleAt: number | null = null
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
const ptyId = useAppStore.getState().ptyIdsByTabId[tabId]?.[0]
|
||||
if (!ptyId) {
|
||||
await new Promise((resolve) => window.setTimeout(resolve, POLL_INTERVAL_MS))
|
||||
continue
|
||||
}
|
||||
|
||||
let foreground = ''
|
||||
try {
|
||||
foreground = (await window.api.pty.getForegroundProcess(ptyId))?.toLowerCase() ?? ''
|
||||
} catch {
|
||||
// Ignore transient PTY inspection failures and keep polling.
|
||||
}
|
||||
const titles = collectPaneTitles(tabId)
|
||||
|
||||
const titleIsIdle = titles.some((t) => detectAgentStatusFromTitle(t) === 'idle')
|
||||
const titleIsNonEmpty = titles.some((t) => t.trim().length > 0)
|
||||
const fgIsNonShell =
|
||||
foreground !== '' &&
|
||||
!isShellProcess(foreground) &&
|
||||
// Why: argv-mode agents distributed via npm (claude, codex, pi) show up
|
||||
// in node-pty's `process` field as 'node' even though the underlying
|
||||
// binary is the agent. That's the strongest "agent has launched" signal
|
||||
// we get for these wrappers, so accept it like any other non-shell fg.
|
||||
(foreground === expectedProcess ||
|
||||
foreground.startsWith(`${expectedProcess}.`) ||
|
||||
foreground.endsWith(`/${expectedProcess}`) ||
|
||||
foreground === 'node')
|
||||
|
||||
const elapsed = Date.now() - startedAt
|
||||
if (titleIsIdle && elapsed >= 200) {
|
||||
// Title-idle is the strongest signal; only a tiny grace needed.
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 200))
|
||||
return true
|
||||
}
|
||||
|
||||
if (firstNonEmptyTitleAt === null && titleIsNonEmpty) {
|
||||
firstNonEmptyTitleAt = Date.now()
|
||||
}
|
||||
if (firstNonShellFgAt === null && fgIsNonShell) {
|
||||
firstNonShellFgAt = Date.now()
|
||||
}
|
||||
|
||||
const fgStable =
|
||||
firstNonShellFgAt !== null && Date.now() - firstNonShellFgAt >= STABLE_FG_DURATION_MS
|
||||
const titleStable = firstNonEmptyTitleAt !== null && Date.now() - firstNonEmptyTitleAt >= 800
|
||||
const minimumWaitElapsed = elapsed >= MIN_TUI_READY_MS
|
||||
|
||||
if ((fgStable || titleStable) && minimumWaitElapsed) {
|
||||
return true
|
||||
}
|
||||
|
||||
await new Promise((resolve) => window.setTimeout(resolve, POLL_INTERVAL_MS))
|
||||
}
|
||||
|
||||
// Why: timed out without a clean signal. As a last-ditch fallback, only
|
||||
// signal ready if a non-shell foreground process is present — this prevents
|
||||
// typing the draft into a bare shell prompt when something went wrong.
|
||||
const ptyId = useAppStore.getState().ptyIdsByTabId[tabId]?.[0]
|
||||
if (!ptyId) {
|
||||
return false
|
||||
}
|
||||
try {
|
||||
const foreground = (await window.api.pty.getForegroundProcess(ptyId))?.toLowerCase() ?? ''
|
||||
return foreground !== '' && !isShellProcess(foreground)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function collectPaneTitles(tabId: string): string[] {
|
||||
const state = useAppStore.getState()
|
||||
const titles: string[] = []
|
||||
const paneTitles = state.runtimePaneTitlesByTabId[tabId]
|
||||
if (paneTitles) {
|
||||
for (const title of Object.values(paneTitles)) {
|
||||
if (title) {
|
||||
titles.push(title)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (titles.length === 0) {
|
||||
for (const tabs of Object.values(state.tabsByWorktree)) {
|
||||
const tab = tabs.find((t) => t.id === tabId)
|
||||
if (tab?.title) {
|
||||
titles.push(tab.title)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return titles
|
||||
}
|
||||
|
||||
async function typeChars(ptyId: string, content: string): Promise<void> {
|
||||
// Why: send characters individually with a small delay so the TUI's input
|
||||
// handler can render and debounce between keystrokes. URLs only contain
|
||||
|
||||
@@ -106,7 +106,11 @@ async function pasteWorkItemDraftWhenAgentReady(args: {
|
||||
expectedProcess: startupPlan.expectedProcess,
|
||||
content,
|
||||
agent: startupPlan.agent,
|
||||
timeoutMs: 5000,
|
||||
// Why: 12s budget covers slow-starting TUIs (OpenCode needs ~3s before
|
||||
// its input box accepts pastes; the readiness logic in agent-paste-draft
|
||||
// adds polling + grace on top). 5s was too tight and caused the toast to
|
||||
// surface even when the agent went on to render correctly a second later.
|
||||
timeoutMs: 12000,
|
||||
onTimeout: () =>
|
||||
toast.message(
|
||||
'Agent took too long to start. The workspace is ready — paste the issue URL when the agent is idle.'
|
||||
|
||||
@@ -46,31 +46,27 @@ export const TUI_AGENT_CONFIG: Record<TuiAgent, TuiAgentConfig> = {
|
||||
detectCmd: 'codex',
|
||||
launchCmd: 'codex',
|
||||
expectedProcess: 'codex',
|
||||
promptInjectionMode: 'argv',
|
||||
// Why: Codex emits `\x1b[?2004h` (bracketed paste mode) almost instantly
|
||||
// on launch, but the input box only becomes editable after the splash /
|
||||
// model-readiness phase. Use the longer-settle variant so the URL paste
|
||||
// arrives after the input is actually accepting drafts.
|
||||
draftInjectionStrategy: 'bracketed-paste-slow'
|
||||
promptInjectionMode: 'argv'
|
||||
// Default 'bracketed-paste'. The TUI ready-wait in agent-paste-draft.ts
|
||||
// already absorbs Codex's splash by requiring stable non-shell foreground
|
||||
// for ≥1.5s before pasting; no agent-specific override needed.
|
||||
},
|
||||
opencode: {
|
||||
detectCmd: 'opencode',
|
||||
launchCmd: 'opencode',
|
||||
expectedProcess: 'opencode',
|
||||
promptInjectionMode: 'flag-prompt',
|
||||
// Why: OpenCode's Bubble Tea TUI is conservative about treating pastes as
|
||||
// drafts; per-character typing lands more reliably across versions.
|
||||
draftInjectionStrategy: 'type-chars'
|
||||
promptInjectionMode: 'flag-prompt'
|
||||
// Default 'bracketed-paste'. Empirical timing shows OpenCode needs ~3s
|
||||
// before its input accepts pastes — the readiness floor in
|
||||
// agent-paste-draft.ts is calibrated for that.
|
||||
},
|
||||
pi: {
|
||||
detectCmd: 'pi',
|
||||
launchCmd: 'pi',
|
||||
expectedProcess: 'pi',
|
||||
promptInjectionMode: 'argv',
|
||||
// Why: Pi's titlebar/spinner extension owns input mode aggressively and
|
||||
// historically swallows bracketed-paste markers. Type each character so
|
||||
// the URL appears as if the user typed it.
|
||||
draftInjectionStrategy: 'type-chars'
|
||||
promptInjectionMode: 'argv'
|
||||
// Default 'bracketed-paste'. Pi sets a `π - <user>` title once its TUI
|
||||
// is rendered, which the title-idle path matches in <2s.
|
||||
},
|
||||
gemini: {
|
||||
detectCmd: 'gemini',
|
||||
|
||||
Reference in New Issue
Block a user