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:
nwparker
2026-05-05 17:38:58 -07:00
co-authored by Orca
parent a0002f8e82
commit 5fea5492f0
19 changed files with 1062 additions and 72 deletions
@@ -93,6 +93,7 @@ function createSettings(overrides: Partial<GlobalSettings> = {}): GlobalSettings
opencodeWorkspaceId: '',
geminiCliOAuthEnabled: false,
agentCmdOverrides: {},
customAgents: [],
terminalMacOptionAsAlt: 'false',
terminalMacOptionAsAltMigrated: true,
experimentalAgentDashboard: false,
+1
View File
@@ -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"
+51 -16
View File
@@ -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
+29 -10
View File
@@ -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', () => {
+26 -5
View File
@@ -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,
+10 -2
View File
@@ -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
}),
+1
View File
@@ -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
+60
View File
@@ -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')
})
})
+51
View File
@@ -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
View File
@@ -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