fix: speed up Codex draft prefill (#2105)
This commit is contained in:
parent
087f1aa9bf
commit
312677132e
|
|
@ -25,7 +25,7 @@ vi.mock('node:os', async () => {
|
|||
}
|
||||
})
|
||||
|
||||
const { markCopilotFolderTrusted, markCursorWorkspaceTrusted } =
|
||||
const { markCodexProjectTrusted, markCopilotFolderTrusted, markCursorWorkspaceTrusted } =
|
||||
await import('./agent-trust-presets')
|
||||
|
||||
beforeEach(() => {
|
||||
|
|
@ -111,3 +111,55 @@ describe('markCopilotFolderTrusted', () => {
|
|||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('markCodexProjectTrusted', () => {
|
||||
it('writes ~/.codex/config.toml with the project marked trusted', () => {
|
||||
const workspace = mkdtempSync(join(tmpdir(), 'orca-codex-ws-'))
|
||||
try {
|
||||
const realpath = realpathSync(workspace)
|
||||
markCodexProjectTrusted(workspace)
|
||||
const configPath = join(testState.fakeHomeDir, '.codex', 'config.toml')
|
||||
expect(existsSync(configPath)).toBe(true)
|
||||
const written = readFileSync(configPath, 'utf-8')
|
||||
expect(written).toContain(`[projects."${escapeTomlBasicString(realpath)}"]`)
|
||||
expect(written).toContain('trust_level = "trusted"')
|
||||
} finally {
|
||||
rmSync(workspace, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('preserves existing config keys and updates an existing project block', () => {
|
||||
const workspace = mkdtempSync(join(tmpdir(), 'orca-codex-ws-'))
|
||||
const realpath = realpathSync(workspace)
|
||||
try {
|
||||
const codexDir = join(testState.fakeHomeDir, '.codex')
|
||||
mkdirSync(codexDir, { recursive: true })
|
||||
writeFileSync(
|
||||
join(codexDir, 'config.toml'),
|
||||
[
|
||||
'model = "gpt-5.5"',
|
||||
'',
|
||||
`[projects."${escapeTomlBasicString(realpath)}"]`,
|
||||
'notes = "keep"',
|
||||
'trust_level = "untrusted"',
|
||||
''
|
||||
].join('\n'),
|
||||
'utf-8'
|
||||
)
|
||||
|
||||
markCodexProjectTrusted(workspace)
|
||||
|
||||
const written = readFileSync(join(codexDir, 'config.toml'), 'utf-8')
|
||||
expect(written).toContain('model = "gpt-5.5"')
|
||||
expect(written).toContain('notes = "keep"')
|
||||
expect(written).toContain('trust_level = "trusted"')
|
||||
expect(written).not.toContain('trust_level = "untrusted"')
|
||||
} finally {
|
||||
rmSync(workspace, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
function escapeTomlBasicString(value: string): string {
|
||||
return value.replaceAll('\\', '\\\\').replaceAll('"', '\\"')
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,10 +2,12 @@ import { existsSync, mkdirSync, readFileSync, realpathSync } from 'node:fs'
|
|||
import { homedir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { writeFileAtomically } from './codex-accounts/fs-utils'
|
||||
import { upsertProjectTrustLevel } from './codex/config-toml-trust'
|
||||
|
||||
/**
|
||||
* Pre-mark a workspace as trusted for cursor-agent / GitHub Copilot CLI so
|
||||
* the agent's "Do you trust this folder?" menu does not fire on first launch.
|
||||
* Pre-mark a workspace as trusted for cursor-agent, GitHub Copilot CLI, or
|
||||
* Codex so the agent's "Do you trust this folder?" menu does not fire on
|
||||
* first launch.
|
||||
*
|
||||
* Why: Orca's "drop URL into agent input as a draft" flow injects the URL
|
||||
* via bracketed-paste once the TUI is up. If the trust menu intercepts the
|
||||
|
|
@ -18,6 +20,8 @@ import { writeFileAtomically } from './codex-accounts/fs-utils'
|
|||
* Side note: a `--trust`-style CLI flag exists in cursor-agent but only
|
||||
* applies in `--print/headless` mode (per its --help). Copilot has no
|
||||
* documented flag at all (verified against @github/copilot 1.0.32 bundle).
|
||||
* Codex's `--dangerously-bypass-approvals-and-sandbox` would also change
|
||||
* approval/sandbox policy, so it is not equivalent to "trust this project".
|
||||
*/
|
||||
|
||||
/**
|
||||
|
|
@ -93,6 +97,20 @@ export function markCopilotFolderTrusted(workspacePath: string): void {
|
|||
writeFileAtomically(configPath, `${JSON.stringify(config, null, 2)}\n`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Codex stores project trust in ~/.codex/config.toml under:
|
||||
* [projects."<realpath>"]
|
||||
* trust_level = "trusted"
|
||||
*
|
||||
* Verified against codex-rs/tui/src/onboarding/trust_directory.rs and
|
||||
* codex-rs/core/src/config/config_tests.rs in the Codex CLI source.
|
||||
*/
|
||||
export function markCodexProjectTrusted(workspacePath: string): void {
|
||||
const absPath = canonicalize(workspacePath)
|
||||
const configPath = join(homedir(), '.codex', 'config.toml')
|
||||
upsertProjectTrustLevel(configPath, absPath, 'trusted')
|
||||
}
|
||||
|
||||
function canonicalize(p: string): string {
|
||||
// Why: macOS reports `/tmp/x` and `/private/tmp/x` as the same inode, but
|
||||
// both Cursor and Copilot's trust comparators run realpath() before the
|
||||
|
|
|
|||
|
|
@ -10,6 +10,8 @@ import {
|
|||
readHookTrustEntries,
|
||||
removeHookTrustEntries,
|
||||
upsertHookTrustEntries,
|
||||
upsertProjectTrustLevel,
|
||||
upsertProjectTrustLevelInContent,
|
||||
type CodexTrustEntry
|
||||
} from './config-toml-trust'
|
||||
|
||||
|
|
@ -633,6 +635,76 @@ describe('upsertHookTrustEntries', () => {
|
|||
})
|
||||
})
|
||||
|
||||
describe('upsertProjectTrustLevel', () => {
|
||||
it('creates a projects trust block when the config is empty', () => {
|
||||
expect(upsertProjectTrustLevelInContent('', '/tmp/codex-ws', 'trusted')).toBe(
|
||||
['[projects."/tmp/codex-ws"]', 'trust_level = "trusted"', ''].join('\n')
|
||||
)
|
||||
})
|
||||
|
||||
it('updates an existing project block without touching unrelated keys', () => {
|
||||
const original = [
|
||||
'model = "gpt-5.5"',
|
||||
'',
|
||||
'[projects."/tmp/codex-ws"]',
|
||||
'notes = "keep"',
|
||||
'trust_level = "untrusted"',
|
||||
'',
|
||||
'[profiles.default]',
|
||||
'sandbox_mode = "workspace-write"',
|
||||
''
|
||||
].join('\n')
|
||||
|
||||
const updated = upsertProjectTrustLevelInContent(original, '/tmp/codex-ws', 'trusted')
|
||||
|
||||
expect(updated).toContain('model = "gpt-5.5"')
|
||||
expect(updated).toContain('[projects."/tmp/codex-ws"]\nnotes = "keep"')
|
||||
expect(updated).toContain('trust_level = "trusted"')
|
||||
expect(updated).not.toContain('trust_level = "untrusted"')
|
||||
expect(updated).toContain('[profiles.default]\nsandbox_mode = "workspace-write"')
|
||||
})
|
||||
|
||||
it('adds trust_level to an existing project block that does not have one', () => {
|
||||
const original = [
|
||||
'[projects."/tmp/codex-ws"]',
|
||||
'notes = "keep"',
|
||||
'',
|
||||
'[other]',
|
||||
'value = 1',
|
||||
''
|
||||
].join('\n')
|
||||
|
||||
const updated = upsertProjectTrustLevelInContent(original, '/tmp/codex-ws', 'trusted')
|
||||
|
||||
expect(updated).toContain(
|
||||
['[projects."/tmp/codex-ws"]', 'trust_level = "trusted"', 'notes = "keep"'].join('\n')
|
||||
)
|
||||
expect(updated).toContain('[other]\nvalue = 1')
|
||||
})
|
||||
|
||||
it('preserves CRLF endings and escapes the project path in the header', () => {
|
||||
const original = ['[profiles.default]', 'model = "gpt-5"', ''].join('\r\n')
|
||||
|
||||
const updated = upsertProjectTrustLevelInContent(original, 'C:\\Users\\nw\\repo', 'trusted')
|
||||
|
||||
expect(updated).toContain(
|
||||
['[projects."C:\\\\Users\\\\nw\\\\repo"]', 'trust_level = "trusted"', ''].join('\r\n')
|
||||
)
|
||||
expect(updated).toContain('[profiles.default]\r\nmodel = "gpt-5"')
|
||||
})
|
||||
|
||||
it('writes config.toml and avoids rewriting an already-trusted project', () => {
|
||||
upsertProjectTrustLevel(configPath, '/tmp/codex-ws', 'trusted')
|
||||
const firstWrite = readFileSync(configPath, 'utf-8')
|
||||
|
||||
rmSync(`${configPath}.bak`, { force: true })
|
||||
upsertProjectTrustLevel(configPath, '/tmp/codex-ws', 'trusted')
|
||||
|
||||
expect(readFileSync(configPath, 'utf-8')).toBe(firstWrite)
|
||||
expect(existsSync(`${configPath}.bak`)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('removeHookTrustEntries', () => {
|
||||
it('is a no-op (creates no file) when the config does not exist', () => {
|
||||
removeHookTrustEntries(configPath, ['/x/hooks.json:pre_tool_use:0:0'])
|
||||
|
|
|
|||
|
|
@ -56,6 +56,8 @@ export type CodexHookTrustState = {
|
|||
enabled?: boolean
|
||||
}
|
||||
|
||||
export type CodexProjectTrustLevel = 'trusted' | 'untrusted'
|
||||
|
||||
// Why: matches Codex's canonical_json. Sorts object keys recursively before
|
||||
// SHA-256ing; arrays preserve order.
|
||||
function canonicalize(value: unknown): unknown {
|
||||
|
|
@ -210,6 +212,61 @@ export function upsertHookTrustEntriesInContent(
|
|||
return updated
|
||||
}
|
||||
|
||||
export function upsertProjectTrustLevel(
|
||||
configPath: string,
|
||||
projectPath: string,
|
||||
trustLevel: CodexProjectTrustLevel
|
||||
): void {
|
||||
const existing = existsSync(configPath) ? readTomlFile(configPath) : ''
|
||||
const updated = upsertProjectTrustLevelInContent(existing, projectPath, trustLevel)
|
||||
if (updated === existing) {
|
||||
return
|
||||
}
|
||||
writeConfigAtomically(configPath, updated)
|
||||
}
|
||||
|
||||
export function upsertProjectTrustLevelInContent(
|
||||
existingContent: string,
|
||||
projectPath: string,
|
||||
trustLevel: CodexProjectTrustLevel
|
||||
): string {
|
||||
const existing =
|
||||
existingContent.charCodeAt(0) === 0xfeff ? existingContent.slice(1) : existingContent
|
||||
const headerPattern = buildProjectHeaderPattern(projectPath)
|
||||
const match = headerPattern.exec(existing)
|
||||
const eol = existing.includes('\r\n') ? '\r\n' : '\n'
|
||||
const trustLine = `trust_level = "${trustLevel}"`
|
||||
|
||||
if (!match) {
|
||||
const block = [`[projects."${escapeTomlString(projectPath)}"]`, trustLine].join(eol)
|
||||
if (existing.length === 0) {
|
||||
return `${block}${eol}`
|
||||
}
|
||||
const separator = existing.endsWith(`${eol}${eol}`)
|
||||
? ''
|
||||
: existing.endsWith(eol)
|
||||
? eol
|
||||
: eol + eol
|
||||
return `${existing}${separator}${block}${eol}`
|
||||
}
|
||||
|
||||
const headerLineEnd = match.index + match[0].length
|
||||
const after = existing.slice(headerLineEnd)
|
||||
const nextHeaderRel = findNextTableHeader(after)
|
||||
const blockEnd = nextHeaderRel === -1 ? existing.length : headerLineEnd + nextHeaderRel
|
||||
const existingBlock = existing.slice(headerLineEnd, blockEnd)
|
||||
const trustLevelLinePattern =
|
||||
/^[ \t]*trust_level[ \t]*=[ \t]*(?:"(?:trusted|untrusted)"|'(?:trusted|untrusted)')[ \t\r]*(?:#.*)?$/m
|
||||
if (trustLevelLinePattern.test(existingBlock)) {
|
||||
return (
|
||||
existing.slice(0, headerLineEnd) +
|
||||
existingBlock.replace(trustLevelLinePattern, trustLine) +
|
||||
existing.slice(blockEnd)
|
||||
)
|
||||
}
|
||||
return `${existing.slice(0, headerLineEnd)}${eol}${trustLine}${existing.slice(headerLineEnd)}`
|
||||
}
|
||||
|
||||
// Why: build the canonical block we own. The two field names mirror what
|
||||
// Codex itself writes when the user approves via /hooks (HookStateToml
|
||||
// fields). `enabled` is plumbed through so an existing user-set
|
||||
|
|
@ -289,6 +346,13 @@ function buildHeaderPattern(key: string): RegExp {
|
|||
)
|
||||
}
|
||||
|
||||
function buildProjectHeaderPattern(projectPath: string): RegExp {
|
||||
const escapedPath = escapeRegex(escapeTomlString(projectPath))
|
||||
return new RegExp(
|
||||
`(^|\\r?\\n)[ \\t]*\\[projects\\."${escapedPath}"\\][ \\t]*(?:#[^\\r\\n]*)?(?=\\r?\\n|$)`
|
||||
)
|
||||
}
|
||||
|
||||
function escapeRegex(value: string): string {
|
||||
return value.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,15 @@
|
|||
import { ipcMain } from 'electron'
|
||||
import { markCopilotFolderTrusted, markCursorWorkspaceTrusted } from '../agent-trust-presets'
|
||||
import {
|
||||
markCodexProjectTrusted,
|
||||
markCopilotFolderTrusted,
|
||||
markCursorWorkspaceTrusted
|
||||
} from '../agent-trust-presets'
|
||||
|
||||
export type AgentTrustPreset = 'cursor' | 'copilot'
|
||||
export type AgentTrustPreset = 'cursor' | 'copilot' | 'codex'
|
||||
|
||||
/**
|
||||
* Why: cursor-agent and GitHub Copilot CLI gate first-launch in an unfamiliar
|
||||
* directory behind a "Do you trust this folder?" menu that consumes
|
||||
* Why: cursor-agent, GitHub Copilot CLI, and Codex gate first-launch in an
|
||||
* unfamiliar directory behind a "Do you trust this folder?" menu that consumes
|
||||
* keystrokes (numbered options / single-letter shortcuts). Orca's draft-URL
|
||||
* paste flow needs the input box, not the menu, so before Orca spawns the
|
||||
* agent it asks main to write the same trust artifacts the agents write
|
||||
|
|
@ -25,6 +29,8 @@ export function registerAgentTrustHandlers(): void {
|
|||
markCursorWorkspaceTrusted(args.workspacePath)
|
||||
} else if (args.preset === 'copilot') {
|
||||
markCopilotFolderTrusted(args.workspacePath)
|
||||
} else if (args.preset === 'codex') {
|
||||
markCodexProjectTrusted(args.workspacePath)
|
||||
}
|
||||
} catch {
|
||||
// Best-effort: see Why above. The user can still accept the trust
|
||||
|
|
|
|||
|
|
@ -1118,7 +1118,10 @@ export type PreloadApi = {
|
|||
hermesStatus: () => Promise<AgentHookInstallStatus>
|
||||
}
|
||||
agentTrust: {
|
||||
markTrusted: (args: { preset: 'cursor' | 'copilot'; workspacePath: string }) => Promise<void>
|
||||
markTrusted: (args: {
|
||||
preset: 'cursor' | 'copilot' | 'codex'
|
||||
workspacePath: string
|
||||
}) => Promise<void>
|
||||
}
|
||||
preflight: PreflightApi
|
||||
notifications: {
|
||||
|
|
|
|||
|
|
@ -1149,8 +1149,10 @@ const api = {
|
|||
},
|
||||
|
||||
agentTrust: {
|
||||
markTrusted: (args: { preset: 'cursor' | 'copilot'; workspacePath: string }): Promise<void> =>
|
||||
ipcRenderer.invoke('agentTrust:markTrusted', args)
|
||||
markTrusted: (args: {
|
||||
preset: 'cursor' | 'copilot' | 'codex'
|
||||
workspacePath: string
|
||||
}): Promise<void> => ipcRenderer.invoke('agentTrust:markTrusted', args)
|
||||
},
|
||||
|
||||
preflight: {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,147 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { pasteDraftWhenAgentReady } from './agent-paste-draft'
|
||||
|
||||
const testState = vi.hoisted(() => ({
|
||||
appState: {
|
||||
settings: {},
|
||||
ptyIdsByTabId: { 'tab-1': ['pty-1'] }
|
||||
},
|
||||
ptyObserver: null as ((data: string) => void) | null,
|
||||
unsubscribe: vi.fn(),
|
||||
subscribeToPtyData: vi.fn(),
|
||||
isRemoteRuntimePtyId: vi.fn(),
|
||||
sendRuntimePtyInput: vi.fn(),
|
||||
subscribeToRuntimeTerminalData: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/store', () => ({
|
||||
useAppStore: {
|
||||
getState: () => testState.appState
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/components/terminal-pane/pty-dispatcher', () => ({
|
||||
subscribeToPtyData: testState.subscribeToPtyData
|
||||
}))
|
||||
|
||||
vi.mock('@/runtime/runtime-terminal-inspection', () => ({
|
||||
isRemoteRuntimePtyId: testState.isRemoteRuntimePtyId,
|
||||
sendRuntimePtyInput: testState.sendRuntimePtyInput
|
||||
}))
|
||||
|
||||
vi.mock('@/runtime/runtime-terminal-stream', () => ({
|
||||
subscribeToRuntimeTerminalData: testState.subscribeToRuntimeTerminalData
|
||||
}))
|
||||
|
||||
const DECSET_BRACKETED_PASTE = '\x1b[?2004h'
|
||||
const CODEX_COMPOSER_PROMPT_RENDER = '\x1b[1m›\x1b[0m Ask Codex to do anything'
|
||||
const ISSUE_URL = 'https://github.com/stablyai/orca/issues/123'
|
||||
const PASTED_ISSUE_URL = `\x1b[200~${ISSUE_URL}\x1b[201~`
|
||||
|
||||
describe('pasteDraftWhenAgentReady', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.stubGlobal('window', {
|
||||
setTimeout: globalThis.setTimeout,
|
||||
clearTimeout: globalThis.clearTimeout
|
||||
})
|
||||
testState.appState.settings = {}
|
||||
testState.appState.ptyIdsByTabId = { 'tab-1': ['pty-1'] }
|
||||
testState.ptyObserver = null
|
||||
testState.unsubscribe.mockReset()
|
||||
testState.subscribeToPtyData.mockReset()
|
||||
testState.subscribeToPtyData.mockImplementation(
|
||||
(_ptyId: string, observer: (data: string) => void) => {
|
||||
testState.ptyObserver = observer
|
||||
return testState.unsubscribe
|
||||
}
|
||||
)
|
||||
testState.isRemoteRuntimePtyId.mockReset()
|
||||
testState.isRemoteRuntimePtyId.mockReturnValue(false)
|
||||
testState.sendRuntimePtyInput.mockReset()
|
||||
testState.subscribeToRuntimeTerminalData.mockReset()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('pastes into Codex as soon as its composer prompt renders after bracketed paste is enabled', async () => {
|
||||
const promise = pasteDraftWhenAgentReady({
|
||||
tabId: 'tab-1',
|
||||
content: ISSUE_URL,
|
||||
agent: 'codex'
|
||||
})
|
||||
await flushMicrotasks()
|
||||
|
||||
testState.ptyObserver?.(CODEX_COMPOSER_PROMPT_RENDER)
|
||||
await flushMicrotasks()
|
||||
expect(testState.sendRuntimePtyInput).not.toHaveBeenCalled()
|
||||
|
||||
testState.ptyObserver?.(DECSET_BRACKETED_PASTE)
|
||||
await flushMicrotasks()
|
||||
expect(testState.sendRuntimePtyInput).not.toHaveBeenCalled()
|
||||
|
||||
testState.ptyObserver?.(CODEX_COMPOSER_PROMPT_RENDER)
|
||||
|
||||
await expect(promise).resolves.toBe(true)
|
||||
expect(testState.sendRuntimePtyInput).toHaveBeenCalledWith({}, 'pty-1', PASTED_ISSUE_URL)
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('detects the Codex composer prompt inside a large first render chunk', async () => {
|
||||
const promise = pasteDraftWhenAgentReady({
|
||||
tabId: 'tab-1',
|
||||
content: ISSUE_URL,
|
||||
agent: 'codex'
|
||||
})
|
||||
await flushMicrotasks()
|
||||
|
||||
testState.ptyObserver?.(
|
||||
`${DECSET_BRACKETED_PASTE}${CODEX_COMPOSER_PROMPT_RENDER}${'x'.repeat(900)}`
|
||||
)
|
||||
|
||||
await expect(promise).resolves.toBe(true)
|
||||
expect(testState.sendRuntimePtyInput).toHaveBeenCalledWith({}, 'pty-1', PASTED_ISSUE_URL)
|
||||
})
|
||||
|
||||
it('keeps the render-quiet wait for agents without the Codex ready signal', async () => {
|
||||
const promise = pasteDraftWhenAgentReady({
|
||||
tabId: 'tab-1',
|
||||
content: ISSUE_URL,
|
||||
agent: 'opencode'
|
||||
})
|
||||
await flushMicrotasks()
|
||||
|
||||
testState.ptyObserver?.(DECSET_BRACKETED_PASTE)
|
||||
await flushMicrotasks()
|
||||
expect(testState.sendRuntimePtyInput).not.toHaveBeenCalled()
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1499)
|
||||
expect(testState.sendRuntimePtyInput).not.toHaveBeenCalled()
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
|
||||
await expect(promise).resolves.toBe(true)
|
||||
expect(testState.sendRuntimePtyInput).toHaveBeenCalledWith({}, 'pty-1', PASTED_ISSUE_URL)
|
||||
})
|
||||
|
||||
it('does not paste for agents that already use native draft prefill', async () => {
|
||||
await expect(
|
||||
pasteDraftWhenAgentReady({
|
||||
tabId: 'tab-1',
|
||||
content: ISSUE_URL,
|
||||
agent: 'pi'
|
||||
})
|
||||
).resolves.toBe(false)
|
||||
|
||||
expect(testState.subscribeToPtyData).not.toHaveBeenCalled()
|
||||
expect(testState.sendRuntimePtyInput).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
async function flushMicrotasks(): Promise<void> {
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import type { TuiAgent } from '../../../shared/types'
|
||||
import { TUI_AGENT_CONFIG } from '../../../shared/tui-agent-config'
|
||||
import { TUI_AGENT_CONFIG, type DraftPasteReadySignal } from '../../../shared/tui-agent-config'
|
||||
import { useAppStore } from '@/store'
|
||||
import { subscribeToPtyData } from '@/components/terminal-pane/pty-dispatcher'
|
||||
import { isRemoteRuntimePtyId, sendRuntimePtyInput } from '@/runtime/runtime-terminal-inspection'
|
||||
|
|
@ -18,20 +18,21 @@ const BRACKETED_PASTE_END = '\x1b[201~'
|
|||
// opencode / gemini / cursor-agent / copilot) emits `CSI ? 2004 h` (DECSET
|
||||
// 2004 — bracketed-paste-enable) on its output stream when its input layer
|
||||
// is wired up. That sequence is the protocol-level "I accept bracketed
|
||||
// paste" handshake — but on its own it doesn't mean "the input box is
|
||||
// rendered and visible". OpenCode in particular emits DECSET 2004 during
|
||||
// its alt-screen setup at ~500ms, then runs a 1.3s splash render with NO
|
||||
// paste" handshake. For most agents it still does not prove the input box
|
||||
// is rendered and visible. OpenCode in particular emits DECSET 2004 during
|
||||
// its alt-screen setup at ~500ms, then runs a 1.3s splash render with no
|
||||
// data on the PTY, then paints the actual input box at ~1.85s. Pasting
|
||||
// during the silent gap drops the bytes.
|
||||
//
|
||||
// Strategy: take DECSET 2004 as the necessary precondition, then wait for
|
||||
// the TUI's render burst to finish — defined as `BRACKETED_PASTE_QUIET_MS`
|
||||
// of stream silence after the most recent post-`?2004h` byte. This
|
||||
// captures both the fast TUIs (claude/pi/codex emit their setup escapes
|
||||
// in one burst, then go quiet) and the slow ones (opencode emits, sleeps,
|
||||
// emits again, then goes quiet). Verified against opencode/claude/pi in
|
||||
// a node-pty rig: paste lands on the first try with a 1500ms quiet window.
|
||||
// Default strategy: take DECSET 2004 as the necessary precondition, then
|
||||
// wait for the TUI's render burst to finish — defined as
|
||||
// `BRACKETED_PASTE_QUIET_MS` of stream silence after the most recent
|
||||
// post-`?2004h` byte. This captures both the fast TUIs and the slow ones
|
||||
// (opencode emits, sleeps, emits again, then goes quiet). Codex opts into a
|
||||
// faster source-backed path: after DECSET, wait only until its composer
|
||||
// prompt glyph renders.
|
||||
const DECSET_BRACKETED_PASTE = '\x1b[?2004h'
|
||||
const CODEX_COMPOSER_PROMPT = '›'
|
||||
const BRACKETED_PASTE_QUIET_MS = 1500
|
||||
|
||||
// Why: deterministic signal can fail in two ways: (1) the agent never
|
||||
|
|
@ -50,14 +51,12 @@ const READINESS_TIMEOUT_MS = 8000
|
|||
* PTY. `onTimeout` lets the caller surface a UI hint (e.g. toast) when
|
||||
* the agent doesn't reach a ready state inside `timeoutMs`.
|
||||
*
|
||||
* Readiness combines two stream signals:
|
||||
* Readiness combines DECSET 2004 with one agent-specific follow-up signal:
|
||||
* 1. `\x1b[?2004h` (DECSET 2004 — bracketed-paste-enable) on the PTY
|
||||
* output. This is the protocol-level "I accept bracketed paste"
|
||||
* handshake.
|
||||
* 2. ≥`BRACKETED_PASTE_QUIET_MS` of silence after the last byte of the
|
||||
* post-handshake render burst. Captures TUIs (OpenCode) that emit
|
||||
* DECSET 2004 early and then run a multi-second splash before
|
||||
* drawing the actual input box.
|
||||
* 2. Either ≥`BRACKETED_PASTE_QUIET_MS` of silence after the last byte of
|
||||
* the post-handshake render burst, or Codex's composer prompt glyph.
|
||||
*/
|
||||
export async function pasteDraftWhenAgentReady(args: {
|
||||
tabId: string
|
||||
|
|
@ -69,23 +68,26 @@ export async function pasteDraftWhenAgentReady(args: {
|
|||
}): Promise<boolean> {
|
||||
const { tabId, content, agent, submit, timeoutMs, onTimeout } = args
|
||||
|
||||
// Why: agents with a documented prefill flag (currently Claude — see
|
||||
// TUI_AGENT_CONFIG.claude.draftPromptFlag) launch with the URL already
|
||||
// in their input box. Pasting again would duplicate it. Callers should
|
||||
// not invoke this helper for those agents; the early return guards
|
||||
// against accidental double-injection if a stale call slips through.
|
||||
if (agent && TUI_AGENT_CONFIG[agent].draftPromptFlag) {
|
||||
const agentConfig = agent ? TUI_AGENT_CONFIG[agent] : null
|
||||
|
||||
// Why: agents with a native draft prefill mechanism (flag or env var)
|
||||
// launch with the URL already in their input box. Pasting again would
|
||||
// duplicate it. Callers should not invoke this helper for those agents;
|
||||
// the early return guards against accidental double-injection if a stale
|
||||
// call slips through.
|
||||
if (agentConfig?.draftPromptFlag || agentConfig?.draftPromptEnvVar) {
|
||||
return false
|
||||
}
|
||||
|
||||
const budget = timeoutMs ?? READINESS_TIMEOUT_MS
|
||||
const readySignal = agentConfig?.draftPasteReadySignal ?? 'render-quiet-after-bracketed-paste'
|
||||
const ptyId = await waitForPtyId(tabId, budget)
|
||||
if (!ptyId) {
|
||||
onTimeout?.()
|
||||
return false
|
||||
}
|
||||
|
||||
const ready = await waitForInputBoxReady(ptyId, budget)
|
||||
const ready = await waitForInputBoxReady(ptyId, budget, readySignal)
|
||||
if (!ready) {
|
||||
onTimeout?.()
|
||||
return false
|
||||
|
|
@ -102,21 +104,29 @@ export async function pasteDraftWhenAgentReady(args: {
|
|||
/**
|
||||
* Tap the PTY data stream as a side-channel observer (does NOT take over
|
||||
* the primary handler that feeds xterm) and resolve `true` once we see
|
||||
* DECSET 2004 *and* the post-handshake render burst settles for
|
||||
* `BRACKETED_PASTE_QUIET_MS`. Resolves `false` on hard timeout.
|
||||
* DECSET 2004. Most agents also wait for the post-handshake render burst to
|
||||
* settle for `BRACKETED_PASTE_QUIET_MS`; Codex waits for its composer prompt
|
||||
* glyph instead. Resolves `false` on hard timeout.
|
||||
*
|
||||
* Why a sidecar subscription:
|
||||
* - the main pane may attach mid-flight; we must not race against its
|
||||
* handler registration on the dispatcher's primary slot.
|
||||
* - DECSET 2004 may straddle two data chunks at ANSI parser boundaries,
|
||||
* so we keep a small ring of recent bytes and search the union.
|
||||
* - DECSET 2004 and the Codex composer prompt may straddle two data chunks
|
||||
* at ANSI parser boundaries, so we keep a small ring of recent bytes and
|
||||
* search the union.
|
||||
*/
|
||||
function waitForInputBoxReady(ptyId: string, timeoutMs: number): Promise<boolean> {
|
||||
function waitForInputBoxReady(
|
||||
ptyId: string,
|
||||
timeoutMs: number,
|
||||
readySignal: DraftPasteReadySignal
|
||||
): Promise<boolean> {
|
||||
return new Promise<boolean>((resolve) => {
|
||||
let settled = false
|
||||
let recent = ''
|
||||
let postHandshakeRecent = ''
|
||||
let saw2004 = false
|
||||
let quietTimer: number | null = null
|
||||
let hardTimer: number | null = null
|
||||
let unsubscribe: (() => void) | null = null
|
||||
|
||||
const finish = (value: boolean): void => {
|
||||
|
|
@ -124,7 +134,9 @@ function waitForInputBoxReady(ptyId: string, timeoutMs: number): Promise<boolean
|
|||
return
|
||||
}
|
||||
settled = true
|
||||
window.clearTimeout(hardTimer)
|
||||
if (hardTimer !== null) {
|
||||
window.clearTimeout(hardTimer)
|
||||
}
|
||||
if (quietTimer !== null) {
|
||||
window.clearTimeout(quietTimer)
|
||||
}
|
||||
|
|
@ -141,11 +153,40 @@ function waitForInputBoxReady(ptyId: string, timeoutMs: number): Promise<boolean
|
|||
|
||||
const observeData = (data: string): void => {
|
||||
// Why: keep just enough recent bytes that an escape sequence split
|
||||
// across two IPC frames is still detectable. 64 bytes >> 8-byte
|
||||
// sequence; cheap and bounded.
|
||||
recent = (recent + data).slice(-64)
|
||||
if (!saw2004 && recent.includes(DECSET_BRACKETED_PASTE)) {
|
||||
// across two IPC frames is still detectable. 512 bytes also covers
|
||||
// Codex's prompt render around ANSI styling without retaining a large
|
||||
// terminal scrollback copy.
|
||||
const combined = recent + data
|
||||
recent = combined.slice(-512)
|
||||
if (!saw2004) {
|
||||
const markerIndex = combined.indexOf(DECSET_BRACKETED_PASTE)
|
||||
if (markerIndex === -1) {
|
||||
return
|
||||
}
|
||||
saw2004 = true
|
||||
const postHandshakeChunk = combined.slice(markerIndex + DECSET_BRACKETED_PASTE.length)
|
||||
if (readySignal === 'codex-composer-prompt') {
|
||||
if (postHandshakeChunk.includes(CODEX_COMPOSER_PROMPT)) {
|
||||
finish(true)
|
||||
return
|
||||
}
|
||||
postHandshakeRecent = postHandshakeChunk.slice(-512)
|
||||
return
|
||||
}
|
||||
postHandshakeRecent = postHandshakeChunk.slice(-512)
|
||||
} else {
|
||||
if (
|
||||
readySignal === 'codex-composer-prompt' &&
|
||||
(data.includes(CODEX_COMPOSER_PROMPT) ||
|
||||
(postHandshakeRecent + data).includes(CODEX_COMPOSER_PROMPT))
|
||||
) {
|
||||
finish(true)
|
||||
return
|
||||
}
|
||||
postHandshakeRecent = (postHandshakeRecent + data).slice(-512)
|
||||
}
|
||||
if (readySignal === 'codex-composer-prompt') {
|
||||
return
|
||||
}
|
||||
if (saw2004) {
|
||||
// Reset the quiet window on every byte we see post-handshake.
|
||||
|
|
@ -175,7 +216,9 @@ function waitForInputBoxReady(ptyId: string, timeoutMs: number): Promise<boolean
|
|||
unsubscribe = subscribeToPtyData(ptyId, observeData)
|
||||
}
|
||||
|
||||
const hardTimer = window.setTimeout(() => finish(false), timeoutMs)
|
||||
if (!settled) {
|
||||
hardTimer = window.setTimeout(() => finish(false), timeoutMs)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ export type AgentPromptInjectionMode =
|
|||
| 'flag-interactive'
|
||||
| 'stdin-after-start'
|
||||
|
||||
export type DraftPasteReadySignal = 'render-quiet-after-bracketed-paste' | 'codex-composer-prompt'
|
||||
|
||||
export type TuiAgentConfig = {
|
||||
detectCmd: string
|
||||
launchCmd: string
|
||||
|
|
@ -32,12 +34,19 @@ export type TuiAgentConfig = {
|
|||
* passes the text via this env var instead of pasting after ready. */
|
||||
draftPromptEnvVar?: string
|
||||
/** Why: agents that gate first-launch behind a "Do you trust this
|
||||
* folder?" menu (Cursor-Agent, GitHub Copilot CLI) consume the bracketed
|
||||
* paste as menu input. Pre-write the same trust artifact the agent writes
|
||||
* after the user accepts so the menu never fires. The actual file/path
|
||||
* written lives in src/main/agent-trust-presets.ts; this flag just routes
|
||||
* the workspace path through the matching preset before the agent spawns. */
|
||||
preflightTrust?: 'cursor' | 'copilot'
|
||||
* folder?" menu (Cursor-Agent, GitHub Copilot CLI, Codex) consume the
|
||||
* bracketed paste as menu input. Pre-write the same trust artifact the
|
||||
* agent writes after the user accepts so the menu never fires. The actual
|
||||
* file/path written lives in src/main/agent-trust-presets.ts; this flag
|
||||
* just routes the workspace path through the matching preset before the
|
||||
* agent spawns. */
|
||||
preflightTrust?: 'cursor' | 'copilot' | 'codex'
|
||||
/** Why: most TUIs need both bracketed-paste enablement and a quiet render
|
||||
* window before pasted bytes reliably land in the composer. Codex can use
|
||||
* a stronger signal from its own renderer: chat_composer.rs writes the
|
||||
* `›` prompt only when the composer row exists, so Orca can paste as soon
|
||||
* as that prompt appears after bracketed paste is enabled. */
|
||||
draftPasteReadySignal?: DraftPasteReadySignal
|
||||
}
|
||||
|
||||
// Why: the new-workspace handoff depends on three pieces of per-agent
|
||||
|
|
@ -62,7 +71,14 @@ export const TUI_AGENT_CONFIG: Record<TuiAgent, TuiAgentConfig> = {
|
|||
detectCmd: 'codex',
|
||||
launchCmd: 'codex',
|
||||
expectedProcess: 'codex',
|
||||
promptInjectionMode: 'argv'
|
||||
promptInjectionMode: 'argv',
|
||||
// Why: Codex's positional prompt auto-submits the first turn, so Orca
|
||||
// must still paste a draft. The Codex TUI enables bracketed paste before
|
||||
// the first render, then chat_composer.rs emits `›` when the composer row
|
||||
// is visible. Waiting for that prompt skips the generic quiet timer while
|
||||
// avoiding startup/onboarding screens that ignore paste.
|
||||
preflightTrust: 'codex',
|
||||
draftPasteReadySignal: 'codex-composer-prompt'
|
||||
},
|
||||
autohand: {
|
||||
detectCmd: 'autohand',
|
||||
|
|
|
|||
Loading…
Reference in New Issue