From bdebe53568291114fa9fdfd7a7c9129d4b2b4f1f Mon Sep 17 00:00:00 2001 From: BingZ Date: Tue, 11 Aug 2026 15:07:49 +0800 Subject: [PATCH] fix(settings): paste bash-native skill setup under WSL PTY (#13710) * fix(settings): paste bash-native skill setup under WSL PTY WSL worktree setup terminals force wsl.exe even when shellOverride is powershell.exe. Auto-pasting the PowerShell `& { wsl.exe ... }` wrapper into bash fails with a leading-& syntax error (#13305). Rewrite that wrapper to a bash login-shell script for setup-terminal paste only; clipboard copy still keeps the PS host wrapper for manual use outside Orca. * fix(settings): align skill paste with resolved PTY shell * fix(onboarding): keep shell preparation out of render --------- Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> --- .../feature-tips/CliSkillSetupTerminal.tsx | 10 +-- .../FeatureSetupInlineTerminal.test.tsx | 19 ++++- .../onboarding/FeatureSetupInlineTerminal.tsx | 7 +- ...eCommandTerminal.command-finished.test.tsx | 85 ++++++++++++++++++- .../OnboardingInlineCommandTerminal.tsx | 41 ++++++--- .../settings/AgentSkillSetupPanel.tsx | 4 +- .../settings/CliSkillRuntimeSetup.test.tsx | 23 ++++- .../settings/CliSkillRuntimeSetup.tsx | 42 +++++++-- ...nt-skill-installed-command-callers.test.ts | 8 +- .../terminal-pane/pty-connection.test.ts | 22 +++++ .../terminal-pane/pty-connection.ts | 2 +- .../src/store/slices/store-cascades.test.ts | 8 ++ src/renderer/src/store/slices/terminals.ts | 6 +- src/shared/types.ts | 2 + 14 files changed, 240 insertions(+), 39 deletions(-) diff --git a/src/renderer/src/components/feature-tips/CliSkillSetupTerminal.tsx b/src/renderer/src/components/feature-tips/CliSkillSetupTerminal.tsx index d1e025cbb0d..f03cb767f39 100644 --- a/src/renderer/src/components/feature-tips/CliSkillSetupTerminal.tsx +++ b/src/renderer/src/components/feature-tips/CliSkillSetupTerminal.tsx @@ -21,12 +21,6 @@ export function CliSkillSetupTerminal(): React.JSX.Element { ORCA_CLI_ORCHESTRATION_SKILL_INSTALL_COMMAND, activeSkillRuntime.installDisabledReason ? undefined : activeSkillRuntime.agentRuntime ) - // The copied string stays as built; only what we execute is adapted. - const setupTerminalCommand = buildSkillSetupTerminalCommand( - skillCommand, - activeSkillRuntime.terminalShellOverride - ) - const handleCopySkillCommand = async (): Promise => { try { await window.api.ui.writeClipboardText(skillCommand) @@ -78,7 +72,8 @@ export function CliSkillSetupTerminal(): React.JSX.Element { ) diff --git a/src/renderer/src/components/onboarding/FeatureSetupInlineTerminal.test.tsx b/src/renderer/src/components/onboarding/FeatureSetupInlineTerminal.test.tsx index 024e617490e..779ae837b82 100644 --- a/src/renderer/src/components/onboarding/FeatureSetupInlineTerminal.test.tsx +++ b/src/renderer/src/components/onboarding/FeatureSetupInlineTerminal.test.tsx @@ -13,7 +13,15 @@ const mocks = vi.hoisted(() => ({ (command: string, runtime?: { runtime: 'host' | 'wsl' }) => `${runtime?.runtime ?? 'host'}:${command}` ), - terminalProps: null as { command: string; shellOverride?: string } | null + buildSetupCommand: vi.fn( + (command: string, shellOverride?: string) => `${shellOverride ?? 'default'}:${command}` + ), + terminalProps: null as { + command: string + forceHostRuntime?: boolean + prepareCommandForShell?: (command: string, shellOverride?: string) => string + shellOverride?: string + } | null })) vi.mock('@/hooks/useActiveProjectSkillRuntime', () => ({ @@ -21,7 +29,8 @@ vi.mock('@/hooks/useActiveProjectSkillRuntime', () => ({ })) vi.mock('../settings/CliSkillRuntimeSetup', () => ({ - buildSkillCommandForRuntime: mocks.buildCommand + buildSkillCommandForRuntime: mocks.buildCommand, + buildSkillSetupTerminalCommand: mocks.buildSetupCommand })) vi.mock('./OnboardingInlineCommandTerminal', () => ({ @@ -57,8 +66,13 @@ describe('FeatureSetupInlineTerminal', () => { }) expect(mocks.terminalProps).toMatchObject({ command: 'wsl:npx skills add orchestration', + forceHostRuntime: false, + prepareCommandForShell: mocks.buildSetupCommand, shellOverride: 'powershell.exe' }) + expect( + mocks.terminalProps?.prepareCommandForShell?.('wsl:npx skills add orchestration', 'wsl.exe') + ).toBe('wsl.exe:wsl:npx skills add orchestration') }) it('uses the host command builder when the WSL runtime needs repair', () => { @@ -71,6 +85,7 @@ describe('FeatureSetupInlineTerminal', () => { expect(mocks.buildCommand).toHaveBeenCalledWith('npx skills add orchestration', undefined) expect(mocks.terminalProps).toMatchObject({ command: 'host:npx skills add orchestration', + forceHostRuntime: true, shellOverride: 'powershell.exe' }) }) diff --git a/src/renderer/src/components/onboarding/FeatureSetupInlineTerminal.tsx b/src/renderer/src/components/onboarding/FeatureSetupInlineTerminal.tsx index 3d7f2fcb3da..c6502476c20 100644 --- a/src/renderer/src/components/onboarding/FeatureSetupInlineTerminal.tsx +++ b/src/renderer/src/components/onboarding/FeatureSetupInlineTerminal.tsx @@ -2,7 +2,10 @@ import { useCallback, useMemo, useRef, type KeyboardEvent } from 'react' import { track } from '@/lib/telemetry' import { notifyInstalledAgentSkillsChanged } from '@/hooks/useInstalledAgentSkills' import { useActiveProjectSkillRuntime } from '@/hooks/useActiveProjectSkillRuntime' -import { buildSkillCommandForRuntime } from '../settings/CliSkillRuntimeSetup' +import { + buildSkillCommandForRuntime, + buildSkillSetupTerminalCommand +} from '../settings/CliSkillRuntimeSetup' import { OnboardingInlineCommandTerminal } from './OnboardingInlineCommandTerminal' import { getOnboardingFeatureSetupAgentRuntime, @@ -71,7 +74,9 @@ export function FeatureSetupInlineTerminal({ return ( ({ - createTab: vi.fn(() => ({ id: 'tab-1' })), + createTab: vi.fn(() => ({ id: 'tab-1', shellOverride: 'wsl.exe' })), closeTab: vi.fn(), setActiveTabForWorktree: vi.fn(), setTabCustomTitle: vi.fn() @@ -27,7 +28,12 @@ vi.mock('@/store', () => ({ })) vi.mock('@/components/terminal-pane/TerminalPane', () => ({ - default: () =>
+ default: (props: { tabId: string }) => ( +
+
+
$
+
+ ) })) vi.mock('@/lib/focus-terminal-tab-surface', () => ({ @@ -69,6 +75,81 @@ describe('OnboardingInlineCommandTerminal command-finished forwarding', () => { container?.remove() container = null Reflect.deleteProperty(window, 'api') + vi.useRealTimers() + }) + + it('prepares auto-paste again when the resolved tab shell changes', async () => { + vi.useFakeTimers() + mocks.createTab + .mockReturnValueOnce({ id: 'tab-1', shellOverride: 'wsl.exe' }) + .mockReturnValueOnce({ id: 'tab-2', shellOverride: 'powershell.exe' }) + const prepareCommandForShell = vi.fn( + (command: string, shellOverride: string | undefined) => `${shellOverride}:${command}` + ) + const pasted: PasteTerminalTextDetail[] = [] + const handlePaste = (event: Event): void => { + pasted.push((event as CustomEvent).detail) + } + window.addEventListener(PASTE_TERMINAL_TEXT_EVENT, handlePaste) + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + + try { + await act(async () => { + root?.render( + + ) + }) + await act(async () => {}) + await act(async () => { + vi.advanceTimersByTime(250) + }) + await act(async () => { + root?.render( + + ) + }) + await act(async () => {}) + await act(async () => { + vi.advanceTimersByTime(250) + }) + + expect(prepareCommandForShell).toHaveBeenCalledWith('npx skills add orchestration', 'wsl.exe') + expect(prepareCommandForShell).toHaveBeenCalledWith( + 'npx skills add orchestration', + 'powershell.exe' + ) + expect(mocks.createTab).toHaveBeenCalledWith( + 'ephemeral-setup-terminal:onboarding-inline-terminal', + undefined, + 'powershell.exe', + expect.objectContaining({ forceHostRuntime: true }) + ) + expect(pasted).toContainEqual({ + tabId: 'tab-1', + text: 'wsl.exe:npx skills add orchestration' + }) + expect(pasted).toContainEqual({ + tabId: 'tab-2', + text: 'powershell.exe:npx skills add orchestration' + }) + } finally { + window.removeEventListener(PASTE_TERMINAL_TEXT_EVENT, handlePaste) + } }) it('forwards exit codes only for its own branded worktree id', async () => { diff --git a/src/renderer/src/components/onboarding/OnboardingInlineCommandTerminal.tsx b/src/renderer/src/components/onboarding/OnboardingInlineCommandTerminal.tsx index 0a7f0ae7a8f..e2e6141ef0e 100644 --- a/src/renderer/src/components/onboarding/OnboardingInlineCommandTerminal.tsx +++ b/src/renderer/src/components/onboarding/OnboardingInlineCommandTerminal.tsx @@ -21,6 +21,7 @@ const PTY_TEXT_FALLBACK_MS = 750 type OnboardingInlineCommandTerminalProps = { command: string + prepareCommandForShell?: (command: string, shellOverride: string | undefined) => string title: string description?: string ariaLabel: string @@ -30,6 +31,7 @@ type OnboardingInlineCommandTerminalProps = { autoScrollIntoView?: boolean worktreeId?: string shellOverride?: string + forceHostRuntime?: boolean onOpened?: () => void onInteracted?: (method: 'keyboard' | 'pointer', event?: KeyboardEvent) => void onTerminalExit?: () => void @@ -43,6 +45,7 @@ type OnboardingInlineCommandTerminalProps = { */ export function OnboardingInlineCommandTerminal({ command, + prepareCommandForShell, title, description, ariaLabel, @@ -52,6 +55,7 @@ export function OnboardingInlineCommandTerminal({ autoScrollIntoView = true, worktreeId: worktreeIdProp = ONBOARDING_INLINE_TERMINAL_WORKTREE_ID, shellOverride, + forceHostRuntime = false, onOpened, onInteracted, onTerminalExit, @@ -75,13 +79,17 @@ export function OnboardingInlineCommandTerminal({ [] ) const [cwd, setCwd] = useState(null) - const [tabId, setTabId] = useState(null) + const [createdTab, setCreatedTab] = useState<{ + id: string + shellOverride: string | undefined + } | null>(null) + const tabId = createdTab?.id ?? null // Why: starts at `prefersReducedMotion` so users opted out of motion never // see the slide-in frame; otherwise we flip to true after first paint so the // CSS transition has a starting state to interpolate from. const [entered, setEntered] = useState(prefersReducedMotion) const terminalSectionRef = useRef(null) - const autoInsertedRef = useRef(null) + const autoInsertedRef = useRef<{ tabId: string; command: string } | null>(null) useEffect(() => { onOpened?.() @@ -120,11 +128,12 @@ export function OnboardingInlineCommandTerminal({ useEffect(() => { const tab = createTab(worktreeId, undefined, shellOverride, { activate: false, - recordInteraction: false + recordInteraction: false, + forceHostRuntime }) setActiveTabForWorktree(worktreeId, tab.id) setTabCustomTitle(tab.id, title, { recordInteraction: false }) - setTabId(tab.id) + setCreatedTab({ id: tab.id, shellOverride: tab.shellOverride }) return () => { // Why: inline setup panels can disappear after detection succeeds; close // the backing tab so installer shells do not keep running invisibly. @@ -133,6 +142,7 @@ export function OnboardingInlineCommandTerminal({ }, [ closeTab, createTab, + forceHostRuntime, setActiveTabForWorktree, setTabCustomTitle, shellOverride, @@ -201,9 +211,17 @@ export function OnboardingInlineCommandTerminal({ }, [autoScrollIntoView, entered, prefersReducedMotion]) const insertCommand = useCallback(() => { - if (!tabId) { + if (!createdTab) { return } + const terminalCommand = prepareCommandForShell?.(command, createdTab.shellOverride) ?? command + if ( + autoInsertedRef.current?.tabId === createdTab.id && + autoInsertedRef.current.command === terminalCommand + ) { + return + } + autoInsertedRef.current = { tabId: createdTab.id, command: terminalCommand } if (autoScrollIntoView) { terminalSectionRef.current?.scrollIntoView({ behavior: 'auto', @@ -213,16 +231,16 @@ export function OnboardingInlineCommandTerminal({ window.dispatchEvent( new CustomEvent(PASTE_TERMINAL_TEXT_EVENT, { detail: { - tabId, - text: command.trim() + tabId: createdTab.id, + text: terminalCommand.trim() } }) ) - focusTerminalTabSurface(tabId) - }, [autoScrollIntoView, command, tabId]) + focusTerminalTabSurface(createdTab.id) + }, [autoScrollIntoView, command, createdTab, prepareCommandForShell]) useEffect(() => { - if (!tabId || !cwd || autoInsertedRef.current === command) { + if (!tabId || !cwd) { return } let canceled = false @@ -236,7 +254,6 @@ export function OnboardingInlineCommandTerminal({ } insertionTimer = window.setTimeout(() => { if (!canceled) { - autoInsertedRef.current = command insertCommand() } }, AUTO_INSERT_DELAY_MS) @@ -280,7 +297,7 @@ export function OnboardingInlineCommandTerminal({ window.clearTimeout(insertionTimer) } } - }, [command, cwd, insertCommand, tabId]) + }, [cwd, insertCommand, tabId]) // Why: grid 0fr → 1fr animates to the child's natural height without a // hardcoded max-height, so we don't leave dead space if the terminal diff --git a/src/renderer/src/components/settings/AgentSkillSetupPanel.tsx b/src/renderer/src/components/settings/AgentSkillSetupPanel.tsx index 8db579cfa2f..d1a5d307b04 100644 --- a/src/renderer/src/components/settings/AgentSkillSetupPanel.tsx +++ b/src/renderer/src/components/settings/AgentSkillSetupPanel.tsx @@ -387,11 +387,11 @@ export function AgentSkillSetupPanel({
- {/* The copied string above stays as built; only what we run is adapted. */} { } }) - it('leaves WSL and non-Windows setup terminal commands untouched', () => { + it('rewrites WSL PowerShell wrappers to bash for setup-terminal auto-paste', () => { + const skillCommand = 'npx skills add orchestration --global' const wslCommand = buildSkillCommandForRuntime( - 'npx skills add orchestration --global', + skillCommand, { runtime: 'wsl', wslDistro: 'Ubuntu', label: 'WSL Ubuntu' }, 'win32' ) + expect(wslCommand.startsWith('& {')).toBe(true) expect(buildSkillSetupTerminalCommand(wslCommand, 'powershell.exe', 'win32')).toBe(wslCommand) + const setupCommand = buildSkillSetupTerminalCommand(wslCommand, 'wsl.exe', 'win32') + expect(setupCommand.startsWith('&')).toBe(false) + expect(setupCommand).toBe(buildWslLoginShellCommand(skillCommand)) + expect(setupCommand).toContain('npx skills add orchestration --global') expect( buildSkillSetupTerminalCommand('npx skills add orchestration --global', undefined, 'linux') ).toBe('npx skills add orchestration --global') }) + it('preserves the exact WSL script when adapting setup-terminal auto-paste', () => { + const skillCommand = "printf 'héllo\n# Runs: unchanged'" + const copiedCommand = buildSkillCommandForRuntime(skillCommand, { + runtime: 'wsl', + wslDistro: 'Ubuntu', + label: 'WSL Ubuntu' + }) + + expect(buildSkillSetupTerminalCommand(copiedCommand, 'wsl.exe', 'win32')).toBe( + buildWslLoginShellCommand(skillCommand) + ) + }) + it('keeps the bare reinstall rewrite for POSIX-family Windows skill updates', () => { const installCommand = buildAgentFeatureSkillInstallCommand(['orchestration']) const previous = useAppStore.getState() diff --git a/src/renderer/src/components/settings/CliSkillRuntimeSetup.tsx b/src/renderer/src/components/settings/CliSkillRuntimeSetup.tsx index 1e8ac06c414..9fc8d4dc0b0 100644 --- a/src/renderer/src/components/settings/CliSkillRuntimeSetup.tsx +++ b/src/renderer/src/components/settings/CliSkillRuntimeSetup.tsx @@ -8,6 +8,7 @@ import { quotePowerShellNativeArgument } from '../../../../shared/powershell-native-argument' import { buildWslLoginShellCommand } from '../../../../shared/wsl-login-shell-command' +import { isWslShellName } from '../../../../shared/local-windows-terminal-runtime' import { resolveWindowsShellStartupFamily } from '../../../../shared/windows-terminal-shell' import { getProjectAgentSkillTerminalShellOverride } from '@/lib/project-skill-runtime' import { useAppStore } from '@/store' @@ -138,16 +139,23 @@ function normalizeWindowsSkillUpdateCommand( type SkillCommandTarget = 'copied-command' | 'orca-setup-terminal' /** - * Re-adds the npx preflight for Orca's own setup terminal, which - * `getAgentSkillTerminalShellOverride` forces onto powershell.exe. The copied - * string stays bare for POSIX-family shells; only the executed one is wrapped. + * Adapts a copied skill command for Orca's inline setup terminal auto-paste. + * Host Windows installs may gain an npx preflight; WSL-targeted PowerShell wrappers + * must become bash-native because the daemon forces wsl.exe for WSL worktrees. */ export function buildSkillSetupTerminalCommand( copiedCommand: string, - terminalShellOverride: string | undefined, + effectiveShell: string | undefined, currentPlatform = getSkillCommandPlatform() ): string { - if (!isSetupTerminalForcedToPowerShell(terminalShellOverride)) { + // Why: the created tab is authoritative when project runtime replaces the requested shell. + const wslNative = isWslShellName(effectiveShell) + ? decodeWslSetupTerminalCommand(copiedCommand) + : null + if (wslNative) { + return wslNative + } + if (!isSetupTerminalForcedToPowerShell(effectiveShell)) { return copiedCommand } return wrapWindowsSkillCommandWithNpxPrerequisite( @@ -157,6 +165,30 @@ export function buildSkillSetupTerminalCommand( ) } +function decodeWslSetupTerminalCommand(command: string): string | null { + if ( + !command.startsWith("& { $PSNativeCommandArgumentPassing = 'Legacy'; wsl.exe") || + !command.includes(' } # Runs: ') + ) { + return null + } + + const encoded = /-- sh -c 'eval \\"`printf %s ([A-Za-z0-9+/=]+) \| base64 -d`\\"'/.exec( + command + )?.[1] + if (!encoded) { + return null + } + + try { + const binary = atob(encoded) + const bytes = Uint8Array.from(binary, (character) => character.charCodeAt(0)) + return new TextDecoder().decode(bytes) + } catch { + return null + } +} + function isSetupTerminalForcedToPowerShell(terminalShellOverride: string | undefined): boolean { const trimmedOverride = terminalShellOverride?.trim() return ( diff --git a/src/renderer/src/components/settings/agent-skill-installed-command-callers.test.ts b/src/renderer/src/components/settings/agent-skill-installed-command-callers.test.ts index 26287618412..b91f640a9c3 100644 --- a/src/renderer/src/components/settings/agent-skill-installed-command-callers.test.ts +++ b/src/renderer/src/components/settings/agent-skill-installed-command-callers.test.ts @@ -170,11 +170,11 @@ describe('AgentSkillSetupPanel installed-command call sites', () => { ) expect(source).toContain('buildSkillCommandForRuntime(') - // The copied string stays bare for POSIX-family shells; the forced-PowerShell - // setup terminal keeps the npx preflight. + // Clipboard and auto-paste share the source command until the created tab + // resolves the shell that prepares the executable form. expect(source).toContain('writeClipboardText(skillCommand)') - expect(source).toContain('buildSkillSetupTerminalCommand(') - expect(source).toContain('command={setupTerminalCommand}') + expect(source).toContain('command={skillCommand}') + expect(source).toContain('prepareCommandForShell={buildSkillSetupTerminalCommand}') expect(source).toContain('shellOverride={activeSkillRuntime.terminalShellOverride}') expect(source).not.toContain('command={ORCA_CLI_ORCHESTRATION_SKILL_INSTALL_COMMAND}') // This terminal auto-pastes with no install gate, so a repair-required runtime diff --git a/src/renderer/src/components/terminal-pane/pty-connection.test.ts b/src/renderer/src/components/terminal-pane/pty-connection.test.ts index 2ff73043cc3..c78ac18274b 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -153,6 +153,7 @@ type StoreState = { title?: string launchAgent?: string shellOverride?: string + forceHostRuntime?: boolean generation?: number }[] > @@ -2316,6 +2317,27 @@ describe('connectPanePty', () => { }) }) + it('keeps an explicit host fallback out of the project runtime', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { + 'wt-1': [{ id: 'tab-1', ptyId: null, forceHostRuntime: true }] + }, + settings: { + ...mockStoreState.settings, + localWindowsRuntimeDefault: { kind: 'wsl', distro: 'Ubuntu' } + } + } + + connectPanePty(createPane(1) as never, createManager(1) as never, createDeps() as never) + await flushAsyncTicks() + + expect(createdTransportOptions[0]?.projectRuntime).toBeUndefined() + }) + it('observes live terminal GitHub PR URLs before agent completion', async () => { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport() diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index 5ee42f9c784..41cc4cb1383 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -3646,7 +3646,7 @@ export function connectPanePty( ? getCachedWindowsTerminalCapabilities() : null const projectRuntime = - !connectionId && runtimeEnvironmentId === null + !tab?.forceHostRuntime && !connectionId && runtimeEnvironmentId === null ? getLocalProjectExecutionRuntimeContext(state, deps.worktreeId, undefined, { wslAvailable: localWindowsTerminalCapabilities?.wslAvailable, availableWslDistros: localWindowsTerminalCapabilities?.wslDistros ?? null diff --git a/src/renderer/src/store/slices/store-cascades.test.ts b/src/renderer/src/store/slices/store-cascades.test.ts index 06de2c2b9ae..4eaee9fa68b 100644 --- a/src/renderer/src/store/slices/store-cascades.test.ts +++ b/src/renderer/src/store/slices/store-cascades.test.ts @@ -1658,6 +1658,14 @@ describe('setActiveWorktree', () => { const terminal = store.getState().createTab(wt, undefined, 'cmd.exe') expect(terminal.shellOverride).toBe('wsl.exe') + + const hostTerminal = store + .getState() + .createTab(wt, undefined, 'powershell.exe', { forceHostRuntime: true }) + expect(hostTerminal).toMatchObject({ + shellOverride: 'powershell.exe', + forceHostRuntime: true + }) } finally { Object.defineProperty(globalThis, 'navigator', { value: originalNavigator, diff --git a/src/renderer/src/store/slices/terminals.ts b/src/renderer/src/store/slices/terminals.ts index 3eb70095083..89fc0657479 100644 --- a/src/renderer/src/store/slices/terminals.ts +++ b/src/renderer/src/store/slices/terminals.ts @@ -670,6 +670,7 @@ export type TerminalSlice = { /** Initial native-chat view mode; agent launches pass 'chat' when openAgentTabsInChatByDefault is on, else omitted for the 'terminal' default. */ viewMode?: Tab['viewMode'] startupCwd?: string + forceHostRuntime?: boolean } ) => TerminalTab openNewTerminalTabInActiveWorkspace: (groupId: string) => Promise @@ -1379,7 +1380,9 @@ export const createTerminalSlice: StateCreator : null, // Why: new terminals enter the worktree's repo-scoped WSL distro even when the global Windows shell is PowerShell/cmd.exe. isWslWorktree, - isRemoteWorktree ? undefined : getLocalProjectExecutionRuntimeContext(s, worktreeId) + isRemoteWorktree || options?.forceHostRuntime + ? undefined + : getLocalProjectExecutionRuntimeContext(s, worktreeId) ) tab = { id, @@ -1396,6 +1399,7 @@ export const createTerminalSlice: StateCreator createdAt: Date.now(), ...(createdShellOverride !== undefined ? { shellOverride: createdShellOverride } : {}), ...(startupCwd && startupCwd.length > 0 ? { startupCwd } : {}), + ...(options?.forceHostRuntime ? { forceHostRuntime: true } : {}), ...(options?.launchAgent ? { launchAgent: options.launchAgent } : {}), // Why: mark click-caused (not work-caused) spawns so updateTabPtyId skips the activity/sortEpoch bump that would reorder Recent/Smart on click. ...(options?.pendingActivationSpawn ? { pendingActivationSpawn: true } : {}) diff --git a/src/shared/types.ts b/src/shared/types.ts index 96c6d14dabf..7cdc273df75 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -903,6 +903,8 @@ export type TerminalTab = { * PTY and tab icon stay stable even if the default shell setting changes * later. Older persisted tabs may omit this field. */ shellOverride?: string + /** Keeps an ephemeral host fallback out of the active project's runtime. */ + forceHostRuntime?: boolean /** Why: explorer-created terminals can start below the workspace root while * still belonging to that workspace for tab/session ownership. */ startupCwd?: string