refactor(agents): drop unused per-agent draft strategy abstraction

The TUI-ready heuristic in agent-paste-draft.ts works for every tested
agent (claude/codex/pi/opencode), so the AgentDraftInjectionStrategy
field, type-chars + bracketed-paste-slow code paths, and per-agent
overrides are dead. Keep the `agent` arg on pasteDraftWhenAgentReady
for future per-agent escape hatches without touching every call site.

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Neil
2026-05-04 22:56:21 -07:00
co-authored by Orca
parent 624efcbed9
commit 1d29e84ca7
2 changed files with 63 additions and 106 deletions
+63 -83
View File
@@ -1,48 +1,44 @@
import {
TUI_AGENT_CONFIG,
type AgentDraftInjectionStrategy
} from '../../../shared/tui-agent-config'
import type { TuiAgent } from '../../../shared/types'
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)
// treat the inserted text as a single atomic paste — they put it in their
// input buffer as a draft instead of echoing character-by-character or
// triggering line-edit shortcuts. Intentionally omit a trailing '\r' so the
// draft never auto-submits; the user gets to review and send themselves.
// Why: bracketed paste markers let modern TUIs (Claude Code / Codex / Pi /
// OpenCode / Gemini) treat the inserted text as a single atomic paste — the
// payload lands in the input buffer as a draft instead of echoing
// character-by-character or triggering line-edit shortcuts. Intentionally
// omit a trailing '\r' so the draft never auto-submits; the user reviews
// and sends the prompt themselves.
const BRACKETED_PASTE_BEGIN = '\x1b[200~'
const BRACKETED_PASTE_END = '\x1b[201~'
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.
// Why: empirical timings (node-pty + xterm-headless rig that simulates the
// new-workspace flow): Claude renders idle title at ~500ms, Pi at ~1s,
// Codex needs the input box ready at ~2.5s, OpenCode at ~3s. The floor
// guarantees we don't paste before the slowest known TUI is mounted; the
// stable-foreground signal handles TUIs (Codex) whose title never reads as
// idle because they show a working spinner indefinitely until user input.
const MIN_TUI_READY_MS = 2500
const STABLE_FG_DURATION_MS = 1500
const STABLE_TITLE_DURATION_MS = 800
const TITLE_IDLE_GRACE_MS = 200
const READINESS_TIMEOUT_MS = 12000
function resolveDraftStrategy(agent: TuiAgent | undefined): AgentDraftInjectionStrategy {
if (!agent) {
return 'bracketed-paste'
}
return TUI_AGENT_CONFIG[agent].draftInjectionStrategy ?? 'bracketed-paste'
}
/**
* Wait for the agent on `tabId` to be ready, then deliver `content` into its
* input buffer as a non-submitted draft. Strategy is per-agent (see
* `TUI_AGENT_CONFIG[agent].draftInjectionStrategy`); falls back to bracketed
* paste when the agent is not specified.
* Wait until the agent on `tabId` has a rendered, input-accepting TUI, then
* paste `content` into its input buffer using bracketed-paste mode. Never
* appends `\r`, so the draft stays editable for the user to review/append
* before sending.
*
* 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 the
* readiness budget.
* Returns true when the paste was issued, false on timeout or missing PTY.
* `onTimeout` lets the caller surface a UI hint (e.g. toast) when the agent
* doesn't reach a ready state inside `timeoutMs`.
*
* `agent` is currently informational only — kept on the call signature so
* future per-agent specializations (e.g. an `unsupported` opt-out) have a
* place to land without retouching every call site.
*/
export async function pasteDraftWhenAgentReady(args: {
tabId: string
@@ -52,11 +48,7 @@ export async function pasteDraftWhenAgentReady(args: {
timeoutMs?: number
onTimeout?: () => void
}): Promise<boolean> {
const { tabId, expectedProcess, content, agent, timeoutMs, onTimeout } = args
const strategy = resolveDraftStrategy(agent)
if (strategy === 'unsupported') {
return false
}
const { tabId, expectedProcess, content, timeoutMs, onTimeout } = args
const ready = await waitForTuiInputReady(tabId, expectedProcess, {
timeoutMs: timeoutMs ?? READINESS_TIMEOUT_MS
@@ -71,19 +63,6 @@ export async function pasteDraftWhenAgentReady(args: {
return false
}
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
}
@@ -91,26 +70,28 @@ export async function pasteDraftWhenAgentReady(args: {
/**
* 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).
* 1. Title parses as `idle` — the strongest signal; only a short grace
* is added before declaring ready.
* 2. Non-shell foreground process held for ≥STABLE_FG_DURATION_MS.
* 3. Hard floor of MIN_TUI_READY_MS to absorb slow renderers (OpenCode).
*
* On timeout, returns true only if a non-shell process is in the
* foreground — never paste into a bare shell.
*/
async function waitForTuiInputReady(
tabId: string,
expectedProcess: string,
opts: { timeoutMs: number }
): Promise<boolean> {
const deadline = Date.now() + opts.timeoutMs
const startedAt = Date.now()
const deadline = startedAt + opts.timeoutMs
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))
await sleep(POLL_INTERVAL_MS)
continue
}
@@ -124,22 +105,11 @@ async function waitForTuiInputReady(
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 fgIsNonShell = isAgentForeground(foreground, expectedProcess)
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))
if (titleIsIdle && elapsed >= TITLE_IDLE_GRACE_MS) {
await sleep(TITLE_IDLE_GRACE_MS)
return true
}
@@ -152,19 +122,20 @@ async function waitForTuiInputReady(
const fgStable =
firstNonShellFgAt !== null && Date.now() - firstNonShellFgAt >= STABLE_FG_DURATION_MS
const titleStable = firstNonEmptyTitleAt !== null && Date.now() - firstNonEmptyTitleAt >= 800
const titleStable =
firstNonEmptyTitleAt !== null && Date.now() - firstNonEmptyTitleAt >= STABLE_TITLE_DURATION_MS
const minimumWaitElapsed = elapsed >= MIN_TUI_READY_MS
if ((fgStable || titleStable) && minimumWaitElapsed) {
return true
}
await new Promise((resolve) => window.setTimeout(resolve, POLL_INTERVAL_MS))
await sleep(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.
// Why: timed out without a clean signal. Fall back to "non-shell foreground
// exists" so we don't blast the URL into a bare shell prompt if the agent
// failed to launch.
const ptyId = useAppStore.getState().ptyIdsByTabId[tabId]?.[0]
if (!ptyId) {
return false
@@ -177,6 +148,22 @@ async function waitForTuiInputReady(
}
}
// 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 agent foreground.
function isAgentForeground(foreground: string, expectedProcess: string): boolean {
if (foreground === '' || isShellProcess(foreground)) {
return false
}
return (
foreground === expectedProcess ||
foreground.startsWith(`${expectedProcess}.`) ||
foreground.endsWith(`/${expectedProcess}`) ||
foreground === 'node'
)
}
function collectPaneTitles(tabId: string): string[] {
const state = useAppStore.getState()
const titles: string[] = []
@@ -200,13 +187,6 @@ function collectPaneTitles(tabId: string): string[] {
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
// safe characters (no control codes, no tab/space/newline) so each char is
// a literal keypress with no accidental command-trigger semantics.
for (const char of content) {
window.api.pty.write(ptyId, char)
await new Promise((resolve) => window.setTimeout(resolve, CHAR_TYPING_DELAY_MS))
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => window.setTimeout(resolve, ms))
}
-23
View File
@@ -7,26 +7,11 @@ export type AgentPromptInjectionMode =
| 'flag-interactive'
| 'stdin-after-start'
// Why: how Orca should type a non-submitted draft (e.g. a linked work-item
// URL) into the agent's live input box. Different TUIs accept different
// signals — Claude reliably honors bracketed paste, Codex needs a longer
// settle period before the input is editable, Pi/OpenCode work best with
// per-character typing because they intercept paste markers themselves.
export type AgentDraftInjectionStrategy =
| 'bracketed-paste'
| 'bracketed-paste-slow'
| 'type-chars'
| 'unsupported'
export type TuiAgentConfig = {
detectCmd: string
launchCmd: string
expectedProcess: string
promptInjectionMode: AgentPromptInjectionMode
/** Defaults to 'bracketed-paste' when omitted. Set 'unsupported' for
* agents whose TUI ignores both bracketed paste and char-by-char input
* before user interaction (rare; surface a toast instead). */
draftInjectionStrategy?: AgentDraftInjectionStrategy
}
// Why: the new-workspace handoff depends on three pieces of per-agent
@@ -47,26 +32,18 @@ export const TUI_AGENT_CONFIG: Record<TuiAgent, TuiAgentConfig> = {
launchCmd: 'codex',
expectedProcess: 'codex',
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'
// 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'
// Default 'bracketed-paste'. Pi sets a `π - <user>` title once its TUI
// is rendered, which the title-idle path matches in <2s.
},
gemini: {
detectCmd: 'gemini',