Fix Codex terminal orchestration (#2039)
This commit is contained in:
parent
6fe73ee1b4
commit
b609aa2bec
|
|
@ -190,7 +190,7 @@ Why: `--direction horizontal` splits the pane **left and right** (new pane appea
|
|||
- Use `terminal read` before `terminal send` unless the next input is obvious.
|
||||
- Use `terminal wait --terminal <handle> --for exit` only when the task actually depends on process completion.
|
||||
- Use `terminal wait --terminal <handle> --for tui-idle` to wait for an agent CLI (Claude Code, Gemini, Codex, etc.) to finish its current task. This detects the working→idle OSC title transition. Always pass `--timeout-ms` as a safety net — unsupported CLIs will hang until timeout.
|
||||
- Use `terminal create` to spin up new terminal tabs programmatically, optionally with a `--command` for startup (e.g. `--command "claude"` to launch Claude Code) and `--title` for labeling. After creating a `--command` terminal, use `terminal wait --for tui-idle` to wait for the agent to boot before dispatching.
|
||||
- Use `terminal create` to spin up new terminal tabs programmatically, optionally with a `--command` for startup (e.g. `--command "claude"` to launch Claude Code) and `--title` for labeling. In local Orca sessions, `--command "codex"` is routed through Orca's visible terminal path automatically so Codex does not start as a headless/background PTY. After creating a `--command` terminal, use `terminal wait --for tui-idle` to wait for the agent to boot before dispatching.
|
||||
- Use `terminal split` to create split panes within an existing terminal tab. Pass `--command` to run a command in the new pane.
|
||||
- Prefer Orca worktree selectors over hardcoded paths when Orca identity already exists.
|
||||
- If the user asks for CLI UX feedback, test the public `orca` command first. Only inspect `src/cli` or use `node out/cli/index.js` if the public command is missing or the task is explicitly about implementation internals.
|
||||
|
|
|
|||
|
|
@ -157,7 +157,7 @@ orca terminal close [--terminal <handle>] [--json]
|
|||
|
||||
Why: `--terminal` is optional for most commands. When omitted, Orca auto-resolves to the active terminal in the current worktree.
|
||||
|
||||
Why: `--command "claude"` launches Claude Code in the new terminal. After creating a `--command` terminal, use `terminal wait --for tui-idle` to wait for the agent to boot before dispatching.
|
||||
Why: `--command "claude"` launches Claude Code in the new terminal. In local Orca sessions, `--command "codex"` launches Codex through Orca's visible terminal path automatically so Codex does not start as a headless/background PTY. After creating a `--command` terminal, use `terminal wait --for tui-idle` to wait for the agent to boot before dispatching.
|
||||
|
||||
Why: `--for tui-idle` detects the working→idle OSC title transition for recognized agent CLIs (Claude Code, Gemini, Codex, etc.). Always pass `--timeout-ms` — real coding tasks routinely take 15-60 minutes.
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,39 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { shouldForceVisibleCodexTerminal } from './codex-command-classification'
|
||||
|
||||
describe('shouldForceVisibleCodexTerminal', () => {
|
||||
it('forces visible terminal creation for interactive Codex sessions', () => {
|
||||
expect(shouldForceVisibleCodexTerminal('codex')).toBe(true)
|
||||
expect(shouldForceVisibleCodexTerminal('codex -m gpt-5 "fix the flaky test"')).toBe(true)
|
||||
expect(shouldForceVisibleCodexTerminal('codex resume --last')).toBe(true)
|
||||
expect(shouldForceVisibleCodexTerminal('codex fork')).toBe(true)
|
||||
expect(shouldForceVisibleCodexTerminal('codex login')).toBe(true)
|
||||
expect(shouldForceVisibleCodexTerminal('codex cloud')).toBe(true)
|
||||
expect(shouldForceVisibleCodexTerminal('codex -c active=cloud cloud')).toBe(true)
|
||||
expect(shouldForceVisibleCodexTerminal('codex.cmd resume --last')).toBe(true)
|
||||
expect(shouldForceVisibleCodexTerminal('env OPENAI_API_KEY=stub codex')).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps one-shot Codex commands on the background path', () => {
|
||||
expect(shouldForceVisibleCodexTerminal('codex exec summarize')).toBe(false)
|
||||
expect(shouldForceVisibleCodexTerminal('codex -m gpt-5 review')).toBe(false)
|
||||
expect(shouldForceVisibleCodexTerminal('codex login status')).toBe(false)
|
||||
expect(shouldForceVisibleCodexTerminal('codex login --with-api-key')).toBe(false)
|
||||
expect(shouldForceVisibleCodexTerminal('codex cloud list --json')).toBe(false)
|
||||
expect(shouldForceVisibleCodexTerminal('codex -c active=cloud cloud list --json')).toBe(false)
|
||||
expect(shouldForceVisibleCodexTerminal('codex cloud --enable foo list --json')).toBe(false)
|
||||
expect(
|
||||
shouldForceVisibleCodexTerminal('env -u DEBUG CODEX_HOME=/tmp/codex codex exec summarize')
|
||||
).toBe(false)
|
||||
expect(shouldForceVisibleCodexTerminal('codex cloud exec "fix it"')).toBe(false)
|
||||
expect(shouldForceVisibleCodexTerminal('codex cloud --version')).toBe(false)
|
||||
expect(shouldForceVisibleCodexTerminal('codex --help')).toBe(false)
|
||||
})
|
||||
|
||||
it('ignores non-Codex commands', () => {
|
||||
expect(shouldForceVisibleCodexTerminal(undefined)).toBe(false)
|
||||
expect(shouldForceVisibleCodexTerminal('claude')).toBe(false)
|
||||
expect(shouldForceVisibleCodexTerminal('npm exec codex')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,277 @@
|
|||
const CODEX_NON_INTERACTIVE_SUBCOMMANDS = new Set([
|
||||
'exec',
|
||||
'e',
|
||||
'review',
|
||||
'logout',
|
||||
'mcp',
|
||||
'plugin',
|
||||
'mcp-server',
|
||||
'app-server',
|
||||
'remote-control',
|
||||
'app',
|
||||
'completion',
|
||||
'update',
|
||||
'doctor',
|
||||
'sandbox',
|
||||
'debug',
|
||||
'execpolicy',
|
||||
'apply',
|
||||
'a',
|
||||
'cloud',
|
||||
'cloud-tasks',
|
||||
'responses-api-proxy',
|
||||
'stdio-to-uds',
|
||||
'exec-server',
|
||||
'features',
|
||||
'help',
|
||||
'version'
|
||||
])
|
||||
const CODEX_NON_INTERACTIVE_CLOUD_SUBCOMMANDS = new Set([
|
||||
'exec',
|
||||
'status',
|
||||
'list',
|
||||
'apply',
|
||||
'diff',
|
||||
'help'
|
||||
])
|
||||
const CODEX_NON_INTERACTIVE_LOGIN_SUBCOMMANDS = new Set(['status', 'help'])
|
||||
const CODEX_GLOBAL_FLAGS_WITH_VALUES = new Set([
|
||||
'--config',
|
||||
'-c',
|
||||
'--enable',
|
||||
'--disable',
|
||||
'--remote',
|
||||
'--remote-auth-token-env',
|
||||
'--image',
|
||||
'-i',
|
||||
'--model',
|
||||
'-m',
|
||||
'--local-provider',
|
||||
'--profile',
|
||||
'-p',
|
||||
'--sandbox',
|
||||
'-s',
|
||||
'--cd',
|
||||
'-C',
|
||||
'--add-dir',
|
||||
'--ask-for-approval',
|
||||
'-a'
|
||||
])
|
||||
const CODEX_GLOBAL_BOOLEAN_FLAGS = new Set([
|
||||
'--oss',
|
||||
'--dangerously-bypass-approvals-and-sandbox',
|
||||
'--search',
|
||||
'--no-alt-screen',
|
||||
'--help',
|
||||
'-h',
|
||||
'--version',
|
||||
'-V'
|
||||
])
|
||||
const CODEX_LOGIN_FLAGS_WITH_VALUES = new Set(['-c', '--config', '--enable', '--disable'])
|
||||
const CODEX_LOGIN_BOOLEAN_FLAGS = new Set([
|
||||
'--with-api-key',
|
||||
'--with-access-token',
|
||||
'--device-auth',
|
||||
'--help',
|
||||
'-h'
|
||||
])
|
||||
const CODEX_CLOUD_FLAGS_WITH_VALUES = new Set(['-c', '--config', '--enable', '--disable'])
|
||||
const CODEX_CLOUD_BOOLEAN_FLAGS = new Set(['--help', '-h', '--version', '-V'])
|
||||
|
||||
type CodexCommandToken = {
|
||||
value: string
|
||||
index: number
|
||||
}
|
||||
|
||||
function tokenizeLeadingShellWords(command: string, limit: number): string[] {
|
||||
const tokens: string[] = []
|
||||
let current = ''
|
||||
let quote: '"' | "'" | null = null
|
||||
|
||||
for (let i = 0; i < command.length; i += 1) {
|
||||
const ch = command[i]
|
||||
if (quote) {
|
||||
if (ch === quote) {
|
||||
quote = null
|
||||
} else {
|
||||
current += ch
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (ch === '"' || ch === "'") {
|
||||
quote = ch
|
||||
continue
|
||||
}
|
||||
if (/\s/.test(ch)) {
|
||||
if (current) {
|
||||
tokens.push(current)
|
||||
if (tokens.length >= limit) {
|
||||
return tokens
|
||||
}
|
||||
current = ''
|
||||
}
|
||||
continue
|
||||
}
|
||||
current += ch
|
||||
}
|
||||
|
||||
if (current && tokens.length < limit) {
|
||||
tokens.push(current)
|
||||
}
|
||||
return tokens
|
||||
}
|
||||
|
||||
function commandBasename(command: string): string {
|
||||
const normalized = command.replace(/\\/g, '/')
|
||||
return normalized.slice(normalized.lastIndexOf('/') + 1).toLowerCase()
|
||||
}
|
||||
|
||||
function isCodexExecutable(command: string): boolean {
|
||||
return command === 'codex' || command === 'codex.exe' || command === 'codex.cmd'
|
||||
}
|
||||
|
||||
function isShellAssignment(token: string): boolean {
|
||||
return /^[A-Za-z_][A-Za-z0-9_]*=/.test(token)
|
||||
}
|
||||
|
||||
function stripShellLaunchPrefix(tokens: string[]): string[] {
|
||||
const remaining = [...tokens]
|
||||
while (remaining[0] && isShellAssignment(remaining[0])) {
|
||||
remaining.shift()
|
||||
}
|
||||
if (remaining[0] && commandBasename(remaining[0]) === 'env') {
|
||||
remaining.shift()
|
||||
while (remaining[0]) {
|
||||
const token = remaining[0]
|
||||
if (isShellAssignment(token)) {
|
||||
remaining.shift()
|
||||
continue
|
||||
}
|
||||
if (token === '-u' || token === '--unset') {
|
||||
remaining.splice(0, 2)
|
||||
continue
|
||||
}
|
||||
if (token.startsWith('--unset=')) {
|
||||
remaining.shift()
|
||||
continue
|
||||
}
|
||||
if (token.startsWith('-')) {
|
||||
remaining.shift()
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
return remaining
|
||||
}
|
||||
|
||||
function codexGlobalOptionName(token: string): string {
|
||||
const separatorIndex = token.indexOf('=')
|
||||
return separatorIndex === -1 ? token : token.slice(0, separatorIndex)
|
||||
}
|
||||
|
||||
function isHelpFlag(token: string): boolean {
|
||||
return token === '--help' || token === '-h'
|
||||
}
|
||||
|
||||
function isVersionFlag(token: string): boolean {
|
||||
return token === '--version' || token === '-V'
|
||||
}
|
||||
|
||||
function findCodexSubcommand(
|
||||
tokens: string[],
|
||||
startIndex: number,
|
||||
flagsWithValues: Set<string>,
|
||||
booleanFlags: Set<string>
|
||||
): CodexCommandToken | null {
|
||||
for (let i = startIndex; i < tokens.length; i += 1) {
|
||||
const token = tokens[i]
|
||||
if (token === '--') {
|
||||
return tokens[i + 1] ? { value: '<prompt>', index: i + 1 } : null
|
||||
}
|
||||
|
||||
const optionName = codexGlobalOptionName(token)
|
||||
if (isHelpFlag(optionName) || isVersionFlag(optionName)) {
|
||||
return { value: isVersionFlag(optionName) ? 'version' : 'help', index: i }
|
||||
}
|
||||
if (flagsWithValues.has(optionName)) {
|
||||
if (optionName === token) {
|
||||
i += 1
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (booleanFlags.has(optionName)) {
|
||||
continue
|
||||
}
|
||||
return { value: token, index: i }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function isNonInteractiveCodexSubcommand(tokens: string[]): boolean {
|
||||
const subcommand = findCodexSubcommand(
|
||||
tokens,
|
||||
1,
|
||||
CODEX_GLOBAL_FLAGS_WITH_VALUES,
|
||||
CODEX_GLOBAL_BOOLEAN_FLAGS
|
||||
)
|
||||
if (!subcommand) {
|
||||
return false
|
||||
}
|
||||
|
||||
const normalizedSubcommand = subcommand.value.toLowerCase()
|
||||
if (normalizedSubcommand === 'login') {
|
||||
// Why: bare `codex login` displays an auth flow; only explicit status/help
|
||||
// or stdin-fed token modes are safe to leave in a background PTY.
|
||||
const loginStartIndex = subcommand.index + 1
|
||||
const loginSubcommand = findCodexSubcommand(
|
||||
tokens,
|
||||
loginStartIndex,
|
||||
CODEX_LOGIN_FLAGS_WITH_VALUES,
|
||||
CODEX_LOGIN_BOOLEAN_FLAGS
|
||||
)
|
||||
return (
|
||||
tokens
|
||||
.slice(loginStartIndex)
|
||||
.some((token) => token === '--with-api-key' || token === '--with-access-token') ||
|
||||
tokens.slice(loginStartIndex).some(isHelpFlag) ||
|
||||
(loginSubcommand !== null &&
|
||||
CODEX_NON_INTERACTIVE_LOGIN_SUBCOMMANDS.has(loginSubcommand.value.toLowerCase()))
|
||||
)
|
||||
}
|
||||
if (normalizedSubcommand === 'cloud') {
|
||||
// Why: bare `codex cloud` opens the interactive cloud browser, while its
|
||||
// named child commands are plain one-shot commands.
|
||||
const cloudStartIndex = subcommand.index + 1
|
||||
const cloudSubcommand = findCodexSubcommand(
|
||||
tokens,
|
||||
cloudStartIndex,
|
||||
CODEX_CLOUD_FLAGS_WITH_VALUES,
|
||||
CODEX_CLOUD_BOOLEAN_FLAGS
|
||||
)
|
||||
return (
|
||||
tokens.slice(cloudStartIndex).some((token) => isHelpFlag(token) || isVersionFlag(token)) ||
|
||||
(cloudSubcommand !== null &&
|
||||
CODEX_NON_INTERACTIVE_CLOUD_SUBCOMMANDS.has(cloudSubcommand.value.toLowerCase()))
|
||||
)
|
||||
}
|
||||
|
||||
return CODEX_NON_INTERACTIVE_SUBCOMMANDS.has(normalizedSubcommand)
|
||||
}
|
||||
|
||||
export function shouldForceVisibleCodexTerminal(command: string | undefined): boolean {
|
||||
if (!command) {
|
||||
return false
|
||||
}
|
||||
|
||||
const tokens = stripShellLaunchPrefix(
|
||||
tokenizeLeadingShellWords(command.trim(), 32).filter((token) => token.length > 0)
|
||||
)
|
||||
|
||||
const executable = tokens[0] ? commandBasename(tokens[0]) : ''
|
||||
if (!isCodexExecutable(executable)) {
|
||||
return false
|
||||
}
|
||||
|
||||
return !isNonInteractiveCodexSubcommand(tokens)
|
||||
}
|
||||
|
|
@ -11,6 +11,7 @@ import type {
|
|||
RuntimeTerminalWait
|
||||
} from '../../shared/runtime-types'
|
||||
import type { CommandHandler } from '../dispatch'
|
||||
import { shouldForceVisibleCodexTerminal } from '../codex-command-classification'
|
||||
import {
|
||||
formatTerminalClose,
|
||||
formatTerminalCreate,
|
||||
|
|
@ -122,11 +123,18 @@ export const TERMINAL_HANDLERS: Record<string, CommandHandler> = {
|
|||
'Remote terminal create requires --worktree because the client cwd cannot identify a server worktree.'
|
||||
)
|
||||
}
|
||||
const command = getOptionalStringFlag(flags, 'command')
|
||||
const focus = flags.get('focus') === true
|
||||
const rendererBacked = !client.isRemote && shouldForceVisibleCodexTerminal(command)
|
||||
const result = await client.call<{ terminal: RuntimeTerminalCreate }>('terminal.create', {
|
||||
worktree: await getBrowserWorktreeSelector(flags, cwd, client),
|
||||
command: getOptionalStringFlag(flags, 'command'),
|
||||
command,
|
||||
title: getOptionalStringFlag(flags, 'title'),
|
||||
focus: flags.get('focus') === true
|
||||
// Why: Codex's interactive TUI must be born in a renderer-backed
|
||||
// terminal. The runtime's default create path spawns first in a
|
||||
// headless/background PTY and only adopts into the UI afterward.
|
||||
focus,
|
||||
...(rendererBacked ? { rendererBacked: true, activate: focus } : {})
|
||||
})
|
||||
printResult(result, json, formatTerminalCreate)
|
||||
},
|
||||
|
|
|
|||
|
|
@ -922,6 +922,265 @@ describe('orca cli worktree awareness', () => {
|
|||
})
|
||||
})
|
||||
|
||||
it('forces the visible terminal path for interactive Codex startup commands', async () => {
|
||||
queueFixtures(
|
||||
callMock,
|
||||
okFixture('req_terminal_create', {
|
||||
terminal: {
|
||||
handle: 'term_1',
|
||||
worktreeId: 'repo-1::/tmp/repo/feature',
|
||||
title: 'Codex'
|
||||
}
|
||||
})
|
||||
)
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
|
||||
await main(
|
||||
[
|
||||
'terminal',
|
||||
'create',
|
||||
'--worktree',
|
||||
'path:/tmp/repo/feature',
|
||||
'--title',
|
||||
'Codex',
|
||||
'--command',
|
||||
'codex',
|
||||
'--json'
|
||||
],
|
||||
'/tmp/repo'
|
||||
)
|
||||
|
||||
expect(callMock).toHaveBeenCalledWith('terminal.create', {
|
||||
worktree: 'path:/tmp/repo/feature',
|
||||
command: 'codex',
|
||||
title: 'Codex',
|
||||
focus: false,
|
||||
rendererBacked: true,
|
||||
activate: false
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps explicit focus semantics when forcing Codex through the renderer path', async () => {
|
||||
queueFixtures(
|
||||
callMock,
|
||||
okFixture('req_terminal_create', {
|
||||
terminal: {
|
||||
handle: 'term_1',
|
||||
worktreeId: 'repo-1::/tmp/repo/feature',
|
||||
title: 'Codex'
|
||||
}
|
||||
})
|
||||
)
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
|
||||
await main(
|
||||
[
|
||||
'terminal',
|
||||
'create',
|
||||
'--worktree',
|
||||
'path:/tmp/repo/feature',
|
||||
'--title',
|
||||
'Codex',
|
||||
'--command',
|
||||
'codex',
|
||||
'--focus',
|
||||
'--json'
|
||||
],
|
||||
'/tmp/repo'
|
||||
)
|
||||
|
||||
expect(callMock).toHaveBeenCalledWith('terminal.create', {
|
||||
worktree: 'path:/tmp/repo/feature',
|
||||
command: 'codex',
|
||||
title: 'Codex',
|
||||
focus: true,
|
||||
rendererBacked: true,
|
||||
activate: true
|
||||
})
|
||||
})
|
||||
|
||||
it('does not force the visible terminal path for explicit Codex exec commands', async () => {
|
||||
queueFixtures(
|
||||
callMock,
|
||||
okFixture('req_terminal_create', {
|
||||
terminal: {
|
||||
handle: 'term_1',
|
||||
worktreeId: 'repo-1::/tmp/repo/feature',
|
||||
title: 'Codex exec'
|
||||
}
|
||||
})
|
||||
)
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
|
||||
await main(
|
||||
[
|
||||
'terminal',
|
||||
'create',
|
||||
'--worktree',
|
||||
'path:/tmp/repo/feature',
|
||||
'--title',
|
||||
'Codex exec',
|
||||
'--command',
|
||||
'codex exec summarize',
|
||||
'--json'
|
||||
],
|
||||
'/tmp/repo'
|
||||
)
|
||||
|
||||
expect(callMock).toHaveBeenCalledWith('terminal.create', {
|
||||
worktree: 'path:/tmp/repo/feature',
|
||||
command: 'codex exec summarize',
|
||||
title: 'Codex exec',
|
||||
focus: false
|
||||
})
|
||||
})
|
||||
|
||||
it('does not force the visible terminal path for Codex exec commands after global options', async () => {
|
||||
queueFixtures(
|
||||
callMock,
|
||||
okFixture('req_terminal_create', {
|
||||
terminal: {
|
||||
handle: 'term_1',
|
||||
worktreeId: 'repo-1::/tmp/repo/feature',
|
||||
title: 'Codex exec'
|
||||
}
|
||||
})
|
||||
)
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
|
||||
await main(
|
||||
[
|
||||
'terminal',
|
||||
'create',
|
||||
'--worktree',
|
||||
'path:/tmp/repo/feature',
|
||||
'--title',
|
||||
'Codex exec',
|
||||
'--command',
|
||||
'codex -m gpt-5 --sandbox workspace-write exec summarize',
|
||||
'--json'
|
||||
],
|
||||
'/tmp/repo'
|
||||
)
|
||||
|
||||
expect(callMock).toHaveBeenCalledWith('terminal.create', {
|
||||
worktree: 'path:/tmp/repo/feature',
|
||||
command: 'codex -m gpt-5 --sandbox workspace-write exec summarize',
|
||||
title: 'Codex exec',
|
||||
focus: false
|
||||
})
|
||||
})
|
||||
|
||||
it('does not force the visible terminal path for Codex review commands after long options', async () => {
|
||||
queueFixtures(
|
||||
callMock,
|
||||
okFixture('req_terminal_create', {
|
||||
terminal: {
|
||||
handle: 'term_1',
|
||||
worktreeId: 'repo-1::/tmp/repo/feature',
|
||||
title: 'Codex review'
|
||||
}
|
||||
})
|
||||
)
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
|
||||
await main(
|
||||
[
|
||||
'terminal',
|
||||
'create',
|
||||
'--worktree',
|
||||
'path:/tmp/repo/feature',
|
||||
'--title',
|
||||
'Codex review',
|
||||
'--command',
|
||||
'codex --model=gpt-5 --sandbox=workspace-write review',
|
||||
'--json'
|
||||
],
|
||||
'/tmp/repo'
|
||||
)
|
||||
|
||||
expect(callMock).toHaveBeenCalledWith('terminal.create', {
|
||||
worktree: 'path:/tmp/repo/feature',
|
||||
command: 'codex --model=gpt-5 --sandbox=workspace-write review',
|
||||
title: 'Codex review',
|
||||
focus: false
|
||||
})
|
||||
})
|
||||
|
||||
it('does not force the visible terminal path for Codex help commands', async () => {
|
||||
queueFixtures(
|
||||
callMock,
|
||||
okFixture('req_terminal_create', {
|
||||
terminal: {
|
||||
handle: 'term_1',
|
||||
worktreeId: 'repo-1::/tmp/repo/feature',
|
||||
title: 'Codex help'
|
||||
}
|
||||
})
|
||||
)
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
|
||||
await main(
|
||||
[
|
||||
'terminal',
|
||||
'create',
|
||||
'--worktree',
|
||||
'path:/tmp/repo/feature',
|
||||
'--title',
|
||||
'Codex help',
|
||||
'--command',
|
||||
'codex --help',
|
||||
'--json'
|
||||
],
|
||||
'/tmp/repo'
|
||||
)
|
||||
|
||||
expect(callMock).toHaveBeenCalledWith('terminal.create', {
|
||||
worktree: 'path:/tmp/repo/feature',
|
||||
command: 'codex --help',
|
||||
title: 'Codex help',
|
||||
focus: false
|
||||
})
|
||||
})
|
||||
|
||||
it('forces the visible terminal path for Codex prompts after global options', async () => {
|
||||
queueFixtures(
|
||||
callMock,
|
||||
okFixture('req_terminal_create', {
|
||||
terminal: {
|
||||
handle: 'term_1',
|
||||
worktreeId: 'repo-1::/tmp/repo/feature',
|
||||
title: 'Codex prompt'
|
||||
}
|
||||
})
|
||||
)
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
|
||||
await main(
|
||||
[
|
||||
'terminal',
|
||||
'create',
|
||||
'--worktree',
|
||||
'path:/tmp/repo/feature',
|
||||
'--title',
|
||||
'Codex prompt',
|
||||
'--command',
|
||||
'codex -m gpt-5 "fix the flaky test"',
|
||||
'--json'
|
||||
],
|
||||
'/tmp/repo'
|
||||
)
|
||||
|
||||
expect(callMock).toHaveBeenCalledWith('terminal.create', {
|
||||
worktree: 'path:/tmp/repo/feature',
|
||||
command: 'codex -m gpt-5 "fix the flaky test"',
|
||||
title: 'Codex prompt',
|
||||
focus: false,
|
||||
rendererBacked: true,
|
||||
activate: false
|
||||
})
|
||||
})
|
||||
|
||||
it('uses the resolved enclosing worktree for other worktree consumers', async () => {
|
||||
queueFixtures(
|
||||
callMock,
|
||||
|
|
@ -1121,6 +1380,44 @@ describe('orca cli worktree awareness', () => {
|
|||
})
|
||||
})
|
||||
|
||||
it('does not force remote Codex terminal creates through a local renderer path', async () => {
|
||||
queueFixtures(
|
||||
callMock,
|
||||
okFixture('req_terminal_create', {
|
||||
terminal: {
|
||||
handle: 'term_1',
|
||||
worktreeId: 'repo-1::/srv/orca/feature',
|
||||
title: 'Codex'
|
||||
}
|
||||
})
|
||||
)
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
|
||||
await main(
|
||||
[
|
||||
'terminal',
|
||||
'create',
|
||||
'--worktree',
|
||||
'id:repo-1::/srv/orca/feature',
|
||||
'--command',
|
||||
'codex',
|
||||
'--title',
|
||||
'Codex',
|
||||
'--pairing-code',
|
||||
'remote-runtime',
|
||||
'--json'
|
||||
],
|
||||
'/tmp/client/repo/src'
|
||||
)
|
||||
|
||||
expect(callMock).toHaveBeenCalledWith('terminal.create', {
|
||||
worktree: 'id:repo-1::/srv/orca/feature',
|
||||
command: 'codex',
|
||||
title: 'Codex',
|
||||
focus: false
|
||||
})
|
||||
})
|
||||
|
||||
it('does not resolve implicit remote browser targets from client cwd', async () => {
|
||||
queueFixtures(
|
||||
callMock,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { createServer } from 'http'
|
||||
import { execFileSync, spawnSync } from 'child_process'
|
||||
import { execFile, execFileSync, spawnSync } from 'child_process'
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs'
|
||||
import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
|
|
@ -180,12 +180,17 @@ describe('HermesHookService', () => {
|
|||
' platform="cli",',
|
||||
')'
|
||||
].join('\n')
|
||||
try {
|
||||
execFileSync('python3', ['-c', script], {
|
||||
// Why: the Python hook POSTs back into this process. A synchronous
|
||||
// child process blocks the HTTP server from replying, deadlocking the test.
|
||||
execFile(
|
||||
'python3',
|
||||
['-c', script],
|
||||
{
|
||||
env: {
|
||||
...process.env,
|
||||
ORCA_AGENT_HOOK_PORT: String(address.port),
|
||||
ORCA_AGENT_HOOK_TOKEN: 'token-1',
|
||||
ORCA_AGENT_HOOK_ENDPOINT: '',
|
||||
ORCA_PANE_KEY: PANE_KEY,
|
||||
ORCA_TAB_ID: 'tab-1',
|
||||
ORCA_WORKTREE_ID: 'wt-1',
|
||||
|
|
@ -193,12 +198,16 @@ describe('HermesHookService', () => {
|
|||
ORCA_AGENT_HOOK_VERSION: '1'
|
||||
},
|
||||
encoding: 'utf-8'
|
||||
})
|
||||
} catch (error) {
|
||||
clearTimeout(timeout)
|
||||
server.close()
|
||||
reject(error)
|
||||
}
|
||||
},
|
||||
(error) => {
|
||||
if (!error) {
|
||||
return
|
||||
}
|
||||
clearTimeout(timeout)
|
||||
server.close()
|
||||
reject(error)
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -6098,11 +6098,13 @@ export class OrcaRuntimeService {
|
|||
env?: Record<string, string>
|
||||
title?: string
|
||||
focus?: boolean
|
||||
rendererBacked?: boolean
|
||||
activate?: boolean
|
||||
tabId?: string
|
||||
leafId?: string
|
||||
} = {}
|
||||
): Promise<RuntimeTerminalCreate> {
|
||||
if (opts.focus !== true) {
|
||||
if (opts.focus !== true && opts.rendererBacked !== true) {
|
||||
if (!worktreeSelector) {
|
||||
throw new Error('MISSING_WORKTREE')
|
||||
}
|
||||
|
|
@ -6213,7 +6215,8 @@ export class OrcaRuntimeService {
|
|||
requestId,
|
||||
worktreeId,
|
||||
command: opts.command,
|
||||
title: opts.title
|
||||
title: opts.title,
|
||||
activate: opts.focus === true || opts.activate === true
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -319,6 +319,8 @@ const TerminalCreateParams = z.object({
|
|||
env: z.record(z.string(), z.string()).optional(),
|
||||
title: OptionalString,
|
||||
focus: z.unknown().optional(),
|
||||
rendererBacked: z.unknown().optional(),
|
||||
activate: z.unknown().optional(),
|
||||
tabId: OptionalString,
|
||||
leafId: OptionalString
|
||||
})
|
||||
|
|
@ -546,6 +548,8 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
|
|||
env: params.env,
|
||||
title: params.title,
|
||||
focus: params.focus === true,
|
||||
rendererBacked: params.rendererBacked === true,
|
||||
activate: params.activate === true,
|
||||
tabId: params.tabId,
|
||||
leafId: params.leafId
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1492,6 +1492,7 @@ export type PreloadApi = {
|
|||
afterTabId?: string
|
||||
command?: string
|
||||
title?: string
|
||||
activate?: boolean
|
||||
}) => void
|
||||
) => () => void
|
||||
replyTerminalCreate: (reply: {
|
||||
|
|
|
|||
|
|
@ -2203,6 +2203,7 @@ const api = {
|
|||
afterTabId?: string
|
||||
command?: string
|
||||
title?: string
|
||||
activate?: boolean
|
||||
}) => void
|
||||
): (() => void) => {
|
||||
const listener = (
|
||||
|
|
@ -2213,6 +2214,7 @@ const api = {
|
|||
afterTabId?: string
|
||||
command?: string
|
||||
title?: string
|
||||
activate?: boolean
|
||||
}
|
||||
) => callback(data)
|
||||
ipcRenderer.on('terminal:requestTabCreate', listener)
|
||||
|
|
|
|||
|
|
@ -3,7 +3,11 @@
|
|||
import React, { useEffect, useCallback, useMemo, useRef, useState, lazy, Suspense } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { toast } from 'sonner'
|
||||
import { TOGGLE_TERMINAL_PANE_EXPAND_EVENT } from '@/constants/terminal'
|
||||
import {
|
||||
BACKGROUND_MOUNT_TERMINAL_WORKTREE_EVENT,
|
||||
TOGGLE_TERMINAL_PANE_EXPAND_EVENT,
|
||||
type BackgroundMountTerminalWorktreeDetail
|
||||
} from '@/constants/terminal'
|
||||
import { useAppStore } from '../store'
|
||||
import { useAllWorktrees } from '../store/selectors'
|
||||
import { findWorktreeById } from '../store/slices/worktree-helpers'
|
||||
|
|
@ -48,6 +52,7 @@ import {
|
|||
import TabGroupSplitLayout from './tab-group/TabGroupSplitLayout'
|
||||
import { shouldAutoCreateInitialTerminal } from './terminal/initial-terminal'
|
||||
import { shouldRepairActiveTerminalTab } from './terminal/active-terminal-repair'
|
||||
import { addBackgroundMountedTerminalWorktree } from './terminal/background-terminal-worktree-mount'
|
||||
import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface'
|
||||
import {
|
||||
getEffectiveLayoutForWorktree as getEffectiveLayout,
|
||||
|
|
@ -508,6 +513,26 @@ function Terminal(): React.JSX.Element | null {
|
|||
// Only mount TerminalPanes for visited worktrees to prevent mass PTY
|
||||
// spawning when restoring a session with many saved worktree tabs.
|
||||
const mountedWorktreeIdsRef = useRef(new Set<string>())
|
||||
const [, setBackgroundMountRevision] = useState(0)
|
||||
useEffect(() => {
|
||||
const onBackgroundMountTerminalWorktree = (event: Event): void => {
|
||||
const customEvent = event as CustomEvent<BackgroundMountTerminalWorktreeDetail>
|
||||
addBackgroundMountedTerminalWorktree(
|
||||
mountedWorktreeIdsRef.current,
|
||||
customEvent.detail?.worktreeId,
|
||||
() => setBackgroundMountRevision((revision) => revision + 1)
|
||||
)
|
||||
}
|
||||
window.addEventListener(
|
||||
BACKGROUND_MOUNT_TERMINAL_WORKTREE_EVENT,
|
||||
onBackgroundMountTerminalWorktree as EventListener
|
||||
)
|
||||
return () =>
|
||||
window.removeEventListener(
|
||||
BACKGROUND_MOUNT_TERMINAL_WORKTREE_EVENT,
|
||||
onBackgroundMountTerminalWorktree as EventListener
|
||||
)
|
||||
}, [])
|
||||
// Why: gated on workspaceSessionReady to prevent TerminalPane from mounting
|
||||
// before reconnectPersistedTerminals() has finished eagerly spawning PTYs.
|
||||
// Without this gate, Phase 1 (hydrateWorkspaceSession) sets activeWorktreeId
|
||||
|
|
|
|||
|
|
@ -0,0 +1,26 @@
|
|||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { addBackgroundMountedTerminalWorktree } from './background-terminal-worktree-mount'
|
||||
|
||||
describe('addBackgroundMountedTerminalWorktree', () => {
|
||||
it('adds a hidden worktree mount and notifies the caller once', () => {
|
||||
const mountedWorktreeIds = new Set<string>()
|
||||
const onAdded = vi.fn()
|
||||
|
||||
expect(addBackgroundMountedTerminalWorktree(mountedWorktreeIds, 'wt-1', onAdded)).toBe(true)
|
||||
expect(mountedWorktreeIds.has('wt-1')).toBe(true)
|
||||
expect(onAdded).toHaveBeenCalledTimes(1)
|
||||
|
||||
expect(addBackgroundMountedTerminalWorktree(mountedWorktreeIds, 'wt-1', onAdded)).toBe(false)
|
||||
expect(onAdded).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('ignores missing worktree ids', () => {
|
||||
const mountedWorktreeIds = new Set<string>()
|
||||
const onAdded = vi.fn()
|
||||
|
||||
expect(addBackgroundMountedTerminalWorktree(mountedWorktreeIds, undefined, onAdded)).toBe(false)
|
||||
expect(mountedWorktreeIds.size).toBe(0)
|
||||
expect(onAdded).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
export function addBackgroundMountedTerminalWorktree(
|
||||
mountedWorktreeIds: Set<string>,
|
||||
worktreeId: string | undefined,
|
||||
onAdded: () => void
|
||||
): boolean {
|
||||
if (!worktreeId || mountedWorktreeIds.has(worktreeId)) {
|
||||
return false
|
||||
}
|
||||
mountedWorktreeIds.add(worktreeId)
|
||||
onAdded()
|
||||
return true
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@ export const FOCUS_TERMINAL_PANE_EVENT = 'orca-focus-terminal-pane'
|
|||
export const PASTE_TERMINAL_TEXT_EVENT = 'orca-paste-terminal-text'
|
||||
export const SPLIT_TERMINAL_PANE_EVENT = 'orca-split-terminal-pane'
|
||||
export const CLOSE_TERMINAL_PANE_EVENT = 'orca-close-terminal-pane'
|
||||
export const BACKGROUND_MOUNT_TERMINAL_WORKTREE_EVENT = 'orca-background-mount-terminal-worktree'
|
||||
|
||||
// Why: sidebar open/close is an instantaneous width change. If we wait for
|
||||
// the ResizeObserver rAF (and the 150ms debounced global fit) to catch up,
|
||||
|
|
@ -48,3 +49,7 @@ export type CloseTerminalPaneDetail = {
|
|||
tabId: string
|
||||
paneRuntimeId: number
|
||||
}
|
||||
|
||||
export type BackgroundMountTerminalWorktreeDetail = {
|
||||
worktreeId: string
|
||||
}
|
||||
|
|
|
|||
|
|
@ -491,6 +491,7 @@ describe('useIpcEvents updater integration', () => {
|
|||
const setTabCustomTitle = vi.fn()
|
||||
const queueTabStartupCommand = vi.fn()
|
||||
const replyTerminalCreate = vi.fn()
|
||||
const dispatchEvent = vi.fn()
|
||||
const storeState = {
|
||||
setUpdateStatus: vi.fn(),
|
||||
createTab,
|
||||
|
|
@ -541,6 +542,18 @@ describe('useIpcEvents updater integration', () => {
|
|||
}) => void)
|
||||
| null
|
||||
} = { current: null }
|
||||
const requestTerminalCreateListenerRef: {
|
||||
current:
|
||||
| ((data: {
|
||||
requestId: string
|
||||
worktreeId?: string
|
||||
afterTabId?: string
|
||||
command?: string
|
||||
title?: string
|
||||
activate?: boolean
|
||||
}) => void)
|
||||
| null
|
||||
} = { current: null }
|
||||
|
||||
vi.resetModules()
|
||||
vi.unstubAllGlobals()
|
||||
|
|
@ -586,6 +599,7 @@ describe('useIpcEvents updater integration', () => {
|
|||
}))
|
||||
|
||||
vi.stubGlobal('window', {
|
||||
dispatchEvent,
|
||||
api: {
|
||||
repos: { onChanged: () => () => {} },
|
||||
worktrees: {
|
||||
|
|
@ -620,7 +634,19 @@ describe('useIpcEvents updater integration', () => {
|
|||
createTerminalListenerRef.current = listener
|
||||
return () => {}
|
||||
},
|
||||
onRequestTerminalCreate: () => () => {},
|
||||
onRequestTerminalCreate: (
|
||||
listener: (data: {
|
||||
requestId: string
|
||||
worktreeId?: string
|
||||
afterTabId?: string
|
||||
command?: string
|
||||
title?: string
|
||||
activate?: boolean
|
||||
}) => void
|
||||
) => {
|
||||
requestTerminalCreateListenerRef.current = listener
|
||||
return () => {}
|
||||
},
|
||||
replyTerminalCreate,
|
||||
onSplitTerminal: () => () => {},
|
||||
onRenameTerminal: () => () => {},
|
||||
|
|
@ -711,6 +737,46 @@ describe('useIpcEvents updater integration', () => {
|
|||
expect(setTabCustomTitle).toHaveBeenCalledWith('tab-new', 'Runner')
|
||||
expect(queueTabStartupCommand).toHaveBeenCalledWith('tab-new', { command: 'opencode' })
|
||||
|
||||
if (typeof requestTerminalCreateListenerRef.current !== 'function') {
|
||||
throw new Error('Expected request-terminal-create listener to be registered')
|
||||
}
|
||||
|
||||
createTab.mockClear()
|
||||
setActiveView.mockClear()
|
||||
setActiveWorktree.mockClear()
|
||||
setActiveTabType.mockClear()
|
||||
setActiveTab.mockClear()
|
||||
revealWorktreeInSidebar.mockClear()
|
||||
setTabCustomTitle.mockClear()
|
||||
queueTabStartupCommand.mockClear()
|
||||
requestTerminalCreateListenerRef.current({
|
||||
requestId: 'req-renderer-backed',
|
||||
worktreeId: 'wt-2',
|
||||
title: 'Codex',
|
||||
command: 'codex',
|
||||
activate: false
|
||||
})
|
||||
|
||||
expect(createTab).toHaveBeenCalledWith('wt-2', undefined, undefined, { activate: false })
|
||||
expect(setActiveView).not.toHaveBeenCalled()
|
||||
expect(setActiveWorktree).not.toHaveBeenCalled()
|
||||
expect(setActiveTabType).not.toHaveBeenCalled()
|
||||
expect(setActiveTab).not.toHaveBeenCalled()
|
||||
expect(revealWorktreeInSidebar).not.toHaveBeenCalled()
|
||||
expect(dispatchEvent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'orca-background-mount-terminal-worktree',
|
||||
detail: { worktreeId: 'wt-2' }
|
||||
})
|
||||
)
|
||||
expect(setTabCustomTitle).toHaveBeenCalledWith('tab-new', 'Codex')
|
||||
expect(queueTabStartupCommand).toHaveBeenCalledWith('tab-new', { command: 'codex' })
|
||||
expect(replyTerminalCreate).toHaveBeenCalledWith({
|
||||
requestId: 'req-renderer-backed',
|
||||
tabId: 'tab-new',
|
||||
title: 'Codex'
|
||||
})
|
||||
|
||||
createTab.mockClear()
|
||||
createTerminalListenerRef.current({
|
||||
worktreeId: 'wt-2',
|
||||
|
|
|
|||
|
|
@ -5,7 +5,11 @@ import { getWorktreeMapFromState, getRepoMapFromState } from '@/store/selectors'
|
|||
import { applyUIZoom } from '@/lib/ui-zoom'
|
||||
import { activateAndRevealWorktree } from '@/lib/worktree-activation'
|
||||
import { runSleepWorktree } from '@/components/sidebar/sleep-worktree-flow'
|
||||
import { SPLIT_TERMINAL_PANE_EVENT, CLOSE_TERMINAL_PANE_EVENT } from '@/constants/terminal'
|
||||
import {
|
||||
BACKGROUND_MOUNT_TERMINAL_WORKTREE_EVENT,
|
||||
SPLIT_TERMINAL_PANE_EVENT,
|
||||
CLOSE_TERMINAL_PANE_EVENT
|
||||
} from '@/constants/terminal'
|
||||
import type { SplitTerminalPaneDetail, CloseTerminalPaneDetail } from '@/constants/terminal'
|
||||
import { getVisibleWorktreeIds } from '@/components/sidebar/visible-worktrees'
|
||||
import { nextEditorFontZoomLevel, computeEditorFontSize } from '@/lib/editor-font-zoom'
|
||||
|
|
@ -657,12 +661,28 @@ export function useIpcEvents(): void {
|
|||
})
|
||||
return
|
||||
}
|
||||
store.setActiveView('terminal')
|
||||
store.setActiveWorktree(worktreeId)
|
||||
// Why: CLI-driven terminal-create request is user-initiated; stamp
|
||||
// focus recency for Cmd+J. See docs/cmd-j-empty-query-ordering.md.
|
||||
store.markWorktreeVisited(worktreeId)
|
||||
const tab = store.createTab(worktreeId)
|
||||
const shouldActivate = data.activate !== false
|
||||
if (shouldActivate) {
|
||||
store.setActiveView('terminal')
|
||||
store.setActiveWorktree(worktreeId)
|
||||
// Why: CLI-driven focused terminal-create requests are user-initiated
|
||||
// worktree switches; unfocused renderer-backed creates must not reorder Cmd+J.
|
||||
store.markWorktreeVisited(worktreeId)
|
||||
} else {
|
||||
// Why: renderer-backed Codex startup must mount a TerminalPane so the
|
||||
// PTY is born in the renderer, but it must not switch the active UI.
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(BACKGROUND_MOUNT_TERMINAL_WORKTREE_EVENT, {
|
||||
detail: { worktreeId }
|
||||
})
|
||||
)
|
||||
}
|
||||
const tab = store.createTab(
|
||||
worktreeId,
|
||||
undefined,
|
||||
undefined,
|
||||
shouldActivate ? undefined : { activate: false }
|
||||
)
|
||||
if (data.afterTabId) {
|
||||
const createdUnifiedTab = useAppStore
|
||||
.getState()
|
||||
|
|
@ -688,9 +708,11 @@ export function useIpcEvents(): void {
|
|||
useAppStore.getState().reorderUnifiedTabs(createdUnifiedTab.groupId, order)
|
||||
}
|
||||
}
|
||||
store.setActiveTabType('terminal')
|
||||
store.setActiveTab(tab.id)
|
||||
store.revealWorktreeInSidebar(worktreeId)
|
||||
if (shouldActivate) {
|
||||
store.setActiveTabType('terminal')
|
||||
store.setActiveTab(tab.id)
|
||||
store.revealWorktreeInSidebar(worktreeId)
|
||||
}
|
||||
if (data.title) {
|
||||
store.setTabCustomTitle(tab.id, data.title)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue