diff --git a/skills/orca-cli/SKILL.md b/skills/orca-cli/SKILL.md index f9eb915ec..e7b5a4eaf 100644 --- a/skills/orca-cli/SKILL.md +++ b/skills/orca-cli/SKILL.md @@ -216,6 +216,8 @@ orca terminal read --json Why: `--terminal` is optional for most commands. When omitted, Orca auto-resolves to the active terminal in the current worktree (same as browser commands target the active tab). Use explicit `--terminal ` when operating on a specific pane. +Why: `terminal create` creates a background session unless `--focus` is explicit. Interactive local agent commands such as bare `codex` or bare `claude` use Orca's renderer-backed terminal path so they can start at the app's measured terminal geometry without stealing focus from the user. + Why: long terminal transcripts should be read with cursors. After a limited tail preview without an input cursor, page retained transcript from `oldestCursor`; in that case `nextCursor` already equals `latestCursor` and would skip omitted output. After a cursor read, if `limited` remains true and `nextCursor !== latestCursor`, continue with the returned `nextCursor`. Cursor reads default to the retained transcript size; `--limit` can request a smaller page. If `truncated` is true, older output has already fallen out of the retained buffer; use `oldestCursor` as the earliest available cursor. Why: terminal handles are runtime-scoped and may go stale after reloads. If Orca returns `terminal_handle_stale`, reacquire a fresh handle with `terminal list`. diff --git a/src/cli/codex-command-classification.test.ts b/src/cli/codex-command-classification.test.ts index 7f515b521..ce92d15ee 100644 --- a/src/cli/codex-command-classification.test.ts +++ b/src/cli/codex-command-classification.test.ts @@ -1,39 +1,69 @@ import { describe, expect, it } from 'vitest' -import { shouldForceVisibleCodexTerminal } from './codex-command-classification' +import { + shouldUseRendererBackedCodexTerminal, + shouldUseRendererBackedInteractiveTerminal +} from './codex-command-classification' -describe('shouldForceVisibleCodexTerminal', () => { - it('forces visible terminal creation for interactive Codex sessions', () => { - expect(shouldForceVisibleCodexTerminal('codex')).toBe(true) - expect(shouldForceVisibleCodexTerminal('codex -m gpt-5 "fix the flaky test"')).toBe(true) - expect(shouldForceVisibleCodexTerminal('codex resume --last')).toBe(true) - expect(shouldForceVisibleCodexTerminal('codex fork')).toBe(true) - expect(shouldForceVisibleCodexTerminal('codex login')).toBe(true) - expect(shouldForceVisibleCodexTerminal('codex cloud')).toBe(true) - expect(shouldForceVisibleCodexTerminal('codex -c active=cloud cloud')).toBe(true) - expect(shouldForceVisibleCodexTerminal('codex.cmd resume --last')).toBe(true) - expect(shouldForceVisibleCodexTerminal('env OPENAI_API_KEY=stub codex')).toBe(true) +describe('shouldUseRendererBackedCodexTerminal', () => { + it('uses renderer-backed terminal creation for interactive Codex sessions', () => { + expect(shouldUseRendererBackedCodexTerminal('codex')).toBe(true) + expect(shouldUseRendererBackedCodexTerminal('codex -m gpt-5 "fix the flaky test"')).toBe(true) + expect(shouldUseRendererBackedCodexTerminal('codex resume --last')).toBe(true) + expect(shouldUseRendererBackedCodexTerminal('codex fork')).toBe(true) + expect(shouldUseRendererBackedCodexTerminal('codex login')).toBe(true) + expect(shouldUseRendererBackedCodexTerminal('codex cloud')).toBe(true) + expect(shouldUseRendererBackedCodexTerminal('codex -c active=cloud cloud')).toBe(true) + expect(shouldUseRendererBackedCodexTerminal('codex.cmd resume --last')).toBe(true) + expect(shouldUseRendererBackedCodexTerminal('env OPENAI_API_KEY=stub codex')).toBe(true) }) it('keeps one-shot Codex commands on the background path', () => { - expect(shouldForceVisibleCodexTerminal('codex exec summarize')).toBe(false) - expect(shouldForceVisibleCodexTerminal('codex -m gpt-5 review')).toBe(false) - expect(shouldForceVisibleCodexTerminal('codex login status')).toBe(false) - expect(shouldForceVisibleCodexTerminal('codex login --with-api-key')).toBe(false) - expect(shouldForceVisibleCodexTerminal('codex cloud list --json')).toBe(false) - expect(shouldForceVisibleCodexTerminal('codex -c active=cloud cloud list --json')).toBe(false) - expect(shouldForceVisibleCodexTerminal('codex cloud --enable foo list --json')).toBe(false) + expect(shouldUseRendererBackedCodexTerminal('codex exec summarize')).toBe(false) + expect(shouldUseRendererBackedCodexTerminal('codex -m gpt-5 review')).toBe(false) + expect(shouldUseRendererBackedCodexTerminal('codex login status')).toBe(false) + expect(shouldUseRendererBackedCodexTerminal('codex login --with-api-key')).toBe(false) + expect(shouldUseRendererBackedCodexTerminal('codex cloud list --json')).toBe(false) + expect(shouldUseRendererBackedCodexTerminal('codex -c active=cloud cloud list --json')).toBe( + false + ) + expect(shouldUseRendererBackedCodexTerminal('codex cloud --enable foo list --json')).toBe(false) expect( - shouldForceVisibleCodexTerminal('env -u DEBUG CODEX_HOME=/tmp/codex codex exec summarize') + shouldUseRendererBackedCodexTerminal( + 'env -u DEBUG CODEX_HOME=/tmp/codex codex exec summarize' + ) ).toBe(false) - expect(shouldForceVisibleCodexTerminal('codex cloud exec "fix it"')).toBe(false) - expect(shouldForceVisibleCodexTerminal('codex cloud --version')).toBe(false) - expect(shouldForceVisibleCodexTerminal('codex --help')).toBe(false) + expect(shouldUseRendererBackedCodexTerminal('codex cloud exec "fix it"')).toBe(false) + expect(shouldUseRendererBackedCodexTerminal('codex cloud --version')).toBe(false) + expect(shouldUseRendererBackedCodexTerminal('codex --help')).toBe(false) }) it('ignores non-Codex commands', () => { - expect(shouldForceVisibleCodexTerminal(undefined)).toBe(false) - expect(shouldForceVisibleCodexTerminal('claude')).toBe(false) - expect(shouldForceVisibleCodexTerminal('npm exec codex')).toBe(false) + expect(shouldUseRendererBackedCodexTerminal(undefined)).toBe(false) + expect(shouldUseRendererBackedCodexTerminal('claude')).toBe(false) + expect(shouldUseRendererBackedCodexTerminal('npm exec codex')).toBe(false) + }) +}) + +describe('shouldUseRendererBackedInteractiveTerminal', () => { + it('uses renderer-backed terminal creation for interactive Claude sessions', () => { + expect(shouldUseRendererBackedInteractiveTerminal('claude')).toBe(true) + expect(shouldUseRendererBackedInteractiveTerminal('claude --prefill "review this"')).toBe(true) + expect(shouldUseRendererBackedInteractiveTerminal('/opt/anthropic/bin/claude')).toBe(true) + expect(shouldUseRendererBackedInteractiveTerminal('env ANTHROPIC_BASE_URL=test claude')).toBe( + true + ) + }) + + it('keeps one-shot Claude commands on the background path', () => { + expect(shouldUseRendererBackedInteractiveTerminal('claude -p "summarize"')).toBe(false) + expect(shouldUseRendererBackedInteractiveTerminal('claude --print "summarize"')).toBe(false) + expect(shouldUseRendererBackedInteractiveTerminal('claude --help')).toBe(false) + expect(shouldUseRendererBackedInteractiveTerminal('claude --version')).toBe(false) + }) + + it('preserves Codex command classification', () => { + expect(shouldUseRendererBackedInteractiveTerminal('codex')).toBe(true) + expect(shouldUseRendererBackedInteractiveTerminal('codex exec summarize')).toBe(false) }) }) diff --git a/src/cli/codex-command-classification.ts b/src/cli/codex-command-classification.ts index 0baff6e07..941b02c24 100644 --- a/src/cli/codex-command-classification.ts +++ b/src/cli/codex-command-classification.ts @@ -130,6 +130,10 @@ function isCodexExecutable(command: string): boolean { return command === 'codex' || command === 'codex.exe' || command === 'codex.cmd' } +function isClaudeExecutable(command: string): boolean { + return command === 'claude' || command === 'claude.exe' || command === 'claude.cmd' +} + function isShellAssignment(token: string): boolean { return /^[A-Za-z_][A-Za-z0-9_]*=/.test(token) } @@ -178,6 +182,11 @@ function isVersionFlag(token: string): boolean { return token === '--version' || token === '-V' } +function isClaudePrintFlag(token: string): boolean { + const optionName = codexGlobalOptionName(token) + return optionName === '-p' || optionName === '--print' +} + function findCodexSubcommand( tokens: string[], startIndex: number, @@ -259,7 +268,7 @@ function isNonInteractiveCodexSubcommand(tokens: string[]): boolean { return CODEX_NON_INTERACTIVE_SUBCOMMANDS.has(normalizedSubcommand) } -export function shouldForceVisibleCodexTerminal(command: string | undefined): boolean { +export function shouldUseRendererBackedCodexTerminal(command: string | undefined): boolean { if (!command) { return false } @@ -275,3 +284,24 @@ export function shouldForceVisibleCodexTerminal(command: string | undefined): bo return !isNonInteractiveCodexSubcommand(tokens) } + +export function shouldUseRendererBackedInteractiveTerminal(command: string | undefined): boolean { + if (!command) { + return false + } + + const tokens = stripShellLaunchPrefix( + tokenizeLeadingShellWords(command.trim(), 32).filter((token) => token.length > 0) + ) + + const executable = tokens[0] ? commandBasename(tokens[0]) : '' + if (isCodexExecutable(executable)) { + return !isNonInteractiveCodexSubcommand(tokens) + } + if (isClaudeExecutable(executable)) { + return !tokens + .slice(1) + .some((token) => isHelpFlag(token) || isVersionFlag(token) || isClaudePrintFlag(token)) + } + return false +} diff --git a/src/cli/handlers/terminal.ts b/src/cli/handlers/terminal.ts index 3759081ad..a6039f5e1 100644 --- a/src/cli/handlers/terminal.ts +++ b/src/cli/handlers/terminal.ts @@ -11,7 +11,7 @@ import type { RuntimeTerminalWait } from '../../shared/runtime-types' import type { CommandHandler } from '../dispatch' -import { shouldForceVisibleCodexTerminal } from '../codex-command-classification' +import { shouldUseRendererBackedInteractiveTerminal } from '../codex-command-classification' import { formatTerminalClose, formatTerminalCreate, @@ -130,17 +130,18 @@ export const TERMINAL_HANDLERS: Record = { ) } const command = getOptionalStringFlag(flags, 'command') + const useRendererBackedInteractiveTerminal = + !client.isRemote && shouldUseRendererBackedInteractiveTerminal(command) const focus = flags.get('focus') === true - const rendererBacked = !client.isRemote && shouldForceVisibleCodexTerminal(command) const result = await client.call<{ terminal: RuntimeTerminalCreate }>('terminal.create', { worktree: await getBrowserWorktreeSelector(flags, cwd, client), command, title: getOptionalStringFlag(flags, 'title'), - // Why: Codex's interactive TUI must be born in a renderer-backed - // terminal. The runtime's default create path spawns first in a - // headless/background PTY and only adopts into the UI afterward. + // Why: interactive local agent TUIs need the renderer-backed terminal + // path for browser-side features, but CLI creates must stay backgrounded + // unless the caller explicitly asks for focus. focus, - ...(rendererBacked ? { rendererBacked: true, activate: focus } : {}) + ...(useRendererBackedInteractiveTerminal ? { rendererBacked: true, activate: focus } : {}) }) printResult(result, json, formatTerminalCreate) }, diff --git a/src/cli/index.test.ts b/src/cli/index.test.ts index f97dbbbe6..ec97b5420 100644 --- a/src/cli/index.test.ts +++ b/src/cli/index.test.ts @@ -923,7 +923,7 @@ describe('orca cli worktree awareness', () => { }) }) - it('forces the visible terminal path for interactive Codex startup commands', async () => { + it('keeps interactive Codex startup commands backgrounded unless focus is explicit', async () => { queueFixtures( callMock, okFixture('req_terminal_create', { @@ -1144,7 +1144,7 @@ describe('orca cli worktree awareness', () => { }) }) - it('forces the visible terminal path for Codex prompts after global options', async () => { + it('keeps Codex prompts after global options backgrounded unless focus is explicit', async () => { queueFixtures( callMock, okFixture('req_terminal_create', { @@ -1182,6 +1182,80 @@ describe('orca cli worktree awareness', () => { }) }) + it('keeps interactive Claude startup commands backgrounded unless focus is explicit', async () => { + queueFixtures( + callMock, + okFixture('req_terminal_create', { + terminal: { + handle: 'term_1', + worktreeId: 'repo-1::/tmp/repo/feature', + title: 'Claude' + } + }) + ) + vi.spyOn(console, 'log').mockImplementation(() => {}) + + await main( + [ + 'terminal', + 'create', + '--worktree', + 'path:/tmp/repo/feature', + '--title', + 'Claude', + '--command', + 'claude', + '--json' + ], + '/tmp/repo' + ) + + expect(callMock).toHaveBeenCalledWith('terminal.create', { + worktree: 'path:/tmp/repo/feature', + command: 'claude', + title: 'Claude', + focus: false, + rendererBacked: true, + activate: false + }) + }) + + it('keeps Claude print commands on the background terminal path', async () => { + queueFixtures( + callMock, + okFixture('req_terminal_create', { + terminal: { + handle: 'term_1', + worktreeId: 'repo-1::/tmp/repo/feature', + title: 'Claude print' + } + }) + ) + vi.spyOn(console, 'log').mockImplementation(() => {}) + + await main( + [ + 'terminal', + 'create', + '--worktree', + 'path:/tmp/repo/feature', + '--title', + 'Claude print', + '--command', + 'claude -p "summarize"', + '--json' + ], + '/tmp/repo' + ) + + expect(callMock).toHaveBeenCalledWith('terminal.create', { + worktree: 'path:/tmp/repo/feature', + command: 'claude -p "summarize"', + title: 'Claude print', + focus: false + }) + }) + it('uses the resolved enclosing worktree for other worktree consumers', async () => { queueFixtures( callMock, diff --git a/src/renderer/src/components/Terminal.tsx b/src/renderer/src/components/Terminal.tsx index e77901feb..dfabd4ca9 100644 --- a/src/renderer/src/components/Terminal.tsx +++ b/src/renderer/src/components/Terminal.tsx @@ -519,25 +519,50 @@ function Terminal(): React.JSX.Element | null { // Only mount TerminalPanes for visited worktrees to prevent mass PTY // spawning when restoring a session with many saved worktree tabs. const mountedWorktreeIdsRef = useRef(new Set()) + const measurableBackgroundWorktreeIdsRef = useRef(new Set()) + const measurableBackgroundWorktreeTimersRef = useRef(new Map()) const [, setBackgroundMountRevision] = useState(0) useEffect(() => { + const timers = measurableBackgroundWorktreeTimersRef.current const onBackgroundMountTerminalWorktree = (event: Event): void => { const customEvent = event as CustomEvent - addBackgroundMountedTerminalWorktree( - mountedWorktreeIdsRef.current, - customEvent.detail?.worktreeId, - () => setBackgroundMountRevision((revision) => revision + 1) + const worktreeId = customEvent.detail?.worktreeId + addBackgroundMountedTerminalWorktree(mountedWorktreeIdsRef.current, worktreeId, () => + setBackgroundMountRevision((revision) => revision + 1) ) + if (!worktreeId) { + return + } + measurableBackgroundWorktreeIdsRef.current.add(worktreeId) + const existingTimer = timers.get(worktreeId) + if (existingTimer !== undefined) { + window.clearTimeout(existingTimer) + } + // Why: background renderer-backed terminal creation must be measurable + // for the first xterm fit, but it must not keep hidden worktrees laid + // out indefinitely after the PTY has started. + const timer = window.setTimeout(() => { + measurableBackgroundWorktreeIdsRef.current.delete(worktreeId) + timers.delete(worktreeId) + setBackgroundMountRevision((revision) => revision + 1) + }, 3000) + timers.set(worktreeId, timer) + setBackgroundMountRevision((revision) => revision + 1) } window.addEventListener( BACKGROUND_MOUNT_TERMINAL_WORKTREE_EVENT, onBackgroundMountTerminalWorktree as EventListener ) - return () => + return () => { window.removeEventListener( BACKGROUND_MOUNT_TERMINAL_WORKTREE_EVENT, onBackgroundMountTerminalWorktree as EventListener ) + for (const timer of timers.values()) { + window.clearTimeout(timer) + } + timers.clear() + } }, []) // Why: gated on workspaceSessionReady to prevent TerminalPane from mounting // before reconnectPersistedTerminals() has finished eagerly spawning PTYs. @@ -1384,6 +1409,8 @@ function Terminal(): React.JSX.Element | null { // Why: use strict equality with 'terminal' instead of !== 'settings' // so the terminal/browser surface hides on the tasks page too. const isVisible = activeView === 'terminal' && worktree.id === activeWorktreeId + const shouldMeasureHiddenWorktree = + !isVisible && measurableBackgroundWorktreeIdsRef.current.has(worktree.id) return ( ) @@ -1439,10 +1467,18 @@ function Terminal(): React.JSX.Element | null { // Why: use strict equality with 'terminal' instead of !== 'settings' // so the terminal/browser surface hides on the tasks page too. const isVisible = activeView === 'terminal' && worktree.id === activeWorktreeId + const shouldMeasureHiddenWorktree = + !isVisible && measurableBackgroundWorktreeIdsRef.current.has(worktree.id) return (
@@ -1639,6 +1675,7 @@ const WorktreeSplitSurface = React.memo(function WorktreeSplitSurface({ layout, focusedGroupId, isVisible, + shouldMeasureHiddenWorktree, activityTerminalPortals }: { worktreeId: string @@ -1646,11 +1683,18 @@ const WorktreeSplitSurface = React.memo(function WorktreeSplitSurface({ layout: TabGroupLayoutNode focusedGroupId?: string isVisible: boolean + shouldMeasureHiddenWorktree: boolean activityTerminalPortals: ActivityTerminalPortalTarget[] }): React.JSX.Element { return (
diff --git a/src/renderer/src/components/terminal-pane/TerminalPane.tsx b/src/renderer/src/components/terminal-pane/TerminalPane.tsx index 8c8cd08f1..181b6b9bf 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPane.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPane.tsx @@ -321,6 +321,7 @@ export default function TerminalPane({ // without changing the other platforms' interaction model. const rightClickToPaste = isWindowsUserAgent() && (settings?.terminalRightClickToPaste ?? true) const [startup] = useState(() => useAppStore.getState().pendingStartupByTabId[tabId]) + const shouldMeasureHiddenStartup = startup !== undefined && !isVisible const consumeTabStartupCommand = useAppStore((store) => store.consumeTabStartupCommand) const [setupSplit] = useState(() => useAppStore.getState().pendingSetupSplitByTabId[tabId]) const consumeTabSetupSplit = useAppStore((store) => store.consumeTabSetupSplit) @@ -1486,7 +1487,8 @@ export default function TerminalPane({ // Why: split groups can keep one terminal visible in an unfocused group so // users still see its output while typing elsewhere. Hiding on `isActive` // blanked the previously focused pane and exposed the white group body. - display: isVisible ? 'flex' : 'none', + display: isVisible || shouldMeasureHiddenStartup ? 'flex' : 'none', + ...(shouldMeasureHiddenStartup ? { opacity: 0, pointerEvents: 'none' } : {}), ['--orca-terminal-divider-color' as string]: effectiveAppearance?.dividerColor ?? DEFAULT_TERMINAL_DIVIDER_DARK, ['--orca-terminal-divider-color-strong' as string]: normalizeColor( diff --git a/src/renderer/src/components/terminal-pane/TerminalPaneOverlayLayer.tsx b/src/renderer/src/components/terminal-pane/TerminalPaneOverlayLayer.tsx index cf6151326..08a44186a 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPaneOverlayLayer.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPaneOverlayLayer.tsx @@ -1,4 +1,4 @@ -import { memo, useCallback, useMemo } from 'react' +import { memo, useCallback, useMemo, useState } from 'react' import { createPortal } from 'react-dom' import { useShallow } from 'zustand/react/shallow' import type { Tab, TabGroup, TerminalTab } from '../../../../shared/types' @@ -50,6 +50,9 @@ const TerminalOverlaySlot = memo(function TerminalOverlaySlot({ leaveWorktreeIfEmpty }: TerminalOverlaySlotProps): React.JSX.Element { const anchorName = groupId !== undefined ? tabGroupBodyAnchorName(groupId) : undefined + const [shouldMeasureHiddenStartup] = useState( + () => useAppStore.getState().pendingStartupByTabId[terminalTabId] !== undefined + ) const style: React.CSSProperties = useMemo( () => anchorName @@ -60,7 +63,8 @@ const TerminalOverlaySlot = memo(function TerminalOverlaySlot({ left: `anchor(${anchorName} left)`, width: `anchor-size(${anchorName} width)`, height: `anchor-size(${anchorName} height)`, - display: isVisible ? 'flex' : 'none', + display: isVisible || shouldMeasureHiddenStartup ? 'flex' : 'none', + opacity: isVisible ? 1 : 0, pointerEvents: isVisible ? 'auto' : 'none' } : { @@ -72,7 +76,7 @@ const TerminalOverlaySlot = memo(function TerminalOverlaySlot({ display: 'none', pointerEvents: 'none' }, - [anchorName, isVisible] + [anchorName, isVisible, shouldMeasureHiddenStartup] ) const focusGroup = useCallback(() => { if (groupId !== undefined && onFocusOwningGroup) {