mirror of
https://github.com/stablyai/orca.git
synced 2026-10-13 00:02:54 +00:00
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:
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user