From 624efcbed9e9fb4c843e85aadeea1ec853d413f4 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 4 May 2026 22:19:51 -0700 Subject: [PATCH] fix(agents): smarter TUI-ready heuristic + bracketed paste for codex/pi/opencode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/renderer/src/lib/agent-paste-draft.ts | 159 +++++++++++++++--- .../src/lib/launch-work-item-direct.ts | 6 +- src/shared/tui-agent-config.ts | 26 ++- 3 files changed, 154 insertions(+), 37 deletions(-) diff --git a/src/renderer/src/lib/agent-paste-draft.ts b/src/renderer/src/lib/agent-paste-draft.ts index 5996fc6b421..6699fa1685f 100644 --- a/src/renderer/src/lib/agent-paste-draft.ts +++ b/src/renderer/src/lib/agent-paste-draft.ts @@ -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 { - 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 { + 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 { // Why: send characters individually with a small delay so the TUI's input // handler can render and debounce between keystrokes. URLs only contain diff --git a/src/renderer/src/lib/launch-work-item-direct.ts b/src/renderer/src/lib/launch-work-item-direct.ts index de5effe8a69..de256abc72b 100644 --- a/src/renderer/src/lib/launch-work-item-direct.ts +++ b/src/renderer/src/lib/launch-work-item-direct.ts @@ -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.' diff --git a/src/shared/tui-agent-config.ts b/src/shared/tui-agent-config.ts index ad60ed76f44..d26de901aa9 100644 --- a/src/shared/tui-agent-config.ts +++ b/src/shared/tui-agent-config.ts @@ -46,31 +46,27 @@ export const TUI_AGENT_CONFIG: Record = { 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 `π - ` title once its TUI + // is rendered, which the title-idle path matches in <2s. }, gemini: { detectCmd: 'gemini',