From 28b8d69105191e4b24dfefa931a0f1a653b7f956 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 4 May 2026 20:58:21 -0700 Subject: [PATCH] fix(new-workspace): use bracketed-paste for draft URL injection so it actually appears in the agent input Co-authored-by: Orca --- src/renderer/src/lib/agent-paste-draft.ts | 50 ++++++++ .../src/lib/launch-work-item-direct.ts | 45 ++----- src/renderer/src/lib/new-workspace.ts | 120 +++++++++--------- 3 files changed, 120 insertions(+), 95 deletions(-) create mode 100644 src/renderer/src/lib/agent-paste-draft.ts diff --git a/src/renderer/src/lib/agent-paste-draft.ts b/src/renderer/src/lib/agent-paste-draft.ts new file mode 100644 index 00000000000..eca0a9ae58b --- /dev/null +++ b/src/renderer/src/lib/agent-paste-draft.ts @@ -0,0 +1,50 @@ +import { waitForAgentReady } from '@/lib/agent-ready-wait' +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. +const BRACKETED_PASTE_BEGIN = '\x1b[200~' +const BRACKETED_PASTE_END = '\x1b[201~' + +/** + * Wait for the agent on `tabId` to be ready, 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 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`. + */ +export async function pasteDraftWhenAgentReady(args: { + tabId: string + expectedProcess: string + content: string + timeoutMs?: number + onTimeout?: () => void +}): Promise { + const { tabId, expectedProcess, content, timeoutMs = 15000, onTimeout } = args + const readyResult = await waitForAgentReady(tabId, expectedProcess, { timeoutMs }) + if (!readyResult.ready) { + onTimeout?.() + return false + } + + const ptyId = useAppStore.getState().ptyIdsByTabId[tabId]?.[0] + if (!ptyId) { + 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 graceMs = readyResult.reason === 'title-idle' ? 150 : 600 + await new Promise((resolve) => window.setTimeout(resolve, graceMs)) + + window.api.pty.write(ptyId, `${BRACKETED_PASTE_BEGIN}${content}${BRACKETED_PASTE_END}`) + return true +} diff --git a/src/renderer/src/lib/launch-work-item-direct.ts b/src/renderer/src/lib/launch-work-item-direct.ts index daf74dbdbc8..08a9a8e57df 100644 --- a/src/renderer/src/lib/launch-work-item-direct.ts +++ b/src/renderer/src/lib/launch-work-item-direct.ts @@ -1,7 +1,7 @@ import { toast } from 'sonner' import { useAppStore } from '@/store' import { AGENT_CATALOG } from '@/lib/agent-catalog' -import { waitForAgentReady } from '@/lib/agent-ready-wait' +import { pasteDraftWhenAgentReady } from '@/lib/agent-paste-draft' import { buildAgentStartupPlan } from '@/lib/tui-agent-startup' import { activateAndRevealWorktree } from '@/lib/worktree-activation' import { @@ -30,13 +30,9 @@ export type LaunchableWorkItem = { linearIdentifier?: string } -// Why: bracketed paste markers let modern TUIs treat the inserted text as a -// single atomic paste — Claude Code / Codex / Gemini put it in their input -// buffer as a draft instead of echoing character-by-character. Intentionally -// omit a trailing '\r' so the draft never auto-submits; the user gets to -// review and send the prompt themselves. -const BRACKETED_PASTE_BEGIN = '\x1b[200~' -const BRACKETED_PASTE_END = '\x1b[201~' +// Why: bracketed paste markers and ready-wait grace timing live in +// agent-paste-draft.ts so the new-workspace and "Use" flows share one +// definition of "type into the agent's input as a non-submitted draft". export type LaunchWorkItemDirectArgs = { item: LaunchableWorkItem @@ -105,31 +101,16 @@ async function pasteWorkItemDraftWhenAgentReady(args: { content: string }): Promise { const { primaryTabId, startupPlan, content } = args - const readyResult = await waitForAgentReady(primaryTabId, startupPlan.expectedProcess, { - timeoutMs: 5000 + await pasteDraftWhenAgentReady({ + tabId: primaryTabId, + expectedProcess: startupPlan.expectedProcess, + content, + timeoutMs: 5000, + onTimeout: () => + toast.message( + 'Agent took too long to start. The workspace is ready — paste the issue URL when the agent is idle.' + ) }) - if (!readyResult.ready) { - toast.message( - 'Agent took too long to start. The workspace is ready — paste the issue URL when the agent is idle.' - ) - return - } - - const finalState = useAppStore.getState() - const ptyId = finalState.ptyIdsByTabId[primaryTabId]?.[0] - if (!ptyId) { - return - } - - // 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 graceMs = readyResult.reason === 'title-idle' ? 150 : 600 - await new Promise((resolve) => window.setTimeout(resolve, graceMs)) - - window.api.pty.write(ptyId, `${BRACKETED_PASTE_BEGIN}${content}${BRACKETED_PASTE_END}`) } /** diff --git a/src/renderer/src/lib/new-workspace.ts b/src/renderer/src/lib/new-workspace.ts index df4d857aeb1..5a6c433b3ca 100644 --- a/src/renderer/src/lib/new-workspace.ts +++ b/src/renderer/src/lib/new-workspace.ts @@ -1,4 +1,5 @@ import { useAppStore } from '@/store' +import { pasteDraftWhenAgentReady } from '@/lib/agent-paste-draft' import type { AgentStartupPlan } from '@/lib/tui-agent-startup' import { isShellProcess } from '@/lib/tui-agent-startup' import type { OrcaHooks, TaskViewPresetId } from '../../../shared/types' @@ -195,6 +196,10 @@ export function getWorkspaceSeedName(args: { return 'workspace' } +// Why: bracketed paste markers and ready-wait grace timing live in +// agent-paste-draft.ts so the new-workspace and "Use" flows share one +// definition of "type into the agent's input as a non-submitted draft". + export async function ensureAgentStartupInTerminal(args: { worktreeId: string startup: AgentStartupPlan @@ -205,84 +210,73 @@ export async function ensureAgentStartupInTerminal(args: { return } - let promptInjected = false - let draftInjected = false - + // Why: poll until a terminal tab + PTY exists for the worktree before we + // can interact with it. Activation creates the tab synchronously but the + // PTY spawn is async, so a brief wait is normal. + let tabId: string | null = null + let ptyId: string | null = null for (let attempt = 0; attempt < 30; attempt += 1) { if (attempt > 0) { await new Promise((resolve) => window.setTimeout(resolve, 150)) } - const state = useAppStore.getState() - const tabId = + tabId = state.activeTabIdByWorktree[worktreeId] ?? state.tabsByWorktree[worktreeId]?.[0]?.id ?? null if (!tabId) { continue } - - const ptyId = state.ptyIdsByTabId[tabId]?.[0] - if (!ptyId) { - continue + ptyId = state.ptyIdsByTabId[tabId]?.[0] ?? null + if (ptyId) { + break } + } + if (!tabId || !ptyId) { + return + } + // Why: followupPrompt is the legacy path for stdin-after-start agents + // (aider, goose, etc.) that need their initial prompt typed into the live + // session and submitted. Wait until the agent owns the PTY before writing. + if (startup.followupPrompt) { + await waitForAgentForeground(ptyId, startup.expectedProcess) + window.api.pty.write(ptyId, `${startup.followupPrompt}\r`) + } + + // Why: draftPrompt uses bracketed-paste so the URL lands atomically in the + // agent's input buffer (no per-char echo, no auto-submit). Shared with the + // launch-work-item-direct flow so both behave identically. + if (draftPrompt) { + await pasteDraftWhenAgentReady({ + tabId, + expectedProcess: startup.expectedProcess, + content: draftPrompt + }) + } +} + +// Why: legacy followupPrompt path used `agentOwnsForeground` exclusively (with +// a hasChildProcesses fallback after several polls). Preserve that behavior so +// stdin-after-start agents still receive their prompt under the same +// conditions. Returns when the agent appears ready or the budget expires. +async function waitForAgentForeground(ptyId: string, expectedProcess: string): Promise { + for (let attempt = 0; attempt < 30; attempt += 1) { + if (attempt > 0) { + await new Promise((resolve) => window.setTimeout(resolve, 150)) + } try { const foreground = (await window.api.pty.getForegroundProcess(ptyId))?.toLowerCase() ?? '' - const agentOwnsForeground = - foreground === startup.expectedProcess || - foreground.startsWith(`${startup.expectedProcess}.`) - - if (agentOwnsForeground && !promptInjected && startup.followupPrompt) { - window.api.pty.write(ptyId, `${startup.followupPrompt}\r`) - promptInjected = true - } - - // Why: type the draft into the live agent input WITHOUT a trailing - // carriage return. The user wants the URL pre-filled so they can edit - // before sending — appending \r would auto-submit and defeat the point. - if (agentOwnsForeground && !draftInjected && draftPrompt) { - window.api.pty.write(ptyId, draftPrompt) - draftInjected = true - } - - if ( - agentOwnsForeground && - (promptInjected || startup.followupPrompt === null) && - (draftInjected || draftPrompt === null) - ) { + const owns = + foreground === expectedProcess || + foreground.startsWith(`${expectedProcess}.`) || + foreground.endsWith(`/${expectedProcess}`) + if (owns) { return } - - const hasChildProcesses = await window.api.pty.hasChildProcesses(ptyId) - if ( - !promptInjected && - startup.followupPrompt && - hasChildProcesses && - !isShellProcess(foreground) && - attempt >= 4 - ) { - // Why: the initial agent launch is already queued on the first terminal - // tab. Only agents without a verified startup-prompt flag need extra - // help here: once the TUI owns the PTY, type the draft prompt into the - // live session instead of launching the binary a second time. - window.api.pty.write(ptyId, `${startup.followupPrompt}\r`) - promptInjected = true - } - if ( - !draftInjected && - draftPrompt && - hasChildProcesses && - !isShellProcess(foreground) && - attempt >= 4 - ) { - window.api.pty.write(ptyId, draftPrompt) - draftInjected = true - } - - if ( - (promptInjected || startup.followupPrompt === null) && - (draftInjected || draftPrompt === null) - ) { - return + if (attempt >= 4 && !isShellProcess(foreground)) { + const hasChildProcesses = await window.api.pty.hasChildProcesses(ptyId) + if (hasChildProcesses) { + return + } } } catch { // Ignore transient PTY inspection failures and keep polling.