diff --git a/src/renderer/src/components/onboarding/OnboardingInlineCommandTerminal.command-finished.test.tsx b/src/renderer/src/components/onboarding/OnboardingInlineCommandTerminal.command-finished.test.tsx new file mode 100644 index 00000000000..1b55391dec4 --- /dev/null +++ b/src/renderer/src/components/onboarding/OnboardingInlineCommandTerminal.command-finished.test.tsx @@ -0,0 +1,117 @@ +// @vitest-environment happy-dom + +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { OnboardingInlineCommandTerminal } from './OnboardingInlineCommandTerminal' +import { + ORCA_TERMINAL_COMMAND_FINISHED_EVENT, + type TerminalCommandFinishedEventDetail +} from '@/hooks/terminal-command-finished-event' + +const mocks = vi.hoisted(() => ({ + createTab: vi.fn(() => ({ id: 'tab-1' })), + closeTab: vi.fn(), + setActiveTabForWorktree: vi.fn(), + setTabCustomTitle: vi.fn() +})) + +vi.mock('@/store', () => ({ + useAppStore: (selector: (state: Record) => unknown) => + selector({ + createTab: mocks.createTab, + closeTab: mocks.closeTab, + setActiveTabForWorktree: mocks.setActiveTabForWorktree, + setTabCustomTitle: mocks.setTabCustomTitle + }) +})) + +vi.mock('@/components/terminal-pane/TerminalPane', () => ({ + default: () =>
+})) + +vi.mock('@/lib/focus-terminal-tab-surface', () => ({ + focusTerminalTabSurface: vi.fn() +})) + +function dispatchCommandFinished(worktreeId: string, exitCode: number | null): void { + window.dispatchEvent( + new CustomEvent(ORCA_TERMINAL_COMMAND_FINISHED_EVENT, { + detail: { worktreeId, exitCode } + }) + ) +} + +let root: Root | null = null +let container: HTMLDivElement | null = null + +describe('OnboardingInlineCommandTerminal command-finished forwarding', () => { + beforeEach(() => { + mocks.createTab.mockClear() + mocks.closeTab.mockClear() + Object.defineProperty(window, 'api', { + configurable: true, + value: { + app: { + getFloatingTerminalCwd: vi.fn(async () => '/tmp') + } + } + }) + }) + + afterEach(async () => { + if (root) { + await act(async () => { + root?.unmount() + }) + } + root = null + container?.remove() + container = null + Reflect.deleteProperty(window, 'api') + }) + + it('forwards exit codes only for its own branded worktree id', async () => { + const onCommandFinished = vi.fn() + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + await act(async () => { + root?.render( + + ) + }) + await act(async () => {}) + + await act(async () => { + dispatchCommandFinished('some-other-worktree', 1) + }) + expect(onCommandFinished).not.toHaveBeenCalled() + + // Why unbranded-miss matters: pty-connection dispatches the BRANDED id; + // matching the raw panel id would silently never fire in production. + await act(async () => { + dispatchCommandFinished('settings-orchestration-skill-terminal', 1) + }) + expect(onCommandFinished).not.toHaveBeenCalled() + + await act(async () => { + dispatchCommandFinished('ephemeral-setup-terminal:settings-orchestration-skill-terminal', 1) + }) + expect(onCommandFinished).toHaveBeenCalledWith(1) + + await act(async () => { + dispatchCommandFinished( + 'ephemeral-setup-terminal:settings-orchestration-skill-terminal', + null + ) + }) + expect(onCommandFinished).toHaveBeenLastCalledWith(null) + }) +}) diff --git a/src/renderer/src/components/onboarding/OnboardingInlineCommandTerminal.tsx b/src/renderer/src/components/onboarding/OnboardingInlineCommandTerminal.tsx index a0b8e894b47..0a7f0ae7a8f 100644 --- a/src/renderer/src/components/onboarding/OnboardingInlineCommandTerminal.tsx +++ b/src/renderer/src/components/onboarding/OnboardingInlineCommandTerminal.tsx @@ -3,6 +3,10 @@ import { Loader2 } from 'lucide-react' import TerminalPane from '@/components/terminal-pane/TerminalPane' import { PASTE_TERMINAL_TEXT_EVENT, type PasteTerminalTextDetail } from '@/constants/terminal' import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface' +import { + ORCA_TERMINAL_COMMAND_FINISHED_EVENT, + type TerminalCommandFinishedEventDetail +} from '@/hooks/terminal-command-finished-event' import { useAppStore } from '@/store' import { translate } from '@/i18n/i18n' import { brandEphemeralSetupTerminalWorktreeId } from '../../../../shared/ephemeral-setup-terminal-worktree-id' @@ -29,6 +33,8 @@ type OnboardingInlineCommandTerminalProps = { onOpened?: () => void onInteracted?: (method: 'keyboard' | 'pointer', event?: KeyboardEvent) => void onTerminalExit?: () => void + // OSC 133;D reports the command outcome while the shell remains alive. + onCommandFinished?: (bestEffortExitCode: number | null) => void } /** @@ -48,7 +54,8 @@ export function OnboardingInlineCommandTerminal({ shellOverride, onOpened, onInteracted, - onTerminalExit + onTerminalExit, + onCommandFinished }: OnboardingInlineCommandTerminalProps): React.JSX.Element { // Why: brand the id so a remote runtime scopes this ephemeral terminal to the // floating terminal instead of rejecting the synthetic id. @@ -80,6 +87,24 @@ export function OnboardingInlineCommandTerminal({ onOpened?.() }, [onOpened]) + // Why: the branded id isolates command outcomes to this inline terminal. + useEffect(() => { + if (!onCommandFinished) { + return + } + const handleCommandFinished = (event: Event): void => { + const detail = (event as CustomEvent).detail + if (detail?.worktreeId !== worktreeId) { + return + } + onCommandFinished(detail.exitCode) + } + window.addEventListener(ORCA_TERMINAL_COMMAND_FINISHED_EVENT, handleCommandFinished) + return () => { + window.removeEventListener(ORCA_TERMINAL_COMMAND_FINISHED_EVENT, handleCommandFinished) + } + }, [onCommandFinished, worktreeId]) + useEffect(() => { let cancelled = false void window.api.app.getFloatingTerminalCwd({ path: '~' }).then((nextCwd) => { diff --git a/src/renderer/src/components/settings/AgentSkillSetupFailureNotice.tsx b/src/renderer/src/components/settings/AgentSkillSetupFailureNotice.tsx new file mode 100644 index 00000000000..eba25d637a4 --- /dev/null +++ b/src/renderer/src/components/settings/AgentSkillSetupFailureNotice.tsx @@ -0,0 +1,18 @@ +import { translate } from '@/i18n/i18n' + +export function AgentSkillSetupFailureNotice(props: { + exitCode: number | null +}): React.JSX.Element | null { + if (props.exitCode === null) { + return null + } + return ( +

+ {translate( + 'auto.components.settings.AgentSkillSetupPanel.setupCommandFailed', + 'The setup command exited with code {{value0}}. This error will clear after a successful retry.', + { value0: props.exitCode } + )} +

+ ) +} diff --git a/src/renderer/src/components/settings/AgentSkillSetupPanel.test.tsx b/src/renderer/src/components/settings/AgentSkillSetupPanel.test.tsx index 533144f8bdf..ac44ed3285c 100644 --- a/src/renderer/src/components/settings/AgentSkillSetupPanel.test.tsx +++ b/src/renderer/src/components/settings/AgentSkillSetupPanel.test.tsx @@ -1,6 +1,6 @@ // @vitest-environment happy-dom -import { act, type ComponentProps } from 'react' +import { act, useState, type ComponentProps } from 'react' import { createRoot, type Root } from 'react-dom/client' import { renderToStaticMarkup } from 'react-dom/server' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -12,11 +12,17 @@ const UPDATE_COMMAND = 'npx skills update orca-cli --global' const mocks = vi.hoisted(() => ({ clipboardWrite: vi.fn(), - terminalProps: [] as { command: string; description: string }[], + terminalProps: [] as { + command: string + description: string + onTerminalExit?: () => void + onCommandFinished?: (bestEffortExitCode: number | null) => void + }[], toastError: vi.fn(), toastSuccess: vi.fn(), skillsChanged: vi.fn(), - skillsRefreshed: vi.fn() + skillsRefreshed: vi.fn(), + terminalInstanceCount: 0 })) vi.mock('sonner', () => ({ @@ -32,13 +38,23 @@ vi.mock('@/hooks/useInstalledAgentSkills', () => ({ })) vi.mock('../onboarding/OnboardingInlineCommandTerminal', () => ({ - OnboardingInlineCommandTerminal: (props: { command: string; description: string }) => { + OnboardingInlineCommandTerminal: (props: { + command: string + description: string + onTerminalExit?: () => void + onCommandFinished?: (bestEffortExitCode: number | null) => void + }) => { + const [instance] = useState(() => { + mocks.terminalInstanceCount += 1 + return mocks.terminalInstanceCount + }) mocks.terminalProps.push(props) return (
{props.command}
@@ -139,6 +155,7 @@ describe('AgentSkillSetupPanel', () => { mocks.toastSuccess.mockReset() mocks.skillsChanged.mockReset() mocks.skillsRefreshed.mockReset() + mocks.terminalInstanceCount = 0 Object.defineProperty(window, 'api', { configurable: true, value: { @@ -354,4 +371,167 @@ describe('AgentSkillSetupPanel', () => { expect(container?.textContent).toContain(INSTALL_COMMAND) expect(mocks.terminalProps.at(-1)).toMatchObject({ command: INSTALL_COMMAND }) }) + + it('keeps a failed setup command visible with durable recovery', async () => { + const onRecheck = vi.fn() + await renderInteractivePanel({ onRecheck }) + await clickButton('Install') + onRecheck.mockClear() + + await act(async () => { + const onCommandFinished = mocks.terminalProps.at(-1)?.onCommandFinished + onCommandFinished?.(1) + onCommandFinished?.(0) + }) + + expect(container?.textContent).toContain( + 'The setup command exited with code 1. This error will clear after a successful retry.' + ) + expect(container?.textContent).toContain('Setup failed') + expect(container?.querySelector('[data-testid="inline-command-terminal"]')).not.toBeNull() + expect(findButton('Retry').disabled).toBe(false) + expect(onRecheck).toHaveBeenCalledTimes(1) + }) + + it('clears the failure notice when a later command succeeds', async () => { + await renderInteractivePanel() + await clickButton('Install') + + await act(async () => { + mocks.terminalProps.at(-1)?.onCommandFinished?.(1) + }) + await clickButton('Retry') + await act(async () => { + mocks.terminalProps.at(-1)?.onCommandFinished?.(0) + }) + + expect(container?.textContent).not.toContain('exited with code') + }) + + it('keeps the failure verdict when a command finishes without an exit code', async () => { + await renderInteractivePanel() + await clickButton('Install') + + await act(async () => { + mocks.terminalProps.at(-1)?.onCommandFinished?.(1) + }) + await clickButton('Retry') + await act(async () => { + mocks.terminalProps.at(-1)?.onCommandFinished?.(null) + }) + + expect(container?.textContent).toContain( + 'The setup command exited with code 1. This error will clear after a successful retry.' + ) + }) + + it('retries a failed command in a fresh interactive terminal', async () => { + let finishRetryPreflight: (() => void) | null = null + const retryPreflight = new Promise((resolve) => { + finishRetryPreflight = resolve + }) + let preflightCount = 0 + await renderInteractivePanel({ + onBeforeOpenTerminal: () => { + preflightCount += 1 + return preflightCount === 1 ? undefined : retryPreflight + } + }) + await clickButton('Install') + const firstInstance = container + ?.querySelector('[data-testid="inline-command-terminal"]') + ?.getAttribute('data-instance') + + await act(async () => { + mocks.terminalProps.at(-1)?.onCommandFinished?.(1) + }) + await clickButton('Retry') + expect(container?.querySelector('[data-testid="inline-command-terminal"]')).toBeNull() + + await act(async () => { + finishRetryPreflight?.() + await retryPreflight + }) + await act(async () => {}) + + expect(mocks.terminalProps.at(-1)).toMatchObject({ command: INSTALL_COMMAND }) + expect( + container + ?.querySelector('[data-testid="inline-command-terminal"]') + ?.getAttribute('data-instance') + ).not.toBe(firstInstance) + expect(findButton('Retry').disabled).toBe(true) + }) + + it('keeps the command failure authoritative over presence discovery', async () => { + await renderInteractivePanel({ freshnessSkillName: 'orca-cli' }) + await clickButton('Install') + + await act(async () => { + mocks.terminalProps.at(-1)?.onCommandFinished?.(1) + }) + await rerenderInteractivePanel({ installed: true, freshnessSkillName: 'orca-cli' }) + + expect(container?.textContent).toContain('Setup failed') + expect(container?.textContent).toContain('exited with code 1') + expect(container?.textContent).not.toContain('Installed') + expect(container?.querySelector('[data-testid="skill-freshness"]')).toBeNull() + expect(findButton('Retry').disabled).toBe(false) + }) + + it('keeps failed updates recoverable when installed re-check is hidden', async () => { + await renderInteractivePanel({ + installed: true, + installedCommand: UPDATE_COMMAND, + showRecheckWhenInstalled: false + }) + await clickButton('Update') + + await act(async () => { + mocks.terminalProps.at(-1)?.onCommandFinished?.(1) + }) + + expect(container?.textContent).toContain('Setup failed') + expect(container?.textContent).toContain('exited with code 1') + expect(findButton('Retry').disabled).toBe(false) + + await clickButton('Retry') + expect(mocks.terminalProps.at(-1)).toMatchObject({ command: UPDATE_COMMAND }) + }) + + it('invalidates shared skill state before the direct completion re-check', async () => { + const calls: string[] = [] + mocks.skillsChanged.mockImplementation(() => calls.push('invalidate')) + const onRecheck = vi.fn(() => { + calls.push('recheck') + }) + await renderInteractivePanel({ freshnessSkillName: 'orca-cli', onRecheck }) + await clickButton('Install') + calls.length = 0 + + await act(async () => { + mocks.terminalProps.at(-1)?.onCommandFinished?.(0) + }) + + expect(calls).toEqual(['invalidate', 'recheck']) + }) + + it('re-enables Install after the setup shell exits so a failed attempt can retry', async () => { + await renderInteractivePanel() + await clickButton('Install') + + expect(findButton('Install').disabled).toBe(true) + + await act(async () => { + mocks.terminalProps.at(-1)?.onTerminalExit?.() + }) + + expect(findButton('Install').disabled).toBe(false) + expect(container?.querySelector('[data-testid="inline-command-terminal"]')).toBeNull() + + await clickButton('Install') + + expect(findButton('Install').disabled).toBe(true) + expect(mocks.terminalProps.at(-1)).toMatchObject({ command: INSTALL_COMMAND }) + }) }) diff --git a/src/renderer/src/components/settings/AgentSkillSetupPanel.tsx b/src/renderer/src/components/settings/AgentSkillSetupPanel.tsx index 96bf0af5a8a..c141b9bf472 100644 --- a/src/renderer/src/components/settings/AgentSkillSetupPanel.tsx +++ b/src/renderer/src/components/settings/AgentSkillSetupPanel.tsx @@ -1,9 +1,11 @@ -import { useCallback, useEffect, useState, type ComponentProps, type ReactNode } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' import { Copy, Loader2, RefreshCw, Terminal } from 'lucide-react' import { toast } from 'sonner' import { IntegrationStatusPill } from '../integration-status-pill' import { SkillFreshnessStatusPill } from '../skills/SkillFreshnessStatusPill' import { OnboardingInlineCommandTerminal } from '../onboarding/OnboardingInlineCommandTerminal' +import { AgentSkillSetupFailureNotice } from './AgentSkillSetupFailureNotice' +import type { AgentSkillSetupPanelProps } from './agent-skill-setup-panel-props' import { Button } from '../ui/button' import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip' import { @@ -15,51 +17,6 @@ import { isOrcaCliAvailableOnPath } from '@/lib/agent-skill-cli-prerequisite' import { cn } from '@/lib/utils' import { translate } from '@/i18n/i18n' -type AgentSkillSetupPanelVariant = 'card' | 'inline' -type SkillPrerequisiteStatus = Awaited> - -type AgentSkillSetupPanelProps = { - title: string - description: ReactNode - command: string - installedCommand?: string - terminalTitle: string - terminalAriaLabel: string - terminalWorktreeId: string - installed: boolean - loading: boolean - error: string | null - installDisabled?: boolean - terminalHeightPx?: number - terminalShellOverride?: string - leading?: ReactNode - icon?: ReactNode - variant?: AgentSkillSetupPanelVariant - className?: string - // Why: when an enclosing surface (e.g. a modal) already shows the title and - // status, hide the panel's own header row to avoid a duplicate heading. - hideHeader?: boolean - preInstallNotice?: ReactNode - getPrerequisiteStatus?: () => Promise - isPrerequisiteAvailable?: (status: SkillPrerequisiteStatus) => boolean - onBeforeOpenTerminal?: () => void | Promise - showInstallWhenInstalled?: boolean - showRecheckWhenInstalled?: boolean - installLabel?: string - installedInstallLabel?: string - // Why: defaults to 'outline' so settings panels stay unchanged; modals that make - // Install the sole footer CTA pass 'default' for a filled primary hierarchy. - installVariant?: ComponentProps['variant'] - actionHint?: ReactNode - openingHint?: ReactNode - footer?: ReactNode - onRecheck: () => void | Promise - // Why: when set, the installed pill reflects skill freshness and Re-check also - // refreshes the freshness inventory. Callers omit it for non-local runtimes, - // which the local-host-only freshness scan cannot vouch for. - freshnessSkillName?: string -} - export function AgentSkillSetupPanel({ title, description, @@ -102,7 +59,11 @@ export function AgentSkillSetupPanel({ translate('auto.components.settings.AgentSkillSetupPanel.updateLabel', 'Update') const [terminalOpen, setTerminalOpen] = useState(false) const [terminalCommand, setTerminalCommand] = useState(null) + const [terminalAttempt, setTerminalAttempt] = useState(0) const [terminalOpening, setTerminalOpening] = useState(false) + const [setupAttemptRunning, setSetupAttemptRunning] = useState(false) + const [setupCommandFailedCode, setSetupCommandFailedCode] = useState(null) + const setupAttemptRunningRef = useRef(false) const [preInstallNoticeVisible, setPreInstallNoticeVisible] = useState( Boolean(preInstallNotice && !installed) ) @@ -116,6 +77,68 @@ export function AgentSkillSetupPanel({ // already-open terminal pinned to the command selected by the user's click. const openTerminalCommand = terminalCommand ?? activeCommand + const openSetupTerminal = (): void => { + if (terminalOpening || setupAttemptRunning) { + return + } + const nextCommand = + setupCommandFailedCode !== null && terminalCommand ? terminalCommand : activeCommand + setTerminalOpening(true) + if (setupCommandFailedCode !== null) { + setTerminalOpen(false) + } + void (async () => { + let shouldOpenTerminal = false + try { + await onBeforeOpenTerminal?.() + await refreshPreInstallNotice() + shouldOpenTerminal = true + } catch { + shouldOpenTerminal = false + } finally { + if (mountedRef.current) { + setTerminalOpening(false) + if (shouldOpenTerminal) { + setTerminalCommand(nextCommand) + setTerminalAttempt((attempt) => attempt + 1) + setTerminalOpen(true) + setupAttemptRunningRef.current = true + setSetupAttemptRunning(true) + } + } + } + })() + } + + // Why: PTY exit is the shell's status; OSC 133;D reports the install command. + const handleSetupCommandFinished = useCallback( + (bestEffortExitCode: number | null): void => { + // Nested shells can emit duplicate completion markers in one PTY chunk. + if (!setupAttemptRunningRef.current) { + return + } + setupAttemptRunningRef.current = false + setSetupAttemptRunning(false) + if (bestEffortExitCode !== null) { + setSetupCommandFailedCode(bestEffortExitCode === 0 ? null : bestEffortExitCode) + } + if (freshnessSkillName) { + notifyInstalledAgentSkillsChanged() + } + void onRecheck() + }, + [freshnessSkillName, onRecheck] + ) + + const handleTerminalExit = useCallback((): void => { + if (mountedRef.current) { + setupAttemptRunningRef.current = false + setTerminalOpen(false) + setSetupAttemptRunning(false) + } + notifyInstalledAgentSkillsChanged() + }, [mountedRef]) + useEffect(() => { if (!preInstallNotice) { setPreInstallNoticeVisible(false) @@ -180,36 +203,12 @@ export function AgentSkillSetupPanel({ const actionRow = (
- {!installed || showInstallWhenInstalled ? ( + {(!installed || showInstallWhenInstalled) && setupCommandFailedCode === null ? ( ) : null} - {!installed || showRecheckWhenInstalled ? ( + {setupCommandFailedCode !== null || !installed || showRecheckWhenInstalled ? ( ) : null} {terminalOpening ? ( @@ -269,7 +278,7 @@ export function AgentSkillSetupPanel({ <> {error ?

{error}

: null} {/* hideHeader drops the title row; keep freshness so guided hubs still surface updates. */} - {installed && freshnessSkillName ? ( + {installed && freshnessSkillName && setupCommandFailedCode === null ? (
@@ -286,7 +295,14 @@ export function AgentSkillSetupPanel({

{title}

- {loading && !installed ? ( + {setupCommandFailedCode !== null ? ( + + {translate( + 'auto.components.settings.AgentSkillSetupPanel.setupFailed', + 'Setup failed' + )} + + ) : loading && !installed ? ( {translate( 'auto.components.settings.AgentSkillSetupPanel.68a468752e', @@ -322,6 +338,7 @@ export function AgentSkillSetupPanel({

{description}

) : null} {actionRow} + {actionHint ?
{actionHint}
: null} {!installed && preInstallNotice && preInstallNoticeVisible ? (

@@ -373,6 +390,7 @@ export function AgentSkillSetupPanel({

) : null} diff --git a/src/renderer/src/components/settings/agent-skill-setup-panel-props.ts b/src/renderer/src/components/settings/agent-skill-setup-panel-props.ts new file mode 100644 index 00000000000..9596d47d911 --- /dev/null +++ b/src/renderer/src/components/settings/agent-skill-setup-panel-props.ts @@ -0,0 +1,43 @@ +import type { ComponentProps, ReactNode } from 'react' +import type { Button } from '../ui/button' + +type AgentSkillSetupPanelVariant = 'card' | 'inline' +type SkillPrerequisiteStatus = Awaited> + +export type AgentSkillSetupPanelProps = { + title: string + description: ReactNode + command: string + installedCommand?: string + terminalTitle: string + terminalAriaLabel: string + terminalWorktreeId: string + installed: boolean + loading: boolean + error: string | null + installDisabled?: boolean + terminalHeightPx?: number + terminalShellOverride?: string + leading?: ReactNode + icon?: ReactNode + variant?: AgentSkillSetupPanelVariant + className?: string + // Enclosing modals can own the title and status. + hideHeader?: boolean + preInstallNotice?: ReactNode + getPrerequisiteStatus?: () => Promise + isPrerequisiteAvailable?: (status: SkillPrerequisiteStatus) => boolean + onBeforeOpenTerminal?: () => void | Promise + showInstallWhenInstalled?: boolean + showRecheckWhenInstalled?: boolean + installLabel?: string + installedInstallLabel?: string + // Modal footers can promote Install to the primary action. + installVariant?: ComponentProps['variant'] + actionHint?: ReactNode + openingHint?: ReactNode + footer?: ReactNode + onRecheck: () => void | Promise + // Freshness inventory is local-host-only. + freshnessSkillName?: string +} diff --git a/src/renderer/src/components/terminal-pane/parked-terminal-command-status.test.ts b/src/renderer/src/components/terminal-pane/parked-terminal-command-status.test.ts index c2479267e24..24991e01914 100644 --- a/src/renderer/src/components/terminal-pane/parked-terminal-command-status.test.ts +++ b/src/renderer/src/components/terminal-pane/parked-terminal-command-status.test.ts @@ -271,7 +271,7 @@ describe('createParkedTerminalCommandStatusPolicy', () => { ssh.dispose() expect(dispatchTerminalCommandFinishedEvent).toHaveBeenCalledTimes(2) - expect(dispatchTerminalCommandFinishedEvent).toHaveBeenCalledWith(WORKTREE_ID) + expect(dispatchTerminalCommandFinishedEvent).toHaveBeenCalledWith(WORKTREE_ID, 0) }) it('drops a same-turn status row on command finished for SSH PTYs only', async () => { diff --git a/src/renderer/src/components/terminal-pane/parked-terminal-command-status.ts b/src/renderer/src/components/terminal-pane/parked-terminal-command-status.ts index ff422e55de5..781625318bd 100644 --- a/src/renderer/src/components/terminal-pane/parked-terminal-command-status.ts +++ b/src/renderer/src/components/terminal-pane/parked-terminal-command-status.ts @@ -126,13 +126,13 @@ export function createParkedTerminalCommandStatusPolicy(options: { ) return { - onCommandFinished: (): void => { + onCommandFinished: (bestEffortExitCode: number | null): void => { if (disposed) { return } // Why: the finished command may have moved HEAD or the index (an agent running // `git checkout` in a parked worktree); nudge git UI now instead of waiting for a poll. - dispatchTerminalCommandFinishedEvent(worktreeId) + dispatchTerminalCommandFinishedEvent(worktreeId, bestEffortExitCode) // Why: drop the same-turn status row only for SSH PTYs — exact parity with the mounted // path, whose foreground tracker refuses SSH ids and drops un-probed. Local PTYs need // pty-connection's process-confirm ladder to tell a leaked nested-shell 133;D from a diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index 49c11f55cb3..f4a4da3b9ac 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -2095,13 +2095,13 @@ export function connectPanePty( // (remote PTYs, kill switch off) or as a main-derived pty:sideEffect fact — // routing both through this handler keeps the drop/interrupt semantics // identical across authority modes. - const handleCommandFinished = (_bestEffortExitCode: number | null): void => { + const handleCommandFinished = (bestEffortExitCode: number | null): void => { clearCommandInferredPaneAgentAfterPtySideEffects() visibleForegroundSamplePending = false const shouldDeferStatusDrop = paneForegroundAgentTracker.onCommandFinished() // Why: the finished command may have moved HEAD or the index (e.g. // `git checkout`); nudge git UI now instead of waiting for a poll. - dispatchTerminalCommandFinishedEvent(deps.worktreeId) + dispatchTerminalCommandFinishedEvent(deps.worktreeId, bestEffortExitCode) const state = useAppStore.getState() const entry = state.agentStatusByPaneKey[cacheKey] const inferenceResult = flushPendingInterruptInference() diff --git a/src/renderer/src/hooks/terminal-command-finished-event.ts b/src/renderer/src/hooks/terminal-command-finished-event.ts index 5a304b41846..2bdd0e4b666 100644 --- a/src/renderer/src/hooks/terminal-command-finished-event.ts +++ b/src/renderer/src/hooks/terminal-command-finished-event.ts @@ -2,12 +2,16 @@ export const ORCA_TERMINAL_COMMAND_FINISHED_EVENT = 'orca:terminal-command-finis export type TerminalCommandFinishedEventDetail = { worktreeId: string + // OSC 133;D may omit the command's exit code. + exitCode: number | null } // Why: the OSC 133;D handler lives in a per-pane closure; a window event lets -// decoupled consumers (e.g. git status refresh) react to shell commands -// finishing without reaching into terminal internals. -export function dispatchTerminalCommandFinishedEvent(worktreeId: string): void { +// decoupled consumers react without reaching into terminal internals. +export function dispatchTerminalCommandFinishedEvent( + worktreeId: string, + exitCode: number | null +): void { // Why: unit tests and non-DOM renderer shims may expose only the preload API. if (typeof window.dispatchEvent !== 'function') { return @@ -15,7 +19,7 @@ export function dispatchTerminalCommandFinishedEvent(worktreeId: string): void { window.dispatchEvent( new CustomEvent(ORCA_TERMINAL_COMMAND_FINISHED_EVENT, { - detail: { worktreeId } + detail: { worktreeId, exitCode } }) ) } diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 9e1cd986862..76557b35550 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -5319,7 +5319,10 @@ "5f818f12ab": "Preparing...", "4c05b9d7cb": "Preparing setup terminal.", "installLabel": "Install", - "updateLabel": "Update" + "updateLabel": "Update", + "setupCommandFailed": "The setup command exited with code {{value0}}. This error will clear after a successful retry.", + "setupFailed": "Setup failed", + "retrySetup": "Retry" }, "AgentsPane": { "d83834f5e6": "Detecting installed agents…",