Support background Orca CLI terminals

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo-H
2026-05-12 02:09:13 -04:00
co-authored by Orca
parent 19d11db802
commit 66fdf4ece3
25 changed files with 1630 additions and 215 deletions
+2 -1
View File
@@ -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 {
+2 -1
View File
@@ -119,7 +119,8 @@ export const TERMINAL_HANDLERS: Record<string, CommandHandler> = {
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)
},
+2 -1
View File
@@ -59,7 +59,8 @@ export const WORKTREE_HANDLERS: Record<string, CommandHandler> = {
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)
+5 -3
View File
@@ -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 <selector>] [--limit <n>] [--json]
orca worktree create --repo <selector> --name <name> [--base-branch <ref>] [--issue <number>] [--comment <text>] [--run-hooks] [--json]
orca worktree create --repo <selector> --name <name> [--base-branch <ref>] [--issue <number>] [--comment <text>] [--run-hooks] [--activate] [--json]
orca worktree show --worktree <selector> [--json]
orca worktree current [--json]
orca worktree set --worktree <selector> [--display-name <name>] [--issue <number|null>] [--comment <text>] [--json]
@@ -152,7 +152,7 @@ Common Commands:
orca terminal send [--terminal <handle>] [--text <text>] [--enter] [--interrupt] [--json]
orca terminal wait [--terminal <handle>] --for exit|tui-idle [--timeout-ms <ms>] [--json]
orca terminal stop --worktree <selector> [--json]
orca terminal create [--worktree <selector>] [--title <name>] [--command <text>] [--json]
orca terminal create [--worktree <selector>] [--title <name>] [--command <text>] [--focus] [--json]
orca terminal split [--terminal <handle>] [--direction horizontal|vertical] [--json]
orca terminal switch [--terminal <handle>] [--json]
orca terminal close [--terminal <handle>] [--json]
@@ -307,6 +307,7 @@ export function formatFlagHelp(flag: string): string {
comment: '--comment <text> Comment stored in Orca metadata',
cursor: '--cursor <n> Line cursor from a previous read (returns only new output)',
action: '--action <name> Secondary accessibility action name',
activate: '--activate Reveal the new worktree in the Orca app',
app: '--app <app> App name, bundle ID, or pid:N',
direction:
'--direction <dir> Direction: up|down|left|right for scroll, horizontal|vertical for split',
@@ -315,6 +316,7 @@ export function formatFlagHelp(flag: string): string {
title: '--title <text> 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 <n> Source element index from get-app-state',
'from-x': '--from-x <x> Source window-local x coordinate',
+86
View File
@@ -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,
+23 -8
View File
@@ -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 <selector> --name <name> [--base-branch <ref>] [--issue <number>] [--comment <text>] [--run-hooks] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'repo', 'name', 'base-branch', 'issue', 'comment', 'run-hooks'],
'orca worktree create --repo <selector> --name <name> [--base-branch <ref>] [--issue <number>] [--comment <text>] [--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 <selector>] [--title <name>] [--command <text>] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'worktree', 'command', 'title'],
'orca terminal create [--worktree <selector>] [--title <name>] [--command <text>] [--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'
]
},
{
+81 -1
View File
@@ -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<string, string>
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<string, string>
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
+136 -14
View File
@@ -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<string, string>()
const trustedTerminalHandleEnv = new Set<string>()
let flushTimer: ReturnType<typeof setTimeout> | 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<string, string> | 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) {
+600 -5
View File
@@ -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<string, string> }).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)
})
+312 -47
View File
@@ -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<string, string>
connectionId?: string | null
worktreeId?: string
preAllocatedHandle?: string
}): Promise<{ id: string }>
write(ptyId: string, data: string): boolean
kill(ptyId: string): boolean
getForegroundProcess(ptyId: string): Promise<string | null>
@@ -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<void> {
// 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<RuntimeTerminalWait> {
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<RuntimeTerminalWait>((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<CreateWorktreeResult> {
@@ -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<RuntimeTerminalRename> {
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<string, string>; title?: string; focus?: boolean } = {}
): Promise<RuntimeTerminalCreate> {
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<string> {
@@ -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<RuntimeTerminalClose> {
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
+4 -2
View File
@@ -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
})
})
}),
+2
View File
@@ -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
})
@@ -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,
+8 -1
View File
@@ -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: {
+16 -2
View File
@@ -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)
+5 -8
View File
@@ -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
@@ -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)
})
})
@@ -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
}
@@ -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) {
+117 -38
View File
@@ -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<string, { id: string; ptyId?: string | null; title?: string }[]>,
ptyIdsByTabId: {} as Record<string, string[]>,
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'
})
})
})
+53 -19
View File
@@ -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
+6 -46
View File
@@ -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'
)
}
+25 -13
View File
@@ -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<AppState, [], [], TerminalSlice>
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<AppState, [], [], TerminalSlice>
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<AppState, [], [], TerminalSlice>
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<AppState, [], [], TerminalSlice>
...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<AppState, [], [], TerminalSlice>
...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()
+1
View File
@@ -115,6 +115,7 @@ export type RuntimeTerminalCreate = {
handle: string
worktreeId: string
title: string | null
surface?: 'background' | 'visible'
}
export type RuntimeTerminalSplit = {
+39
View File
@@ -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, '""')}"`
}