Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/main/broker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ function augmentedPath(): string {
return Array.from(entries).join(delimiter)
}

function resolveCommandWithAugmentedPath(command: string): string | undefined {
export function resolveCommandWithAugmentedPath(command: string): string | undefined {
const resolved = resolveCommandOnPath(command)
if (resolved) return resolved

Expand Down
6 changes: 5 additions & 1 deletion src/main/ipc-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import {
addProjectIntegration,
removeProjectIntegration
} from './store'
import { brokerManager } from './broker'
import { brokerManager, resolveCommandWithAugmentedPath } from './broker'
import * as git from './git'
import * as filesystem from './filesystem'
import * as auth from './auth'
Expand Down Expand Up @@ -351,6 +351,10 @@ export function registerIpcHandlers(): void {
return brokerManager.listAgents(projectId)
})

ipcMain.handle('broker:check-cli-available', (_, cli: string) => {
return Boolean(resolveCommandWithAugmentedPath(cli))
})

ipcMain.handle('broker:list-details', async () => {
return brokerManager.listBrokerDetails()
})
Expand Down
2 changes: 2 additions & 0 deletions src/preload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,8 @@ const api = {
invoke<void>('broker:release-agent', projectId, name),
listAgents: (projectId?: string) =>
invoke<BrokerListAgent[]>('broker:list-agents', projectId),
checkCliAvailable: (cli: string) =>
invoke<boolean>('broker:check-cli-available', cli),
listDetails: () => invoke<BrokerDetails[]>('broker:list-details'),
listEvents: () => invoke<BrokerEventRecord[]>('broker:list-events'),
shutdown: () => invoke<void>('broker:shutdown'),
Expand Down
28 changes: 22 additions & 6 deletions src/renderer/src/components/sidebar/SpawnAgentDialog.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
import type React from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { Loader2, X } from 'lucide-react'
import { ClaudeIcon, CodexIcon } from '@/components/common/AgentIcons'
import { ClaudeIcon, CodexIcon, OpenCodeIcon } from '@/components/common/AgentIcons'
import { listProjectPersonas, spawnProjectAgent, spawnProjectPersona, type SpawnAgentCli } from '@/lib/spawn-agent'
import type { WorkforcePersona } from '@/lib/ipc'
import { pear, type WorkforcePersona } from '@/lib/ipc'
import { useProjectStore, type ProjectRoot } from '@/stores/project-store'
import { useUIStore } from '@/stores/ui-store'

const AGENT_OPTIONS: Array<{ cli: SpawnAgentCli; label: string; Icon: typeof ClaudeIcon }> = [
{ cli: 'claude', label: 'Claude', Icon: ClaudeIcon },
{ cli: 'codex', label: 'Codex', Icon: CodexIcon }
{ cli: 'codex', label: 'Codex', Icon: CodexIcon },
{ cli: 'opencode', label: 'OpenCode', Icon: OpenCodeIcon }
]
Comment on lines 10 to 14
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The type of the Icon property in AGENT_OPTIONS is hardcoded to typeof ClaudeIcon. While this works because all agent icons share the same signature, it is more idiomatic and maintainable to use a generic React component type. This prevents potential type issues when adding new agent icons with different implementations in the future.

Suggested change
const AGENT_OPTIONS: Array<{ cli: SpawnAgentCli; label: string; Icon: typeof ClaudeIcon }> = [
{ cli: 'claude', label: 'Claude', Icon: ClaudeIcon },
{ cli: 'codex', label: 'Codex', Icon: CodexIcon }
{ cli: 'codex', label: 'Codex', Icon: CodexIcon },
{ cli: 'opencode', label: 'OpenCode', Icon: OpenCodeIcon }
]
const AGENT_OPTIONS: Array<{ cli: SpawnAgentCli; label: string; Icon: React.ComponentType<{ className?: string }> }> = [
{ cli: 'claude', label: 'Claude', Icon: ClaudeIcon },
{ cli: 'codex', label: 'Codex', Icon: CodexIcon },
{ cli: 'opencode', label: 'OpenCode', Icon: OpenCodeIcon }
]


export function SpawnAgentDialog(): React.ReactNode {
Expand All @@ -20,6 +21,7 @@ export function SpawnAgentDialog(): React.ReactNode {
const [selectedPersonaId, setSelectedPersonaId] = useState('')
const [customName, setCustomName] = useState('')
const [error, setError] = useState<string | null>(null)
const [cliAvailability, setCliAvailability] = useState<Partial<Record<SpawnAgentCli, boolean>>>({})
const [selectedRootId, setSelectedRootId] = useState<string | null>(null)
const project = useProjectStore((s) => s.getActiveProject())
const defaultRoot = useProjectStore((s) => s.getActiveRoot())
Expand All @@ -45,6 +47,16 @@ export function SpawnAgentDialog(): React.ReactNode {
}
}, [project, root?.id, selectedRootId])

useEffect(() => {
const clis: SpawnAgentCli[] = ['claude', 'codex', 'opencode']
void Promise.all(clis.map(async (cli) => {
const available = await pear.broker.checkCliAvailable(cli).catch(() => false)
return [cli, available] as const
})).then((results) => {
setCliAvailability(Object.fromEntries(results))
})
}, [])

useEffect(() => {
let cancelled = false

Expand Down Expand Up @@ -207,16 +219,20 @@ export function SpawnAgentDialog(): React.ReactNode {
className="h-9 w-full rounded-md border border-[var(--pear-border-subtle)] bg-[var(--pear-bg)] px-3 text-sm text-[var(--pear-text)] outline-none placeholder:text-[var(--pear-text-faint)] focus:border-[var(--pear-accent-dim)] disabled:opacity-50"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="grid grid-cols-3 gap-3">
{AGENT_OPTIONS.map(({ cli, label, Icon }) => (
<button
key={cli}
type="button"
autoFocus={cli === 'claude'}
onClick={() => handleSpawn(cli)}
disabled={!root?.pathExists || spawning}
disabled={!root?.pathExists || spawning || cliAvailability[cli] === false}
className="flex min-h-[92px] flex-col items-center justify-center gap-2 rounded-lg border border-[var(--pear-border)] text-sm text-[var(--pear-text-dim)] hover:border-[var(--pear-accent-dim)] hover:text-[var(--pear-text)] disabled:cursor-not-allowed disabled:opacity-40"
title={root?.pathExists ? `Spawn ${label}` : `Path not found: ${root?.path || project.rootPath}`}
title={
!root?.pathExists ? `Path not found: ${root?.path || project.rootPath}`
: cliAvailability[cli] === false ? `${label} is not installed — run: npm install -g ${cli}`
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
: `Spawn ${label}`
}
>
<Icon className="h-6 w-6" />
<span>{spawningCli === cli ? 'Starting' : label}</span>
Expand Down
56 changes: 34 additions & 22 deletions src/renderer/src/components/terminal/TerminalPane.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type React from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { AlertTriangle, ChevronLeft, ChevronRight, Columns2, Loader2, Network, PanelTop, X } from 'lucide-react'
import { AgentHarnessIcon, ClaudeIcon, CodexIcon } from '@/components/common/AgentIcons'
import { AgentHarnessIcon, ClaudeIcon, CodexIcon, OpenCodeIcon } from '@/components/common/AgentIcons'
import { GraphView } from '@/components/graph/GraphView'
import { ChatComposerInput } from '@/components/chat/ChatComposerInput'
import { spawnProjectAgent, type SpawnAgentCli } from '@/lib/spawn-agent'
Expand Down Expand Up @@ -432,6 +432,7 @@ export function TerminalPane(): React.ReactNode {
const setTerminalLayout = useUIStore((s) => s.setTerminalLayout)
const [spawningCli, setSpawningCli] = useState<SpawnAgentCli | null>(null)
const [spawnError, setSpawnError] = useState<string | null>(null)
const [cliAvailability, setCliAvailability] = useState<Partial<Record<SpawnAgentCli, boolean>>>({})
const spawnRequestRef = useRef(false)
const [burnSummariesByAgentKey, setBurnSummariesByAgentKey] = useState<Record<string, BurnAgentSummary>>({})
const [splitPage, setSplitPage] = useState(0)
Expand Down Expand Up @@ -567,6 +568,16 @@ export function TerminalPane(): React.ReactNode {
})
}

useEffect(() => {
const clis: SpawnAgentCli[] = ['claude', 'codex', 'opencode']
void Promise.all(clis.map(async (cli) => {
const available = await pear.broker.checkCliAvailable(cli).catch(() => false)
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
return [cli, available] as const
})).then((results) => {
setCliAvailability(Object.fromEntries(results))
})
}, [])

useEffect(() => {
let cancelled = false

Expand Down Expand Up @@ -781,27 +792,28 @@ export function TerminalPane(): React.ReactNode {
{activeProject ? 'No agents running' : 'No project selected'}
</p>
{activeProject ? (
<div className="mt-4 grid w-full max-w-[340px] grid-cols-2 gap-3">
<button
type="button"
onClick={() => handleSpawn('claude')}
disabled={!activeRoot?.pathExists || spawningCli !== null}
className="flex items-center justify-center gap-2 rounded-lg border border-[var(--pear-border)] px-4 py-3 text-sm text-[var(--pear-text-dim)] hover:border-[var(--pear-accent-dim)] hover:text-[var(--pear-text)] disabled:cursor-not-allowed disabled:opacity-40"
title={activeRoot?.pathExists ? 'Spawn Claude' : `Path not found: ${activeRoot?.path || activeProject.rootPath}`}
>
<ClaudeIcon className="h-4 w-4" />
<span>{spawningCli === 'claude' ? 'Starting' : 'Claude'}</span>
</button>
<button
type="button"
onClick={() => handleSpawn('codex')}
disabled={!activeRoot?.pathExists || spawningCli !== null}
className="flex items-center justify-center gap-2 rounded-lg border border-[var(--pear-border)] px-4 py-3 text-sm text-[var(--pear-text-dim)] hover:border-[var(--pear-accent-dim)] hover:text-[var(--pear-text)] disabled:cursor-not-allowed disabled:opacity-40"
title={activeRoot?.pathExists ? 'Spawn Codex' : `Path not found: ${activeRoot?.path || activeProject.rootPath}`}
>
<CodexIcon className="h-4 w-4" />
<span>{spawningCli === 'codex' ? 'Starting' : 'Codex'}</span>
</button>
<div className="mt-4 grid w-full max-w-[420px] grid-cols-3 gap-3">
{([
{ cli: 'claude', label: 'Claude', Icon: ClaudeIcon },
{ cli: 'codex', label: 'Codex', Icon: CodexIcon },
{ cli: 'opencode', label: 'OpenCode', Icon: OpenCodeIcon }
] as const).map(({ cli, label, Icon }) => (
<button
key={cli}
type="button"
onClick={() => handleSpawn(cli)}
disabled={!activeRoot?.pathExists || spawningCli !== null || cliAvailability[cli] === false}
className="flex items-center justify-center gap-2 rounded-lg border border-[var(--pear-border)] px-4 py-3 text-sm text-[var(--pear-text-dim)] hover:border-[var(--pear-accent-dim)] hover:text-[var(--pear-text)] disabled:cursor-not-allowed disabled:opacity-40"
title={
!activeRoot?.pathExists ? `Path not found: ${activeRoot?.path || activeProject.rootPath}`
: cliAvailability[cli] === false ? `${label} is not installed`
: `Spawn ${label}`
}
>
<Icon className="h-4 w-4" />
<span>{spawningCli === cli ? 'Starting' : label}</span>
</button>
))}
</div>
) : (
<button
Expand Down
2 changes: 1 addition & 1 deletion src/renderer/src/lib/spawn-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { getAgentKey, useAgentStore } from '@/stores/agent-store'
import { useProjectStore, type Project, type ProjectRoot } from '@/stores/project-store'
import { useUIStore } from '@/stores/ui-store'

export type SpawnAgentCli = 'claude' | 'codex'
export type SpawnAgentCli = 'claude' | 'codex' | 'opencode'

function nextAgentName(cli: SpawnAgentCli, projectId: string, liveNames: string[]): string {
const existingNames = new Set([
Expand Down
1 change: 1 addition & 0 deletions src/shared/types/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -883,6 +883,7 @@ export interface PearAPI {
unsubscribeAgentChannel: (projectId: string | undefined, name: string, channel: string) => Promise<void>
releaseAgent: (projectId: string | undefined, name: string) => Promise<void>
listAgents: (projectId?: string) => Promise<BrokerListAgent[]>
checkCliAvailable: (cli: string) => Promise<boolean>
listDetails: () => Promise<BrokerDetails[]>
listEvents: () => Promise<BrokerEventRecord[]>
shutdown: () => Promise<void>
Expand Down
Loading