diff --git a/src/main/ipc/handlers.ts b/src/main/ipc/handlers.ts index 839a4181e3..c050f68f78 100644 --- a/src/main/ipc/handlers.ts +++ b/src/main/ipc/handlers.ts @@ -5,6 +5,7 @@ import { ipcMain, dialog } from 'electron' import { IPC_CHANNELS } from '../../shared/ipc-channels' import { DEFAULT_AGENTS } from '../config/defaults' +import { checkAgents } from '../utils/agent-check' import type { Conductor } from '../conductor/Conductor' import type { ListSessionsOptions, MulticaSession } from '../../shared/types' @@ -122,5 +123,11 @@ export function registerIPCHandlers(conductor: Conductor): void { return result.filePaths[0] }) + // --- System handlers --- + + ipcMain.handle(IPC_CHANNELS.SYSTEM_CHECK_AGENTS, async () => { + return checkAgents() + }) + console.log('[IPC] All handlers registered') } diff --git a/src/main/utils/agent-check.ts b/src/main/utils/agent-check.ts new file mode 100644 index 0000000000..a74294660a --- /dev/null +++ b/src/main/utils/agent-check.ts @@ -0,0 +1,81 @@ +/** + * Utility to check agent installations + */ +import { execSync } from 'node:child_process' +import { platform } from 'node:os' +import { DEFAULT_AGENTS } from '../config/defaults' + +export interface AgentCheckResult { + id: string + name: string + command: string + installed: boolean + path?: string + version?: string + installHint?: string +} + +// Install hints for each agent +const INSTALL_HINTS: Record = { + opencode: 'go install github.com/anomalyco/opencode@latest', + codex: 'npm install -g @openai/codex', + gemini: 'npm install -g @anthropic-ai/gemini-cli', +} + +/** + * Check if a command exists in the system PATH + */ +export function commandExists(cmd: string): { exists: boolean; path?: string; version?: string } { + const isWindows = platform() === 'win32' + const whichCmd = isWindows ? 'where' : 'which' + + try { + const path = execSync(`${whichCmd} ${cmd}`, { + encoding: 'utf-8', + stdio: ['pipe', 'pipe', 'pipe'], + }) + .trim() + .split('\n')[0] + + // Try to get version + let version: string | undefined + try { + const versionOutput = execSync(`${cmd} --version`, { + encoding: 'utf-8', + stdio: ['pipe', 'pipe', 'pipe'], + timeout: 5000, + }).trim() + // Extract first line or first meaningful part + version = versionOutput.split('\n')[0].slice(0, 50) + } catch { + // Some commands don't support --version + } + + return { exists: true, path, version } + } catch { + return { exists: false } + } +} + +/** + * Check all configured agents and return their installation status + */ +export function checkAgents(): AgentCheckResult[] { + const results: AgentCheckResult[] = [] + + for (const [id, config] of Object.entries(DEFAULT_AGENTS)) { + const check = commandExists(config.command) + + results.push({ + id, + name: config.name, + command: config.command, + installed: check.exists, + path: check.path, + version: check.version, + installHint: INSTALL_HINTS[id], + }) + } + + return results +} diff --git a/src/preload/index.ts b/src/preload/index.ts index c963e2124b..055fae4cc3 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -44,6 +44,9 @@ const electronAPI: ElectronAPI = { // Dialog selectDirectory: () => ipcRenderer.invoke(IPC_CHANNELS.DIALOG_SELECT_DIRECTORY), + // System + checkAgents: () => ipcRenderer.invoke(IPC_CHANNELS.SYSTEM_CHECK_AGENTS), + // Event listeners onAgentMessage: (callback) => { const listener = (_event: Electron.IpcRendererEvent, message: unknown) => diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index e9b11a4cca..670c0d4407 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -3,7 +3,7 @@ */ import { useState, useEffect } from 'react' import { useApp } from './hooks/useApp' -import { SessionList, ChatView, MessageInput, StatusBar } from './components' +import { SessionList, ChatView, MessageInput, StatusBar, Settings } from './components' function App(): React.JSX.Element { const { @@ -21,6 +21,7 @@ function App(): React.JSX.Element { deleteSession, startAgent, stopAgent, + switchAgent, sendPrompt, cancelRequest, clearError, @@ -30,6 +31,9 @@ function App(): React.JSX.Element { const [showNewSession, setShowNewSession] = useState(false) const [newSessionCwd, setNewSessionCwd] = useState('') + // Settings dialog state + const [showSettings, setShowSettings] = useState(false) + // Auto-show new session dialog when agent is running but no session useEffect(() => { if (agentStatus.state === 'running' && !currentSession && sessions.length === 0) { @@ -101,6 +105,7 @@ function App(): React.JSX.Element { currentSession={currentSession} onStartAgent={() => startAgent('opencode')} onStopAgent={stopAgent} + onOpenSettings={() => setShowSettings(true)} /> {/* Chat view */} @@ -171,6 +176,14 @@ function App(): React.JSX.Element { )} + + {/* Settings dialog */} + setShowSettings(false)} + currentAgentId={agentStatus.state === 'running' ? agentStatus.agentId : null} + onSwitchAgent={switchAgent} + /> ) } diff --git a/src/renderer/src/components/Settings.tsx b/src/renderer/src/components/Settings.tsx new file mode 100644 index 0000000000..d011faf53a --- /dev/null +++ b/src/renderer/src/components/Settings.tsx @@ -0,0 +1,191 @@ +/** + * Settings dialog component + * Allows switching agents and viewing installation status + */ +import { useState, useEffect } from 'react' +import type { AgentCheckResult } from '../../../shared/electron-api' + +interface SettingsProps { + isOpen: boolean + onClose: () => void + currentAgentId: string | null + onSwitchAgent: (agentId: string) => Promise +} + +export function Settings({ isOpen, onClose, currentAgentId, onSwitchAgent }: SettingsProps) { + const [agents, setAgents] = useState([]) + const [loading, setLoading] = useState(true) + const [switching, setSwitching] = useState(null) + + useEffect(() => { + if (isOpen) { + loadAgents() + } + }, [isOpen]) + + async function loadAgents() { + setLoading(true) + try { + const results = await window.electronAPI.checkAgents() + setAgents(results) + } catch (err) { + console.error('Failed to check agents:', err) + } finally { + setLoading(false) + } + } + + async function handleSwitch(agentId: string) { + if (switching || agentId === currentAgentId) return + + setSwitching(agentId) + try { + await onSwitchAgent(agentId) + onClose() + } catch (err) { + console.error('Failed to switch agent:', err) + } finally { + setSwitching(null) + } + } + + if (!isOpen) return null + + return ( +
+
+
+

Settings

+ +
+ + {/* Agent Selection */} +
+

+ Select Coding Agent +

+ + {loading ? ( +
+
+
+ ) : ( +
+ {agents.map((agent) => ( + handleSwitch(agent.id)} + /> + ))} +
+ )} +
+ + {/* Refresh button */} +
+ + +
+
+
+ ) +} + +interface AgentItemProps { + agent: AgentCheckResult + isActive: boolean + isSwitching: boolean + onSelect: () => void +} + +function AgentItem({ agent, isActive, isSwitching, onSelect }: AgentItemProps) { + const canSelect = agent.installed && !isActive && !isSwitching + + return ( +
+ {/* Status indicator */} +
+ + {/* Agent info */} +
+
+ {agent.name} + {isActive && ( + + Active + + )} +
+
+ {agent.installed ? ( + <> + {agent.command} + {agent.version && {agent.version}} + + ) : ( + Not installed + )} +
+ {!agent.installed && agent.installHint && ( +
+ Install: {agent.installHint} +
+ )} +
+ + {/* Action */} + {isSwitching ? ( +
+ ) : agent.installed && !isActive ? ( + + ) : null} +
+ ) +} diff --git a/src/renderer/src/components/StatusBar.tsx b/src/renderer/src/components/StatusBar.tsx index f9c2b62fe6..a1f5c6ef2a 100644 --- a/src/renderer/src/components/StatusBar.tsx +++ b/src/renderer/src/components/StatusBar.tsx @@ -8,6 +8,7 @@ interface StatusBarProps { currentSession: MulticaSession | null onStartAgent: () => void onStopAgent: () => void + onOpenSettings: () => void } export function StatusBar({ @@ -15,6 +16,7 @@ export function StatusBar({ currentSession, onStartAgent, onStopAgent, + onOpenSettings, }: StatusBarProps) { return (
@@ -53,6 +55,28 @@ export function StatusBar({ Stop ) : null} + + {/* Settings button */} +
) diff --git a/src/renderer/src/components/index.ts b/src/renderer/src/components/index.ts index 2e7de1480f..1bfb1e434d 100644 --- a/src/renderer/src/components/index.ts +++ b/src/renderer/src/components/index.ts @@ -2,3 +2,4 @@ export { SessionList } from './SessionList' export { ChatView } from './ChatView' export { MessageInput } from './MessageInput' export { StatusBar } from './StatusBar' +export { Settings } from './Settings' diff --git a/src/renderer/src/hooks/useApp.ts b/src/renderer/src/hooks/useApp.ts index f6333bb584..21b8d1fbed 100644 --- a/src/renderer/src/hooks/useApp.ts +++ b/src/renderer/src/hooks/useApp.ts @@ -32,6 +32,7 @@ export interface AppActions { // Agent actions startAgent: (agentId: string) => Promise stopAgent: () => Promise + switchAgent: (agentId: string) => Promise sendPrompt: (content: string) => Promise cancelRequest: () => Promise @@ -202,6 +203,22 @@ export function useApp(): AppState & AppActions { } }, [loadAgentStatus]) + const switchAgent = useCallback(async (agentId: string) => { + try { + setError(null) + // Stop current agent first + await window.electronAPI.stopAgent() + // Clear current session (it's bound to the old agent) + setCurrentSession(null) + setSessionUpdates([]) + // Start new agent + await window.electronAPI.startAgent(agentId) + await loadAgentStatus() + } catch (err) { + setError(`Failed to switch agent: ${err}`) + } + }, [loadAgentStatus]) + const sendPrompt = useCallback(async (content: string) => { if (!currentSession) { setError('No active session') @@ -266,6 +283,7 @@ export function useApp(): AppState & AppActions { deleteSession, startAgent, stopAgent, + switchAgent, sendPrompt, cancelRequest, clearError, diff --git a/src/shared/electron-api.d.ts b/src/shared/electron-api.d.ts index 53e68da873..6b58ca2432 100644 --- a/src/shared/electron-api.d.ts +++ b/src/shared/electron-api.d.ts @@ -15,6 +15,16 @@ export interface AgentMessage { done: boolean } +export interface AgentCheckResult { + id: string + name: string + command: string + installed: boolean + path?: string + version?: string + installHint?: string +} + export interface ElectronAPI { // Agent lifecycle startAgent(agentId: string): Promise<{ success: boolean; agentId: string }> @@ -41,6 +51,9 @@ export interface ElectronAPI { // Dialog selectDirectory(): Promise + // System + checkAgents(): Promise + // Event listeners (return unsubscribe function) onAgentMessage(callback: (message: AgentMessage) => void): () => void onAgentStatus(callback: (status: AgentStatus) => void): () => void diff --git a/src/shared/ipc-channels.ts b/src/shared/ipc-channels.ts index 779b0e49b3..e4e47b9763 100644 --- a/src/shared/ipc-channels.ts +++ b/src/shared/ipc-channels.ts @@ -27,6 +27,9 @@ export const IPC_CHANNELS = { // Dialog DIALOG_SELECT_DIRECTORY: 'dialog:select-directory', + // System + SYSTEM_CHECK_AGENTS: 'system:check-agents', + // File system (V2) FILE_APPROVAL_REQUEST: 'file:approval-request', FILE_APPROVAL_RESPONSE: 'file:approval-response',