From 66fdf4ece393c77e6eb9e9ebf68d611c00a4ce1b Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Mon, 11 May 2026 21:10:49 -0400 Subject: [PATCH] Support background Orca CLI terminals Co-authored-by: Orca --- src/cli/format.ts | 3 +- src/cli/handlers/terminal.ts | 3 +- src/cli/handlers/worktree.ts | 3 +- src/cli/help.ts | 8 +- src/cli/index.test.ts | 86 +++ src/cli/specs/core.ts | 31 +- src/main/ipc/pty.test.ts | 82 ++- src/main/ipc/pty.ts | 150 ++++- src/main/runtime/orca-runtime.test.ts | 605 +++++++++++++++++- src/main/runtime/orca-runtime.ts | 359 +++++++++-- src/main/runtime/rpc/methods/terminal.ts | 6 +- src/main/runtime/rpc/methods/worktree.ts | 2 + .../window/attach-main-window-services.ts | 32 + src/preload/api-types.ts | 9 +- src/preload/index.ts | 18 +- src/renderer/src/components/Terminal.tsx | 13 +- .../terminal/active-terminal-repair.test.ts | 52 ++ .../terminal/active-terminal-repair.ts | 18 + .../components/terminal/useTerminalTabs.ts | 8 +- src/renderer/src/hooks/useIpcEvents.test.ts | 155 +++-- src/renderer/src/hooks/useIpcEvents.ts | 72 ++- src/renderer/src/lib/setup-runner.ts | 52 +- src/renderer/src/store/slices/terminals.ts | 38 +- src/shared/runtime-types.ts | 1 + src/shared/setup-runner-command.ts | 39 ++ 25 files changed, 1630 insertions(+), 215 deletions(-) create mode 100644 src/renderer/src/components/terminal/active-terminal-repair.test.ts create mode 100644 src/renderer/src/components/terminal/active-terminal-repair.ts create mode 100644 src/shared/setup-runner-command.ts diff --git a/src/cli/format.ts b/src/cli/format.ts index bff5a880505..93bc9d6fb5c 100644 --- a/src/cli/format.ts +++ b/src/cli/format.ts @@ -152,7 +152,8 @@ export function formatTerminalRename(result: { rename: RuntimeTerminalRename }): export function formatTerminalCreate(result: { terminal: RuntimeTerminalCreate }): string { const titleNote = result.terminal.title ? ` (title: "${result.terminal.title}")` : '' - return `Created terminal ${result.terminal.handle}${titleNote}` + const surfaceNote = result.terminal.surface ? ` [${result.terminal.surface}]` : '' + return `Created terminal ${result.terminal.handle}${titleNote}${surfaceNote}` } export function formatTerminalSplit(result: { split: RuntimeTerminalSplit }): string { diff --git a/src/cli/handlers/terminal.ts b/src/cli/handlers/terminal.ts index c397021a8cf..e59ad46e712 100644 --- a/src/cli/handlers/terminal.ts +++ b/src/cli/handlers/terminal.ts @@ -119,7 +119,8 @@ export const TERMINAL_HANDLERS: Record = { const result = await client.call<{ terminal: RuntimeTerminalCreate }>('terminal.create', { worktree: await getBrowserWorktreeSelector(flags, cwd, client), command: getOptionalStringFlag(flags, 'command'), - title: getOptionalStringFlag(flags, 'title') + title: getOptionalStringFlag(flags, 'title'), + focus: flags.get('focus') === true }) printResult(result, json, formatTerminalCreate) }, diff --git a/src/cli/handlers/worktree.ts b/src/cli/handlers/worktree.ts index 7377f817c25..5dfe9873d60 100644 --- a/src/cli/handlers/worktree.ts +++ b/src/cli/handlers/worktree.ts @@ -59,7 +59,8 @@ export const WORKTREE_HANDLERS: Record = { baseBranch: getOptionalStringFlag(flags, 'base-branch'), linkedIssue: getOptionalNumberFlag(flags, 'issue'), comment: getOptionalStringFlag(flags, 'comment'), - runHooks: flags.get('run-hooks') === true + runHooks: flags.get('run-hooks') === true, + activate: flags.get('activate') === true || flags.get('run-hooks') === true } ) printHookWarning(result.result, json) diff --git a/src/cli/help.ts b/src/cli/help.ts index 4b34a18a7f9..411ddd98715 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -33,7 +33,7 @@ Terminals: terminal send Send input to a live terminal terminal wait Wait for a terminal condition (exit, tui-idle) terminal stop Stop terminals for a worktree - terminal create Create a new terminal tab in a worktree + terminal create Create a terminal session in a worktree terminal rename Set or clear the title of a terminal tab terminal split Split an existing terminal pane terminal switch Bring a terminal tab to the foreground @@ -140,7 +140,7 @@ Common Commands: orca open [--json] orca status [--json] orca worktree list [--repo ] [--limit ] [--json] - orca worktree create --repo --name [--base-branch ] [--issue ] [--comment ] [--run-hooks] [--json] + orca worktree create --repo --name [--base-branch ] [--issue ] [--comment ] [--run-hooks] [--activate] [--json] orca worktree show --worktree [--json] orca worktree current [--json] orca worktree set --worktree [--display-name ] [--issue ] [--comment ] [--json] @@ -152,7 +152,7 @@ Common Commands: orca terminal send [--terminal ] [--text ] [--enter] [--interrupt] [--json] orca terminal wait [--terminal ] --for exit|tui-idle [--timeout-ms ] [--json] orca terminal stop --worktree [--json] - orca terminal create [--worktree ] [--title ] [--command ] [--json] + orca terminal create [--worktree ] [--title ] [--command ] [--focus] [--json] orca terminal split [--terminal ] [--direction horizontal|vertical] [--json] orca terminal switch [--terminal ] [--json] orca terminal close [--terminal ] [--json] @@ -307,6 +307,7 @@ export function formatFlagHelp(flag: string): string { comment: '--comment Comment stored in Orca metadata', cursor: '--cursor Line cursor from a previous read (returns only new output)', action: '--action Secondary accessibility action name', + activate: '--activate Reveal the new worktree in the Orca app', app: '--app App name, bundle ID, or pid:N', direction: '--direction Direction: up|down|left|right for scroll, horizontal|vertical for split', @@ -315,6 +316,7 @@ export function formatFlagHelp(flag: string): string { title: '--title Custom title for the terminal tab (omit to reset)', enter: '--enter Append Enter after sending text', force: '--force Force worktree removal when supported', + focus: '--focus Reveal the created terminal session in Orca', for: '--for exit|tui-idle Wait condition to satisfy', 'from-element-index': '--from-element-index Source element index from get-app-state', 'from-x': '--from-x Source window-local x coordinate', diff --git a/src/cli/index.test.ts b/src/cli/index.test.ts index 675c1105932..668354a2607 100644 --- a/src/cli/index.test.ts +++ b/src/cli/index.test.ts @@ -1,3 +1,4 @@ +/* eslint-disable max-lines -- Why: CLI parser tests share one mocked runtime client and fixture queue; splitting this file would duplicate setup and make command coverage harder to audit. */ import path from 'path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -145,6 +146,91 @@ describe('orca cli worktree awareness', () => { }) }) + it('passes explicit activation through worktree.create', async () => { + queueFixtures( + callMock, + okFixture('req_create', { + worktree: buildWorktree('/tmp/repo/feature', 'feature', 'abc', 'repo-1') + }) + ) + vi.spyOn(console, 'log').mockImplementation(() => {}) + + await main( + ['worktree', 'create', '--repo', 'id:repo-1', '--name', 'feature', '--activate', '--json'], + '/tmp/repo' + ) + + expect(callMock).toHaveBeenCalledWith('worktree.create', { + repo: 'id:repo-1', + name: 'feature', + baseBranch: undefined, + linkedIssue: undefined, + comment: undefined, + runHooks: false, + activate: true + }) + }) + + it('opts into setup and activation when worktree.create runs hooks', async () => { + queueFixtures( + callMock, + okFixture('req_create', { + worktree: buildWorktree('/tmp/repo/feature', 'feature', 'abc', 'repo-1') + }) + ) + vi.spyOn(console, 'log').mockImplementation(() => {}) + + await main( + ['worktree', 'create', '--repo', 'id:repo-1', '--name', 'feature', '--run-hooks', '--json'], + '/tmp/repo' + ) + + expect(callMock).toHaveBeenCalledWith('worktree.create', { + repo: 'id:repo-1', + name: 'feature', + baseBranch: undefined, + linkedIssue: undefined, + comment: undefined, + runHooks: true, + activate: true + }) + }) + + it('passes explicit focus through terminal.create', async () => { + queueFixtures( + callMock, + okFixture('req_terminal_create', { + terminal: { + handle: 'term_1', + worktreeId: 'repo-1::/tmp/repo/feature', + title: 'RUNNER' + } + }) + ) + vi.spyOn(console, 'log').mockImplementation(() => {}) + + await main( + [ + 'terminal', + 'create', + '--worktree', + 'path:/tmp/repo/feature', + '--title', + 'RUNNER', + '--focus', + '--json' + ], + '/tmp/repo' + ) + + expect(callMock).toHaveBeenCalledWith('terminal.create', { + worktree: 'path:/tmp/repo/feature', + command: undefined, + title: 'RUNNER', + focus: true + }) + }) + it('uses the resolved enclosing worktree for other worktree consumers', async () => { queueFixtures( callMock, diff --git a/src/cli/specs/core.ts b/src/cli/specs/core.ts index ca40b6bd846..b85424e8ac0 100644 --- a/src/cli/specs/core.ts +++ b/src/cli/specs/core.ts @@ -72,11 +72,22 @@ export const CORE_COMMAND_SPECS: CommandSpec[] = [ path: ['worktree', 'create'], summary: 'Create a new Orca-managed worktree', usage: - 'orca worktree create --repo --name [--base-branch ] [--issue ] [--comment ] [--run-hooks] [--json]', - allowedFlags: [...GLOBAL_FLAGS, 'repo', 'name', 'base-branch', 'issue', 'comment', 'run-hooks'], + 'orca worktree create --repo --name [--base-branch ] [--issue ] [--comment ] [--run-hooks] [--activate] [--json]', + allowedFlags: [ + ...GLOBAL_FLAGS, + 'repo', + 'name', + 'base-branch', + 'issue', + 'comment', + 'run-hooks', + 'activate' + ], notes: [ - 'By default this matches the Orca UI flow and activates the new worktree in the app.', - 'Repo-defined orca.yaml hooks are skipped unless --run-hooks is passed.' + 'By default this creates the worktree and its first terminal without switching the active Orca workspace.', + 'Repo-defined setup hooks follow the repository setup policy; pass --run-hooks to force them.', + 'Pass --activate when the CLI caller intentionally wants to reveal the new worktree in the app.', + 'Passing --run-hooks reveals the worktree so the setup hook can run in its first terminal.' ] }, { @@ -148,13 +159,17 @@ export const CORE_COMMAND_SPECS: CommandSpec[] = [ }, { path: ['terminal', 'create'], - summary: 'Create a new terminal tab in the current worktree', + summary: 'Create a terminal session in the current worktree', usage: - 'orca terminal create [--worktree ] [--title ] [--command ] [--json]', - allowedFlags: [...GLOBAL_FLAGS, 'worktree', 'command', 'title'], + 'orca terminal create [--worktree ] [--title ] [--command ] [--focus] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'worktree', 'command', 'title', 'focus'], + notes: [ + 'Creates a visible terminal tab without switching focus when possible; falls back to a background handle if the UI cannot adopt it. Pass --focus to switch to it.' + ], examples: [ 'orca terminal create --json', - 'orca terminal create --worktree path:/projects/myapp --title "RUNNER" --command "opencode"' + 'orca terminal create --worktree path:/projects/myapp --title "RUNNER" --command "opencode"', + 'orca terminal create --worktree path:/projects/myapp --command "opencode" --focus' ] }, { diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index 8651d7d6222..af3d0b99d62 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -135,10 +135,14 @@ describe('registerPtyHandlers', () => { } const savedPiAgentDir = process.env.PI_CODING_AGENT_DIR + const savedOrcaOpenCodeConfigDir = process.env.ORCA_OPENCODE_CONFIG_DIR + const savedOrcaPiAgentDir = process.env.ORCA_PI_CODING_AGENT_DIR beforeEach(() => { delete process.env.OPENCODE_CONFIG_DIR delete process.env.PI_CODING_AGENT_DIR + delete process.env.ORCA_OPENCODE_CONFIG_DIR + delete process.env.ORCA_PI_CODING_AGENT_DIR handlers.clear() handleMock.mockReset() onMock.mockReset() @@ -201,9 +205,21 @@ describe('registerPtyHandlers', () => { afterEach(() => { unregisterSshPtyProvider('ssh-1') setLocalPtyProvider(new LocalPtyProvider()) - if (savedPiAgentDir !== undefined) { + if (savedPiAgentDir === undefined) { + delete process.env.PI_CODING_AGENT_DIR + } else { process.env.PI_CODING_AGENT_DIR = savedPiAgentDir } + if (savedOrcaOpenCodeConfigDir === undefined) { + delete process.env.ORCA_OPENCODE_CONFIG_DIR + } else { + process.env.ORCA_OPENCODE_CONFIG_DIR = savedOrcaOpenCodeConfigDir + } + if (savedOrcaPiAgentDir === undefined) { + delete process.env.ORCA_PI_CODING_AGENT_DIR + } else { + process.env.ORCA_PI_CODING_AGENT_DIR = savedOrcaPiAgentDir + } }) function createMockProc() { @@ -902,6 +918,70 @@ describe('registerPtyHandlers', () => { ) }) + it('reuses the runtime background handle in local PTY spawn env', async () => { + type RuntimeSpawnController = { + spawn(args: { + cols: number + rows: number + worktreeId?: string + preAllocatedHandle?: string + }): Promise<{ id: string }> + } + let controller: RuntimeSpawnController | null = null + const runtime = { + setPtyController: vi.fn((value) => { + controller = value + }), + preAllocateHandleForPty: vi.fn(() => 'term_wrong'), + registerPreAllocatedHandleForPty: vi.fn(), + registerPty: vi.fn(), + onPtySpawned: vi.fn(), + onPtyExit: vi.fn(), + onPtyData: vi.fn() + } + + registerPtyHandlers(mainWindow as never, runtime as never) + expect(controller).not.toBeNull() + const spawnController = controller as unknown as RuntimeSpawnController + await spawnController.spawn({ + cols: 80, + rows: 24, + worktreeId: 'wt-1', + preAllocatedHandle: 'term_expected' + }) + + const spawnCall = spawnMock.mock.calls.at(-1)! + const env = spawnCall[2].env as Record + expect(env.ORCA_TERMINAL_HANDLE).toBe('term_expected') + expect(runtime.preAllocateHandleForPty).not.toHaveBeenCalled() + expect(runtime.registerPreAllocatedHandleForPty).toHaveBeenCalledWith( + expect.any(String), + 'term_expected' + ) + }) + + it('ignores renderer-provided ORCA_TERMINAL_HANDLE for local PTY spawns', async () => { + const runtime = { + setPtyController: vi.fn(), + preAllocateHandleForPty: vi.fn(() => 'term_trusted'), + onPtySpawned: vi.fn(), + onPtyExit: vi.fn(), + onPtyData: vi.fn() + } + + registerPtyHandlers(mainWindow as never, runtime as never) + await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + env: { ORCA_TERMINAL_HANDLE: 'term_untrusted' } + }) + + const spawnCall = spawnMock.mock.calls.at(-1)! + const env = spawnCall[2].env as Record + expect(env.ORCA_TERMINAL_HANDLE).toBe('term_trusted') + expect(runtime.preAllocateHandleForPty).toHaveBeenCalledWith(expect.any(String)) + }) + describe('Windows UTF-8 code page', () => { let originalPlatform: string let originalComspec: string | undefined diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index cbbe63a4de0..8805b80d702 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -138,6 +138,21 @@ function getProviderForPty(ptyId: string): IPtyProvider { return getProvider(connectionId) } +function normalizeNodePtySpawnError(err: unknown): Error { + const rawMessage = err instanceof Error ? err.message : String(err) + const hintedMessage = addNodePtyRecoveryHint(rawMessage) + if (hintedMessage === rawMessage && err instanceof Error) { + return err + } + if (err instanceof Error) { + // Why: preserve the original stack/name/custom fields while returning the + // same recovery guidance as the renderer-driven pty:spawn path. + err.message = hintedMessage + return err + } + return new Error(hintedMessage) +} + // ─── Host PTY env assembly ────────────────────────────────────────── // Why: both the LocalPtyProvider.buildSpawnEnv closure and the daemon-active // fallback in pty:spawn need the same set of host-local env injections @@ -467,7 +482,14 @@ export function registerPtyHandlers( }) // Why: agents need their own terminal handle at process start so they // can self-identify in orchestration messages without an extra RPC. - const preAllocatedHandle = runtime?.preAllocateHandleForPty(id) + const requestedHandle = baseEnv.ORCA_TERMINAL_HANDLE + const preAllocatedHandle = + requestedHandle && trustedTerminalHandleEnv.has(requestedHandle) + ? requestedHandle + : runtime?.preAllocateHandleForPty(id) + if (requestedHandle && requestedHandle !== preAllocatedHandle) { + delete env.ORCA_TERMINAL_HANDLE + } if (preAllocatedHandle) { env.ORCA_TERMINAL_HANDLE = preAllocatedHandle } @@ -488,6 +510,7 @@ export function registerPtyHandlers( // reduces IPC round-trips from hundreds/sec to ~120/sec under high // throughput, with no perceptible latency increase for interactive use. const pendingData = new Map() + const trustedTerminalHandleEnv = new Set() let flushTimer: ReturnType | null = null const PTY_BATCH_INTERVAL_MS = 8 @@ -675,6 +698,110 @@ export function registerPtyHandlers( // CLI commands (terminal.send, terminal.stop) work for both local and remote PTYs. // Hardcoding localProvider.getPtyProcess() would silently fail for remote PTYs. runtime?.setPtyController({ + spawn: async (args) => { + const provider = getProvider(args.connectionId) + const isClaudeLaunch = !args.connectionId && isClaudeLaunchCommand(args.command) + if (isClaudeLaunch && isClaudeAuthSwitchInProgress()) { + throw new Error('A Claude account switch is in progress. Try again after it finishes.') + } + const claudeAuth = isClaudeLaunch && prepareClaudeAuth ? await prepareClaudeAuth() : null + if (isClaudeLaunch && isClaudeAuthSwitchInProgress()) { + throw new Error('A Claude account switch is in progress. Try again after it finishes.') + } + if (claudeAuth?.stripAuthEnv && hasClaudeAuthEnvConflict(args.env)) { + throw new Error( + 'This Claude launch defines explicit Anthropic auth environment variables. Remove those overrides before using a managed Claude account.' + ) + } + + const isDaemonHostSpawn = !args.connectionId && !(provider instanceof LocalPtyProvider) + const sessionId = isDaemonHostSpawn ? mintPtySessionId(args.worktreeId) : undefined + let env: Record | undefined = claudeAuth + ? { ...args.env, ...claudeAuth.envPatch } + : args.env + if (args.preAllocatedHandle) { + env = { ...env, ORCA_TERMINAL_HANDLE: args.preAllocatedHandle } + } + if (isDaemonHostSpawn && sessionId) { + if (!isSafePtySessionId(sessionId, app.getPath('userData'))) { + throw new Error('Invalid PTY session id') + } + env = buildPtyHostEnv(sessionId, env ?? {}, { + isPackaged: app.isPackaged, + userDataPath: app.getPath('userData'), + selectedCodexHomePath: getSelectedCodexHomePath?.() ?? null, + githubAttributionEnabled: getSettings?.()?.enableGitHubAttribution ?? false + }) + } + + const spawnOptions: PtySpawnOptions = { + cols: args.cols, + rows: args.rows, + cwd: args.cwd, + env + } + if (claudeAuth?.stripAuthEnv) { + spawnOptions.envToDelete = [...CLAUDE_AUTH_ENV_VARS, 'ANTHROPIC_CUSTOM_HEADERS'] + } + if (args.command !== undefined) { + spawnOptions.command = args.command + } + if (args.worktreeId !== undefined) { + spawnOptions.worktreeId = args.worktreeId + } + if (sessionId !== undefined) { + spawnOptions.sessionId = sessionId + ptySizes.set(sessionId, { cols: args.cols, rows: args.rows }) + } + if (process.platform === 'win32' && !args.connectionId) { + spawnOptions.shellOverride = getSettings?.()?.terminalWindowsShell + spawnOptions.terminalWindowsPowerShellImplementation = getSettings + ? (getSettings()?.terminalWindowsPowerShellImplementation ?? 'auto') + : undefined + } + + let result: PtySpawnResult + try { + if (args.preAllocatedHandle) { + trustedTerminalHandleEnv.add(args.preAllocatedHandle) + } + result = await provider.spawn(spawnOptions) + } catch (err) { + if (sessionId !== undefined) { + ptySizes.delete(sessionId) + clearProviderPtyState(sessionId) + } + throw normalizeNodePtySpawnError(err) + } finally { + if (args.preAllocatedHandle) { + trustedTerminalHandleEnv.delete(args.preAllocatedHandle) + } + } + ptyOwnership.set(result.id, args.connectionId ?? null) + ptySizes.set(result.id, { cols: args.cols, rows: args.rows }) + if (args.preAllocatedHandle) { + runtime?.registerPreAllocatedHandleForPty(result.id, args.preAllocatedHandle) + } + if (args.worktreeId) { + runtime?.registerPty(result.id, args.worktreeId) + } + if (isClaudeLaunch) { + markClaudePtySpawned(result.id) + } + if (!args.connectionId) { + registerPty({ + ptyId: result.id, + worktreeId: args.worktreeId ?? null, + sessionId: sessionId ?? null, + paneKey: null, + pid: + typeof result.pid === 'number' && Number.isFinite(result.pid) && result.pid > 0 + ? result.pid + : null + }) + } + return { id: result.id } + }, write: (ptyId, data) => { const provider = getProviderForPty(ptyId) try { @@ -923,21 +1050,12 @@ export function registerPtyHandlers( } let result: PtySpawnResult try { + if (preAllocatedHandle) { + trustedTerminalHandleEnv.add(preAllocatedHandle) + } result = await provider.spawn(spawnOptions) } catch (err) { - const rawMessage = err instanceof Error ? err.message : String(err) - const hintedMessage = addNodePtyRecoveryHint(rawMessage) - let spawnError: Error - if (hintedMessage === rawMessage && err instanceof Error) { - spawnError = err - } else if (err instanceof Error) { - // Why: rewrite the message in place so the original stack trace, - // name, and any custom fields survive into telemetry and logs. - err.message = hintedMessage - spawnError = err - } else { - spawnError = new Error(hintedMessage) - } + const spawnError = normalizeNodePtySpawnError(err) if (effectiveSessionId !== undefined) { ptySizes.delete(effectiveSessionId) } @@ -972,6 +1090,10 @@ export function registerPtyHandlers( }) } throw spawnError + } finally { + if (preAllocatedHandle) { + trustedTerminalHandleEnv.delete(preAllocatedHandle) + } } ptyOwnership.set(result.id, args.connectionId ?? null) if (preAllocatedHandle) { diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index c1a098ae3de..ba6a643d975 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -401,6 +401,305 @@ describe('OrcaRuntimeService', () => { expect(writes).toEqual(['continue', '\r']) }) + it('creates visible terminal sessions without asking the renderer to focus a tab', async () => { + const spawn = vi.fn().mockResolvedValue({ id: 'pty-bg' }) + const createTerminal = vi.fn() + const revealTerminalSession = vi.fn().mockResolvedValue({ tabId: 'tab-bg' }) + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + runtime.setNotifier({ + worktreesChanged: vi.fn(), + reposChanged: vi.fn(), + activateWorktree: vi.fn(), + createTerminal, + revealTerminalSession, + splitTerminal: vi.fn(), + renameTerminal: vi.fn(), + focusTerminal: vi.fn(), + closeTerminal: vi.fn(), + sleepWorktree: vi.fn(), + terminalFitOverrideChanged: vi.fn(), + terminalDriverChanged: vi.fn() + }) + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { tabs: [], leaves: [] }) + + const result = await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`, { + command: 'codex', + title: 'worker' + }) + + expect(spawn).toHaveBeenCalledWith( + expect.objectContaining({ + cwd: TEST_WORKTREE_PATH, + command: 'codex', + worktreeId: TEST_WORKTREE_ID, + preAllocatedHandle: expect.stringMatching(/^term_/) + }) + ) + expect(result).toMatchObject({ + worktreeId: TEST_WORKTREE_ID, + title: 'worker', + surface: 'visible' + }) + expect(result.handle).toMatch(/^term_/) + expect(createTerminal).not.toHaveBeenCalled() + expect(revealTerminalSession).toHaveBeenCalledWith(TEST_WORKTREE_ID, { + ptyId: 'pty-bg', + title: 'worker', + activate: false + }) + }) + + it('creates background terminal sessions while the renderer graph is unavailable', async () => { + const spawn = vi.fn().mockResolvedValue({ id: 'pty-bg' }) + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + + await expect(runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`)).resolves.toMatchObject({ + worktreeId: TEST_WORKTREE_ID, + surface: 'background' + }) + expect(spawn).toHaveBeenCalledWith( + expect.objectContaining({ + worktreeId: TEST_WORKTREE_ID + }) + ) + }) + + it('returns a background handle when inactive tab adoption fails after spawn', async () => { + const spawn = vi.fn().mockResolvedValue({ id: 'pty-bg' }) + const revealTerminalSession = vi.fn().mockRejectedValue(new Error('Renderer timed out')) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + runtime.setNotifier({ + worktreesChanged: vi.fn(), + reposChanged: vi.fn(), + activateWorktree: vi.fn(), + createTerminal: vi.fn(), + revealTerminalSession, + splitTerminal: vi.fn(), + renameTerminal: vi.fn(), + focusTerminal: vi.fn(), + closeTerminal: vi.fn(), + sleepWorktree: vi.fn(), + terminalFitOverrideChanged: vi.fn(), + terminalDriverChanged: vi.fn() + }) + + try { + await expect(runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`)).resolves.toMatchObject({ + worktreeId: TEST_WORKTREE_ID, + surface: 'background', + handle: expect.stringMatching(/^term_/) + }) + expect(revealTerminalSession).toHaveBeenCalledWith(TEST_WORKTREE_ID, { + ptyId: 'pty-bg', + title: null, + activate: false + }) + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('[terminal-create] failed to create inactive tab for pty-bg:'), + expect.any(Error) + ) + } finally { + warn.mockRestore() + } + }) + + it('waits for exit on background terminal handles', async () => { + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + spawn: vi.fn().mockResolvedValue({ id: 'pty-bg' }), + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { tabs: [], leaves: [] }) + const { handle } = await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`) + + const waiting = runtime.waitForTerminal(handle, { condition: 'exit', timeoutMs: 1000 }) + runtime.onPtyExit('pty-bg', 7) + + await expect(waiting).resolves.toMatchObject({ + handle, + condition: 'exit', + status: 'exited', + exitCode: 7 + }) + await expect(runtime.readTerminal(handle)).resolves.toMatchObject({ + status: 'exited' + }) + }) + + it('splits text and enter writes for background terminal handles', async () => { + const writes: string[] = [] + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + spawn: vi.fn().mockResolvedValue({ id: 'pty-bg' }), + write: (_ptyId, data) => { + writes.push(data) + return true + }, + kill: () => true, + getForegroundProcess: async () => null + }) + const { handle } = await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`) + + await runtime.sendTerminal(handle, { text: 'continue', enter: true }) + + expect(writes).toEqual(['continue', '\r']) + }) + + it('reveals a background terminal session when focusing its handle', async () => { + const revealTerminalSession = vi.fn().mockResolvedValue({ tabId: 'tab-adopted' }) + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + spawn: vi.fn().mockResolvedValue({ id: 'pty-bg' }), + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + runtime.setNotifier({ + worktreesChanged: vi.fn(), + reposChanged: vi.fn(), + activateWorktree: vi.fn(), + createTerminal: vi.fn(), + revealTerminalSession, + splitTerminal: vi.fn(), + renameTerminal: vi.fn(), + focusTerminal: vi.fn(), + closeTerminal: vi.fn(), + sleepWorktree: vi.fn(), + terminalFitOverrideChanged: vi.fn(), + terminalDriverChanged: vi.fn() + }) + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { tabs: [], leaves: [] }) + const { handle } = await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`, { + title: 'worker' + }) + + await expect(runtime.focusTerminal(handle)).resolves.toMatchObject({ + handle, + tabId: 'tab-adopted', + worktreeId: TEST_WORKTREE_ID + }) + expect(revealTerminalSession).toHaveBeenCalledWith(TEST_WORKTREE_ID, { + ptyId: 'pty-bg', + title: 'worker' + }) + }) + + it('rejects focusing an exited background terminal session', async () => { + const revealTerminalSession = vi.fn() + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + spawn: vi.fn().mockResolvedValue({ id: 'pty-bg' }), + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + runtime.setNotifier({ + worktreesChanged: vi.fn(), + reposChanged: vi.fn(), + activateWorktree: vi.fn(), + createTerminal: vi.fn(), + revealTerminalSession, + splitTerminal: vi.fn(), + renameTerminal: vi.fn(), + focusTerminal: vi.fn(), + closeTerminal: vi.fn(), + sleepWorktree: vi.fn(), + terminalFitOverrideChanged: vi.fn(), + terminalDriverChanged: vi.fn() + }) + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { tabs: [], leaves: [] }) + const { handle } = await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`) + revealTerminalSession.mockClear() + runtime.onPtyExit('pty-bg', 0) + + await expect(runtime.focusTerminal(handle)).rejects.toThrow('terminal_exited') + expect(revealTerminalSession).not.toHaveBeenCalled() + }) + + it('renames background terminal handles without requiring a visible tab', async () => { + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + spawn: vi.fn().mockResolvedValue({ id: 'pty-bg' }), + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { tabs: [], leaves: [] }) + const { handle } = await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`) + + await expect(runtime.renameTerminal(handle, 'Worker')).resolves.toMatchObject({ + handle, + tabId: 'pty:pty-bg', + title: 'Worker' + }) + await expect(runtime.showTerminal(handle)).resolves.toMatchObject({ + title: 'Worker' + }) + }) + + it('keeps a background terminal handle stable while reveal adoption is racing', async () => { + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + spawn: vi.fn().mockResolvedValue({ id: 'pty-bg' }), + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + runtime.setNotifier({ + worktreesChanged: vi.fn(), + reposChanged: vi.fn(), + activateWorktree: vi.fn(), + createTerminal: vi.fn(), + revealTerminalSession: vi.fn(), + splitTerminal: vi.fn(), + renameTerminal: vi.fn(), + focusTerminal: vi.fn(), + closeTerminal: vi.fn(), + sleepWorktree: vi.fn(), + terminalFitOverrideChanged: vi.fn(), + terminalDriverChanged: vi.fn() + }) + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { tabs: [], leaves: [] }) + const { handle } = await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`, { + title: 'worker' + }) + + await runtime.focusTerminal(handle) + ;(runtime as unknown as { handleByPtyId: Map }).handleByPtyId.delete('pty-bg') + + await expect(runtime.showTerminal(handle)).resolves.toMatchObject({ + handle, + ptyId: 'pty-bg' + }) + }) + it('waits for terminal exit and resolves with the exit status', async () => { const runtime = new OrcaRuntimeService(store) @@ -592,6 +891,36 @@ describe('OrcaRuntimeService', () => { expect(read.tail).toEqual(['after unavailable']) }) + it('keeps runtime-created PTY handles valid after graph unavailable', async () => { + const writes: string[] = [] + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + spawn: vi.fn().mockResolvedValue({ id: 'pty-bg' }), + write: (_ptyId, data) => { + writes.push(data) + return true + }, + kill: () => true, + getForegroundProcess: async () => null + }) + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { tabs: [], leaves: [] }) + const { handle } = await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`) + + runtime.markGraphUnavailable(1) + runtime.onPtyData('pty-bg', 'after unavailable\n', 100) + + await expect(runtime.readTerminal(handle)).resolves.toMatchObject({ + handle, + tail: ['after unavailable'] + }) + await expect(runtime.sendTerminal(handle, { text: 'still writable' })).resolves.toMatchObject({ + handle, + accepted: true + }) + expect(writes).toEqual(['still writable']) + }) + it('keeps already-idle status after tui-idle wait for immediate message delivery', async () => { const runtime = new OrcaRuntimeService(store) const db = new OrchestrationDb(':memory:') @@ -1060,7 +1389,7 @@ describe('OrcaRuntimeService', () => { expect(activateWorktree).toHaveBeenCalledWith('repo-1', expect.any(String), result.setup) }) - it('skips setup hooks for CLI-created worktrees by default', async () => { + it('passes setup payloads through when explicitly activating CLI-created worktrees', async () => { const runtime = new OrcaRuntimeService(store) const activateWorktree = vi.fn() runtime.setNotifier({ @@ -1078,6 +1407,70 @@ describe('OrcaRuntimeService', () => { }) runtime.attachWindow(1) + computeWorktreePathMock.mockReturnValue('/tmp/workspaces/runtime-hook-activate') + ensurePathWithinWorkspaceMock.mockReturnValue('/tmp/workspaces/runtime-hook-activate') + vi.mocked(getEffectiveHooks).mockReturnValue({ + scripts: { + setup: 'pnpm worktree:setup' + } + }) + vi.mocked(createSetupRunnerScript).mockReturnValue({ + runnerScriptPath: '/tmp/repo/.git/orca/setup-runner.sh', + envVars: { + ORCA_ROOT_PATH: '/tmp/repo', + ORCA_WORKTREE_PATH: '/tmp/workspaces/runtime-hook-activate' + } + }) + vi.mocked(listWorktrees).mockResolvedValueOnce([ + { + path: '/tmp/workspaces/runtime-hook-activate', + head: 'def', + branch: 'runtime-hook-activate', + isBare: false, + isMainWorktree: false + } + ]) + + const result = await runtime.createManagedWorktree({ + repoSelector: 'id:repo-1', + name: 'runtime-hook-activate', + runHooks: true, + activate: true + }) + + expect(activateWorktree).toHaveBeenCalledWith('repo-1', expect.any(String), result.setup) + }) + + it('follows normal setup policy for CLI-created worktrees without activating them', async () => { + const runtime = new OrcaRuntimeService(store) + const activateWorktree = vi.fn() + const revealTerminalSession = vi.fn().mockResolvedValue({ tabId: 'tab-created-worktree' }) + const spawn = vi + .fn() + .mockResolvedValueOnce({ id: 'pty-primary' }) + .mockResolvedValueOnce({ id: 'pty-setup' }) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + runtime.setNotifier({ + worktreesChanged: vi.fn(), + reposChanged: vi.fn(), + activateWorktree, + createTerminal: vi.fn(), + revealTerminalSession, + splitTerminal: vi.fn(), + renameTerminal: vi.fn(), + focusTerminal: vi.fn(), + closeTerminal: vi.fn(), + sleepWorktree: vi.fn(), + terminalFitOverrideChanged: vi.fn(), + terminalDriverChanged: vi.fn() + }) + runtime.attachWindow(1) + computeWorktreePathMock.mockReturnValue('/tmp/workspaces/runtime-hook-skip') ensurePathWithinWorkspaceMock.mockReturnValue('/tmp/workspaces/runtime-hook-skip') vi.mocked(getEffectiveHooks).mockReturnValue({ @@ -1085,7 +1478,15 @@ describe('OrcaRuntimeService', () => { setup: 'pnpm worktree:setup' } }) - vi.mocked(listWorktrees).mockResolvedValueOnce([ + vi.mocked(shouldRunSetupForCreate).mockReturnValue(true) + vi.mocked(createSetupRunnerScript).mockReturnValue({ + runnerScriptPath: '/tmp/repo/.git/orca/setup-runner.sh', + envVars: { + ORCA_ROOT_PATH: '/tmp/repo', + ORCA_WORKTREE_PATH: '/tmp/workspaces/runtime-hook-skip' + } + }) + vi.mocked(listWorktrees).mockResolvedValue([ { path: '/tmp/workspaces/runtime-hook-skip', head: 'def', @@ -1100,7 +1501,11 @@ describe('OrcaRuntimeService', () => { name: 'runtime-hook-skip' }) - expect(createSetupRunnerScript).not.toHaveBeenCalled() + expect(createSetupRunnerScript).toHaveBeenCalledWith( + expect.objectContaining({ id: 'repo-1', path: '/tmp/repo' }), + '/tmp/workspaces/runtime-hook-skip', + 'pnpm worktree:setup' + ) expect(runHook).not.toHaveBeenCalled() expect(result).toEqual({ worktree: expect.objectContaining({ @@ -1108,9 +1513,199 @@ describe('OrcaRuntimeService', () => { path: '/tmp/workspaces/runtime-hook-skip', branch: 'runtime-hook-skip' }), - warning: - 'orca.yaml setup hook skipped for /tmp/workspaces/runtime-hook-skip; pass --run-hooks to run it.' + setup: { + runnerScriptPath: '/tmp/repo/.git/orca/setup-runner.sh', + envVars: { + ORCA_ROOT_PATH: '/tmp/repo', + ORCA_WORKTREE_PATH: '/tmp/workspaces/runtime-hook-skip' + } + } }) + expect(activateWorktree).not.toHaveBeenCalled() + expect(spawn).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + cwd: '/tmp/workspaces/runtime-hook-skip', + command: undefined, + worktreeId: result.worktree.id + }) + ) + expect(spawn).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + cwd: '/tmp/workspaces/runtime-hook-skip', + command: 'bash /tmp/repo/.git/orca/setup-runner.sh', + env: { + ORCA_ROOT_PATH: '/tmp/repo', + ORCA_WORKTREE_PATH: '/tmp/workspaces/runtime-hook-skip' + }, + worktreeId: result.worktree.id + }) + ) + expect(revealTerminalSession).toHaveBeenLastCalledWith(result.worktree.id, { + ptyId: 'pty-setup', + title: 'Setup', + activate: false + }) + }) + + it('creates the first terminal for CLI-created worktrees without activating them', async () => { + const runtime = new OrcaRuntimeService(store) + const activateWorktree = vi.fn() + const revealTerminalSession = vi.fn().mockResolvedValue({ tabId: 'tab-created-worktree' }) + const spawn = vi.fn().mockResolvedValue({ id: 'pty-created-worktree' }) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + runtime.setNotifier({ + worktreesChanged: vi.fn(), + reposChanged: vi.fn(), + activateWorktree, + createTerminal: vi.fn(), + revealTerminalSession, + splitTerminal: vi.fn(), + renameTerminal: vi.fn(), + focusTerminal: vi.fn(), + closeTerminal: vi.fn(), + sleepWorktree: vi.fn(), + terminalFitOverrideChanged: vi.fn(), + terminalDriverChanged: vi.fn() + }) + runtime.attachWindow(1) + + computeWorktreePathMock.mockReturnValue('/tmp/workspaces/runtime-initial-terminal') + ensurePathWithinWorkspaceMock.mockReturnValue('/tmp/workspaces/runtime-initial-terminal') + vi.mocked(getEffectiveHooks).mockReturnValue(null) + vi.mocked(listWorktrees).mockResolvedValue([ + { + path: '/tmp/workspaces/runtime-initial-terminal', + head: 'def', + branch: 'runtime-initial-terminal', + isBare: false, + isMainWorktree: false + } + ]) + + const result = await runtime.createManagedWorktree({ + repoSelector: 'id:repo-1', + name: 'runtime-initial-terminal' + }) + + expect(activateWorktree).not.toHaveBeenCalled() + expect(spawn).toHaveBeenCalledWith( + expect.objectContaining({ + cwd: '/tmp/workspaces/runtime-initial-terminal', + worktreeId: result.worktree.id, + preAllocatedHandle: expect.stringMatching(/^term_/) + }) + ) + expect(revealTerminalSession).toHaveBeenCalledWith(result.worktree.id, { + ptyId: 'pty-created-worktree', + title: null, + activate: false + }) + }) + + it('keeps CLI-created worktrees successful when initial terminal creation fails', async () => { + const runtime = new OrcaRuntimeService(store) + const spawn = vi.fn().mockRejectedValue(new Error('pty unavailable')) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + runtime.setNotifier({ + worktreesChanged: vi.fn(), + reposChanged: vi.fn(), + activateWorktree: vi.fn(), + createTerminal: vi.fn(), + splitTerminal: vi.fn(), + renameTerminal: vi.fn(), + focusTerminal: vi.fn(), + closeTerminal: vi.fn(), + sleepWorktree: vi.fn(), + terminalFitOverrideChanged: vi.fn(), + terminalDriverChanged: vi.fn() + }) + runtime.attachWindow(1) + + computeWorktreePathMock.mockReturnValue('/tmp/workspaces/runtime-terminal-fail') + ensurePathWithinWorkspaceMock.mockReturnValue('/tmp/workspaces/runtime-terminal-fail') + vi.mocked(getEffectiveHooks).mockReturnValue(null) + vi.mocked(listWorktrees).mockResolvedValue([ + { + path: '/tmp/workspaces/runtime-terminal-fail', + head: 'def', + branch: 'runtime-terminal-fail', + isBare: false, + isMainWorktree: false + } + ]) + + try { + await expect( + runtime.createManagedWorktree({ + repoSelector: 'id:repo-1', + name: 'runtime-terminal-fail' + }) + ).resolves.toMatchObject({ + worktree: expect.objectContaining({ + path: '/tmp/workspaces/runtime-terminal-fail' + }), + warning: + 'Failed to create the initial terminal for /tmp/workspaces/runtime-terminal-fail: pty unavailable' + }) + expect(spawn).toHaveBeenCalled() + expect(warn).toHaveBeenCalledWith( + '[worktree-create] Failed to create the initial terminal for /tmp/workspaces/runtime-terminal-fail: pty unavailable' + ) + } finally { + warn.mockRestore() + } + }) + + it('activates CLI-created worktrees only when explicitly requested', async () => { + const runtime = new OrcaRuntimeService(store) + const activateWorktree = vi.fn() + runtime.setNotifier({ + worktreesChanged: vi.fn(), + reposChanged: vi.fn(), + activateWorktree, + createTerminal: vi.fn(), + splitTerminal: vi.fn(), + renameTerminal: vi.fn(), + focusTerminal: vi.fn(), + closeTerminal: vi.fn(), + sleepWorktree: vi.fn(), + terminalFitOverrideChanged: vi.fn(), + terminalDriverChanged: vi.fn() + }) + runtime.attachWindow(1) + + computeWorktreePathMock.mockReturnValue('/tmp/workspaces/runtime-activate') + ensurePathWithinWorkspaceMock.mockReturnValue('/tmp/workspaces/runtime-activate') + vi.mocked(getEffectiveHooks).mockReturnValue(null) + vi.mocked(listWorktrees).mockResolvedValueOnce([ + { + path: '/tmp/workspaces/runtime-activate', + head: 'def', + branch: 'runtime-activate', + isBare: false, + isMainWorktree: false + } + ]) + + await runtime.createManagedWorktree({ + repoSelector: 'id:repo-1', + name: 'runtime-activate', + activate: true + }) + expect(activateWorktree).toHaveBeenCalledWith('repo-1', expect.any(String), undefined) }) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index e73d9855e8d..01548ea00ca 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -24,6 +24,7 @@ import type { WorktreeStartupLaunch } from '../../shared/types' import { isFolderRepo } from '../../shared/repo-kind' +import { buildSetupRunnerCommand } from '../../shared/setup-runner-command' import { DESKTOP_PROTOCOL_VERSION, MIN_COMPATIBLE_MOBILE_VERSION @@ -217,6 +218,10 @@ type RuntimePtyWorktreeRecord = { ptyId: string worktreeId: string connected: boolean + lastExitCode: number | null + lastAgentStatus: AgentStatus | null + lastOscTitle: string | null + title: string | null lastOutputAt: number | null tailBuffer: string[] tailPartialLine: string @@ -231,6 +236,16 @@ type RuntimeHeadlessTerminal = { } type RuntimePtyController = { + spawn?(opts: { + cols: number + rows: number + cwd?: string + command?: string + env?: Record + connectionId?: string | null + worktreeId?: string + preAllocatedHandle?: string + }): Promise<{ id: string }> write(ptyId: string, data: string): boolean kill(ptyId: string): boolean getForegroundProcess(ptyId: string): Promise @@ -259,6 +274,13 @@ type RuntimeNotifier = { startup?: WorktreeStartupLaunch ): void createTerminal(worktreeId: string, opts: { command?: string; title?: string }): void + revealTerminalSession?( + worktreeId: string, + opts: { ptyId: string; title?: string | null; activate?: boolean } + ): + | Promise<{ tabId: string; title?: string | null }> + | { tabId: string; title?: string | null } + | void splitTerminal( tabId: string, paneRuntimeId: number, @@ -903,6 +925,14 @@ export class OrcaRuntimeService { pty.tailTruncated = pty.tailTruncated || nextTail.truncated pty.tailLinesTotal += nextTail.newCompleteLines pty.preview = buildPreview(pty.tailBuffer, pty.tailPartialLine) + if (oscTitle !== null) { + const prevStatus = pty.lastAgentStatus + pty.lastOscTitle = oscTitle + pty.lastAgentStatus = agentStatus + if (agentStatus === 'idle' && prevStatus !== 'idle') { + this.resolvePtyTuiIdleWaiters(pty, ptyId) + } + } } for (const leaf of this.leaves.values()) { @@ -1717,6 +1747,8 @@ export class OrcaRuntimeService { const pty = this.ptysById.get(ptyId) if (pty) { pty.connected = false + pty.lastExitCode = exitCode + this.resolvePtyExitWaiters(pty, ptyId) } for (const leaf of this.leaves.values()) { @@ -2969,10 +3001,7 @@ export class OrcaRuntimeService { if (payload === null) { throw new Error('invalid_terminal_send') } - const wrote = this.ptyController?.write(pty.pty.ptyId, payload) ?? false - if (!wrote) { - throw new Error('terminal_not_writable') - } + await this.writeTerminalAction(pty.pty.ptyId, action, payload) return { handle, accepted: true, @@ -2989,31 +3018,7 @@ export class OrcaRuntimeService { throw new Error('invalid_terminal_send') } - // Why: TUI apps (Claude Code, etc.) treat a single large write as a paste - // event. If \r is included in the same write as multi-line text, the TUI - // interprets it as part of the paste rather than a discrete Enter keypress. - // Splitting the text and the trailing control characters into separate - // writes with a small delay ensures the TUI processes the paste first, - // then receives Enter as a distinct input event. - const hasText = typeof action.text === 'string' && action.text.length > 0 - const hasSuffix = action.enter || action.interrupt - if (hasText && hasSuffix) { - const textWrote = this.ptyController?.write(leaf.ptyId, action.text!) ?? false - if (!textWrote) { - throw new Error('terminal_not_writable') - } - const suffix = (action.enter ? '\r' : '') + (action.interrupt ? '\x03' : '') - await new Promise((resolve) => setTimeout(resolve, 500)) - const suffixWrote = this.ptyController?.write(leaf.ptyId, suffix) ?? false - if (!suffixWrote) { - throw new Error('terminal_not_writable') - } - } else { - const wrote = this.ptyController?.write(leaf.ptyId, payload) ?? false - if (!wrote) { - throw new Error('terminal_not_writable') - } - } + await this.writeTerminalAction(leaf.ptyId, action, payload) return { handle, @@ -3022,6 +3027,36 @@ export class OrcaRuntimeService { } } + private async writeTerminalAction( + ptyId: string, + action: { text?: string; enter?: boolean; interrupt?: boolean }, + payload: string + ): Promise { + // Why: TUI apps (Claude Code, etc.) treat a single large write as a paste + // event. Keep Enter/interrupt as a second write for both visible and + // background PTYs so CLI automation behaves the same either way. + const hasText = typeof action.text === 'string' && action.text.length > 0 + const hasSuffix = action.enter || action.interrupt + if (hasText && hasSuffix) { + const textWrote = this.ptyController?.write(ptyId, action.text!) ?? false + if (!textWrote) { + throw new Error('terminal_not_writable') + } + const suffix = (action.enter ? '\r' : '') + (action.interrupt ? '\x03' : '') + await new Promise((resolve) => setTimeout(resolve, 500)) + const suffixWrote = this.ptyController?.write(ptyId, suffix) ?? false + if (!suffixWrote) { + throw new Error('terminal_not_writable') + } + return + } + + const wrote = this.ptyController?.write(ptyId, payload) ?? false + if (!wrote) { + throw new Error('terminal_not_writable') + } + } + async waitForTerminal( handle: string, options?: { @@ -3030,6 +3065,52 @@ export class OrcaRuntimeService { } ): Promise { const condition = options?.condition ?? 'exit' + const pty = this.getLivePtyForHandle(handle) + if (pty) { + if (condition === 'exit' && !pty.pty.connected) { + return buildPtyTerminalWaitResult(handle, condition, pty.pty) + } + if (condition === 'tui-idle' && pty.pty.lastAgentStatus === 'idle') { + return buildPtyTerminalWaitResult(handle, condition, pty.pty) + } + return await new Promise((resolve, reject) => { + const effectiveTimeoutMs = + typeof options?.timeoutMs === 'number' && options.timeoutMs > 0 + ? options.timeoutMs + : condition === 'tui-idle' + ? TUI_IDLE_DEFAULT_TIMEOUT_MS + : 0 + const waiter: TerminalWaiter = { + handle, + condition, + resolve, + reject, + timeout: null, + pollInterval: null + } + if (effectiveTimeoutMs > 0) { + waiter.timeout = setTimeout(() => { + this.removeWaiter(waiter) + reject(new Error('timeout')) + }, effectiveTimeoutMs) + } + let waiters = this.waitersByHandle.get(handle) + if (!waiters) { + waiters = new Set() + this.waitersByHandle.set(handle, waiters) + } + waiters.add(waiter) + const live = this.getLivePtyForHandle(handle) + if (!live) { + this.removeWaiter(waiter) + reject(new Error('terminal_handle_stale')) + } else if (condition === 'exit' && !live.pty.connected) { + this.resolveWaiter(waiter, buildPtyTerminalWaitResult(handle, condition, live.pty)) + } else if (condition === 'tui-idle' && live.pty.lastAgentStatus === 'idle') { + this.resolveWaiter(waiter, buildPtyTerminalWaitResult(handle, condition, live.pty)) + } + }) + } const { leaf } = this.getLiveLeafForHandle(handle) if (condition === 'exit' && getTerminalState(leaf) === 'exited') { @@ -3389,6 +3470,7 @@ export class OrcaRuntimeService { linkedIssue?: number | null comment?: string runHooks?: boolean + activate?: boolean setupDecision?: 'run' | 'skip' | 'inherit' startup?: WorktreeStartupLaunch }): Promise { @@ -3529,16 +3611,6 @@ export class OrcaRuntimeService { console.warn(`[hooks] ${warning}`) } - this.notifier?.worktreesChanged(repo.id) - // Why: the editor currently creates the first Orca-managed terminal as a - // renderer-side consequence of activating a worktree. CLI-created - // worktrees must trigger that same activation path or they will exist on - // disk without becoming the active workspace in the UI. - if (args.startup) { - this.notifier?.activateWorktree(repo.id, worktree.id, setup, args.startup) - } else { - this.notifier?.activateWorktree(repo.id, worktree.id, setup) - } this.invalidateResolvedWorktreeCache() // Why: the filesystem-auth layer maintains a separate cache of registered // worktree roots used by git IPC handlers (branchCompare, diff, status, etc.) @@ -3546,6 +3618,38 @@ export class OrcaRuntimeService { // are not recognized and all git operations fail with "Access denied: // unknown repository or worktree path". invalidateAuthorizedRootsCache() + this.notifier?.worktreesChanged(repo.id) + const shouldActivate = args.activate === true || args.runHooks === true || Boolean(args.startup) + if (shouldActivate) { + // Why: plain CLI creates should not steal the user's current workspace. + // Startup launches still use renderer activation because they are an + // explicit request to start visible work in the new worktree. + if (args.startup) { + this.notifier?.activateWorktree(repo.id, worktree.id, setup, args.startup) + } else { + this.notifier?.activateWorktree(repo.id, worktree.id, setup) + } + } else if (this.ptyController?.spawn) { + try { + await this.createTerminal(`path:${worktree.path}`) + if (setup) { + await this.createTerminal(`path:${worktree.path}`, { + title: 'Setup', + command: buildSetupRunnerCommand( + setup.runnerScriptPath, + process.platform === 'win32' ? 'windows' : 'posix' + ), + env: setup.envVars + }) + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + warning = warning + ? `${warning} Also failed to create the initial terminal for ${worktreePath}: ${message}` + : `Failed to create the initial terminal for ${worktreePath}: ${message}` + console.warn(`[worktree-create] ${warning}`) + } + } return { worktree, ...(setup ? { setup } : {}), @@ -4007,6 +4111,17 @@ export class OrcaRuntimeService { async renameTerminal(handle: string, title: string | null): Promise { this.assertGraphReady() + const pty = this.getLivePtyForHandle(handle) + if (pty) { + pty.pty.title = title + for (const leaf of this.leaves.values()) { + if (leaf.ptyId === pty.pty.ptyId) { + this.notifier?.renameTerminal(leaf.tabId, title) + return { handle, tabId: leaf.tabId, title } + } + } + return { handle, tabId: pty.record.tabId, title } + } const { leaf } = this.getLiveLeafForHandle(handle) this.notifier?.renameTerminal(leaf.tabId, title) return { handle, tabId: leaf.tabId, title } @@ -4014,8 +4129,53 @@ export class OrcaRuntimeService { async createTerminal( worktreeSelector?: string, - opts: { command?: string; title?: string } = {} + opts: { command?: string; env?: Record; title?: string; focus?: boolean } = {} ): Promise { + if (opts.focus !== true) { + if (!worktreeSelector) { + throw new Error('MISSING_WORKTREE') + } + if (!this.ptyController?.spawn) { + throw new Error('runtime_unavailable') + } + const worktree = await this.resolveWorktreeSelector(worktreeSelector) + const repo = this.store?.getRepo(worktree.repoId) + const preAllocatedHandle = this.createPreAllocatedTerminalHandle() + const result = await this.ptyController.spawn({ + cols: 120, + rows: 40, + cwd: worktree.path, + command: opts.command, + env: opts.env, + connectionId: repo?.connectionId ?? null, + worktreeId: worktree.id, + preAllocatedHandle + }) + this.registerPreAllocatedHandleForPty(result.id, preAllocatedHandle) + this.registerPty(result.id, worktree.id) + const pty = this.getOrCreatePtyWorktreeRecord(result.id) + if (pty) { + pty.title = opts.title ?? null + } + const handle = pty ? this.issuePtyHandle(pty) : preAllocatedHandle + let surface: RuntimeTerminalCreate['surface'] = 'background' + if (this.notifier?.revealTerminalSession) { + try { + // Why: after the PTY is spawned, renderer tab adoption is best-effort; + // failing here must not strand a live process without returning a handle. + await this.notifier.revealTerminalSession(worktree.id, { + ptyId: result.id, + title: opts.title ?? null, + activate: false + }) + surface = 'visible' + } catch (err) { + console.warn(`[terminal-create] failed to create inactive tab for ${result.id}:`, err) + } + } + return { handle, worktreeId: worktree.id, title: opts.title ?? null, surface } + } + this.assertGraphReady() const win = this.getAuthoritativeWindow() // Why: mirrors browserTabCreate — when no worktree is specified, pass @@ -4062,7 +4222,7 @@ export class OrcaRuntimeService { // populates this.leaves may not have arrived yet. Wait for the leaf to // appear so we can return a valid handle the caller can use right away. const handle = await this.waitForTerminalHandle(reply.tabId) - return { handle, worktreeId: worktreeId ?? '', title: reply.title } + return { handle, worktreeId: worktreeId ?? '', title: reply.title, surface: 'visible' } } private waitForTerminalHandle(tabId: string, timeoutMs = 10_000): Promise { @@ -4173,7 +4333,18 @@ export class OrcaRuntimeService { this.assertGraphReady() const pty = this.getLivePtyForHandle(handle) if (pty) { - return { handle, tabId: pty.record.tabId, worktreeId: pty.pty.worktreeId } + if (!pty.pty.connected) { + throw new Error('terminal_exited') + } + const revealed = await this.notifier?.revealTerminalSession?.(pty.pty.worktreeId, { + ptyId: pty.pty.ptyId, + title: pty.pty.title ?? pty.pty.lastOscTitle + }) + return { + handle, + tabId: revealed?.tabId ?? pty.record.tabId, + worktreeId: pty.pty.worktreeId + } } const { leaf } = this.getLiveLeafForHandle(handle) this.notifier?.focusTerminal(leaf.tabId, leaf.worktreeId) @@ -4182,6 +4353,11 @@ export class OrcaRuntimeService { async closeTerminal(handle: string): Promise { this.assertGraphReady() + const pty = this.getLivePtyForHandle(handle) + if (pty) { + const ptyKilled = this.ptyController?.kill(pty.pty.ptyId) ?? false + return { handle, tabId: pty.record.tabId, ptyKilled } + } const { leaf } = this.getLiveLeafForHandle(handle) let ptyKilled = false if (leaf.ptyId) { @@ -4283,6 +4459,11 @@ export class OrcaRuntimeService { ptyIds.add(leaf.ptyId) } } + for (const pty of this.ptysById.values()) { + if (pty.worktreeId === worktree.id && pty.connected) { + ptyIds.add(pty.ptyId) + } + } let stopped = 0 for (const ptyId of ptyIds) { @@ -4493,6 +4674,10 @@ export class OrcaRuntimeService { ptyId, worktreeId, connected: state.connected ?? true, + lastExitCode: null, + lastAgentStatus: null, + lastOscTitle: null, + title: null, lastOutputAt: state.lastOutputAt ?? null, tailBuffer: [], tailPartialLine: '', @@ -4756,7 +4941,7 @@ export class OrcaRuntimeService { branch: worktree?.branch ?? '', tabId: `pty:${pty.ptyId}`, leafId: `pty:${pty.ptyId}`, - title: null, + title: pty.title ?? pty.lastOscTitle, connected: pty.connected, writable: pty.connected, lastOutputAt: pty.lastOutputAt, @@ -4788,7 +4973,19 @@ export class OrcaRuntimeService { record: TerminalHandleRecord pty: RuntimePtyWorktreeRecord } | null { - const record = this.handles.get(handle) + let record = this.handles.get(handle) + if (!record) { + const ptyId = [...this.handleByPtyId.entries()].find( + ([, mappedHandle]) => mappedHandle === handle + )?.[0] + const pty = ptyId ? this.ptysById.get(ptyId) : null + if (pty) { + // Why: graph reload/unavailability clears renderer handle records, but + // runtime-owned PTY handles remain the caller's control identity. + this.issuePtyHandle(pty) + record = this.handles.get(handle) + } + } if (!record || record.runtimeId !== this.runtimeId || !record.tabId.startsWith('pty:')) { return null } @@ -4799,6 +4996,10 @@ export class OrcaRuntimeService { if (!pty || pty.ptyId !== record.ptyId) { return null } + // Why: renderer adoption can race with CLI reads. If this synthetic PTY + // handle is valid, keep ptyId -> handle populated so summaries do not mint + // a second handle for the same terminal. + this.handleByPtyId.set(record.ptyId, handle) return { record, pty } } @@ -4824,7 +5025,7 @@ export class OrcaRuntimeService { return { handle, - status: pty.connected ? 'running' : 'unknown', + status: pty.connected ? 'running' : pty.lastExitCode !== null ? 'exited' : 'unknown', tail, truncated, nextCursor: String(pty.tailLinesTotal) @@ -4888,7 +5089,8 @@ export class OrcaRuntimeService { } private issuePtyHandle(pty: RuntimePtyWorktreeRecord): string { - const existingHandle = this.handleByPtyId.get(pty.ptyId) + const existingHandle = + this.handleByPtyId.get(pty.ptyId) ?? this.findHandleForPtyRecord(pty.ptyId) if (existingHandle) { const existingRecord = this.handles.get(existingHandle) if ( @@ -4896,11 +5098,12 @@ export class OrcaRuntimeService { existingRecord.runtimeId === this.runtimeId && existingRecord.ptyId === pty.ptyId ) { + this.handleByPtyId.set(pty.ptyId, existingHandle) return existingHandle } } - const handle = `term_${randomUUID()}` + const handle = existingHandle ?? `term_${randomUUID()}` const syntheticId = `pty:${pty.ptyId}` this.handles.set(handle, { handle, @@ -4916,6 +5119,19 @@ export class OrcaRuntimeService { return handle } + private findHandleForPtyRecord(ptyId: string): string | null { + for (const [handle, record] of this.handles) { + if ( + record.runtimeId === this.runtimeId && + record.ptyId === ptyId && + record.tabId.startsWith('pty:') + ) { + return handle + } + } + return null + } + private refreshWritableFlags(): void { for (const leaf of this.leaves.values()) { leaf.writable = this.graphStatus === 'ready' && leaf.connected && leaf.ptyId !== null @@ -4980,6 +5196,41 @@ export class OrcaRuntimeService { } } + private resolvePtyExitWaiters(pty: RuntimePtyWorktreeRecord, ptyId: string): void { + const handle = this.handleByPtyId.get(ptyId) + if (!handle) { + return + } + const waiters = this.waitersByHandle.get(handle) + if (!waiters || waiters.size === 0) { + return + } + for (const waiter of [...waiters]) { + if (waiter.condition === 'exit') { + this.resolveWaiter(waiter, buildPtyTerminalWaitResult(handle, 'exit', pty)) + } else { + this.removeWaiter(waiter) + waiter.reject(new Error('terminal_exited')) + } + } + } + + private resolvePtyTuiIdleWaiters(pty: RuntimePtyWorktreeRecord, ptyId: string): void { + const handle = this.handleByPtyId.get(ptyId) + if (!handle) { + return + } + const waiters = this.waitersByHandle.get(handle) + if (!waiters || waiters.size === 0) { + return + } + for (const waiter of [...waiters]) { + if (waiter.condition === 'tui-idle') { + this.resolveWaiter(waiter, buildPtyTerminalWaitResult(handle, 'tui-idle', pty)) + } + } + } + // Why: OSC title detection via onPtyData is the primary signal for tui-idle, // but daemon-hosted terminals don't flow PTY data through the runtime, and // some agents don't emit recognized titles on startup. This fallback polls @@ -6590,6 +6841,20 @@ function buildTerminalWaitResult( } } +function buildPtyTerminalWaitResult( + handle: string, + condition: RuntimeTerminalWaitCondition, + pty: RuntimePtyWorktreeRecord +): RuntimeTerminalWait { + return { + handle, + condition, + satisfied: true, + status: pty.connected ? 'running' : pty.lastExitCode !== null ? 'exited' : 'unknown', + exitCode: pty.lastExitCode + } +} + function branchSelectorMatches(branch: string, selector: string): boolean { // Why: Git worktree data can report local branches as either `refs/heads/foo` // or `foo` depending on which plumbing path produced the record. Orca's diff --git a/src/main/runtime/rpc/methods/terminal.ts b/src/main/runtime/rpc/methods/terminal.ts index 5f905fd82da..fbf4136aa7a 100644 --- a/src/main/runtime/rpc/methods/terminal.ts +++ b/src/main/runtime/rpc/methods/terminal.ts @@ -83,7 +83,8 @@ const TerminalWait = TerminalHandle.extend({ const TerminalCreateParams = z.object({ worktree: OptionalString, command: OptionalString, - title: OptionalString + title: OptionalString, + focus: z.unknown().optional() }) const TerminalSplit = TerminalHandle.extend({ @@ -268,7 +269,8 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ handler: async (params, { runtime }) => ({ terminal: await runtime.createTerminal(params.worktree, { command: params.command, - title: params.title + title: params.title, + focus: params.focus === true }) }) }), diff --git a/src/main/runtime/rpc/methods/worktree.ts b/src/main/runtime/rpc/methods/worktree.ts index b9f507c1293..e3358fa731b 100644 --- a/src/main/runtime/rpc/methods/worktree.ts +++ b/src/main/runtime/rpc/methods/worktree.ts @@ -33,6 +33,7 @@ const WorktreeCreate = z.object({ linkedIssue: TriStateLinkedIssue, comment: OptionalString, runHooks: OptionalBoolean, + activate: OptionalBoolean, setupDecision: z .unknown() .transform((v) => @@ -96,6 +97,7 @@ export const WORKTREE_METHODS: RpcMethod[] = [ linkedIssue: params.linkedIssue, comment: params.comment, runHooks: params.runHooks === true, + activate: params.activate === true, setupDecision: params.setupDecision, startup: params.startupCommand ? { command: params.startupCommand } : undefined }) diff --git a/src/main/window/attach-main-window-services.ts b/src/main/window/attach-main-window-services.ts index 458f03bd277..09d9aabbd52 100644 --- a/src/main/window/attach-main-window-services.ts +++ b/src/main/window/attach-main-window-services.ts @@ -1,5 +1,6 @@ import fs from 'node:fs/promises' import path from 'node:path' +import { randomUUID } from 'node:crypto' import { app, clipboard, ipcMain, nativeImage, session } from 'electron' import type { BrowserWindow } from 'electron' @@ -228,6 +229,37 @@ function registerRuntimeWindowLifecycle( }, createTerminal: (worktreeId, opts) => send('ui:createTerminal', { worktreeId, command: opts.command, title: opts.title }), + revealTerminalSession: (worktreeId, opts) => + new Promise((resolve, reject) => { + const requestId = randomUUID() + const timer = setTimeout(() => { + ipcMain.removeListener('terminal:tabCreateReply', handler) + reject(new Error('Terminal reveal timed out')) + }, 10_000) + const handler = ( + _event: Electron.IpcMainEvent, + reply: { requestId: string; tabId?: string; title?: string; error?: string } + ): void => { + if (reply.requestId !== requestId) { + return + } + clearTimeout(timer) + ipcMain.removeListener('terminal:tabCreateReply', handler) + if (reply.error) { + reject(new Error(reply.error)) + return + } + resolve({ tabId: reply.tabId!, title: reply.title }) + } + ipcMain.on('terminal:tabCreateReply', handler) + send('ui:createTerminal', { + requestId, + worktreeId, + ptyId: opts.ptyId, + title: opts.title ?? undefined, + activate: opts.activate !== false + }) + }), splitTerminal: (tabId, paneRuntimeId, opts) => { send('ui:splitTerminal', { tabId, diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index d67b0e39d96..d78e6679904 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -1084,7 +1084,14 @@ export type PreloadApi = { }) => void ) => () => void onCreateTerminal: ( - callback: (data: { worktreeId: string; command?: string; title?: string }) => void + callback: (data: { + requestId?: string + worktreeId: string + command?: string + title?: string + ptyId?: string + activate?: boolean + }) => void ) => () => void onRequestTerminalCreate: ( callback: (data: { diff --git a/src/preload/index.ts b/src/preload/index.ts index f84585abb5f..9dec589e101 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1839,11 +1839,25 @@ const api = { return () => ipcRenderer.removeListener('ui:activateWorktree', listener) }, onCreateTerminal: ( - callback: (data: { worktreeId: string; command?: string; title?: string }) => void + callback: (data: { + requestId?: string + worktreeId: string + command?: string + title?: string + ptyId?: string + activate?: boolean + }) => void ): (() => void) => { const listener = ( _event: Electron.IpcRendererEvent, - data: { worktreeId: string; command?: string; title?: string } + data: { + requestId?: string + worktreeId: string + command?: string + title?: string + ptyId?: string + activate?: boolean + } ) => callback(data) ipcRenderer.on('ui:createTerminal', listener) return () => ipcRenderer.removeListener('ui:createTerminal', listener) diff --git a/src/renderer/src/components/Terminal.tsx b/src/renderer/src/components/Terminal.tsx index b05e04874ae..db6fcc4fe40 100644 --- a/src/renderer/src/components/Terminal.tsx +++ b/src/renderer/src/components/Terminal.tsx @@ -46,6 +46,7 @@ import { } from '../hooks/ipc-tab-switch' import TabGroupSplitLayout from './tab-group/TabGroupSplitLayout' import { shouldAutoCreateInitialTerminal } from './terminal/initial-terminal' +import { shouldRepairActiveTerminalTab } from './terminal/active-terminal-repair' import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface' import { getEffectiveLayoutForWorktree as getEffectiveLayout, @@ -466,23 +467,19 @@ function Terminal(): React.JSX.Element | null { }, [queueEditorCloseRequests]) useEffect(() => { - if (tabs.length === 0) { - return - } - if (activeTabId && tabs.some((tab) => tab.id === activeTabId)) { + if (!shouldRepairActiveTerminalTab({ activeTabType, activeTabId, tabs })) { return } // Why: mutating Zustand during render trips React's "Cannot update a - // component while rendering a different component" warning. Keep the - // legacy active-tab repair, but run it as an effect after the render that - // observed the stale activeTabId. + // component while rendering a different component" warning. Keep the repair + // terminal-only so inactive CLI-created tabs cannot steal editor/browser focus. setActiveTab(tabs[0].id) // Why: `tabs` is intentionally the dependency here because the repair must // react to tab-order/content changes, not just scalar IDs. The list comes // from Zustand selectors and is small in practice, so this explicit repair // effect is preferred over duplicating reconciliation state. // eslint-disable-next-line react-hooks/exhaustive-deps - }, [activeTabId, setActiveTab, tabs]) + }, [activeTabId, activeTabType, setActiveTab, tabs]) // Track which worktrees have been activated during this app session. // Only mount TerminalPanes for visited worktrees to prevent mass PTY diff --git a/src/renderer/src/components/terminal/active-terminal-repair.test.ts b/src/renderer/src/components/terminal/active-terminal-repair.test.ts new file mode 100644 index 00000000000..2d08164d4f0 --- /dev/null +++ b/src/renderer/src/components/terminal/active-terminal-repair.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from 'vitest' +import type { TerminalTab } from '../../../../shared/types' +import { shouldRepairActiveTerminalTab } from './active-terminal-repair' + +function tab(id: string): TerminalTab { + return { + id, + ptyId: null, + worktreeId: 'wt-1', + title: id, + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 0 + } +} + +describe('shouldRepairActiveTerminalTab', () => { + it('does not repair while editor or browser content is active', () => { + expect( + shouldRepairActiveTerminalTab({ + activeTabType: 'editor', + activeTabId: 'missing', + tabs: [tab('cli-terminal')] + }) + ).toBe(false) + expect( + shouldRepairActiveTerminalTab({ + activeTabType: 'browser', + activeTabId: null, + tabs: [tab('cli-terminal')] + }) + ).toBe(false) + }) + + it('repairs stale terminal active ids only while terminal content is active', () => { + expect( + shouldRepairActiveTerminalTab({ + activeTabType: 'terminal', + activeTabId: 'missing', + tabs: [tab('terminal-1')] + }) + ).toBe(true) + expect( + shouldRepairActiveTerminalTab({ + activeTabType: 'terminal', + activeTabId: 'terminal-1', + tabs: [tab('terminal-1')] + }) + ).toBe(false) + }) +}) diff --git a/src/renderer/src/components/terminal/active-terminal-repair.ts b/src/renderer/src/components/terminal/active-terminal-repair.ts new file mode 100644 index 00000000000..8f6691f957b --- /dev/null +++ b/src/renderer/src/components/terminal/active-terminal-repair.ts @@ -0,0 +1,18 @@ +import type { TerminalTab, WorkspaceVisibleTabType } from '../../../../shared/types' + +export function shouldRepairActiveTerminalTab(args: { + activeTabType: WorkspaceVisibleTabType + activeTabId: string | null + tabs: TerminalTab[] +}): boolean { + if (args.activeTabType !== 'terminal') { + return false + } + if (args.tabs.length === 0) { + return false + } + if (args.activeTabId && args.tabs.some((tab) => tab.id === args.activeTabId)) { + return false + } + return true +} diff --git a/src/renderer/src/components/terminal/useTerminalTabs.ts b/src/renderer/src/components/terminal/useTerminalTabs.ts index 51bc0c1c34e..e0f4d6b546a 100644 --- a/src/renderer/src/components/terminal/useTerminalTabs.ts +++ b/src/renderer/src/components/terminal/useTerminalTabs.ts @@ -12,6 +12,7 @@ import { activateEditorFile, toggleTerminalPaneExpand } from './terminal-tab-actions' +import { shouldRepairActiveTerminalTab } from './active-terminal-repair' export type UnifiedTerminalItem = { type: 'terminal' | 'editor' @@ -114,14 +115,11 @@ export function useTerminalTabs() { ) useEffect(() => { - if (tabs.length === 0) { - return - } - if (activeTabId && tabs.some((tab) => tab.id === activeTabId)) { + if (!shouldRepairActiveTerminalTab({ activeTabType, activeTabId, tabs })) { return } setActiveTab(tabs[0].id) - }, [activeTabId, setActiveTab, tabs]) + }, [activeTabId, activeTabType, setActiveTab, tabs]) useEffect(() => { if (!workspaceSessionReady) { diff --git a/src/renderer/src/hooks/useIpcEvents.test.ts b/src/renderer/src/hooks/useIpcEvents.test.ts index 629a0643aec..f175a3d06bd 100644 --- a/src/renderer/src/hooks/useIpcEvents.test.ts +++ b/src/renderer/src/hooks/useIpcEvents.test.ts @@ -466,8 +466,55 @@ describe('useIpcEvents updater integration', () => { const revealWorktreeInSidebar = vi.fn() const setTabCustomTitle = vi.fn() const queueTabStartupCommand = vi.fn() + const replyTerminalCreate = vi.fn() + const storeState = { + setUpdateStatus: vi.fn(), + createTab, + setActiveView, + setActiveWorktree, + markWorktreeVisited: vi.fn(), + setActiveTabType, + setActiveTab, + revealWorktreeInSidebar, + setTabCustomTitle, + queueTabStartupCommand, + tabsByWorktree: {} as Record, + ptyIdsByTabId: {} as Record, + fetchRepos: vi.fn(), + fetchWorktrees: vi.fn(), + activeModal: null, + closeModal: vi.fn(), + openModal: vi.fn(), + activeWorktreeId: 'wt-1', + activeView: 'terminal', + setActiveRepo: vi.fn(), + setIsFullScreen: vi.fn(), + updateBrowserPageState: vi.fn(), + activeTabType: 'terminal', + editorFontZoomLevel: 0, + setEditorFontZoomLevel: vi.fn(), + setRateLimitsFromPush: vi.fn(), + setSshConnectionState: vi.fn(), + setSshTargetLabels: vi.fn(), + setPortForwards: vi.fn(), + clearPortForwards: vi.fn(), + setDetectedPorts: vi.fn(), + enqueueSshCredentialRequest: vi.fn(), + removeSshCredentialRequest: vi.fn(), + clearTabPtyId: vi.fn(), + settings: { terminalFontSize: 13 } + } const createTerminalListenerRef: { - current: ((data: { worktreeId: string; command?: string; title?: string }) => void) | null + current: + | ((data: { + requestId?: string + worktreeId: string + command?: string + title?: string + ptyId?: string + activate?: boolean + }) => void) + | null } = { current: null } vi.resetModules() @@ -486,41 +533,7 @@ describe('useIpcEvents updater integration', () => { vi.doMock('../store', () => ({ useAppStore: { subscribe: vi.fn(() => () => {}), - getState: () => ({ - setUpdateStatus: vi.fn(), - createTab, - setActiveView, - setActiveWorktree, - markWorktreeVisited: vi.fn(), - setActiveTabType, - setActiveTab, - revealWorktreeInSidebar, - setTabCustomTitle, - queueTabStartupCommand, - fetchRepos: vi.fn(), - fetchWorktrees: vi.fn(), - activeModal: null, - closeModal: vi.fn(), - openModal: vi.fn(), - activeWorktreeId: 'wt-1', - activeView: 'terminal', - setActiveRepo: vi.fn(), - setIsFullScreen: vi.fn(), - updateBrowserPageState: vi.fn(), - activeTabType: 'terminal', - editorFontZoomLevel: 0, - setEditorFontZoomLevel: vi.fn(), - setRateLimitsFromPush: vi.fn(), - setSshConnectionState: vi.fn(), - setSshTargetLabels: vi.fn(), - setPortForwards: vi.fn(), - clearPortForwards: vi.fn(), - setDetectedPorts: vi.fn(), - enqueueSshCredentialRequest: vi.fn(), - removeSshCredentialRequest: vi.fn(), - clearTabPtyId: vi.fn(), - settings: { terminalFontSize: 13 } - }) + getState: () => storeState } })) @@ -566,13 +579,20 @@ describe('useIpcEvents updater integration', () => { onActivateWorktree: () => () => {}, onWorktreeHistoryNavigate: () => () => {}, onCreateTerminal: ( - listener: (data: { worktreeId: string; command?: string; title?: string }) => void + listener: (data: { + requestId?: string + worktreeId: string + command?: string + title?: string + ptyId?: string + activate?: boolean + }) => void ) => { createTerminalListenerRef.current = listener return () => {} }, onRequestTerminalCreate: () => () => {}, - replyTerminalCreate: () => {}, + replyTerminalCreate, onSplitTerminal: () => () => {}, onRenameTerminal: () => () => {}, onFocusTerminal: () => () => {}, @@ -657,6 +677,65 @@ describe('useIpcEvents updater integration', () => { expect(revealWorktreeInSidebar).toHaveBeenCalledWith('wt-2') expect(setTabCustomTitle).toHaveBeenCalledWith('tab-new', 'Runner') expect(queueTabStartupCommand).toHaveBeenCalledWith('tab-new', { command: 'opencode' }) + + createTab.mockClear() + createTerminalListenerRef.current({ + worktreeId: 'wt-2', + ptyId: 'pty-bg' + }) + + expect(createTab).toHaveBeenCalledWith('wt-2', undefined, undefined, { + initialPtyId: 'pty-bg', + activate: true + }) + + createTab.mockClear() + setActiveView.mockClear() + setActiveWorktree.mockClear() + setActiveTabType.mockClear() + setActiveTab.mockClear() + revealWorktreeInSidebar.mockClear() + createTerminalListenerRef.current({ + worktreeId: 'wt-2', + ptyId: 'pty-bg-2', + activate: false + }) + + expect(createTab).toHaveBeenCalledWith('wt-2', undefined, undefined, { + initialPtyId: 'pty-bg-2', + activate: false + }) + expect(setActiveView).not.toHaveBeenCalled() + expect(setActiveWorktree).not.toHaveBeenCalled() + expect(setActiveTabType).not.toHaveBeenCalled() + expect(setActiveTab).not.toHaveBeenCalled() + expect(revealWorktreeInSidebar).not.toHaveBeenCalled() + + storeState.tabsByWorktree = { + 'wt-2': [{ id: 'tab-existing', ptyId: 'pty-bg', title: 'Terminal 1' }] + } + storeState.ptyIdsByTabId = { 'tab-existing': ['pty-bg'] } + createTab.mockClear() + setActiveTab.mockClear() + createTerminalListenerRef.current({ + worktreeId: 'wt-2', + ptyId: 'pty-bg' + }) + + expect(createTab).not.toHaveBeenCalled() + expect(setActiveTab).toHaveBeenCalledWith('tab-existing') + + createTerminalListenerRef.current({ + requestId: 'req-reveal', + worktreeId: 'wt-2', + ptyId: 'pty-bg' + }) + + expect(replyTerminalCreate).toHaveBeenCalledWith({ + requestId: 'req-reveal', + tabId: 'tab-existing', + title: 'Terminal 1' + }) }) }) diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index a77ece1e88b..217a5a43279 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -220,26 +220,60 @@ export function useIpcEvents(): void { ) unsubs.push( - window.api.ui.onCreateTerminal(({ worktreeId, command, title }) => { - const store = useAppStore.getState() - store.setActiveView('terminal') - store.setActiveWorktree(worktreeId) - // Why: CLI-driven terminal creation is a user-initiated worktree switch - // and must stamp focus recency for Cmd+J. Doesn't route through - // activateAndRevealWorktree because it has custom terminal-creation - // logic; see docs/cmd-j-empty-query-ordering.md. - store.markWorktreeVisited(worktreeId) - const tab = store.createTab(worktreeId) - store.setActiveTabType('terminal') - store.setActiveTab(tab.id) - store.revealWorktreeInSidebar(worktreeId) - if (title) { - store.setTabCustomTitle(tab.id, title) + window.api.ui.onCreateTerminal( + ({ requestId, worktreeId, command, title, ptyId, activate }) => { + try { + const store = useAppStore.getState() + const shouldActivate = activate !== false + if (shouldActivate) { + store.setActiveView('terminal') + store.setActiveWorktree(worktreeId) + // Why: CLI-driven terminal focus is a user-initiated worktree switch + // and must stamp focus recency for Cmd+J. Doesn't route through + // activateAndRevealWorktree because it has custom terminal-creation + // logic; see docs/cmd-j-empty-query-ordering.md. + store.markWorktreeVisited(worktreeId) + } + const tab = ptyId + ? ((store.tabsByWorktree[worktreeId] ?? []).find( + (candidate) => + candidate.ptyId === ptyId || + (store.ptyIdsByTabId[candidate.id] ?? []).includes(ptyId) + ) ?? + store.createTab(worktreeId, undefined, undefined, { + initialPtyId: ptyId, + activate: shouldActivate + })) + : store.createTab(worktreeId) + if (shouldActivate) { + store.setActiveTabType('terminal') + store.setActiveTab(tab.id) + store.revealWorktreeInSidebar(worktreeId) + } + if (title) { + store.setTabCustomTitle(tab.id, title) + } + if (command) { + store.queueTabStartupCommand(tab.id, { command }) + } + if (requestId) { + window.api.ui.replyTerminalCreate({ + requestId, + tabId: tab.id, + title: title ?? tab.title + }) + } + } catch (err) { + if (!requestId) { + throw err + } + window.api.ui.replyTerminalCreate({ + requestId, + error: err instanceof Error ? err.message : 'Terminal reveal failed' + }) + } } - if (command) { - store.queueTabStartupCommand(tab.id, { command }) - } - }) + ) ) // Why: CLI-driven terminal creation sends a request and waits for the diff --git a/src/renderer/src/lib/setup-runner.ts b/src/renderer/src/lib/setup-runner.ts index 1dcf95868db..b4e57fdc0ca 100644 --- a/src/renderer/src/lib/setup-runner.ts +++ b/src/renderer/src/lib/setup-runner.ts @@ -1,48 +1,8 @@ -/** - * Why WSL check: on Windows, worktrees for WSL repos have setup scripts - * written as bash .sh files (not .cmd). The terminal for these worktrees - * runs bash inside WSL, so the command must invoke bash directly with the - * Linux-native path, not cmd.exe with a Windows path. - */ +import { buildSetupRunnerCommand as buildSharedSetupRunnerCommand } from '../../../shared/setup-runner-command' + export function buildSetupRunnerCommand(runnerScriptPath: string): string { - if (navigator.userAgent.includes('Windows')) { - if (isWslUncPath(runnerScriptPath)) { - const linuxPath = wslUncToLinuxPath(runnerScriptPath) - return `bash ${quotePosixArg(linuxPath)}` - } - return `cmd.exe /c ${quoteWindowsArg(runnerScriptPath)}` - } - - return `bash ${quotePosixArg(runnerScriptPath)}` -} - -/** - * Check if a path is a WSL UNC path (\\wsl.localhost\... or \\wsl$\...). - * Lightweight renderer-side check — no Node imports needed. - */ -function isWslUncPath(path: string): boolean { - const normalized = path.replace(/\\/g, '/') - return /^\/\/(wsl\.localhost|wsl\$)\//.test(normalized) -} - -/** - * Convert a WSL UNC path to its Linux equivalent. - * \\wsl.localhost\Ubuntu\home\user\file → /home/user/file - */ -function wslUncToLinuxPath(windowsPath: string): string { - const normalized = windowsPath.replace(/\\/g, '/') - const match = normalized.match(/^\/\/(wsl\.localhost|wsl\$)\/[^/]+(\/.*)?$/) - return match?.[2] || '/' -} - -function quotePosixArg(value: string): string { - if (/^[A-Za-z0-9_./:-]+$/.test(value)) { - return value - } - - return `'${value.replace(/'/g, `'\\''`)}'` -} - -function quoteWindowsArg(value: string): string { - return `"${value.replace(/"/g, '""')}"` + return buildSharedSetupRunnerCommand( + runnerScriptPath, + navigator.userAgent.includes('Windows') ? 'windows' : 'posix' + ) } diff --git a/src/renderer/src/store/slices/terminals.ts b/src/renderer/src/store/slices/terminals.ts index 562abda053b..639bb8cd310 100644 --- a/src/renderer/src/store/slices/terminals.ts +++ b/src/renderer/src/store/slices/terminals.ts @@ -137,7 +137,7 @@ export type TerminalSlice = { worktreeId: string, targetGroupId?: string, shellOverride?: string, - options?: { pendingActivationSpawn?: boolean } + options?: { pendingActivationSpawn?: boolean; initialPtyId?: string; activate?: boolean } ) => TerminalTab closeTab: (tabId: string) => void reorderTabs: (worktreeId: string, tabIds: string[]) => void @@ -329,11 +329,14 @@ export const createTerminalSlice: StateCreator const existing = (s.tabsByWorktree[worktreeId] ?? []).filter( (entry) => !orphanTerminalIds.has(entry.id) ) + const shouldActivate = options?.activate !== false const nextOrdinal = getNextTerminalOrdinal(existing) const defaultTitle = `Terminal ${nextOrdinal}` tab = { id, - ptyId: null, + // Why: CLI-created background sessions already own a PTY; revealing + // one later should attach the pane instead of spawning a duplicate. + ptyId: options?.initialPtyId ?? null, worktreeId, // Why: users expect terminal labels to reflect the currently open set, // not a monotonic creation counter. Reusing the lowest free ordinal @@ -364,9 +367,10 @@ export const createTerminalSlice: StateCreator worktreeId, validTargetGroupId ?? s.activeGroupIdByWorktree[worktreeId] ) - const nextActiveGroupIdByWorktree = validTargetGroupId - ? { ...activeGroupIdByWorktree, [worktreeId]: validTargetGroupId } - : activeGroupIdByWorktree + const nextActiveGroupIdByWorktree = + shouldActivate && validTargetGroupId + ? { ...activeGroupIdByWorktree, [worktreeId]: validTargetGroupId } + : activeGroupIdByWorktree const existingUnifiedTabs = s.unifiedTabsByWorktree[worktreeId] ?? [] const existingTerminalTab = findTabByEntityInGroup( s.unifiedTabsByWorktree, @@ -388,10 +392,12 @@ export const createTerminalSlice: StateCreator createdAt: tab.createdAt } const nextGroupOrder = dedupeTabOrder([...group.tabOrder, unifiedTab.id]) - const nextRecent = pushRecentTabId( - sanitizeRecentTabIds(group.recentTabIds, nextGroupOrder), - unifiedTab.id - ) + const nextRecent = shouldActivate + ? pushRecentTabId(sanitizeRecentTabIds(group.recentTabIds, nextGroupOrder), unifiedTab.id) + : sanitizeRecentTabIds(group.recentTabIds, nextGroupOrder) + const nextActiveTabIdForWorktree = shouldActivate + ? tab.id + : (s.activeTabIdByWorktree[worktreeId] ?? group.activeTabId ?? tab.id) return { ...orphanCleanupPatch, tabsByWorktree: { @@ -411,7 +417,7 @@ export const createTerminalSlice: StateCreator ...groupsByWorktree, [worktreeId]: updateGroup(groupsByWorktree[worktreeId] ?? [], { ...group, - activeTabId: unifiedTab.id, + activeTabId: shouldActivate ? unifiedTab.id : (group.activeTabId ?? unifiedTab.id), tabOrder: nextGroupOrder, recentTabIds: nextRecent }) @@ -421,9 +427,15 @@ export const createTerminalSlice: StateCreator ...s.layoutByWorktree, [worktreeId]: s.layoutByWorktree[worktreeId] ?? { type: 'leaf', groupId: group.id } }, - activeTabId: tab.id, - activeTabIdByWorktree: { ...s.activeTabIdByWorktree, [worktreeId]: tab.id }, - ptyIdsByTabId: { ...s.ptyIdsByTabId, [tab.id]: [] }, + activeTabId: shouldActivate ? tab.id : s.activeTabId, + activeTabIdByWorktree: { + ...s.activeTabIdByWorktree, + [worktreeId]: nextActiveTabIdForWorktree + }, + ptyIdsByTabId: { + ...s.ptyIdsByTabId, + [tab.id]: options?.initialPtyId ? [options.initialPtyId] : [] + }, terminalLayoutsByTabId: { ...s.terminalLayoutsByTabId, [tab.id]: emptyLayoutSnapshot() diff --git a/src/shared/runtime-types.ts b/src/shared/runtime-types.ts index adc13f3f2ed..d44040433f6 100644 --- a/src/shared/runtime-types.ts +++ b/src/shared/runtime-types.ts @@ -115,6 +115,7 @@ export type RuntimeTerminalCreate = { handle: string worktreeId: string title: string | null + surface?: 'background' | 'visible' } export type RuntimeTerminalSplit = { diff --git a/src/shared/setup-runner-command.ts b/src/shared/setup-runner-command.ts new file mode 100644 index 00000000000..69f3406c12d --- /dev/null +++ b/src/shared/setup-runner-command.ts @@ -0,0 +1,39 @@ +export type SetupRunnerCommandPlatform = 'windows' | 'posix' + +export function buildSetupRunnerCommand( + runnerScriptPath: string, + platform: SetupRunnerCommandPlatform +): string { + if (platform === 'windows') { + if (isWslUncPath(runnerScriptPath)) { + const linuxPath = wslUncToLinuxPath(runnerScriptPath) + return `bash ${quotePosixArg(linuxPath)}` + } + return `cmd.exe /c ${quoteWindowsArg(runnerScriptPath)}` + } + + return `bash ${quotePosixArg(runnerScriptPath)}` +} + +function isWslUncPath(path: string): boolean { + const normalized = path.replace(/\\/g, '/') + return /^\/\/(wsl\.localhost|wsl\$)\//.test(normalized) +} + +function wslUncToLinuxPath(windowsPath: string): string { + const normalized = windowsPath.replace(/\\/g, '/') + const match = normalized.match(/^\/\/(wsl\.localhost|wsl\$)\/[^/]+(\/.*)?$/) + return match?.[2] || '/' +} + +function quotePosixArg(value: string): string { + if (/^[A-Za-z0-9_./:-]+$/.test(value)) { + return value + } + + return `'${value.replace(/'/g, `'\\''`)}'` +} + +function quoteWindowsArg(value: string): string { + return `"${value.replace(/"/g, '""')}"` +}