mirror of
https://github.com/stablyai/orca.git
synced 2026-10-27 08:02:51 +00:00
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:
@@ -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
|
||||
}
|
||||
@@ -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}`)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user