From b609aa2bec2fb78f814b61c7f899afc59e450e5c Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Fri, 15 May 2026 20:31:28 -0700 Subject: [PATCH] Fix Codex terminal orchestration (#2039) --- skills/orca-cli/SKILL.md | 2 +- skills/orchestration/SKILL.md | 2 +- src/cli/codex-command-classification.test.ts | 39 +++ src/cli/codex-command-classification.ts | 277 ++++++++++++++++ src/cli/handlers/terminal.ts | 12 +- src/cli/index.test.ts | 297 ++++++++++++++++++ src/main/hermes/hook-service.test.ts | 27 +- src/main/runtime/orca-runtime.ts | 7 +- src/main/runtime/rpc/methods/terminal.ts | 4 + src/preload/api-types.ts | 1 + src/preload/index.ts | 2 + src/renderer/src/components/Terminal.tsx | 27 +- ...background-terminal-worktree-mount.test.ts | 26 ++ .../background-terminal-worktree-mount.ts | 12 + src/renderer/src/constants/terminal.ts | 5 + src/renderer/src/hooks/useIpcEvents.test.ts | 68 +++- src/renderer/src/hooks/useIpcEvents.ts | 42 ++- 17 files changed, 823 insertions(+), 27 deletions(-) create mode 100644 src/cli/codex-command-classification.test.ts create mode 100644 src/cli/codex-command-classification.ts create mode 100644 src/renderer/src/components/terminal/background-terminal-worktree-mount.test.ts create mode 100644 src/renderer/src/components/terminal/background-terminal-worktree-mount.ts diff --git a/skills/orca-cli/SKILL.md b/skills/orca-cli/SKILL.md index b4831d58a..f44cdae4b 100644 --- a/skills/orca-cli/SKILL.md +++ b/skills/orca-cli/SKILL.md @@ -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 --for exit` only when the task actually depends on process completion. - Use `terminal wait --terminal --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. diff --git a/skills/orchestration/SKILL.md b/skills/orchestration/SKILL.md index a911c3519..293e60ce2 100644 --- a/skills/orchestration/SKILL.md +++ b/skills/orchestration/SKILL.md @@ -157,7 +157,7 @@ orca terminal close [--terminal ] [--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. diff --git a/src/cli/codex-command-classification.test.ts b/src/cli/codex-command-classification.test.ts new file mode 100644 index 000000000..7f515b521 --- /dev/null +++ b/src/cli/codex-command-classification.test.ts @@ -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) + }) +}) diff --git a/src/cli/codex-command-classification.ts b/src/cli/codex-command-classification.ts new file mode 100644 index 000000000..0baff6e07 --- /dev/null +++ b/src/cli/codex-command-classification.ts @@ -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, + booleanFlags: Set +): CodexCommandToken | null { + for (let i = startIndex; i < tokens.length; i += 1) { + const token = tokens[i] + if (token === '--') { + return tokens[i + 1] ? { value: '', 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) +} diff --git a/src/cli/handlers/terminal.ts b/src/cli/handlers/terminal.ts index 831796a48..6d4092c11 100644 --- a/src/cli/handlers/terminal.ts +++ b/src/cli/handlers/terminal.ts @@ -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 = { '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) }, diff --git a/src/cli/index.test.ts b/src/cli/index.test.ts index b5d8b372e..addffeb3b 100644 --- a/src/cli/index.test.ts +++ b/src/cli/index.test.ts @@ -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, diff --git a/src/main/hermes/hook-service.test.ts b/src/main/hermes/hook-service.test.ts index 9077fe69f..bc6bc1a5a 100644 --- a/src/main/hermes/hook-service.test.ts +++ b/src/main/hermes/hook-service.test.ts @@ -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) + } + ) }) }) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 22d771746..bafbee32d 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -6098,11 +6098,13 @@ export class OrcaRuntimeService { env?: Record title?: string focus?: boolean + rendererBacked?: boolean + activate?: boolean tabId?: string leafId?: string } = {} ): Promise { - 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 }) }) diff --git a/src/main/runtime/rpc/methods/terminal.ts b/src/main/runtime/rpc/methods/terminal.ts index 45d144777..982be3d9f 100644 --- a/src/main/runtime/rpc/methods/terminal.ts +++ b/src/main/runtime/rpc/methods/terminal.ts @@ -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 }) diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 2cd66348d..31f32d315 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -1492,6 +1492,7 @@ export type PreloadApi = { afterTabId?: string command?: string title?: string + activate?: boolean }) => void ) => () => void replyTerminalCreate: (reply: { diff --git a/src/preload/index.ts b/src/preload/index.ts index ea337c4d8..dcba078a0 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -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) diff --git a/src/renderer/src/components/Terminal.tsx b/src/renderer/src/components/Terminal.tsx index 55c287bb8..4ba403581 100644 --- a/src/renderer/src/components/Terminal.tsx +++ b/src/renderer/src/components/Terminal.tsx @@ -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()) + const [, setBackgroundMountRevision] = useState(0) + useEffect(() => { + const onBackgroundMountTerminalWorktree = (event: Event): void => { + const customEvent = event as CustomEvent + 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 diff --git a/src/renderer/src/components/terminal/background-terminal-worktree-mount.test.ts b/src/renderer/src/components/terminal/background-terminal-worktree-mount.test.ts new file mode 100644 index 000000000..f2cef1eae --- /dev/null +++ b/src/renderer/src/components/terminal/background-terminal-worktree-mount.test.ts @@ -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() + 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() + const onAdded = vi.fn() + + expect(addBackgroundMountedTerminalWorktree(mountedWorktreeIds, undefined, onAdded)).toBe(false) + expect(mountedWorktreeIds.size).toBe(0) + expect(onAdded).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/terminal/background-terminal-worktree-mount.ts b/src/renderer/src/components/terminal/background-terminal-worktree-mount.ts new file mode 100644 index 000000000..3aff5faf5 --- /dev/null +++ b/src/renderer/src/components/terminal/background-terminal-worktree-mount.ts @@ -0,0 +1,12 @@ +export function addBackgroundMountedTerminalWorktree( + mountedWorktreeIds: Set, + worktreeId: string | undefined, + onAdded: () => void +): boolean { + if (!worktreeId || mountedWorktreeIds.has(worktreeId)) { + return false + } + mountedWorktreeIds.add(worktreeId) + onAdded() + return true +} diff --git a/src/renderer/src/constants/terminal.ts b/src/renderer/src/constants/terminal.ts index 570685d88..86b35c48d 100644 --- a/src/renderer/src/constants/terminal.ts +++ b/src/renderer/src/constants/terminal.ts @@ -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 +} diff --git a/src/renderer/src/hooks/useIpcEvents.test.ts b/src/renderer/src/hooks/useIpcEvents.test.ts index b73359289..a045f7716 100644 --- a/src/renderer/src/hooks/useIpcEvents.test.ts +++ b/src/renderer/src/hooks/useIpcEvents.test.ts @@ -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', diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index 172094ce5..8ba2b1361 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -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) }