feat: add settings dialog for switching agents

- Add Settings component with agent list and installation status
- Extract agent check utility from CLI for reuse
- Add SYSTEM_CHECK_AGENTS IPC channel and handler
- Add switchAgent action to useApp hook
- Add settings gear button to StatusBar
- Show installed agents with version/path, install hints for missing ones

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Jiayuan
2026-01-14 03:48:53 +08:00
parent 1f6808b070
commit 05f1de43fb
10 changed files with 355 additions and 1 deletions

View File

@@ -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')
}

View File

@@ -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<string, string> = {
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
}

View File

@@ -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) =>

View File

@@ -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 {
</div>
</div>
)}
{/* Settings dialog */}
<Settings
isOpen={showSettings}
onClose={() => setShowSettings(false)}
currentAgentId={agentStatus.state === 'running' ? agentStatus.agentId : null}
onSwitchAgent={switchAgent}
/>
</div>
)
}

View File

@@ -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<void>
}
export function Settings({ isOpen, onClose, currentAgentId, onSwitchAgent }: SettingsProps) {
const [agents, setAgents] = useState<AgentCheckResult[]>([])
const [loading, setLoading] = useState(true)
const [switching, setSwitching] = useState<string | null>(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 (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="w-full max-w-lg rounded-lg bg-[var(--color-surface)] p-6 shadow-xl">
<div className="mb-6 flex items-center justify-between">
<h2 className="text-lg font-semibold">Settings</h2>
<button
onClick={onClose}
className="rounded p-1 text-[var(--color-text-muted)] transition-colors hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text)]"
>
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
{/* Agent Selection */}
<div className="mb-6">
<h3 className="mb-3 text-sm font-medium text-[var(--color-text-muted)]">
Select Coding Agent
</h3>
{loading ? (
<div className="flex items-center justify-center py-8">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-[var(--color-primary)] border-t-transparent" />
</div>
) : (
<div className="space-y-2">
{agents.map((agent) => (
<AgentItem
key={agent.id}
agent={agent}
isActive={agent.id === currentAgentId}
isSwitching={switching === agent.id}
onSelect={() => handleSwitch(agent.id)}
/>
))}
</div>
)}
</div>
{/* Refresh button */}
<div className="flex justify-end gap-2">
<button
onClick={loadAgents}
disabled={loading}
className="rounded-lg px-4 py-2 text-[var(--color-text-muted)] transition-colors hover:bg-[var(--color-surface-hover)] disabled:opacity-50"
>
Refresh
</button>
<button
onClick={onClose}
className="rounded-lg bg-[var(--color-primary)] px-4 py-2 font-medium text-white transition-colors hover:bg-[var(--color-primary-dark)]"
>
Done
</button>
</div>
</div>
</div>
)
}
interface AgentItemProps {
agent: AgentCheckResult
isActive: boolean
isSwitching: boolean
onSelect: () => void
}
function AgentItem({ agent, isActive, isSwitching, onSelect }: AgentItemProps) {
const canSelect = agent.installed && !isActive && !isSwitching
return (
<div
onClick={canSelect ? onSelect : undefined}
className={`flex items-center gap-3 rounded-lg border p-3 transition-colors ${
isActive
? 'border-[var(--color-primary)] bg-[var(--color-primary)]/10'
: agent.installed
? 'cursor-pointer border-[var(--color-border)] hover:border-[var(--color-primary)] hover:bg-[var(--color-surface-hover)]'
: 'border-[var(--color-border)] opacity-60'
}`}
>
{/* Status indicator */}
<div
className={`h-3 w-3 flex-shrink-0 rounded-full ${
agent.installed ? 'bg-green-500' : 'bg-gray-400'
}`}
/>
{/* Agent info */}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="font-medium">{agent.name}</span>
{isActive && (
<span className="rounded bg-[var(--color-primary)] px-1.5 py-0.5 text-xs text-white">
Active
</span>
)}
</div>
<div className="text-sm text-[var(--color-text-muted)]">
{agent.installed ? (
<>
<span className="font-mono">{agent.command}</span>
{agent.version && <span className="ml-2 text-xs">{agent.version}</span>}
</>
) : (
<span className="text-red-400">Not installed</span>
)}
</div>
{!agent.installed && agent.installHint && (
<div className="mt-1 text-xs text-[var(--color-text-muted)]">
Install: <code className="rounded bg-[var(--color-background)] px-1">{agent.installHint}</code>
</div>
)}
</div>
{/* Action */}
{isSwitching ? (
<div className="h-5 w-5 animate-spin rounded-full border-2 border-[var(--color-primary)] border-t-transparent" />
) : agent.installed && !isActive ? (
<button
onClick={(e) => {
e.stopPropagation()
onSelect()
}}
className="flex-shrink-0 rounded-lg bg-[var(--color-surface-hover)] px-3 py-1.5 text-sm font-medium transition-colors hover:bg-[var(--color-border)]"
>
Switch
</button>
) : null}
</div>
)
}

View File

@@ -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 (
<div className="flex items-center justify-between border-b border-[var(--color-border)] bg-[var(--color-surface)] px-4 py-2">
@@ -53,6 +55,28 @@ export function StatusBar({
Stop
</button>
) : null}
{/* Settings button */}
<button
onClick={onOpenSettings}
className="rounded p-1.5 text-[var(--color-text-muted)] transition-colors hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text)]"
title="Settings"
>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
</button>
</div>
</div>
)

View File

@@ -2,3 +2,4 @@ export { SessionList } from './SessionList'
export { ChatView } from './ChatView'
export { MessageInput } from './MessageInput'
export { StatusBar } from './StatusBar'
export { Settings } from './Settings'

View File

@@ -32,6 +32,7 @@ export interface AppActions {
// Agent actions
startAgent: (agentId: string) => Promise<void>
stopAgent: () => Promise<void>
switchAgent: (agentId: string) => Promise<void>
sendPrompt: (content: string) => Promise<void>
cancelRequest: () => Promise<void>
@@ -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,

View File

@@ -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<string | null>
// System
checkAgents(): Promise<AgentCheckResult[]>
// Event listeners (return unsubscribe function)
onAgentMessage(callback: (message: AgentMessage) => void): () => void
onAgentStatus(callback: (status: AgentStatus) => void): () => void

View File

@@ -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',