diff --git a/src/renderer/src/lib/ai-vault-resume-command.test.ts b/src/renderer/src/lib/ai-vault-resume-command.test.ts index f83576fee..1fa255907 100644 --- a/src/renderer/src/lib/ai-vault-resume-command.test.ts +++ b/src/renderer/src/lib/ai-vault-resume-command.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from 'vitest' import type { AppState } from '@/store/types' +import { buildAiVaultResumeCommand } from '../../../shared/ai-vault-types' import { buildAiVaultResumeCommandForWorktree, buildAiVaultResumeStartupForWorktree, @@ -27,6 +28,7 @@ type AiVaultResumeCommandState = Pick< function makeState(args: { worktreePath: string localWindowsRuntimePreference?: RuntimePreference + terminalWindowsShell?: string }): AiVaultResumeCommandState { return { activeRepoId: 'repo-1', @@ -45,6 +47,7 @@ function makeState(args: { ], settings: { localWindowsRuntimeDefault: { kind: 'windows-host' }, + ...(args.terminalWindowsShell ? { terminalWindowsShell: args.terminalWindowsShell } : {}), agentDefaultArgs: { claude: '', codex: '' }, agentDefaultEnv: { claude: {}, codex: {} } }, @@ -61,9 +64,31 @@ function makeState(args: { } describe('ai vault resume command runtime', () => { - it('uses Windows command wrapping for Windows-host projects', () => { + it('queues a PowerShell-valid command for the default Windows shell', () => { + // Why: the queued command is typed into the live tab shell (default + // PowerShell), which mis-parses the cmd `""`-doubled wrapper (#6152). const state = makeState({ worktreePath: 'C:\\Users\\alice\\repo' }) + expect( + buildAiVaultResumeCommandForWorktree({ + state, + worktreeId: 'repo-1::worktree-1', + session: { + agent: 'claude', + sessionId: 'session one', + cwd: 'C:\\Users\\alice\\repo', + codexHome: null + } + }) + ).toBe("Set-Location -LiteralPath 'C:\\Users\\alice\\repo'; claude '--resume' 'session one'") + }) + + it('keeps the cmd wrapper when the configured Windows shell is cmd.exe', () => { + const state = makeState({ + worktreePath: 'C:\\Users\\alice\\repo', + terminalWindowsShell: 'cmd.exe' + }) + expect( buildAiVaultResumeCommandForWorktree({ state, @@ -78,6 +103,40 @@ describe('ai vault resume command runtime', () => { ).toBe('cmd /d /s /c "cd /d ""C:\\Users\\alice\\repo"" && claude ""--resume"" ""session one"""') }) + it('queues a POSIX command for the Git Bash Windows shell', () => { + const state = makeState({ + worktreePath: 'C:\\Users\\alice\\repo', + terminalWindowsShell: 'git-bash' + }) + + expect( + buildAiVaultResumeCommandForWorktree({ + state, + worktreeId: 'repo-1::worktree-1', + session: { + agent: 'claude', + sessionId: 'session one', + cwd: 'C:\\Users\\alice\\repo', + codexHome: null + } + }) + ).toBe("cd 'C:\\Users\\alice\\repo' && claude '--resume' 'session one'") + }) + + it('keeps the cmd wrapper for the copy-to-clipboard command on Windows', () => { + // Regression guard: the copy path is self-contained for pasting into cmd.exe + // and must stay cmd-wrapped even though the queued path now follows the shell. + expect( + buildAiVaultResumeCommand({ + agent: 'claude', + sessionId: 'session one', + cwd: 'C:\\Users\\alice\\repo', + platform: 'win32', + codexHome: null + }) + ).toBe('cmd /d /s /c "cd /d ""C:\\Users\\alice\\repo"" && claude --resume ""session one"""') + }) + it('uses configured agent defaults for resumable session history entries', () => { const state = makeState({ worktreePath: 'C:\\Users\\alice\\repo', diff --git a/src/renderer/src/lib/ai-vault-resume-command.ts b/src/renderer/src/lib/ai-vault-resume-command.ts index 2858d2207..95da5ee49 100644 --- a/src/renderer/src/lib/ai-vault-resume-command.ts +++ b/src/renderer/src/lib/ai-vault-resume-command.ts @@ -12,6 +12,8 @@ import { resolveTuiAgentLaunchEnv } from '../../../shared/tui-agent-launch-defaults' import { parseWslUncPath } from '../../../shared/wsl-paths' +import { resolveWindowsShellStartupFamily } from '../../../shared/windows-terminal-shell' +import type { AgentStartupShell } from '../../../shared/tui-agent-startup-shell' import type { AppState } from '@/store/types' import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-context' import { CLIENT_PLATFORM } from '@/lib/new-workspace' @@ -65,6 +67,14 @@ export function buildAiVaultResumeStartupForWorktree(args: { }): AiVaultResumeStartup { const platform = getAiVaultResumePlatform(args.state, args.worktreeId) const codexHome = getAiVaultResumeCodexHome(args.session.codexHome, platform) + // Why: the queued command is typed verbatim into the freshly spawned tab whose + // live shell is the configured Windows shell (default PowerShell). Hardcoding + // cmd quoting made PowerShell mis-parse the `""`-doubled wrapper (#6152), so + // resolve the actual shell to quote per-shell instead. + const queuedShell: AgentStartupShell | undefined = + platform === 'win32' + ? resolveWindowsShellStartupFamily(args.state.settings?.terminalWindowsShell) + : undefined if (isResumableTuiAgent(args.session.agent)) { const startupPlan = buildAgentResumeStartupPlan({ agent: args.session.agent, @@ -74,9 +84,7 @@ export function buildAiVaultResumeStartupForWorktree(args: { ...(args.commandOverride?.trim() ? { [args.session.agent]: args.commandOverride } : {}) }, platform, - // Why: copied AI Vault commands are shell-wrapped for portability; the - // same inner command must be queued so drag/click resume match copy. - shell: platform === 'win32' ? 'cmd' : undefined, + shell: queuedShell, agentArgs: resolveTuiAgentLaunchArgs( args.session.agent, args.state.settings?.agentDefaultArgs @@ -89,7 +97,8 @@ export function buildAiVaultResumeStartupForWorktree(args: { resumeCommand: startupPlan.launchCommand, cwd: args.session.cwd, platform, - codexHome + codexHome, + shell: queuedShell }), ...(startupPlan.env ? { env: startupPlan.env } : {}), launchConfig: startupPlan.launchConfig diff --git a/src/shared/ai-vault-types.ts b/src/shared/ai-vault-types.ts index d05674627..70d2054d6 100644 --- a/src/shared/ai-vault-types.ts +++ b/src/shared/ai-vault-types.ts @@ -1,4 +1,9 @@ import { TUI_AGENT_CONFIG } from './tui-agent-config' +import { + commandSeparator, + quoteStartupArg, + type AgentStartupShell +} from './tui-agent-startup-shell' import type { TuiAgent } from './types' export const AI_VAULT_AGENTS = [ @@ -106,8 +111,26 @@ export function buildAiVaultResumeShellCommand(args: { cwd: string | null platform: NodeJS.Platform codexHome?: string | null + // Why: the QUEUED resume command is typed into the live tab shell, so its + // cd/env prefix must match that shell. The copy-to-clipboard string omits this + // and keeps the self-contained `cmd /d /s /c` wrapper (its documented purpose). + shell?: AgentStartupShell }): string { - const { cwd, platform, codexHome } = args + const { cwd, platform, codexHome, shell } = args + + // Why: on Windows the queued command must target the configured live shell + // (default PowerShell). PowerShell mis-parses the cmd `""`-doubled wrapper and + // reports "operable program or batch file", so only re-wrap with cmd when the + // live shell actually is cmd (or when no shell is given, i.e. the copy path). + if (platform === 'win32' && shell && shell !== 'cmd') { + return buildResumeShellCommandForShell({ + resumeCommand: args.resumeCommand, + cwd, + codexHome: codexHome?.trim() || null, + shell + }) + } + const resumeCommand = `${codexHomeEnvPrefix(codexHome?.trim() || null, platform)}${ args.resumeCommand }` @@ -123,6 +146,33 @@ export function buildAiVaultResumeShellCommand(args: { return `cd ${quoteShellArg(cwd, platform)} && ${resumeCommand}` } +function buildResumeShellCommandForShell(args: { + resumeCommand: string + cwd: string | null + codexHome: string | null + shell: Exclude +}): string { + const { cwd, codexHome, shell } = args + if (shell === 'posix') { + // Why: git-bash on a Windows host runs a POSIX shell, so reuse the same + // inline-env + `cd ''` prefix as the non-Windows path. + const envPrefix = codexHome ? `CODEX_HOME=${quoteStartupArg(codexHome, shell)} ` : '' + const command = `${envPrefix}${args.resumeCommand}` + return cwd ? `cd ${quoteStartupArg(cwd, shell)} && ${command}` : command + } + + const separator = commandSeparator(shell) + const segments: string[] = [] + if (cwd) { + segments.push(`Set-Location -LiteralPath ${quoteStartupArg(cwd, shell)}`) + } + if (codexHome) { + segments.push(`$env:CODEX_HOME=${quoteStartupArg(codexHome, shell)}`) + } + segments.push(args.resumeCommand) + return segments.join(separator) +} + export function aiVaultAgentLabel(agent: AiVaultAgent): string { return AI_VAULT_AGENT_LABELS[agent] } diff --git a/src/shared/windows-terminal-shell.test.ts b/src/shared/windows-terminal-shell.test.ts new file mode 100644 index 000000000..dbc566ca2 --- /dev/null +++ b/src/shared/windows-terminal-shell.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest' +import { resolveWindowsShellStartupFamily } from './windows-terminal-shell' + +describe('resolveWindowsShellStartupFamily', () => { + it('defaults to PowerShell when unset', () => { + expect(resolveWindowsShellStartupFamily(undefined)).toBe('powershell') + expect(resolveWindowsShellStartupFamily(null)).toBe('powershell') + expect(resolveWindowsShellStartupFamily(' ')).toBe('powershell') + }) + + it('treats PowerShell and pwsh as PowerShell', () => { + expect(resolveWindowsShellStartupFamily('powershell.exe')).toBe('powershell') + expect(resolveWindowsShellStartupFamily('pwsh.exe')).toBe('powershell') + expect(resolveWindowsShellStartupFamily('C:\\Program Files\\PowerShell\\7\\pwsh.exe')).toBe( + 'powershell' + ) + }) + + it('maps cmd.exe to cmd quoting', () => { + expect(resolveWindowsShellStartupFamily('cmd.exe')).toBe('cmd') + expect(resolveWindowsShellStartupFamily('C:\\Windows\\System32\\cmd.exe')).toBe('cmd') + }) + + it('maps Git Bash and WSL shells to POSIX quoting', () => { + expect(resolveWindowsShellStartupFamily('git-bash')).toBe('posix') + expect(resolveWindowsShellStartupFamily('wsl.exe')).toBe('posix') + expect(resolveWindowsShellStartupFamily('C:\\Program Files\\Git\\bin\\bash.exe')).toBe('posix') + }) +}) diff --git a/src/shared/windows-terminal-shell.ts b/src/shared/windows-terminal-shell.ts index ab61c40df..2b64c374e 100644 --- a/src/shared/windows-terminal-shell.ts +++ b/src/shared/windows-terminal-shell.ts @@ -1,3 +1,5 @@ +import type { AgentStartupShell } from './tui-agent-startup-shell' + export const WINDOWS_GIT_BASH_SHELL = 'git-bash' export type BuiltInWindowsTerminalShell = @@ -5,3 +7,31 @@ export type BuiltInWindowsTerminalShell = | 'cmd.exe' | 'wsl.exe' | typeof WINDOWS_GIT_BASH_SHELL + +/** + * Classifies a configured `terminalWindowsShell` value into the startup-shell + * family used to quote queued commands. Git Bash / wsl.exe run a POSIX shell; + * cmd.exe needs cmd quoting; everything else (PowerShell, pwsh, unknown) is + * treated as PowerShell, matching the Windows default. + */ +export function resolveWindowsShellStartupFamily( + shell: string | null | undefined +): AgentStartupShell { + const trimmed = shell?.trim() + if (!trimmed) { + return 'powershell' + } + if (trimmed === WINDOWS_GIT_BASH_SHELL) { + return 'posix' + } + const basename = trimmed.replaceAll('\\', '/').split('/').pop()?.toLowerCase() ?? '' + if (basename === 'cmd.exe') { + return 'cmd' + } + // Why: wsl.exe and bash.exe (Git for Windows) launch POSIX shells, so queued + // commands must use POSIX quoting and `cd ''` rather than cmd/PowerShell. + if (basename === 'wsl.exe' || basename === 'wsl' || basename === 'bash.exe') { + return 'posix' + } + return 'powershell' +}