diff --git a/src/__tests__/main/agents/detector.test.ts b/src/__tests__/main/agents/detector.test.ts index 26a3a366ba..03c5ad018d 100644 --- a/src/__tests__/main/agents/detector.test.ts +++ b/src/__tests__/main/agents/detector.test.ts @@ -338,6 +338,38 @@ describe('agent-detector', () => { expect(agents.find((a) => a.id === 'codex')?.available).toBe(false); }); + it('prefers the Codex multi-auth wrapper while retaining plain Codex as a candidate', async () => { + mockExecFileNoThrow.mockImplementation(async (_cmd, args) => { + const binaryName = args[0]; + if (binaryName === 'bash') { + return { stdout: '/bin/bash\n', stderr: '', exitCode: 0 }; + } + if (binaryName === 'codex-multi-auth-codex') { + return { + stdout: '/Users/test/.nvm/versions/node/v25.3.0/bin/codex-multi-auth-codex\n', + stderr: '', + exitCode: 0, + }; + } + if (binaryName === 'codex') { + return { stdout: '/opt/homebrew/bin/codex\n', stderr: '', exitCode: 0 }; + } + return { stdout: '', stderr: 'not found', exitCode: 1 }; + }); + + const agents = await detector.detectAgents(); + const codexAgent = agents.find((a) => a.id === 'codex'); + + expect(codexAgent?.available).toBe(true); + expect(codexAgent?.path).toBe( + '/Users/test/.nvm/versions/node/v25.3.0/bin/codex-multi-auth-codex' + ); + expect(codexAgent?.pathCandidates).toEqual([ + '/Users/test/.nvm/versions/node/v25.3.0/bin/codex-multi-auth-codex', + '/opt/homebrew/bin/codex', + ]); + }); + it('should use deduplication for parallel calls', async () => { let callCount = 0; mockExecFileNoThrow.mockImplementation(async () => { @@ -1457,8 +1489,8 @@ describe('agent-detector', () => { vi.doMock('../../../main/agents/path-prober', () => ({ getExpandedEnv: () => ({ PATH: 'C:\\Tools' }), checkCustomPath: vi.fn(async () => ({ exists: false })), - checkBinaryExists: vi.fn(async (binaryName: string) => - binaryName === 'opencode' ? { exists: true } : { exists: false } + findBinaryCandidates: vi.fn(async (binaryName: string) => + binaryName === 'opencode' ? ['opencode'] : [] ), })); vi.doMock('../../../main/utils/execFile', () => ({ @@ -1501,7 +1533,7 @@ describe('agent-detector', () => { expect(details?.agents).toContainEqual( expect.objectContaining({ id: 'opencode', - pathExtension: 'none', + pathExtension: '', willUseShell: true, }) ); diff --git a/src/__tests__/main/agents/path-prober.test.ts b/src/__tests__/main/agents/path-prober.test.ts index db64fdd409..e4d1717d1d 100644 --- a/src/__tests__/main/agents/path-prober.test.ts +++ b/src/__tests__/main/agents/path-prober.test.ts @@ -32,6 +32,7 @@ import { getExpandedEnv, checkCustomPath, checkBinaryExists, + findBinaryCandidates, probeWindowsPaths, probeUnixPaths, type BinaryDetectionResult, @@ -367,6 +368,46 @@ describe('path-prober', () => { }); }); + describe('findBinaryCandidates', () => { + let accessMock: ReturnType; + const execMock = vi.mocked(execFileNoThrow); + + beforeEach(() => { + accessMock = vi.spyOn(fs.promises, 'access'); + }); + + afterEach(() => { + accessMock.mockRestore(); + }); + + it('returns multiple Unix candidates in detection priority order', async () => { + const originalPlatform = process.platform; + Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true }); + + try { + accessMock.mockImplementation(async (probePath) => { + const candidate = String(probePath); + if (candidate === '/opt/homebrew/bin/codex' || candidate === '/usr/local/bin/codex') { + return undefined; + } + throw new Error('ENOENT'); + }); + execMock.mockResolvedValue({ + exitCode: 0, + stdout: '/opt/homebrew/bin/codex\n', + stderr: '', + }); + + const result = await findBinaryCandidates('codex'); + + expect(result).toEqual(['/opt/homebrew/bin/codex', '/usr/local/bin/codex']); + expect(execMock).not.toHaveBeenCalled(); + } finally { + Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true }); + } + }); + }); + describe('checkBinaryExists', () => { let accessMock: ReturnType; const execMock = vi.mocked(execFileNoThrow); diff --git a/src/__tests__/renderer/components/NewInstanceModal.test.tsx b/src/__tests__/renderer/components/NewInstanceModal.test.tsx index 7377515c23..cebea1eacc 100644 --- a/src/__tests__/renderer/components/NewInstanceModal.test.tsx +++ b/src/__tests__/renderer/components/NewInstanceModal.test.tsx @@ -1498,7 +1498,7 @@ describe('NewInstanceModal', () => { expect(screen.getByPlaceholderText('/path/to/claude')).toBeInTheDocument(); }); - // Set custom path and args, then blur both inputs. These values are local until create. + // Set custom path and args, then blur both inputs. const customPathInput = screen.getByPlaceholderText('/path/to/claude'); fireEvent.change(customPathInput, { target: { value: '/custom/path/to/claude' } }); fireEvent.blur(customPathInput); @@ -1729,6 +1729,49 @@ describe('NewInstanceModal', () => { expect(customPathInput).toHaveValue('/detected/bin/claude'); }); + it('persists a selected detected path as the next default for new agents', async () => { + vi.mocked(window.maestro.agents.detect).mockResolvedValue([ + createAgentConfig({ + id: 'codex', + name: 'Codex', + available: true, + binaryName: 'codex', + path: '/Users/test/.nvm/versions/node/v25.3.0/bin/codex-multi-auth-codex', + pathCandidates: [ + '/Users/test/.nvm/versions/node/v25.3.0/bin/codex-multi-auth-codex', + '/opt/homebrew/bin/codex', + ], + }), + ]); + + render( + + ); + + await waitFor(() => { + expect(screen.getByText('Codex')).toBeInTheDocument(); + }); + fireEvent.click(screen.getByText('Codex')); + + await waitFor(() => { + expect(screen.getByTitle('Choose detected path')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByTitle('Choose detected path')); + fireEvent.click(screen.getByText('/opt/homebrew/bin/codex')); + + expect(window.maestro.agents.setCustomPath).toHaveBeenCalledWith( + 'codex', + '/opt/homebrew/bin/codex' + ); + }); + it('should preload saved per-agent path, arguments, and environment variables', async () => { vi.mocked(window.maestro.agents.detect).mockResolvedValue([ createAgentConfig({ id: 'claude-code', name: 'Claude Code', available: true }), diff --git a/src/__tests__/renderer/components/shared/AgentConfigPanel.test.tsx b/src/__tests__/renderer/components/shared/AgentConfigPanel.test.tsx index cc59e43132..abce59190d 100644 --- a/src/__tests__/renderer/components/shared/AgentConfigPanel.test.tsx +++ b/src/__tests__/renderer/components/shared/AgentConfigPanel.test.tsx @@ -379,6 +379,30 @@ describe('AgentConfigPanel', () => { expect(props.onRefreshAgent).toHaveBeenCalled(); }); + it('offers detected path choices and commits the selected path', () => { + const props = createDefaultProps({ + agent: createMockAgent({ + id: 'codex', + name: 'Codex', + binaryName: 'codex', + path: '/Users/test/.nvm/versions/node/v25.3.0/bin/codex-multi-auth-codex', + }), + pathOptions: [ + '/Users/test/.nvm/versions/node/v25.3.0/bin/codex-multi-auth-codex', + '/opt/homebrew/bin/codex', + ], + onPathOptionSelect: vi.fn(), + }); + + render(); + + fireEvent.click(screen.getByTitle('Choose detected path')); + fireEvent.click(screen.getByText('/opt/homebrew/bin/codex')); + + expect(props.onCustomPathChange).toHaveBeenCalledWith('/opt/homebrew/bin/codex'); + expect(props.onPathOptionSelect).toHaveBeenCalledWith('/opt/homebrew/bin/codex'); + }); + it('shows a read-only remote command field when SSH is enabled without a custom path', () => { const props = createDefaultProps({ isSshEnabled: true, diff --git a/src/main/agents/definitions.ts b/src/main/agents/definitions.ts index 5d81793e89..f0631a7c8d 100644 --- a/src/main/agents/definitions.ts +++ b/src/main/agents/definitions.ts @@ -77,6 +77,7 @@ export interface AgentConfig { args: string[]; // Base args always included (excludes batch mode prefix) available: boolean; path?: string; + pathCandidates?: string[]; customPath?: string; // User-specified custom path (shown in UI even if not available) requiresPty?: boolean; // Whether this agent needs a pseudo-terminal configOptions?: AgentConfigOption[]; // Agent-specific configuration @@ -99,6 +100,7 @@ export interface AgentConfig { defaultEnvVars?: Record; // Default environment variables for this agent (merged with user customEnvVars) readOnlyEnvOverrides?: Record; // Env var overrides applied in read-only mode (replaces keys from defaultEnvVars) readOnlyCliEnforced?: boolean; // Whether the agent's CLI enforces read-only mode (false = prompt-only enforcement) + pathCandidateBinaryNames?: string[]; // Extra binary names to offer as selectable executable paths } /** @@ -145,6 +147,7 @@ export const AGENT_DEFINITIONS: AgentDefinition[] = [ name: 'Codex', binaryName: 'codex', command: 'codex', + pathCandidateBinaryNames: ['codex-multi-auth-codex'], // Base args for interactive mode (no flags that are exec-only) args: [], // Codex CLI argument builders diff --git a/src/main/agents/detector.ts b/src/main/agents/detector.ts index 01cd99bf9d..afe914b88a 100644 --- a/src/main/agents/detector.ts +++ b/src/main/agents/detector.ts @@ -18,7 +18,7 @@ import { execFileNoThrow } from '../utils/execFile'; import { logger } from '../utils/logger'; import { captureException } from '../utils/sentry'; import { getAgentCapabilities } from './capabilities'; -import { checkBinaryExists, checkCustomPath, getExpandedEnv } from './path-prober'; +import { checkCustomPath, findBinaryCandidates, getExpandedEnv } from './path-prober'; import { AGENT_DEFINITIONS, type AgentConfig } from './definitions'; import { isWindows } from '../../shared/platformDetection'; @@ -96,12 +96,24 @@ export class AgentDetector { for (const agentDef of AGENT_DEFINITIONS) { const customPath = this.customPaths[agentDef.id]; - let detection: { exists: boolean; path?: string }; + let detection: { exists: boolean; path?: string; paths?: string[] }; + const binaryNames = Array.from( + new Set([...(agentDef.pathCandidateBinaryNames ?? []), agentDef.binaryName]) + ); + const detectCandidatePaths = async () => { + const candidatesByBinary = await Promise.all( + binaryNames.map((binaryName) => findBinaryCandidates(binaryName)) + ); + return Array.from(new Set(candidatesByBinary.flat())); + }; // If user has specified a custom path, check that first if (customPath) { detection = await checkCustomPath(customPath); + const detectedPaths = await detectCandidatePaths(); if (detection.exists) { + const orderedPaths = Array.from(new Set([detection.path!, ...detectedPaths])); + detection.paths = orderedPaths; logger.info( `Agent "${agentDef.name}" found at custom path: ${detection.path}`, LOG_CONTEXT @@ -109,7 +121,11 @@ export class AgentDetector { } else { logger.warn(`Agent "${agentDef.name}" custom path not valid: ${customPath}`, LOG_CONTEXT); // Fall back to PATH detection - detection = await checkBinaryExists(agentDef.binaryName); + detection = { + exists: detectedPaths.length > 0, + path: detectedPaths[0], + paths: detectedPaths, + }; if (detection.exists) { logger.info( `Agent "${agentDef.name}" found in PATH at: ${detection.path}`, @@ -118,7 +134,12 @@ export class AgentDetector { } } } else { - detection = await checkBinaryExists(agentDef.binaryName); + const detectedPaths = await detectCandidatePaths(); + detection = { + exists: detectedPaths.length > 0, + path: detectedPaths[0], + paths: detectedPaths, + }; if (detection.exists) { logger.info(`Agent "${agentDef.name}" found at: ${detection.path}`, LOG_CONTEXT); @@ -136,6 +157,7 @@ export class AgentDetector { ...agentDef, available: detection.exists, path: detection.path, + pathCandidates: detection.paths, customPath: customPath || undefined, capabilities: getAgentCapabilities(agentDef.id), }); diff --git a/src/main/agents/index.ts b/src/main/agents/index.ts index 4748060deb..803b59b8d8 100644 --- a/src/main/agents/index.ts +++ b/src/main/agents/index.ts @@ -43,7 +43,10 @@ export { getExpandedEnv, checkCustomPath, probeWindowsPaths, + probeWindowsPathCandidates, probeUnixPaths, + probeUnixPathCandidates, + findBinaryCandidates, checkBinaryExists, } from './path-prober'; diff --git a/src/main/agents/path-prober.ts b/src/main/agents/path-prober.ts index c0a4195b75..f5b238bbb3 100644 --- a/src/main/agents/path-prober.ts +++ b/src/main/agents/path-prober.ts @@ -30,6 +30,7 @@ const LOG_CONTEXT = 'PathProber'; export interface BinaryDetectionResult { exists: boolean; path?: string; + paths?: string[]; } // ============ Environment Expansion ============ @@ -104,6 +105,7 @@ export function getExpandedEnv(): NodeJS.ProcessEnv { } else { // Unix-like paths (macOS/Linux) additionalPaths = [ + ...detectNodeVersionManagerBinPaths(), '/opt/homebrew/bin', // Homebrew on Apple Silicon '/opt/homebrew/sbin', '/usr/local/bin', // Homebrew on Intel, common install location @@ -321,6 +323,31 @@ function getWindowsKnownPaths(binaryName: string): string[] { return knownPaths[binaryName] || []; } +function dedupePaths(pathsToDedupe: string[]): string[] { + const seen = new Set(); + const deduped: string[] = []; + + for (const candidate of pathsToDedupe) { + const trimmed = candidate.trim(); + if (!trimmed || seen.has(trimmed)) continue; + seen.add(trimmed); + deduped.push(trimmed); + } + + return deduped; +} + +function sortWindowsCandidates(candidates: string[]): string[] { + const dedupedCandidates = dedupePaths(candidates); + const exeCandidates = dedupedCandidates.filter((p) => p.toLowerCase().endsWith('.exe')); + const cmdCandidates = dedupedCandidates.filter((p) => p.toLowerCase().endsWith('.cmd')); + const extensionlessCandidates = dedupedCandidates.filter( + (p) => !p.toLowerCase().endsWith('.exe') && !p.toLowerCase().endsWith('.cmd') + ); + + return dedupePaths([...exeCandidates, ...extensionlessCandidates, ...cmdCandidates]); +} + /** * On Windows, directly probe known installation paths for a binary. * This is more reliable than `where` command which may fail in packaged Electron apps. @@ -329,10 +356,15 @@ function getWindowsKnownPaths(binaryName: string): string[] { * Uses parallel probing for performance on slow file systems. */ export async function probeWindowsPaths(binaryName: string): Promise { + const candidates = await probeWindowsPathCandidates(binaryName); + return candidates[0] ?? null; +} + +export async function probeWindowsPathCandidates(binaryName: string): Promise { const pathsToCheck = getWindowsKnownPaths(binaryName); if (pathsToCheck.length === 0) { - return null; + return []; } // Check all paths in parallel for performance @@ -343,16 +375,16 @@ export async function probeWindowsPaths(binaryName: string): Promise { + const candidates = await probeUnixPathCandidates(binaryName); + return candidates[0] ?? null; +} + +export async function probeUnixPathCandidates(binaryName: string): Promise { const pathsToCheck = getUnixKnownPaths(binaryName); if (pathsToCheck.length === 0) { - return null; + return []; } // Check all paths in parallel for performance @@ -455,16 +492,125 @@ export async function probeUnixPaths(binaryName: string): Promise }) ); - // Return the first successful result (maintains priority order from pathsToCheck) + const candidates: string[] = []; for (let i = 0; i < results.length; i++) { const result = results[i]; if (result.status === 'fulfilled') { logger.debug(`Direct probe found ${binaryName}`, LOG_CONTEXT, { path: result.value }); - return result.value; + candidates.push(result.value); + } + } + + return dedupePaths(candidates); +} + +async function scanPathCandidates(binaryName: string): Promise { + const env = await getExpandedEnvWithShell(); + const pathParts = (env.PATH || '').split(path.delimiter).filter(Boolean); + const candidates: string[] = []; + + for (const pathPart of pathParts) { + if (isWindows()) { + const basePath = path.join(pathPart, binaryName); + const candidatePaths = binaryName.toLowerCase().endsWith('.exe') + ? [basePath] + : binaryName.toLowerCase().endsWith('.cmd') + ? [basePath] + : [`${basePath}.exe`, basePath, `${basePath}.cmd`]; + + for (const candidate of candidatePaths) { + try { + await fs.promises.access(candidate, fs.constants.F_OK); + candidates.push(candidate); + } catch { + // Continue probing remaining PATH entries. + } + } + } else { + const candidate = path.join(pathPart, binaryName); + try { + await fs.promises.access(candidate, fs.constants.F_OK | fs.constants.X_OK); + candidates.push(candidate); + } catch { + // Continue probing remaining PATH entries. + } } } - return null; + return dedupePaths(candidates); +} + +async function lookupCommandCandidates(binaryName: string): Promise { + try { + const command = getWhichCommand(); + const env = await getExpandedEnvWithShell(); + const result = await execFileNoThrow(command, [binaryName], undefined, env); + + if (result.exitCode !== 0 || !result.stdout.trim()) { + return []; + } + + const matches = result.stdout + .trim() + .split(/\r?\n/) + .map((p) => p.trim()) + .filter(Boolean); + + if (!isWindows()) { + return matches; + } + + const resolvedMatches: string[] = []; + for (const match of matches) { + if (match.toLowerCase().endsWith('.exe') || match.toLowerCase().endsWith('.cmd')) { + resolvedMatches.push(match); + continue; + } + + const exePath = `${match}.exe`; + const cmdPath = `${match}.cmd`; + try { + await fs.promises.access(exePath, fs.constants.F_OK); + resolvedMatches.push(exePath); + logger.debug(`Found .exe version of ${binaryName}`, LOG_CONTEXT, { + path: exePath, + }); + continue; + } catch { + try { + await fs.promises.access(cmdPath, fs.constants.F_OK); + resolvedMatches.push(cmdPath); + logger.debug(`Found .cmd version of ${binaryName}`, LOG_CONTEXT, { + path: cmdPath, + }); + continue; + } catch { + resolvedMatches.push(match); + } + } + } + + return resolvedMatches; + } catch { + return []; + } +} + +export async function findBinaryCandidates(binaryName: string): Promise { + const directCandidates = isWindows() + ? await probeWindowsPathCandidates(binaryName) + : await probeUnixPathCandidates(binaryName); + const pathCandidates = await scanPathCandidates(binaryName); + const candidatesBeforeLookup = dedupePaths([...directCandidates, ...pathCandidates]); + const lookupCandidates = + candidatesBeforeLookup.length === 0 ? await lookupCommandCandidates(binaryName) : []; + + if (!isWindows()) { + return dedupePaths([...candidatesBeforeLookup, ...lookupCandidates]); + } + + const candidates = dedupePaths([...candidatesBeforeLookup, ...lookupCandidates]); + return sortWindowsCandidates(candidates); } // ============ Binary Detection ============ @@ -478,104 +624,24 @@ export async function probeUnixPaths(binaryName: string): Promise * 2. Fall back to which/where command with expanded PATH */ export async function checkBinaryExists(binaryName: string): Promise { - // First try direct file probing of known installation paths - // This is more reliable than which/where in packaged Electron apps + const directCandidates = isWindows() + ? sortWindowsCandidates(await probeWindowsPathCandidates(binaryName)) + : await probeUnixPathCandidates(binaryName); + if (directCandidates.length > 0) { + return { exists: true, path: directCandidates[0], paths: directCandidates }; + } + if (isWindows()) { - const probedPath = await probeWindowsPaths(binaryName); - if (probedPath) { - return { exists: true, path: probedPath }; - } logger.debug(`Direct probe failed for ${binaryName}, falling back to where`, LOG_CONTEXT); } else { - // macOS/Linux: probe known paths first - const probedPath = await probeUnixPaths(binaryName); - if (probedPath) { - return { exists: true, path: probedPath }; - } logger.debug(`Direct probe failed for ${binaryName}, falling back to which`, LOG_CONTEXT); } - try { - // Use 'which' on Unix-like systems, 'where' on Windows - const command = getWhichCommand(); - - // Use expanded PATH to find binaries in common installation locations. - // Prefer shell-provided PATH entries when available (they should be - // prioritized). This helps packaged apps locate user-installed tools. - const env = await getExpandedEnvWithShell(); - const result = await execFileNoThrow(command, [binaryName], undefined, env); - - if (result.exitCode === 0 && result.stdout.trim()) { - // Get all matches (Windows 'where' can return multiple) - // Handle both Unix (\n) and Windows (\r\n) line endings - const matches = result.stdout - .trim() - .split(/\r?\n/) - .map((p) => p.trim()) - .filter((p) => p); - - if (isWindows() && matches.length > 0) { - // On Windows, prefer .exe > extensionless (shell scripts) > .cmd - // This helps avoid cmd.exe limitations and supports PowerShell/bash scripts - const exeMatch = matches.find((p) => p.toLowerCase().endsWith('.exe')); - const cmdMatch = matches.find((p) => p.toLowerCase().endsWith('.cmd')); - const extensionlessMatch = matches.find( - (p) => !p.toLowerCase().endsWith('.exe') && !p.toLowerCase().endsWith('.cmd') - ); - - // Return the best match: .exe > extensionless shell scripts > .cmd - let bestMatch = (exeMatch || extensionlessMatch || cmdMatch)!; - - // If the first match doesn't have an extension, check if .cmd or .exe version exists - // This handles cases where 'where' returns a path without extension - if ( - !bestMatch.toLowerCase().endsWith('.exe') && - !bestMatch.toLowerCase().endsWith('.cmd') - ) { - const cmdPath = bestMatch + '.cmd'; - const exePath = bestMatch + '.exe'; - - // Check if the .exe or .cmd version exists - try { - await fs.promises.access(exePath, fs.constants.F_OK); - bestMatch = exePath; - logger.debug(`Found .exe version of ${binaryName}`, LOG_CONTEXT, { - path: exePath, - }); - } catch { - try { - await fs.promises.access(cmdPath, fs.constants.F_OK); - bestMatch = cmdPath; - logger.debug(`Found .cmd version of ${binaryName}`, LOG_CONTEXT, { - path: cmdPath, - }); - } catch { - // Neither .exe nor .cmd exists, use the original path - } - } - } - - logger.debug(`Windows binary detection for ${binaryName}`, LOG_CONTEXT, { - allMatches: matches, - selectedMatch: bestMatch, - isCmd: bestMatch.toLowerCase().endsWith('.cmd'), - isExe: bestMatch.toLowerCase().endsWith('.exe'), - }); - - return { - exists: true, - path: bestMatch, - }; - } - - return { - exists: true, - path: matches[0], // First match for Unix - }; - } - - return { exists: false }; - } catch { - return { exists: false }; + const lookupCandidates = await lookupCommandCandidates(binaryName); + const candidates = isWindows() ? sortWindowsCandidates(lookupCandidates) : lookupCandidates; + if (candidates.length > 0) { + return { exists: true, path: candidates[0], paths: candidates }; } + + return { exists: false }; } diff --git a/src/main/preload/agents.ts b/src/main/preload/agents.ts index e023d7b1a3..50d60e572e 100644 --- a/src/main/preload/agents.ts +++ b/src/main/preload/agents.ts @@ -43,6 +43,7 @@ export interface AgentConfig { args?: string[]; available: boolean; path?: string; + pathCandidates?: string[]; capabilities?: AgentCapabilities; } diff --git a/src/renderer/components/AgentCreationDialog.tsx b/src/renderer/components/AgentCreationDialog.tsx index b0f4e27065..c7bbf0adfa 100644 --- a/src/renderer/components/AgentCreationDialog.tsx +++ b/src/renderer/components/AgentCreationDialog.tsx @@ -440,15 +440,24 @@ export function AgentCreationDialog({ theme={theme} agent={agent} customPath={customAgentPaths[agent.id] || ''} + pathOptions={agent.pathCandidates || []} onCustomPathChange={(value) => { setCustomAgentPaths((prev) => ({ ...prev, [agent.id]: value })); }} + onCustomPathBlur={() => { + const pathToSet = customAgentPaths[agent.id]?.trim() || null; + void window.maestro.agents.setCustomPath(agent.id, pathToSet); + }} onCustomPathClear={() => { setCustomAgentPaths((prev) => { const newPaths = { ...prev }; delete newPaths[agent.id]; return newPaths; }); + void window.maestro.agents.setCustomPath(agent.id, null); + }} + onPathOptionSelect={(value) => { + void window.maestro.agents.setCustomPath(agent.id, value); }} customArgs={customAgentArgs[agent.id] || ''} onCustomArgsChange={(value) => { diff --git a/src/renderer/components/NewInstanceModal.tsx b/src/renderer/components/NewInstanceModal.tsx index f6cf0f93f1..c8dd3bcd94 100644 --- a/src/renderer/components/NewInstanceModal.tsx +++ b/src/renderer/components/NewInstanceModal.tsx @@ -390,6 +390,11 @@ export function NewInstanceModal({ } }, []); + const persistAgentPath = React.useCallback(async (agentId: string, value: string | null) => { + const pathToSet = value?.trim() || null; + await window.maestro.agents.setCustomPath(agentId, pathToSet); + }, []); + // Load available models for an agent that supports model selection const loadModelsForAgent = React.useCallback( async (agentId: string, forceRefresh = false) => { @@ -872,11 +877,12 @@ export function NewInstanceModal({ theme={theme} agent={agent} customPath={customAgentPaths[agent.id] || ''} + pathOptions={agent.pathCandidates || []} onCustomPathChange={(value) => { setCustomAgentPaths((prev) => ({ ...prev, [agent.id]: value })); }} onCustomPathBlur={() => { - /* Saved on agent create */ + void persistAgentPath(agent.id, customAgentPaths[agent.id] || null); }} onCustomPathClear={() => { setCustomAgentPaths((prev) => { @@ -884,6 +890,10 @@ export function NewInstanceModal({ delete newPaths[agent.id]; return newPaths; }); + void persistAgentPath(agent.id, null); + }} + onPathOptionSelect={(value) => { + void persistAgentPath(agent.id, value); }} customArgs={customAgentArgs[agent.id] || ''} onCustomArgsChange={(value) => { @@ -1736,11 +1746,13 @@ export function EditAgentModal({ theme={theme} agent={agent} customPath={customPath} + pathOptions={agent.pathCandidates || []} onCustomPathChange={setCustomPath} onCustomPathBlur={() => { /* Saved on modal save */ }} onCustomPathClear={() => setCustomPath('')} + onPathOptionSelect={setCustomPath} customArgs={customArgs} onCustomArgsChange={setCustomArgs} onCustomArgsBlur={() => { diff --git a/src/renderer/components/Wizard/screens/AgentSelectionScreen.tsx b/src/renderer/components/Wizard/screens/AgentSelectionScreen.tsx index 7da5cb5f2b..1913a02fc4 100644 --- a/src/renderer/components/Wizard/screens/AgentSelectionScreen.tsx +++ b/src/renderer/components/Wizard/screens/AgentSelectionScreen.tsx @@ -935,6 +935,7 @@ export function AgentSelectionScreen({ theme }: AgentSelectionScreenProps): JSX. theme={theme} agent={configuringAgent} customPath={customPath} + pathOptions={configuringAgent.pathCandidates || []} onCustomPathChange={setCustomPath} onCustomPathBlur={async () => { // Sync custom path to agent detector before refreshing detection @@ -949,6 +950,11 @@ export function AgentSelectionScreen({ theme }: AgentSelectionScreenProps): JSX. await window.maestro.agents.setCustomPath(configuringAgentId!, null); await refreshAgentDetection(); }} + onPathOptionSelect={async (value) => { + setCustomPath(value); + await window.maestro.agents.setCustomPath(configuringAgentId!, value); + await refreshAgentDetection(); + }} customArgs={customArgs} onCustomArgsChange={setCustomArgs} onCustomArgsClear={() => { diff --git a/src/renderer/components/shared/AgentConfigPanel.tsx b/src/renderer/components/shared/AgentConfigPanel.tsx index c50ed0b23d..c8da4c68d0 100644 --- a/src/renderer/components/shared/AgentConfigPanel.tsx +++ b/src/renderer/components/shared/AgentConfigPanel.tsx @@ -243,9 +243,11 @@ export interface AgentConfigPanelProps { agent: AgentConfig; // Custom path customPath: string; + pathOptions?: string[]; onCustomPathChange: (value: string) => void; onCustomPathBlur?: () => void; onCustomPathClear: () => void; + onPathOptionSelect?: (value: string) => void; // Custom arguments customArgs: string; onCustomArgsChange: (value: string) => void; @@ -282,9 +284,11 @@ export function AgentConfigPanel({ theme, agent, customPath, + pathOptions = [], onCustomPathChange, onCustomPathBlur, onCustomPathClear, + onPathOptionSelect, customArgs, onCustomArgsChange, onCustomArgsBlur, @@ -318,6 +322,8 @@ export function AgentConfigPanel({ }; const padding = compact ? 'p-2' : 'p-3'; const spacing = compact ? 'space-y-2' : 'space-y-3'; + const [showPathOptions, setShowPathOptions] = useState(false); + const pathChooserRef = useRef(null); // Track which built-in env var tooltip is showing const [showingTooltip, setShowingTooltip] = useState(null); @@ -352,6 +358,35 @@ export function AgentConfigPanel({ return pendingKeyEditsRef.current.get(originalKey) ?? originalKey; }; + const displayedPath = customPath || (isSshEnabled ? agent.binaryName : agent.path) || ''; + const selectablePathOptions = useMemo(() => { + if (isSshEnabled) return []; + + const seen = new Set(); + const options: string[] = []; + for (const option of [customPath, agent.path, ...pathOptions]) { + const trimmed = option?.trim(); + if (!trimmed || seen.has(trimmed)) continue; + seen.add(trimmed); + options.push(trimmed); + } + return options; + }, [agent.path, customPath, isSshEnabled, pathOptions]); + const hasPathOptions = selectablePathOptions.length > 1; + + useEffect(() => { + const handleClickOutside = (e: MouseEvent) => { + if (pathChooserRef.current && !pathChooserRef.current.contains(e.target as Node)) { + setShowPathOptions(false); + } + }; + + if (showPathOptions) { + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + } + }, [showPathOptions]); + // Handle key input change (local only, deferred to blur) const handleKeyInputChange = (originalKey: string, newKey: string) => { pendingKeyEditsRef.current.set(originalKey, newKey); @@ -399,23 +434,72 @@ export function AgentConfigPanel({ )}
- onCustomPathChange(e.target.value)} - onBlur={onCustomPathBlur} - onClick={(e) => e.stopPropagation()} - placeholder={`/path/to/${agent.binaryName}`} - // When showing default SSH binary name, make field read-only to prevent accidental modification - readOnly={isSshEnabled && !customPath} - className="flex-1 p-2 rounded border bg-transparent outline-none text-xs font-mono" - style={{ - borderColor: theme.colors.border, - color: theme.colors.textMain, - // Slightly dim read-only fields to show they're not editable - opacity: isSshEnabled && !customPath ? 0.7 : 1, - }} - /> +
+ onCustomPathChange(e.target.value)} + onBlur={onCustomPathBlur} + onClick={(e) => e.stopPropagation()} + placeholder={`/path/to/${agent.binaryName}`} + // When showing default SSH binary name, make field read-only to prevent accidental modification + readOnly={isSshEnabled && !customPath} + className="w-full p-2 rounded border bg-transparent outline-none text-xs font-mono pr-8" + style={{ + borderColor: theme.colors.border, + color: theme.colors.textMain, + // Slightly dim read-only fields to show they're not editable + opacity: isSshEnabled && !customPath ? 0.7 : 1, + }} + /> + {hasPathOptions && ( + + )} + {showPathOptions && ( +
+ {selectablePathOptions.map((option) => ( + + ))} +
+ )} +
{customPath && (