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:
Neil
2026-05-04 22:19:51 -07:00
co-authored by Orca
parent 0afc44395a
commit 624efcbed9
3 changed files with 154 additions and 37 deletions
+138 -21
View File
@@ -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.'
+11 -15
View File
@@ -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',