diff --git a/src/main/codex-accounts/runtime-home-service.test.ts b/src/main/codex-accounts/runtime-home-service.test.ts index d24edef5f2b..53b07d12116 100644 --- a/src/main/codex-accounts/runtime-home-service.test.ts +++ b/src/main/codex-accounts/runtime-home-service.test.ts @@ -93,6 +93,7 @@ function createSettings(overrides: Partial = {}): GlobalSettings opencodeWorkspaceId: '', geminiCliOAuthEnabled: false, agentCmdOverrides: {}, + customAgents: [], terminalMacOptionAsAlt: 'false', terminalMacOptionAsAltMigrated: true, experimentalAgentDashboard: false, diff --git a/src/main/codex-accounts/service.test.ts b/src/main/codex-accounts/service.test.ts index 2d78c279504..eb8507ab6df 100644 --- a/src/main/codex-accounts/service.test.ts +++ b/src/main/codex-accounts/service.test.ts @@ -87,6 +87,7 @@ function createSettings(overrides: Partial = {}): GlobalSettings opencodeWorkspaceId: '', geminiCliOAuthEnabled: false, agentCmdOverrides: {}, + customAgents: [], terminalMacOptionAsAlt: 'false', terminalMacOptionAsAltMigrated: true, experimentalAgentDashboard: false, diff --git a/src/renderer/src/components/NewWorkspaceComposerCard.tsx b/src/renderer/src/components/NewWorkspaceComposerCard.tsx index 7cacd39f437..abaf8ebd5a3 100644 --- a/src/renderer/src/components/NewWorkspaceComposerCard.tsx +++ b/src/renderer/src/components/NewWorkspaceComposerCard.tsx @@ -33,6 +33,10 @@ type NewWorkspaceComposerCardProps = { nameInputRef?: React.RefObject quickAgent: TuiAgent | null onQuickAgentChange: (agent: TuiAgent | null) => void + /** Custom-agent profile id selected in the quick-create flow, or null when + * the user has the blank/builtin path. */ + quickCustomAgentId?: string | null + onQuickCustomAgentChange?: (id: string | null) => void eligibleRepos: RepoOption[] repoId: string onRepoChange: (value: string) => void @@ -178,6 +182,8 @@ export default function NewWorkspaceComposerCard({ nameInputRef, quickAgent, onQuickAgentChange, + quickCustomAgentId = null, + onQuickCustomAgentChange, eligibleRepos, repoId, onRepoChange, @@ -212,10 +218,11 @@ export default function NewWorkspaceComposerCard({ const { isFileDragOver, dragHandlers } = useComposerFileDragOver() const openModal = useAppStore((s) => s.openModal) const defaultTuiAgent = useAppStore((s) => s.settings?.defaultTuiAgent ?? null) + const customAgents = useAppStore((s) => s.settings?.customAgents ?? []) const updateSettings = useAppStore((s) => s.updateSettings) const handleSetDefaultAgent = React.useCallback( - (next: TuiAgent | 'blank' | null) => { + (next: TuiAgent | 'blank' | { kind: 'custom'; id: string } | null) => { updateSettings({ defaultTuiAgent: next }) }, [updateSettings] @@ -353,8 +360,37 @@ export default function NewWorkspaceComposerCard({ { + const profile = customAgents.find((p) => p.id === quickCustomAgentId) + return profile + ? { kind: 'custom' as const, profile } + : quickAgent === null + ? { kind: 'blank' as const } + : { kind: 'builtin' as const, agent: quickAgent } + })() + : quickAgent === null + ? { kind: 'blank' as const } + : { kind: 'builtin' as const, agent: quickAgent } + } + onValueChange={(selection) => { + if (selection.kind === 'blank') { + onQuickAgentChange(null) + onQuickCustomAgentChange?.(null) + } else if (selection.kind === 'builtin') { + onQuickAgentChange(selection.agent) + onQuickCustomAgentChange?.(null) + } else { + // Why: custom selections set the underlying TuiAgent to the + // profile's baseAgent so prompt-injection mode and telemetry + // stay valid downstream; the customAgentId carries the + // override. + onQuickAgentChange(selection.profile.baseAgent) + onQuickCustomAgentChange?.(selection.profile.id) + } + }} onOpenManageAgents={onOpenAgentSettings} defaultAgent={defaultTuiAgent} onSetDefault={handleSetDefaultAgent} diff --git a/src/renderer/src/components/NewWorkspaceComposerModal.tsx b/src/renderer/src/components/NewWorkspaceComposerModal.tsx index a8cda39fab6..0a0b676b00e 100644 --- a/src/renderer/src/components/NewWorkspaceComposerModal.tsx +++ b/src/renderer/src/components/NewWorkspaceComposerModal.tsx @@ -124,6 +124,9 @@ function QuickTabBody({ const [quickAgentOverride, setQuickAgentOverride] = useState( undefined ) + const [quickCustomAgentOverride, setQuickCustomAgentOverride] = useState< + string | null | undefined + >(undefined) const preferredQuickAgent = useMemo(() => { const pref = settings?.defaultTuiAgent if (pref === 'blank') { @@ -131,21 +134,42 @@ function QuickTabBody({ // model already uses null to mean "blank terminal", so translate here. return null } + if (pref && typeof pref === 'object' && pref.kind === 'custom') { + const profile = (settings?.customAgents ?? []).find((p) => p.id === pref.id) + return profile?.baseAgent ?? null + } if (pref) { - return pref + return pref as TuiAgent } const detected = cardProps.detectedAgentIds return AGENT_CATALOG.find((agent) => detected === null || detected.has(agent.id))?.id ?? null - }, [cardProps.detectedAgentIds, settings?.defaultTuiAgent]) + }, [cardProps.detectedAgentIds, settings?.defaultTuiAgent, settings?.customAgents]) + const preferredQuickCustomAgentId = useMemo(() => { + const pref = settings?.defaultTuiAgent + if (pref && typeof pref === 'object' && pref.kind === 'custom') { + const profile = (settings?.customAgents ?? []).find((p) => p.id === pref.id) + return profile ? profile.id : null + } + return null + }, [settings?.defaultTuiAgent, settings?.customAgents]) const quickAgent = quickAgentOverride === undefined ? preferredQuickAgent : quickAgentOverride + const quickCustomAgentId = + quickCustomAgentOverride === undefined ? preferredQuickCustomAgentId : quickCustomAgentOverride const handleQuickAgentChange = useCallback((agent: TuiAgent | null) => { setQuickAgentOverride(agent) + // Why: switching to a built-in or blank explicitly clears any active + // custom selection. Mirrors how onValueChange in AgentCombobox sends + // both updates together for a custom → builtin transition. + setQuickCustomAgentOverride(null) + }, []) + const handleQuickCustomAgentChange = useCallback((id: string | null) => { + setQuickCustomAgentOverride(id) }, []) const handleCreate = useCallback(async (): Promise => { - await submitQuick(quickAgent) - }, [quickAgent, submitQuick]) + await submitQuick(quickAgent, quickCustomAgentId) + }, [quickAgent, quickCustomAgentId, submitQuick]) // Cmd/Ctrl+Enter submits, Esc first blurs the focused input (like the full page). useEffect(() => { @@ -208,6 +232,8 @@ function QuickTabBody({ nameInputRef={nameInputRef} quickAgent={quickAgent} onQuickAgentChange={handleQuickAgentChange} + quickCustomAgentId={quickCustomAgentId} + onQuickCustomAgentChange={handleQuickCustomAgentChange} {...cardProps} onOpenAgentSettings={() => setAgentSettingsOpen(true)} onCreate={() => void handleCreate()} diff --git a/src/renderer/src/components/agent/AgentCombobox.tsx b/src/renderer/src/components/agent/AgentCombobox.tsx index 9ee35bcf626..afb60047d10 100644 --- a/src/renderer/src/components/agent/AgentCombobox.tsx +++ b/src/renderer/src/components/agent/AgentCombobox.tsx @@ -1,5 +1,5 @@ import React, { useCallback, useMemo, useState } from 'react' -import { Check, ChevronsUpDown, Star, Terminal } from 'lucide-react' +import { Check, ChevronsUpDown, Star, Terminal, Wrench } from 'lucide-react' import { Button } from '@/components/ui/button' import { Command, @@ -17,15 +17,24 @@ import { import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' import { AgentIcon, type AgentCatalogEntry } from '@/lib/agent-catalog' import { cn } from '@/lib/utils' -import type { TuiAgent } from '../../../../shared/types' +import type { CustomAgentProfile, TuiAgent } from '../../../../shared/types' -type DefaultAgentPreference = TuiAgent | 'blank' | null +type DefaultAgentPreference = TuiAgent | 'blank' | { kind: 'custom'; id: string } | null + +/** Selection emitted by the combobox. The picker treats blank, built-in, and + * custom-profile rows as a tri-state so callers don't have to translate + * between two parallel value/onValueChange channels. */ +export type AgentSelection = + | { kind: 'blank' } + | { kind: 'builtin'; agent: TuiAgent } + | { kind: 'custom'; profile: CustomAgentProfile } type AgentComboboxProps = { agents: AgentCatalogEntry[] - value: TuiAgent | null - onValueChange: (agent: TuiAgent | null) => void - onValueSelected?: (agent: TuiAgent | null) => void + customAgents?: CustomAgentProfile[] + value: AgentSelection + onValueChange: (selection: AgentSelection) => void + onValueSelected?: (selection: AgentSelection) => void onOpenManageAgents?: () => void /** Current saved default agent preference. Used to render a subtle "default" * indicator in the list and to tell which right-click menu item is the @@ -33,7 +42,7 @@ type AgentComboboxProps = { defaultAgent?: DefaultAgentPreference /** Optional handler for right-click "Set as default" action. When provided, * each list item (including Blank Terminal) gets a context menu. */ - onSetDefault?: (agent: DefaultAgentPreference) => void + onSetDefault?: (selection: DefaultAgentPreference) => void triggerClassName?: string /** When set, pressing Enter on the closed combobox trigger invokes this * instead of opening the popover — lets the parent form treat the Agent @@ -117,8 +126,30 @@ function searchAgents(agents: AgentCatalogEntry[], rawQuery: string): AgentCatal return matches.map((m) => m.agent) } +function searchCustomAgents( + customAgents: CustomAgentProfile[], + rawQuery: string +): CustomAgentProfile[] { + const query = rawQuery.trim().toLowerCase() + if (!query) { + return customAgents + } + const matches: { profile: CustomAgentProfile; score: number; index: number }[] = [] + customAgents.forEach((profile, index) => { + const labelIdx = profile.label.toLowerCase().indexOf(query) + const baseIdx = profile.baseAgent.toLowerCase().indexOf(query) + const score = labelIdx !== -1 ? labelIdx : baseIdx !== -1 ? 1000 + baseIdx : -1 + if (score !== -1) { + matches.push({ profile, score, index }) + } + }) + matches.sort((a, b) => a.score - b.score || a.index - b.index) + return matches.map((m) => m.profile) +} + export default function AgentCombobox({ agents, + customAgents = [], value, onValueChange, onValueSelected, @@ -136,11 +167,20 @@ export default function AgentCombobox({ const [commandValue, setCommandValue] = useState('') const triggerRef = React.useRef(null) - const selectedAgent = useMemo( - () => (value ? (agents.find((agent) => agent.id === value) ?? null) : null), + const selectedBuiltin = useMemo( + () => + value.kind === 'builtin' ? (agents.find((agent) => agent.id === value.agent) ?? null) : null, [agents, value] ) + const selectedCustom = useMemo( + () => (value.kind === 'custom' ? value.profile : null), + [value] + ) const filteredAgents = useMemo(() => searchAgents(agents, query), [agents, query]) + const filteredCustomAgents = useMemo( + () => searchCustomAgents(customAgents, query), + [customAgents, query] + ) const blankMatchesQuery = useMemo(() => { const q = query.trim().toLowerCase() if (!q) { @@ -153,7 +193,13 @@ export default function AgentCombobox({ if (!open) { return } - setCommandValue(value ?? BLANK_VALUE) + const seedKey = + value.kind === 'builtin' + ? value.agent + : value.kind === 'custom' + ? `custom:${value.profile.id}` + : BLANK_VALUE + setCommandValue(seedKey) const frame = requestAnimationFrame(() => { const searchInput = document.querySelector( '[data-agent-combobox-root="true"] [data-slot="command-input"]' @@ -179,11 +225,11 @@ export default function AgentCombobox({ }, []) const handleSelect = useCallback( - (nextValue: TuiAgent | null) => { - onValueChange(nextValue) + (next: AgentSelection) => { + onValueChange(next) setOpen(false) setQuery('') - onValueSelected?.(nextValue) + onValueSelected?.(next) }, [onValueChange, onValueSelected] ) @@ -244,10 +290,15 @@ export default function AgentCombobox({ )} data-agent-combobox-root="true" > - {selectedAgent ? ( + {selectedBuiltin ? ( - - {selectedAgent.label} + + {selectedBuiltin.label} + + ) : selectedCustom ? ( + + + {selectedCustom.label} ) : ( @@ -272,9 +323,9 @@ export default function AgentCombobox({ ? renderItem({ key: BLANK_VALUE, itemValue: BLANK_VALUE, - isChecked: value === null, + isChecked: value.kind === 'blank', isDefault: defaultAgent === 'blank', - onSelect: () => handleSelect(null), + onSelect: () => handleSelect({ kind: 'blank' }), onSetDefault: onSetDefault ? () => onSetDefault('blank') : undefined, icon: , label: 'Blank Terminal' @@ -284,14 +335,46 @@ export default function AgentCombobox({ renderItem({ key: agent.id, itemValue: agent.id, - isChecked: value === agent.id, + isChecked: value.kind === 'builtin' && value.agent === agent.id, isDefault: defaultAgent === agent.id, - onSelect: () => handleSelect(agent.id), + onSelect: () => handleSelect({ kind: 'builtin', agent: agent.id }), onSetDefault: onSetDefault ? () => onSetDefault(agent.id) : undefined, icon: , label: agent.label }) )} + {filteredCustomAgents.map((profile) => { + const key = `custom:${profile.id}` + const isCustomDefault = + typeof defaultAgent === 'object' && + defaultAgent !== null && + defaultAgent.kind === 'custom' && + defaultAgent.id === profile.id + return renderItem({ + key, + itemValue: key, + isChecked: value.kind === 'custom' && value.profile.id === profile.id, + isDefault: isCustomDefault, + onSelect: () => handleSelect({ kind: 'custom', profile }), + onSetDefault: onSetDefault + ? () => onSetDefault({ kind: 'custom', id: profile.id }) + : undefined, + // Why: custom profiles inherit the base agent's icon so the + // picker visually groups variants of the same CLI together. + // The Wrench overlay disambiguates that this is a user- + // configured variant rather than a stock entry. + icon: ( + + + + + ), + label: profile.label + }) + })} {onOpenManageAgents ? (
diff --git a/src/renderer/src/components/settings/AgentsPane.tsx b/src/renderer/src/components/settings/AgentsPane.tsx index 980f713a520..6a7c15f9b7e 100644 --- a/src/renderer/src/components/settings/AgentsPane.tsx +++ b/src/renderer/src/components/settings/AgentsPane.tsx @@ -1,11 +1,18 @@ +/* eslint-disable max-lines -- Why: the agents settings pane composes the + default-agent picker, the detected/undetected catalog rows, and the + custom-agents section. Splitting these into separate files would scatter + the per-agent state (cmd overrides, default selection, custom profiles) + and make the cross-section interactions (e.g. custom default also paints + in the picker) harder to follow than keeping them in one file. */ import { useEffect, useMemo, useState } from 'react' -import { Check, ChevronDown, ExternalLink, RefreshCw, Terminal } from 'lucide-react' +import { Check, ChevronDown, ExternalLink, RefreshCw, Terminal, Wrench } from 'lucide-react' import type { GlobalSettings, TuiAgent } from '../../../../shared/types' import { AGENT_CATALOG, AgentIcon } from '@/lib/agent-catalog' import { useDetectedAgents } from '@/hooks/useDetectedAgents' import { Button } from '../ui/button' import { Input } from '../ui/input' import { cn } from '@/lib/utils' +import { CustomAgentsSection } from './CustomAgentsSection' export { AGENTS_PANE_SEARCH_ENTRIES } from './agents-search' @@ -219,8 +226,9 @@ export function AgentsPane({ settings, updateSettings }: AgentsPaneProps): React const defaultAgent = settings.defaultTuiAgent const cmdOverrides = settings.agentCmdOverrides ?? {} + const customAgents = settings.customAgents ?? [] - const setDefault = (id: TuiAgent | 'blank' | null): void => { + const setDefault = (id: TuiAgent | 'blank' | { kind: 'custom'; id: string } | null): void => { updateSettings({ defaultTuiAgent: id }) } @@ -243,8 +251,15 @@ export function AgentsPane({ settings, updateSettings }: AgentsPaneProps): React // so the Auto pill should only light up when the default is null OR when a // selected agent id is no longer detected on PATH. const isAutoDefault = - defaultAgent === null || (defaultAgent !== 'blank' && !detectedIds?.has(defaultAgent)) + defaultAgent === null || + (typeof defaultAgent !== 'object' && + defaultAgent !== 'blank' && + !detectedIds?.has(defaultAgent)) const isBlankDefault = defaultAgent === 'blank' + const defaultCustomAgentId = + defaultAgent && typeof defaultAgent === 'object' && defaultAgent.kind === 'custom' + ? defaultAgent.id + : null return (
@@ -313,9 +328,52 @@ export function AgentsPane({ settings, updateSettings }: AgentsPaneProps): React ) })} + + {/* Custom-agent pills. Only profiles whose baseAgent is detected + show up here — a profile pointed at an uninstalled CLI can't + actually launch, so making it the "default" would just stall. */} + {customAgents + .filter((p) => detectedIds === null || detectedIds.has(p.baseAgent)) + .map((profile) => { + const isActive = defaultCustomAgentId === profile.id + return ( + + ) + })}
+ updateSettings({ customAgents: next })} + defaultCustomAgentId={defaultCustomAgentId} + onSetDefault={(id) => setDefault({ kind: 'custom', id })} + // Why: the env shell-prefix path uses POSIX quoting; on Windows the + // user needs to wrap with `cmd /c …` to apply env vars cleanly. The + // settings hint flags this without blocking the input. + isWindows={typeof navigator !== 'undefined' && navigator.userAgent.includes('Windows')} + /> + {/* Detected agents */} {detectedAgents.length > 0 && (
diff --git a/src/renderer/src/components/settings/CustomAgentsSection.tsx b/src/renderer/src/components/settings/CustomAgentsSection.tsx new file mode 100644 index 00000000000..8b4f27cd7c6 --- /dev/null +++ b/src/renderer/src/components/settings/CustomAgentsSection.tsx @@ -0,0 +1,419 @@ +import { useEffect, useMemo, useRef, useState } from 'react' +import { Plus, Trash2, Wrench } from 'lucide-react' +import type { CustomAgentProfile, TuiAgent } from '../../../../shared/types' +import { AGENT_CATALOG, AgentIcon } from '@/lib/agent-catalog' +import { Button } from '../ui/button' +import { Input } from '../ui/input' +import { cn } from '@/lib/utils' + +type CustomAgentsSectionProps = { + customAgents: CustomAgentProfile[] + onChange: (next: CustomAgentProfile[]) => void + /** Default-agent control — surfaces a "Set as default" affordance per + * profile so the user can wire a custom agent into the auto-pick flow. */ + defaultCustomAgentId: string | null + onSetDefault: (id: string) => void + /** Why: shell-quoting for env values is POSIX-shaped. Surface a hint on + * Windows so users don't paste an env map and silently get a no-op. */ + isWindows: boolean +} + +type DraftRow = Omit & { + envPairs: { key: string; value: string }[] +} + +function profileToDraft(profile: CustomAgentProfile): DraftRow { + return { + id: profile.id, + label: profile.label, + baseAgent: profile.baseAgent, + command: profile.command, + envPairs: Object.entries(profile.env ?? {}).map(([key, value]) => ({ key, value })) + } +} + +function draftToProfile(draft: DraftRow): CustomAgentProfile | null { + const label = draft.label.trim() + const command = draft.command.trim() + if (!label || !command) { + return null + } + const env: Record = {} + for (const pair of draft.envPairs) { + const key = pair.key.trim() + if (!key) { + continue + } + env[key] = pair.value + } + const profile: CustomAgentProfile = { + id: draft.id, + label, + baseAgent: draft.baseAgent, + command + } + if (Object.keys(env).length > 0) { + profile.env = env + } + return profile +} + +function newDraftFor(baseAgent: TuiAgent): DraftRow { + const id = globalThis.crypto.randomUUID() + const entry = AGENT_CATALOG.find((a) => a.id === baseAgent) + return { + id, + label: '', + baseAgent, + command: entry?.cmd ?? baseAgent, + envPairs: [{ key: '', value: '' }] + } +} + +export function CustomAgentsSection({ + customAgents, + onChange, + defaultCustomAgentId, + onSetDefault, + isWindows +}: CustomAgentsSectionProps): React.JSX.Element { + // Why: edits live in local draft state per-row so users can experiment with + // env keys/values without each keystroke writing to global settings (and + // racing with re-renders that would scroll the field out from under them). + // Drafts commit on blur or Save click. + const [drafts, setDrafts] = useState(() => customAgents.map(profileToDraft)) + const [editingId, setEditingId] = useState(null) + const lastExternalRef = useRef(customAgents) + + useEffect(() => { + // Why: external settings updates (e.g. settings sync, undo) should + // refresh local drafts unless the user is mid-edit. Reference-equality + // check skips the trivial case where this component itself wrote. + if (lastExternalRef.current === customAgents) { + return + } + lastExternalRef.current = customAgents + if (editingId) { + // Don't yank focus; merge non-edited rows from external state. + setDrafts((prev) => { + const externalById = new Map(customAgents.map((p) => [p.id, p])) + const next = prev + .filter((d) => d.id === editingId || externalById.has(d.id)) + .map((d) => { + if (d.id === editingId) { + return d + } + const ext = externalById.get(d.id) + return ext ? profileToDraft(ext) : d + }) + // Append newly-external rows we didn't have locally. + for (const ext of customAgents) { + if (!next.find((d) => d.id === ext.id)) { + next.push(profileToDraft(ext)) + } + } + return next + }) + return + } + setDrafts(customAgents.map(profileToDraft)) + }, [customAgents, editingId]) + + const commit = (next: DraftRow[]): void => { + const profiles = next.map(draftToProfile).filter((p): p is CustomAgentProfile => p !== null) + lastExternalRef.current = profiles + onChange(profiles) + } + + const updateDraft = (id: string, patch: Partial): void => { + setDrafts((prev) => prev.map((d) => (d.id === id ? { ...d, ...patch } : d))) + } + + const addProfile = (baseAgent: TuiAgent): void => { + const draft = newDraftFor(baseAgent) + setDrafts((prev) => [...prev, draft]) + setEditingId(draft.id) + } + + const removeProfile = (id: string): void => { + const next = drafts.filter((d) => d.id !== id) + setDrafts(next) + if (editingId === id) { + setEditingId(null) + } + commit(next) + } + + const finishEdit = (id: string): void => { + setEditingId((cur) => (cur === id ? null : cur)) + commit(drafts) + } + + const baseAgentOptions = useMemo( + () => AGENT_CATALOG.map((a) => ({ id: a.id, label: a.label })), + [] + ) + + return ( +
+
+
+

Custom Agents

+

+ Named variants of a built-in agent with their own command and env vars + {isWindows + ? ' (env prefix uses POSIX shell quoting; on Windows wrap with `cmd /c …` if needed)' + : ''} + . +

+
+ +
+ + {drafts.length === 0 ? ( +
+ No custom agents yet. Click Add to define one — for + example, a Claude profile pointed at a self-hosted endpoint via{' '} + ANTHROPIC_BASE_URL. +
+ ) : ( +
+ {drafts.map((draft) => { + const isEditing = editingId === draft.id + const isDefault = defaultCustomAgentId === draft.id + return ( +
+
+
+ + +
+
+ {isEditing ? ( + updateDraft(draft.id, { label: e.target.value })} + placeholder="e.g. Claude (zai)" + className="h-7 text-sm" + /> + ) : ( +
+ {draft.label || Unnamed} +
+ )} +
+ {draft.command || no command} +
+
+
+ {!isEditing && ( + + )} + + +
+
+ + {isEditing && ( +
+ + + + +
+ Environment variables +
+ {draft.envPairs.map((pair, idx) => ( +
+ { + const envPairs = [...draft.envPairs] + envPairs[idx] = { ...pair, key: e.target.value } + updateDraft(draft.id, { envPairs }) + }} + placeholder="KEY" + spellCheck={false} + className="h-7 flex-1 font-mono text-xs" + /> + = + { + const envPairs = [...draft.envPairs] + envPairs[idx] = { ...pair, value: e.target.value } + updateDraft(draft.id, { envPairs }) + }} + placeholder="value" + spellCheck={false} + className="h-7 flex-[2] font-mono text-xs" + /> + +
+ ))} +
+ +
+ +
+ +
+
+ )} +
+ ) + })} +
+ )} +
+ ) +} + +function AddProfileButton({ onAdd }: { onAdd: (baseAgent: TuiAgent) => void }): React.JSX.Element { + const [open, setOpen] = useState(false) + return ( +
+ + {open && ( +
setOpen(false)} + > + {AGENT_CATALOG.map((agent) => ( + + ))} +
+ )} +
+ ) +} diff --git a/src/renderer/src/components/tab-bar/QuickLaunchButton.tsx b/src/renderer/src/components/tab-bar/QuickLaunchButton.tsx index 0e90636aa29..c3edac297e3 100644 --- a/src/renderer/src/components/tab-bar/QuickLaunchButton.tsx +++ b/src/renderer/src/components/tab-bar/QuickLaunchButton.tsx @@ -23,13 +23,18 @@ function getCatalogEntry(agent: TuiAgent): { id: TuiAgent; label: string } | nul } function orderAgents( - defaultAgent: TuiAgent | 'blank' | null | undefined, + defaultAgent: TuiAgent | 'blank' | { kind: 'custom'; id: string } | null | undefined, detected: TuiAgent[] ): TuiAgent[] { const inCatalogOrder = AGENT_CATALOG.filter((entry) => detected.includes(entry.id)).map( (entry) => entry.id ) - if (!defaultAgent || defaultAgent === 'blank' || !inCatalogOrder.includes(defaultAgent)) { + if ( + !defaultAgent || + defaultAgent === 'blank' || + typeof defaultAgent === 'object' || + !inCatalogOrder.includes(defaultAgent) + ) { return inCatalogOrder } // Why: surface the user's configured default first — matches the prior @@ -57,6 +62,7 @@ function QuickLaunchAgentMenuItemsInner({ }) const { detectedIds } = useDetectedAgents(connectionId) const defaultAgent = useAppStore((s) => s.settings?.defaultTuiAgent) + const customAgents = useAppStore((s) => s.settings?.customAgents ?? []) const openSettingsPage = useAppStore((s) => s.openSettingsPage) const openSettingsTarget = useAppStore((s) => s.openSettingsTarget) @@ -66,10 +72,10 @@ function QuickLaunchAgentMenuItemsInner({ }, [openSettingsPage, openSettingsTarget]) const runLaunch = useCallback( - (agent: TuiAgent) => { + (agent: TuiAgent, customAgentId: string | null = null) => { const entry = getCatalogEntry(agent) const label = entry?.label ?? agent - const result = launchAgentInNewTab({ agent, worktreeId, groupId }) + const result = launchAgentInNewTab({ agent, worktreeId, groupId, customAgentId }) if (!result) { toast.error(`Could not build launch command for ${label}.`) return @@ -103,11 +109,16 @@ function QuickLaunchAgentMenuItemsInner({ ) const agents = detectedIds ? orderAgents(defaultAgent, detectedIds) : [] + // Why: only surface custom profiles whose baseAgent is actually detected + // — launching a profile pointing at an uninstalled CLI just hangs. + const visibleCustomAgents = detectedIds + ? customAgents.filter((p) => detectedIds.includes(p.baseAgent)) + : [] return ( <> - {agents.length === 0 ? ( + {agents.length === 0 && visibleCustomAgents.length === 0 ? ( ) })} + {visibleCustomAgents.map((profile) => ( + runLaunch(profile.baseAgent, profile.id)} + className="gap-2 rounded-[7px] px-2 py-1.5 text-[12px] leading-5 font-medium" + title={`Launch ${profile.label} in a new terminal`} + > + + {profile.label} + + ))} void tuiAgent: TuiAgent onTuiAgentChange: (value: TuiAgent) => void + /** Currently selected custom-agent profile id, or null when a built-in is + * selected. The picker treats this as a parallel selection — setting it + * also updates `tuiAgent` to the profile's `baseAgent` so prompt-injection + * mode and telemetry stay valid. */ + customAgentId: string | null + onCustomAgentChange: (id: string | null) => void detectedAgentIds: Set | null onOpenAgentSettings: () => void advancedOpen: boolean @@ -158,7 +165,7 @@ export type UseComposerStateResult = { promptTextareaRef: React.RefObject nameInputRef: React.RefObject submit: () => Promise - submitQuick: (agent: TuiAgent | null) => Promise + submitQuick: (agent: TuiAgent | null, customAgentId?: string | null) => Promise /** Invoked by the Enter handler to re-check whether submission should fire. */ createDisabled: boolean } @@ -294,13 +301,32 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS // null/blank), so 'blank' preferences from global settings must collapse to // the Claude default here — the blank-terminal affordance only lives in the // quick-create flow. - const fallbackDefaultAgent: TuiAgent = - settings?.defaultTuiAgent && settings.defaultTuiAgent !== 'blank' + const defaultPrefAgent: TuiAgent = + settings?.defaultTuiAgent && + settings.defaultTuiAgent !== 'blank' && + typeof settings.defaultTuiAgent !== 'object' ? settings.defaultTuiAgent : 'claude' + // Why: when the saved default is a custom profile, surface its baseAgent + // here so the long-form composer's required `tuiAgent` slot is consistent + // with the customAgentId we initialize below — otherwise the picker would + // show a built-in pill while customAgentId is set, confusing the user. + const defaultPrefCustomProfile = (() => { + const pref = settings?.defaultTuiAgent + if (pref && typeof pref === 'object' && pref.kind === 'custom') { + return (settings?.customAgents ?? []).find((p) => p.id === pref.id) ?? null + } + return null + })() + const fallbackDefaultAgent: TuiAgent = defaultPrefCustomProfile?.baseAgent ?? defaultPrefAgent const [tuiAgent, setTuiAgent] = useState( persistDraft ? (newWorkspaceDraft?.agent ?? fallbackDefaultAgent) : fallbackDefaultAgent ) + const [customAgentId, setCustomAgentId] = useState( + persistDraft + ? (newWorkspaceDraft?.customAgentId ?? defaultPrefCustomProfile?.id ?? null) + : (defaultPrefCustomProfile?.id ?? null) + ) // Why: when the selected repo is remote (has a connectionId), read the // per-connection agent list instead of the local one. This ensures the // Create Workspace dialog shows agents installed on the SSH host, not the @@ -383,10 +409,12 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS return } let cancelled = false - void (window.api.gh.repoSlug({ repoPath: selectedRepoPath }) as Promise<{ - owner: string - repo: string - } | null>) + void ( + window.api.gh.repoSlug({ repoPath: selectedRepoPath }) as Promise<{ + owner: string + repo: string + } | null> + ) .then((result) => { if (cancelled) { return @@ -593,6 +621,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS attachments: attachmentPaths, linkedWorkItem, agent: tuiAgent, + customAgentId, linkedIssue, linkedPR, ...(baseBranch !== undefined ? { baseBranch } : {}) @@ -609,7 +638,8 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS name, repoId, setNewWorkspaceDraft, - tuiAgent + tuiAgent, + customAgentId ]) // Auto-pick the first eligible repo if we somehow start with none selected. @@ -1326,7 +1356,8 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS agent: tuiAgent, prompt: startupPrompt, cmdOverrides: settings?.agentCmdOverrides ?? {}, - platform: CLIENT_PLATFORM + platform: CLIENT_PLATFORM, + customProfile: findCustomAgentProfile(settings, customAgentId) }) // Why: thread agent_started telemetry through the queued startup so @@ -1390,8 +1421,8 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS requiresExplicitSetupChoice, resolvedSetupDecision, selectedRepo, - settings?.agentCmdOverrides, - settings?.rightSidebarOpenByDefault, + settings, + customAgentId, setRightSidebarOpen, setRightSidebarTab, setSidebarOpen, @@ -1409,7 +1440,8 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS ]) const submitQuick = useCallback( - async (agent: TuiAgent | null): Promise => { + async (agent: TuiAgent | null, quickCustomAgentId: string | null = null): Promise => { + const customProfile = findCustomAgentProfile(settings, quickCustomAgentId) const workspaceName = getWorkspaceSeedName({ explicitName: name, prompt: '', @@ -1500,7 +1532,8 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS agent, draft: quickDraftPrompt, cmdOverrides: settings?.agentCmdOverrides ?? {}, - platform: CLIENT_PLATFORM + platform: CLIENT_PLATFORM, + customProfile }) let startupPlan: ReturnType = null @@ -1517,7 +1550,8 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS prompt: quickPrompt, cmdOverrides: settings?.agentCmdOverrides ?? {}, platform: CLIENT_PLATFORM, - allowEmptyPromptLaunch: true + allowEmptyPromptLaunch: true, + customProfile }) if (startupPlan && quickDraftPrompt) { startupPlan.draftPrompt = quickDraftPrompt @@ -1589,8 +1623,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS requiresExplicitSetupChoice, resolvedSetupDecision, selectedRepo, - settings?.agentCmdOverrides, - settings?.rightSidebarOpenByDefault, + settings, setRightSidebarOpen, setRightSidebarTab, setSidebarOpen, @@ -1646,6 +1679,8 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS onSelectLinkedItem: handleSelectLinkedItem, tuiAgent, onTuiAgentChange: setTuiAgent, + customAgentId, + onCustomAgentChange: setCustomAgentId, detectedAgentIds, onOpenAgentSettings: handleOpenAgentSettings, advancedOpen, diff --git a/src/renderer/src/lib/custom-agent-resolve.ts b/src/renderer/src/lib/custom-agent-resolve.ts new file mode 100644 index 00000000000..fcc85cf3ae9 --- /dev/null +++ b/src/renderer/src/lib/custom-agent-resolve.ts @@ -0,0 +1,48 @@ +import type { CustomAgentProfile, GlobalSettings, TuiAgent } from '../../../shared/types' + +/** Find a custom-agent profile by id within the user's settings. Returns + * `null` when settings are not yet hydrated or the id no longer matches a + * configured profile (handles the stale-default case where the user + * deleted the profile that was set as default). */ +export function findCustomAgentProfile( + settings: GlobalSettings | null | undefined, + id: string | null | undefined +): CustomAgentProfile | null { + if (!id || !settings?.customAgents) { + return null + } + return settings.customAgents.find((p) => p.id === id) ?? null +} + +/** Coerce the saved `defaultTuiAgent` preference into the renderer's two-piece + * selection model (built-in agent id + optional custom-profile id). Hides + * the union shape from the dozens of consumer sites that just want + * "what's the agent and is it custom?". */ +export type ResolvedDefaultAgent = + | { kind: 'auto' } + | { kind: 'blank' } + | { kind: 'builtin'; agent: TuiAgent } + | { kind: 'custom'; agent: TuiAgent; profile: CustomAgentProfile } + +export function resolveDefaultTuiAgentPreference( + settings: GlobalSettings | null | undefined +): ResolvedDefaultAgent { + const pref = settings?.defaultTuiAgent + if (!pref) { + return { kind: 'auto' } + } + if (pref === 'blank') { + return { kind: 'blank' } + } + if (typeof pref === 'object' && pref !== null && pref.kind === 'custom') { + const profile = findCustomAgentProfile(settings, pref.id) + if (!profile) { + // Why: a deleted custom profile must not strand the user with a + // dead default. Falling back to auto matches the same behavior as + // pickAgent() does for an uninstalled built-in. + return { kind: 'auto' } + } + return { kind: 'custom', agent: profile.baseAgent, profile } + } + return { kind: 'builtin', agent: pref as TuiAgent } +} diff --git a/src/renderer/src/lib/launch-agent-in-new-tab.ts b/src/renderer/src/lib/launch-agent-in-new-tab.ts index 6e959f9c895..40698249958 100644 --- a/src/renderer/src/lib/launch-agent-in-new-tab.ts +++ b/src/renderer/src/lib/launch-agent-in-new-tab.ts @@ -1,5 +1,6 @@ import { useAppStore } from '@/store' import { buildAgentStartupPlan, type AgentStartupPlan } from '@/lib/tui-agent-startup' +import { findCustomAgentProfile } from '@/lib/custom-agent-resolve' import { CLIENT_PLATFORM } from '@/lib/new-workspace' import { reconcileTabOrder } from '@/components/tab-bar/reconcile-order' import { tuiAgentToAgentKind } from '@/lib/telemetry' @@ -11,6 +12,9 @@ export type LaunchAgentInNewTabArgs = { /** The tab group the user clicked from. Keeps split-group launches in the * pane the user initiated from instead of falling through to the active group. */ groupId?: string + /** Optional custom-agent profile id. When set, the launch uses the + * profile's command + env vars instead of the catalog default for `agent`. */ + customAgentId?: string | null } export type LaunchAgentInNewTabResult = { @@ -35,8 +39,9 @@ export type LaunchAgentInNewTabResult = { * not happen with `allowEmptyPromptLaunch: true` but guarded for safety). */ export function launchAgentInNewTab(args: LaunchAgentInNewTabArgs): LaunchAgentInNewTabResult { - const { agent, worktreeId, groupId } = args + const { agent, worktreeId, groupId, customAgentId = null } = args const store = useAppStore.getState() + const customProfile = findCustomAgentProfile(store.settings, customAgentId) // Why: empty-prompt launch is the whole point of quick-launch — the user // just wants to get into the agent's input box with no prefilled prompt. @@ -45,7 +50,8 @@ export function launchAgentInNewTab(args: LaunchAgentInNewTabArgs): LaunchAgentI prompt: '', cmdOverrides: store.settings?.agentCmdOverrides ?? {}, platform: CLIENT_PLATFORM, - allowEmptyPromptLaunch: true + allowEmptyPromptLaunch: true, + customProfile }) if (!startupPlan) { return null diff --git a/src/renderer/src/lib/launch-work-item-direct.ts b/src/renderer/src/lib/launch-work-item-direct.ts index 8e907875787..af3191ea77a 100644 --- a/src/renderer/src/lib/launch-work-item-direct.ts +++ b/src/renderer/src/lib/launch-work-item-direct.ts @@ -3,6 +3,7 @@ import { useAppStore } from '@/store' import { AGENT_CATALOG } from '@/lib/agent-catalog' import { pasteDraftWhenAgentReady } from '@/lib/agent-paste-draft' import { buildAgentDraftLaunchPlan, buildAgentStartupPlan } from '@/lib/tui-agent-startup' +import { resolveDefaultTuiAgentPreference } from '@/lib/custom-agent-resolve' import { TUI_AGENT_CONFIG } from '../../../shared/tui-agent-config' import { activateAndRevealWorktree, type AgentStartedTelemetry } from '@/lib/worktree-activation' import { @@ -14,6 +15,8 @@ import { import { ensureHooksConfirmed } from '@/lib/ensure-hooks-confirmed' import { track, tuiAgentToAgentKind } from '@/lib/telemetry' import type { + CustomAgentProfile, + GlobalSettings, OrcaHooks, RepoHookSettings, SetupDecision, @@ -66,20 +69,31 @@ export type LaunchWorkItemDirectArgs = { } function pickAgent( - preferred: TuiAgent | 'blank' | null | undefined, + settings: + | { + defaultTuiAgent?: GlobalSettings['defaultTuiAgent'] + customAgents?: GlobalSettings['customAgents'] + } + | null + | undefined, detected: Set -): TuiAgent | null { - // Why: honor the explicit default when the agent is actually installed. A - // stale preference (uninstalled binary) must not block the flow — fall +): { agent: TuiAgent; customProfile: CustomAgentProfile | null } | null { + // Why: honor the explicit default when the underlying base agent is + // actually installed. A stale preference (uninstalled binary, or a custom + // profile referencing a deleted base) must not block the flow — fall // through to the first matching detected agent in catalog order, which // matches the quick-composer's auto-pick behavior and keeps the experience // consistent regardless of where the user launches the workspace from. - if (preferred && preferred !== 'blank' && detected.has(preferred)) { - return preferred + const resolved = resolveDefaultTuiAgentPreference(settings as GlobalSettings | null | undefined) + if (resolved.kind === 'builtin' && detected.has(resolved.agent)) { + return { agent: resolved.agent, customProfile: null } + } + if (resolved.kind === 'custom' && detected.has(resolved.agent)) { + return { agent: resolved.agent, customProfile: resolved.profile } } for (const entry of AGENT_CATALOG) { if (detected.has(entry.id)) { - return entry.id + return { agent: entry.id, customProfile: null } } } return null @@ -205,6 +219,7 @@ export async function launchWorkItemDirect(args: LaunchWorkItemDirectArgs): Prom let primaryTabId: string | null let startupPlan: ReturnType = null let effectiveAgent: TuiAgent | null = null + let effectiveCustomProfile: CustomAgentProfile | null = null let draftLaunchedNatively = false try { const result = await store.createWorktree( @@ -219,7 +234,9 @@ export async function launchWorkItemDirect(args: LaunchWorkItemDirectArgs): Prom const worktreePath = result.worktree.path const detectedIds = new Set(await detectedAgentsPromise) - effectiveAgent = pickAgent(settings?.defaultTuiAgent, detectedIds) + const picked = pickAgent(settings, detectedIds) + effectiveAgent = picked?.agent ?? null + effectiveCustomProfile = picked?.customProfile ?? null const draftContent = item.pasteContent ?? item.url // Why: agents that gate first-launch behind a "Do you trust this folder?" @@ -257,7 +274,8 @@ export async function launchWorkItemDirect(args: LaunchWorkItemDirectArgs): Prom agent: effectiveAgent, draft: draftContent, cmdOverrides: settings?.agentCmdOverrides ?? {}, - platform: CLIENT_PLATFORM + platform: CLIENT_PLATFORM, + customProfile: effectiveCustomProfile }) if (draftLaunchPlan) { startupPlan = { @@ -273,7 +291,8 @@ export async function launchWorkItemDirect(args: LaunchWorkItemDirectArgs): Prom prompt: '', cmdOverrides: settings?.agentCmdOverrides ?? {}, platform: CLIENT_PLATFORM, - allowEmptyPromptLaunch: true + allowEmptyPromptLaunch: true, + customProfile: effectiveCustomProfile }) } diff --git a/src/renderer/src/lib/tui-agent-startup.test.ts b/src/renderer/src/lib/tui-agent-startup.test.ts index 046fe5ef29c..3fdcd5e9fc7 100644 --- a/src/renderer/src/lib/tui-agent-startup.test.ts +++ b/src/renderer/src/lib/tui-agent-startup.test.ts @@ -144,6 +144,48 @@ describe('buildAgentStartupPlan', () => { followupPrompt: null }) }) + + it('uses customProfile command + env shell prefix and ignores per-agent cmdOverrides', () => { + expect( + buildAgentStartupPlan({ + agent: 'claude', + prompt: 'Fix the bug', + cmdOverrides: { claude: '/should/be/ignored' }, + platform: 'darwin', + customProfile: { + id: 'p1', + label: 'Claude (zai)', + baseAgent: 'claude', + command: 'claude', + env: { ANTHROPIC_BASE_URL: 'http://localhost:1234' } + } + }) + ).toEqual({ + agent: 'claude', + launchCommand: "ANTHROPIC_BASE_URL='http://localhost:1234' claude 'Fix the bug'", + expectedProcess: 'claude', + followupPrompt: null + }) + }) + + it('quotes single quotes in env values via the POSIX close-reopen trick', () => { + expect( + buildAgentStartupPlan({ + agent: 'claude', + prompt: '', + cmdOverrides: {}, + platform: 'linux', + allowEmptyPromptLaunch: true, + customProfile: { + id: 'p2', + label: 'Claude (tricky)', + baseAgent: 'claude', + command: 'claude', + env: { TOKEN: "a'b" } + } + })?.launchCommand + ).toBe("TOKEN='a'\\''b' claude") + }) }) describe('buildAgentDraftLaunchPlan', () => { @@ -198,6 +240,28 @@ describe('buildAgentDraftLaunchPlan', () => { expectedProcess: 'claude' }) }) + + it('uses customProfile command + env shell prefix for the prefill flag', () => { + expect( + buildAgentDraftLaunchPlan({ + agent: 'claude', + draft: 'review this', + cmdOverrides: { claude: '/should/be/ignored' }, + platform: 'linux', + customProfile: { + id: 'p1', + label: 'Claude (zai)', + baseAgent: 'claude', + command: 'claude', + env: { ANTHROPIC_BASE_URL: 'http://localhost:1234' } + } + }) + ).toEqual({ + agent: 'claude', + launchCommand: "ANTHROPIC_BASE_URL='http://localhost:1234' claude --prefill 'review this'", + expectedProcess: 'claude' + }) + }) }) describe('isShellProcess', () => { diff --git a/src/renderer/src/lib/tui-agent-startup.ts b/src/renderer/src/lib/tui-agent-startup.ts index ac053735814..1e6f759e1e9 100644 --- a/src/renderer/src/lib/tui-agent-startup.ts +++ b/src/renderer/src/lib/tui-agent-startup.ts @@ -1,5 +1,6 @@ import { TUI_AGENT_CONFIG } from '../../../shared/tui-agent-config' -import type { TuiAgent } from '../../../shared/types' +import { resolveCustomAgentBaseCommand } from '../../../shared/custom-agent-profile' +import type { CustomAgentProfile, TuiAgent } from '../../../shared/types' export type AgentStartupPlan = { /** Why: surfaces the agent id so downstream paste-draft logic can resolve @@ -31,11 +32,26 @@ export function buildAgentStartupPlan(args: { cmdOverrides: Partial> platform: NodeJS.Platform allowEmptyPromptLaunch?: boolean + /** Why: when set, the launch command comes from the profile (with its env + * vars rendered as a shell prefix) instead of the catalog default + the + * generic per-agent override. The base agent (passed via `agent`) still + * drives prompt-injection mode, telemetry kind, and paste-draft logic, so + * custom profiles ride the existing flow without a new code path. */ + customProfile?: CustomAgentProfile | null }): AgentStartupPlan | null { - const { agent, prompt, cmdOverrides, platform, allowEmptyPromptLaunch = false } = args + const { + agent, + prompt, + cmdOverrides, + platform, + allowEmptyPromptLaunch = false, + customProfile = null + } = args const trimmedPrompt = prompt.trim() const config = TUI_AGENT_CONFIG[agent] - const baseCommand = cmdOverrides[agent] ?? config.launchCmd + const baseCommand = customProfile + ? resolveCustomAgentBaseCommand(customProfile, platform) + : (cmdOverrides[agent] ?? config.launchCmd) if (!trimmedPrompt) { if (!allowEmptyPromptLaunch) { @@ -121,8 +137,11 @@ export function buildAgentDraftLaunchPlan(args: { draft: string cmdOverrides: Partial> platform: NodeJS.Platform + /** Same role as in `buildAgentStartupPlan`: when set, the launch command + * comes from the profile + env shell prefix. */ + customProfile?: CustomAgentProfile | null }): AgentDraftLaunchPlan | null { - const { agent, draft, cmdOverrides, platform } = args + const { agent, draft, cmdOverrides, platform, customProfile = null } = args const config = TUI_AGENT_CONFIG[agent] if (!config.draftPromptFlag) { return null @@ -131,7 +150,9 @@ export function buildAgentDraftLaunchPlan(args: { if (!trimmed) { return null } - const baseCommand = cmdOverrides[agent] ?? config.launchCmd + const baseCommand = customProfile + ? resolveCustomAgentBaseCommand(customProfile, platform) + : (cmdOverrides[agent] ?? config.launchCmd) const quoted = quoteStartupArg(trimmed, platform) return { agent, diff --git a/src/renderer/src/store/slices/ui.ts b/src/renderer/src/store/slices/ui.ts index 0acce050518..8aacd2604ee 100644 --- a/src/renderer/src/store/slices/ui.ts +++ b/src/renderer/src/store/slices/ui.ts @@ -30,7 +30,9 @@ import { revokeCustomSidekickBlobUrl } from '../../components/sidekick/sidekick- import { isGitRepoKind } from '../../../../shared/repo-kind' function clampSidekickSize(size: number): number { - if (!Number.isFinite(size)) {return SIDEKICK_SIZE_DEFAULT} + if (!Number.isFinite(size)) { + return SIDEKICK_SIZE_DEFAULT + } return Math.max(SIDEKICK_SIZE_MIN, Math.min(SIDEKICK_SIZE_MAX, Math.round(size))) } @@ -189,6 +191,10 @@ export type UISlice = { url: string } | null agent: TuiAgent + /** Optional custom-agent profile id selected in the picker. When set, + * the launch flow uses the profile's command + env instead of the + * catalog default for `agent` (which equals the profile's baseAgent). */ + customAgentId?: string | null linkedIssue: string linkedPR: number | null // Why: repo-scoped start ref selected via the "Start from" picker. @@ -639,7 +645,9 @@ export const createUISlice: StateCreator = (set, get) // index no longer references it. window.api.sidekick.delete(id, target.fileName, target.kind).catch(console.error) const partial: Partial = { customSidekicks: next } - if (fallback !== s.sidekickId) {partial.sidekickId = fallback} + if (fallback !== s.sidekickId) { + partial.sidekickId = fallback + } return partial }), diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 0f9a3e96326..b3b6882a1e6 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -189,6 +189,7 @@ export function getDefaultSettings(homedir: string): GlobalSettings { opencodeWorkspaceId: '', geminiCliOAuthEnabled: false, agentCmdOverrides: {}, + customAgents: [], // Why: 'auto' runs a layout-aware probe at boot (see // src/renderer/src/lib/keyboard-layout/*) that picks 'true' for US and // US-International and 'false' for every other layout. This mirrors diff --git a/src/shared/custom-agent-profile.test.ts b/src/shared/custom-agent-profile.test.ts new file mode 100644 index 00000000000..531b999cf7b --- /dev/null +++ b/src/shared/custom-agent-profile.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from 'vitest' +import { buildEnvShellPrefix, resolveCustomAgentBaseCommand } from './custom-agent-profile' + +describe('buildEnvShellPrefix', () => { + it('returns empty when env is undefined or empty', () => { + expect(buildEnvShellPrefix(undefined, 'linux')).toBe('') + expect(buildEnvShellPrefix({}, 'linux')).toBe('') + }) + + it('quotes values with POSIX single quotes on linux/darwin', () => { + expect(buildEnvShellPrefix({ FOO: 'bar' }, 'linux')).toBe("FOO='bar' ") + expect(buildEnvShellPrefix({ A: 'a', B: 'b' }, 'darwin')).toBe("A='a' B='b' ") + }) + + it('escapes single quotes via close-reopen on POSIX', () => { + // Why: this is the only way to embed a literal `'` inside a single-quoted + // POSIX string. Regression test guards against accidental switch to a + // simpler-but-wrong escape (e.g. backslash, which single quotes don't honor). + expect(buildEnvShellPrefix({ T: "a'b" }, 'linux')).toBe("T='a'\\''b' ") + }) + + it('uses double-quote-doubled values on win32', () => { + expect(buildEnvShellPrefix({ FOO: 'a"b' }, 'win32')).toBe('FOO="a""b" ') + }) + + it('drops empty keys', () => { + expect(buildEnvShellPrefix({ '': 'x', K: 'v' }, 'linux')).toBe("K='v' ") + }) +}) + +describe('resolveCustomAgentBaseCommand', () => { + it('prepends the env prefix to the user command', () => { + expect( + resolveCustomAgentBaseCommand( + { + id: 'p1', + label: 'Claude (zai)', + baseAgent: 'claude', + command: 'claude --dangerously-skip-permissions', + env: { ANTHROPIC_BASE_URL: 'http://localhost:1234' } + }, + 'linux' + ) + ).toBe("ANTHROPIC_BASE_URL='http://localhost:1234' claude --dangerously-skip-permissions") + }) + + it('returns just the command when no env is set', () => { + expect( + resolveCustomAgentBaseCommand( + { + id: 'p1', + label: 'Claude (default)', + baseAgent: 'claude', + command: 'claude' + }, + 'linux' + ) + ).toBe('claude') + }) +}) diff --git a/src/shared/custom-agent-profile.ts b/src/shared/custom-agent-profile.ts new file mode 100644 index 00000000000..29223a51ae6 --- /dev/null +++ b/src/shared/custom-agent-profile.ts @@ -0,0 +1,51 @@ +import type { CustomAgentProfile } from './types' + +/** Quote a single env-var value for the user's interactive shell. + * - On POSIX shells (bash/zsh/fish) the launch is wrapped as `KEY='value' cmd…`. + * Single-quote everything and escape embedded single quotes via the classic + * `'\''` close-reopen trick. + * - On Windows we go through PowerShell or CMD; both honor `$env:KEY=...` / + * `set "KEY=…"` syntaxes, but the existing startup-command path already + * feeds shell-evaluated strings, so we use the cross-platform-safe form + * `KEY="value"` with embedded double quotes escaped as `""`. PowerShell + * accepts `KEY=value` as a positional env-prefix only via `cmd /c`, so on + * Windows callers should set `command` to a full `cmd /c …` string when + * they need env vars; the prefix model below stays POSIX-style and we + * surface a UI hint for Windows users in the settings pane. + */ +function quoteEnvValuePosix(value: string): string { + return `'${value.replace(/'/g, `'\\''`)}'` +} + +function quoteEnvValueWindows(value: string): string { + return `"${value.replace(/"/g, '""')}"` +} + +/** Build the `KEY1='v1' KEY2='v2' ` shell prefix for a profile's env map. + * Returns an empty string when the map is missing or empty so callers can + * unconditionally concatenate. */ +export function buildEnvShellPrefix( + env: Record | undefined, + platform: NodeJS.Platform +): string { + if (!env) { + return '' + } + const entries = Object.entries(env).filter(([key]) => key.length > 0) + if (entries.length === 0) { + return '' + } + const quote = platform === 'win32' ? quoteEnvValueWindows : quoteEnvValuePosix + return `${entries.map(([key, value]) => `${key}=${quote(value)}`).join(' ')} ` +} + +/** Resolve the base launch command for a custom profile: env prefix + user + * command. Returns a single shell-evaluated string, e.g. + * `ANTHROPIC_BASE_URL='http://localhost:1234' claude`. */ +export function resolveCustomAgentBaseCommand( + profile: CustomAgentProfile, + platform: NodeJS.Platform +): string { + const prefix = buildEnvShellPrefix(profile.env, platform) + return `${prefix}${profile.command}`.trimStart() +} diff --git a/src/shared/types.ts b/src/shared/types.ts index f83802006d8..606af4f68e7 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -994,6 +994,28 @@ export type TuiAgent = | 'hermes' // Hermes Agent | 'copilot' // GitHub Copilot CLI +/** A user-defined CLI agent profile — a named variant of a built-in TuiAgent + * with its own launch command and optional env vars. Surfaced in the agent + * picker alongside built-ins; resolved by `id` at launch time. */ +export type CustomAgentProfile = { + /** Stable opaque id (uuid). Referenced by `defaultTuiAgent` and composer state. */ + id: string + /** Display label shown in the picker, e.g. "Claude (zai)". */ + label: string + /** Built-in agent this profile inherits prompt-injection mode, icon, and + * telemetry kind from. The profile's `command` replaces the catalog + * `launchCmd` for this profile only. */ + baseAgent: TuiAgent + /** Full launch command, e.g. `claude --dangerously-skip-permissions`. + * May include shell metacharacters; runs in the user's interactive shell + * via the existing startup-command path. */ + command: string + /** Optional env vars merged into the launch as a `KEY='value' …` shell + * prefix. Stored as a record so the settings UI can edit individual keys + * without parsing arbitrary shell. */ + env?: Record +} + export type TaskViewPresetId = 'all' | 'issues' | 'review' | 'my-issues' | 'my-prs' | 'prs' /** Where the repo setup script runs when a worktree is created. @@ -1153,8 +1175,11 @@ export type GlobalSettings = { /** Which agent to pre-select in the new-workspace composer. * - null: auto (first detected agent) * - 'blank': blank terminal (no agent launched) - * - TuiAgent: a specific agent id */ - defaultTuiAgent: TuiAgent | 'blank' | null + * - TuiAgent: a specific built-in agent id + * - { kind: 'custom', id }: a user-defined custom agent profile (see + * `customAgents`). Resolved by id at launch; if the id no longer + * exists the picker falls back to auto. */ + defaultTuiAgent: TuiAgent | 'blank' | { kind: 'custom'; id: string } | null /** Why: worktree deletion is destructive (git worktree remove + rm -rf of the * working directory), so Orca shows a confirmation dialog by default. Users * who delete frequently can opt into skipping the dialog via a "Don't ask @@ -1187,6 +1212,12 @@ export type GlobalSettings = { geminiCliOAuthEnabled: boolean /** Per-agent CLI command overrides. A missing key means use the catalog default binary name. */ agentCmdOverrides: Partial> + /** User-defined CLI agent profiles. Each profile is a named variant of a + * built-in `baseAgent` with its own launch command and optional env vars, + * e.g. "Claude (zai)" pointing the same `claude` binary at a different + * ANTHROPIC_BASE_URL. Inherits the base agent's prompt-injection mode, + * icon, and telemetry kind so the launch flow stays unchanged. */ + customAgents: CustomAgentProfile[] /** Why: macOS terminals must choose between letting Option compose layout * characters (@ on German, € on French) or treating Option as Meta/Esc for * readline shortcuts. Mirrors Ghostty's macos-option-as-alt setting — and