Fix Codex terminal orchestration (#2039)

This commit is contained in:
Jinjing 2026-05-15 20:31:28 -07:00 committed by GitHub
parent 6fe73ee1b4
commit b609aa2bec
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 823 additions and 27 deletions

View File

@ -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.

View File

@ -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.

View File

@ -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)
})
})

View File

@ -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)
}

View File

@ -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)
},

View File

@ -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,

View File

@ -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)
}
)
})
})

View File

@ -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
})
})

View File

@ -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
})

View File

@ -1492,6 +1492,7 @@ export type PreloadApi = {
afterTabId?: string
command?: string
title?: string
activate?: boolean
}) => void
) => () => void
replyTerminalCreate: (reply: {

View File

@ -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)

View File

@ -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

View File

@ -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()
})
})

View File

@ -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
}

View File

@ -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
}

View File

@ -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',

View File

@ -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)
}