Fix CLI-launched agent terminal viewport (#2561)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo Hong 2026-05-21 18:35:48 -04:00 committed by GitHub
parent 630de67fcb
commit a75a822339
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 233 additions and 46 deletions

View File

@ -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 <handle>` 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`.

View File

@ -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)
})
})

View File

@ -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
}

View File

@ -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<string, CommandHandler> = {
)
}
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)
},

View File

@ -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,

View File

@ -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<string>())
const measurableBackgroundWorktreeIdsRef = useRef(new Set<string>())
const measurableBackgroundWorktreeTimersRef = useRef(new Map<string, number>())
const [, setBackgroundMountRevision] = useState(0)
useEffect(() => {
const timers = measurableBackgroundWorktreeTimersRef.current
const onBackgroundMountTerminalWorktree = (event: Event): void => {
const customEvent = event as CustomEvent<BackgroundMountTerminalWorktreeDetail>
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 (
<WorktreeSplitSurface
key={`tab-groups-${worktree.id}`}
@ -1392,6 +1419,7 @@ function Terminal(): React.JSX.Element | null {
layout={layout}
focusedGroupId={activeGroupIdByWorktree[worktree.id]}
isVisible={isVisible}
shouldMeasureHiddenWorktree={shouldMeasureHiddenWorktree}
activityTerminalPortals={activityTerminalPortals}
/>
)
@ -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 (
<div
key={worktree.id}
className={isVisible ? 'absolute inset-0' : 'absolute inset-0 hidden'}
className={
isVisible
? 'absolute inset-0'
: shouldMeasureHiddenWorktree
? 'absolute inset-0 opacity-0 pointer-events-none'
: 'absolute inset-0 hidden'
}
aria-hidden={!isVisible}
>
<CodexRestartChip worktreeId={worktree.id} />
@ -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 (
<div
className={isVisible ? 'absolute inset-0 flex' : 'absolute inset-0 hidden'}
className={
isVisible
? 'absolute inset-0 flex'
: shouldMeasureHiddenWorktree
? 'absolute inset-0 flex opacity-0 pointer-events-none'
: 'absolute inset-0 hidden'
}
aria-hidden={!isVisible}
>
<CodexRestartChip worktreeId={worktreeId} />

View File

@ -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(

View File

@ -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) {