mirror of
https://github.com/stablyai/orca.git
synced 2026-10-12 16:03:03 +00:00
feat(agents): custom CLI agent profiles (#1473)
Adds user-defined CLI agent profiles \u2014 named variants of a built-in
TuiAgent with their own launch command and optional env vars. Surfaced
in the agent picker (new-workspace composer + tab-bar quick launch)
alongside built-ins, and in Settings \u2192 Agents with add/edit/delete.
A profile inherits its baseAgent's prompt-injection mode, icon, trust
preflight, and telemetry kind, so the existing launch flow handles
custom agents without a parallel code path. Env vars render as a POSIX
shell prefix (`KEY='value' \u2026`) on the launch command, leveraging the
existing shell-evaluated startup-command path \u2014 no PTY-spawn changes.
defaultTuiAgent widens to also accept { kind: 'custom', id }; a stale
custom default falls back to auto-pick (matches uninstalled-builtin
behavior). Quick-launch dropdown only surfaces custom profiles whose
baseAgent is detected on PATH so the menu can't recommend a stalled
launch.
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
@@ -93,6 +93,7 @@ function createSettings(overrides: Partial<GlobalSettings> = {}): GlobalSettings
|
||||
opencodeWorkspaceId: '',
|
||||
geminiCliOAuthEnabled: false,
|
||||
agentCmdOverrides: {},
|
||||
customAgents: [],
|
||||
terminalMacOptionAsAlt: 'false',
|
||||
terminalMacOptionAsAltMigrated: true,
|
||||
experimentalAgentDashboard: false,
|
||||
|
||||
@@ -87,6 +87,7 @@ function createSettings(overrides: Partial<GlobalSettings> = {}): GlobalSettings
|
||||
opencodeWorkspaceId: '',
|
||||
geminiCliOAuthEnabled: false,
|
||||
agentCmdOverrides: {},
|
||||
customAgents: [],
|
||||
terminalMacOptionAsAlt: 'false',
|
||||
terminalMacOptionAsAltMigrated: true,
|
||||
experimentalAgentDashboard: false,
|
||||
|
||||
@@ -33,6 +33,10 @@ type NewWorkspaceComposerCardProps = {
|
||||
nameInputRef?: React.RefObject<HTMLInputElement | null>
|
||||
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({
|
||||
</div>
|
||||
<AgentCombobox
|
||||
agents={visibleQuickAgents}
|
||||
value={quickAgent}
|
||||
onValueChange={onQuickAgentChange}
|
||||
customAgents={customAgents}
|
||||
value={
|
||||
quickCustomAgentId
|
||||
? (() => {
|
||||
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}
|
||||
|
||||
@@ -124,6 +124,9 @@ function QuickTabBody({
|
||||
const [quickAgentOverride, setQuickAgentOverride] = useState<TuiAgent | null | undefined>(
|
||||
undefined
|
||||
)
|
||||
const [quickCustomAgentOverride, setQuickCustomAgentOverride] = useState<
|
||||
string | null | undefined
|
||||
>(undefined)
|
||||
const preferredQuickAgent = useMemo<TuiAgent | null>(() => {
|
||||
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<string | null>(() => {
|
||||
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<void> => {
|
||||
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()}
|
||||
|
||||
@@ -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<HTMLButtonElement | null>(null)
|
||||
|
||||
const selectedAgent = useMemo<AgentCatalogEntry | null>(
|
||||
() => (value ? (agents.find((agent) => agent.id === value) ?? null) : null),
|
||||
const selectedBuiltin = useMemo<AgentCatalogEntry | null>(
|
||||
() =>
|
||||
value.kind === 'builtin' ? (agents.find((agent) => agent.id === value.agent) ?? null) : null,
|
||||
[agents, value]
|
||||
)
|
||||
const selectedCustom = useMemo<CustomAgentProfile | null>(
|
||||
() => (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<HTMLInputElement>(
|
||||
'[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 ? (
|
||||
<span className="inline-flex min-w-0 items-center gap-1.5">
|
||||
<AgentIcon agent={selectedAgent.id} />
|
||||
<span className="truncate">{selectedAgent.label}</span>
|
||||
<AgentIcon agent={selectedBuiltin.id} />
|
||||
<span className="truncate">{selectedBuiltin.label}</span>
|
||||
</span>
|
||||
) : selectedCustom ? (
|
||||
<span className="inline-flex min-w-0 items-center gap-1.5">
|
||||
<AgentIcon agent={selectedCustom.baseAgent} />
|
||||
<span className="truncate">{selectedCustom.label}</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex min-w-0 items-center gap-1.5">
|
||||
@@ -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: <Terminal className="size-3.5" />,
|
||||
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: <AgentIcon agent={agent.id} />,
|
||||
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: (
|
||||
<span className="relative inline-flex">
|
||||
<AgentIcon agent={profile.baseAgent} />
|
||||
<Wrench
|
||||
className="absolute -right-1 -bottom-1 size-2 rounded-sm bg-background p-[1px] text-muted-foreground"
|
||||
aria-hidden
|
||||
/>
|
||||
</span>
|
||||
),
|
||||
label: profile.label
|
||||
})
|
||||
})}
|
||||
</CommandList>
|
||||
{onOpenManageAgents ? (
|
||||
<div className="border-t border-border">
|
||||
|
||||
@@ -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 (
|
||||
<div className="space-y-8">
|
||||
@@ -313,9 +328,52 @@ export function AgentsPane({ settings, updateSettings }: AgentsPaneProps): React
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* 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 (
|
||||
<button
|
||||
key={`custom:${profile.id}`}
|
||||
type="button"
|
||||
onClick={() => setDefault({ kind: 'custom', id: profile.id })}
|
||||
className={cn(
|
||||
'flex items-center gap-2 rounded-xl border px-3 py-2 text-sm transition-all',
|
||||
isActive
|
||||
? 'border-foreground/20 bg-foreground/8 font-medium ring-1 ring-foreground/15'
|
||||
: 'border-border/50 bg-muted/30 text-muted-foreground hover:border-border hover:bg-muted/50 hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
<span className="relative inline-flex">
|
||||
<AgentIcon agent={profile.baseAgent} size={14} />
|
||||
<Wrench
|
||||
className="absolute -right-1 -bottom-1 size-2 rounded-sm bg-background p-[1px] text-muted-foreground"
|
||||
aria-hidden
|
||||
/>
|
||||
</span>
|
||||
{profile.label}
|
||||
{isActive && <Check className="size-3.5" />}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<CustomAgentsSection
|
||||
customAgents={customAgents}
|
||||
onChange={(next) => 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 && (
|
||||
<section className="space-y-3">
|
||||
|
||||
@@ -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<CustomAgentProfile, 'env'> & {
|
||||
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<string, string> = {}
|
||||
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<DraftRow[]>(() => customAgents.map(profileToDraft))
|
||||
const [editingId, setEditingId] = useState<string | null>(null)
|
||||
const lastExternalRef = useRef<CustomAgentProfile[]>(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<DraftRow>): 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 (
|
||||
<section className="space-y-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-sm font-semibold">Custom Agents</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
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)'
|
||||
: ''}
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
<AddProfileButton onAdd={addProfile} />
|
||||
</div>
|
||||
|
||||
{drafts.length === 0 ? (
|
||||
<div className="rounded-xl border border-dashed border-border/50 px-4 py-6 text-center text-xs text-muted-foreground">
|
||||
No custom agents yet. Click <span className="font-medium">Add</span> to define one — for
|
||||
example, a Claude profile pointed at a self-hosted endpoint via{' '}
|
||||
<code className="rounded bg-muted/40 px-1 py-0.5 font-mono">ANTHROPIC_BASE_URL</code>.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{drafts.map((draft) => {
|
||||
const isEditing = editingId === draft.id
|
||||
const isDefault = defaultCustomAgentId === draft.id
|
||||
return (
|
||||
<div
|
||||
key={draft.id}
|
||||
className={cn(
|
||||
'rounded-xl border bg-card/60 transition-all',
|
||||
isEditing ? 'border-foreground/30' : 'border-border/60'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-3 px-4 py-3">
|
||||
<div className="relative flex size-8 shrink-0 items-center justify-center rounded-lg border border-border/50 bg-background/60">
|
||||
<AgentIcon agent={draft.baseAgent} size={18} />
|
||||
<Wrench
|
||||
className="absolute -right-1 -bottom-1 size-3 rounded-sm bg-background p-[1px] text-muted-foreground"
|
||||
aria-hidden
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
{isEditing ? (
|
||||
<Input
|
||||
value={draft.label}
|
||||
onChange={(e) => updateDraft(draft.id, { label: e.target.value })}
|
||||
placeholder="e.g. Claude (zai)"
|
||||
className="h-7 text-sm"
|
||||
/>
|
||||
) : (
|
||||
<div className="truncate text-sm font-semibold leading-none">
|
||||
{draft.label || <span className="text-muted-foreground">Unnamed</span>}
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-0.5 truncate font-mono text-[11px] text-muted-foreground">
|
||||
{draft.command || <span className="italic">no command</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
{!isEditing && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSetDefault(draft.id)}
|
||||
title={isDefault ? 'Default agent' : 'Set as default'}
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-xs font-medium transition-colors',
|
||||
isDefault
|
||||
? 'bg-foreground/10 text-foreground ring-1 ring-foreground/20'
|
||||
: 'text-muted-foreground hover:bg-muted/60 hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
{isDefault ? 'Default' : 'Set default'}
|
||||
</button>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
onClick={() => (isEditing ? finishEdit(draft.id) : setEditingId(draft.id))}
|
||||
className="h-7 px-2 text-xs"
|
||||
>
|
||||
{isEditing ? 'Done' : 'Edit'}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => removeProfile(draft.id)}
|
||||
title="Delete profile"
|
||||
className="size-7 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isEditing && (
|
||||
<div className="space-y-3 border-t border-border/40 px-4 py-3">
|
||||
<label className="block">
|
||||
<span className="text-xs text-muted-foreground">Base agent</span>
|
||||
<select
|
||||
value={draft.baseAgent}
|
||||
onChange={(e) =>
|
||||
updateDraft(draft.id, { baseAgent: e.target.value as TuiAgent })
|
||||
}
|
||||
className="mt-1 h-8 w-full rounded-md border border-input bg-background px-2 text-xs"
|
||||
>
|
||||
{baseAgentOptions.map((opt) => (
|
||||
<option key={opt.id} value={opt.id}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="mt-1 text-[11px] text-muted-foreground">
|
||||
Inherits prompt-injection mode, icon, and trust preflight from this built-in
|
||||
agent.
|
||||
</p>
|
||||
</label>
|
||||
|
||||
<label className="block">
|
||||
<span className="text-xs text-muted-foreground">Command</span>
|
||||
<Input
|
||||
value={draft.command}
|
||||
onChange={(e) => updateDraft(draft.id, { command: e.target.value })}
|
||||
placeholder="claude"
|
||||
spellCheck={false}
|
||||
className="mt-1 h-7 font-mono text-xs"
|
||||
/>
|
||||
<p className="mt-1 text-[11px] text-muted-foreground">
|
||||
Shell command used to launch the agent. Env vars below are prepended as a
|
||||
shell prefix (POSIX quoting) before this command.
|
||||
</p>
|
||||
</label>
|
||||
|
||||
<div className="space-y-1">
|
||||
<span className="text-xs text-muted-foreground">Environment variables</span>
|
||||
<div className="space-y-1.5">
|
||||
{draft.envPairs.map((pair, idx) => (
|
||||
<div key={idx} className="flex items-center gap-2">
|
||||
<Input
|
||||
value={pair.key}
|
||||
onChange={(e) => {
|
||||
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"
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">=</span>
|
||||
<Input
|
||||
value={pair.value}
|
||||
onChange={(e) => {
|
||||
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"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => {
|
||||
const envPairs = draft.envPairs.filter((_, i) => i !== idx)
|
||||
updateDraft(draft.id, {
|
||||
envPairs:
|
||||
envPairs.length === 0 ? [{ key: '', value: '' }] : envPairs
|
||||
})
|
||||
}}
|
||||
title="Remove env var"
|
||||
className="size-7 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<Trash2 className="size-3" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
onClick={() =>
|
||||
updateDraft(draft.id, {
|
||||
envPairs: [...draft.envPairs, { key: '', value: '' }]
|
||||
})
|
||||
}
|
||||
className="h-7 text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<Plus className="size-3" /> Add variable
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={() => finishEdit(draft.id)}
|
||||
disabled={!draft.label.trim() || !draft.command.trim()}
|
||||
className="h-7 text-xs"
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function AddProfileButton({ onAdd }: { onAdd: (baseAgent: TuiAgent) => void }): React.JSX.Element {
|
||||
const [open, setOpen] = useState(false)
|
||||
return (
|
||||
<div className="relative">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="h-8 gap-1.5 text-xs"
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
Add
|
||||
</Button>
|
||||
{open && (
|
||||
<div
|
||||
// Why: lightweight inline picker to choose the base agent up front
|
||||
// (so users don't open an Edit row pre-filled with the wrong one).
|
||||
// A full shadcn Popover here would pull more focus management than
|
||||
// necessary for a one-click base-agent select.
|
||||
className="absolute right-0 top-9 z-30 max-h-72 w-56 overflow-y-auto rounded-lg border border-border/60 bg-popover p-1 text-popover-foreground shadow-md"
|
||||
onMouseLeave={() => setOpen(false)}
|
||||
>
|
||||
{AGENT_CATALOG.map((agent) => (
|
||||
<button
|
||||
key={agent.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onAdd(agent.id)
|
||||
setOpen(false)
|
||||
}}
|
||||
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-xs hover:bg-muted/60"
|
||||
>
|
||||
<AgentIcon agent={agent.id} size={14} />
|
||||
<span>Based on {agent.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
{agents.length === 0 ? (
|
||||
{agents.length === 0 && visibleCustomAgents.length === 0 ? (
|
||||
<DropdownMenuItem
|
||||
disabled
|
||||
className="gap-2 rounded-[7px] px-2 py-1.5 text-[12px] leading-5 text-muted-foreground"
|
||||
@@ -130,6 +141,17 @@ function QuickLaunchAgentMenuItemsInner({
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
})}
|
||||
{visibleCustomAgents.map((profile) => (
|
||||
<DropdownMenuItem
|
||||
key={`custom:${profile.id}`}
|
||||
onSelect={() => 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`}
|
||||
>
|
||||
<AgentIcon agent={profile.baseAgent} size={14} />
|
||||
{profile.label}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
<DropdownMenuItem
|
||||
onSelect={openAgentSettings}
|
||||
className="gap-2 rounded-[7px] px-2 py-1.5 text-[12px] leading-5 font-medium text-muted-foreground"
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
} from '@/lib/github-links'
|
||||
import { activateAndRevealWorktree, type AgentStartedTelemetry } from '@/lib/worktree-activation'
|
||||
import { buildAgentDraftLaunchPlan, buildAgentStartupPlan } from '@/lib/tui-agent-startup'
|
||||
import { findCustomAgentProfile } from '@/lib/custom-agent-resolve'
|
||||
import { TUI_AGENT_CONFIG } from '../../../shared/tui-agent-config'
|
||||
import { tuiAgentToAgentKind } from '@/lib/telemetry'
|
||||
import { isGitRepoKind } from '../../../shared/repo-kind'
|
||||
@@ -110,6 +111,12 @@ export type ComposerCardProps = {
|
||||
onSelectLinkedItem: (item: GitHubWorkItem) => 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<TuiAgent> | null
|
||||
onOpenAgentSettings: () => void
|
||||
advancedOpen: boolean
|
||||
@@ -158,7 +165,7 @@ export type UseComposerStateResult = {
|
||||
promptTextareaRef: React.RefObject<HTMLTextAreaElement | null>
|
||||
nameInputRef: React.RefObject<HTMLInputElement | null>
|
||||
submit: () => Promise<void>
|
||||
submitQuick: (agent: TuiAgent | null) => Promise<void>
|
||||
submitQuick: (agent: TuiAgent | null, customAgentId?: string | null) => Promise<void>
|
||||
/** 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<TuiAgent>(
|
||||
persistDraft ? (newWorkspaceDraft?.agent ?? fallbackDefaultAgent) : fallbackDefaultAgent
|
||||
)
|
||||
const [customAgentId, setCustomAgentId] = useState<string | null>(
|
||||
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<void> => {
|
||||
async (agent: TuiAgent | null, quickCustomAgentId: string | null = null): Promise<void> => {
|
||||
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<typeof buildAgentStartupPlan> = 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,
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
): 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<typeof buildAgentStartupPlan> = 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
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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<Record<TuiAgent, string>>
|
||||
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<Record<TuiAgent, string>>
|
||||
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,
|
||||
|
||||
@@ -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<AppState, [], [], UISlice> = (set, get)
|
||||
// index no longer references it.
|
||||
window.api.sidekick.delete(id, target.fileName, target.kind).catch(console.error)
|
||||
const partial: Partial<UISlice> = { customSidekicks: next }
|
||||
if (fallback !== s.sidekickId) {partial.sidekickId = fallback}
|
||||
if (fallback !== s.sidekickId) {
|
||||
partial.sidekickId = fallback
|
||||
}
|
||||
return partial
|
||||
}),
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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<string, string> | 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()
|
||||
}
|
||||
+33
-2
@@ -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<string, string>
|
||||
}
|
||||
|
||||
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<Record<TuiAgent, string>>
|
||||
/** 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
|
||||
|
||||
Reference in New Issue
Block a user