fix(new-workspace): use bracketed-paste for draft URL injection so it actually appears in the agent input

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Neil
2026-05-04 20:58:21 -07:00
co-authored by Orca
parent 9b9c74e158
commit 28b8d69105
3 changed files with 120 additions and 95 deletions
+50
View File
@@ -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<boolean> {
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
}
+13 -32
View File
@@ -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<void> {
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}`)
}
/**
+57 -63
View File
@@ -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<void> {
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.