mirror of
https://github.com/stablyai/orca.git
synced 2026-10-13 08:02:52 +00:00
Support background Orca CLI terminals
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
+2
-1
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
|
||||
@@ -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
@@ -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',
|
||||
|
||||
@@ -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
@@ -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'
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
})
|
||||
})
|
||||
}),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -115,6 +115,7 @@ export type RuntimeTerminalCreate = {
|
||||
handle: string
|
||||
worktreeId: string
|
||||
title: string | null
|
||||
surface?: 'background' | 'visible'
|
||||
}
|
||||
|
||||
export type RuntimeTerminalSplit = {
|
||||
|
||||
@@ -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, '""')}"`
|
||||
}
|
||||
Reference in New Issue
Block a user