Use the shared agent picker for terminal quick commands (#38213)

Use the shared searchable agent picker in Quick Commands while preserving the full catalog, disabled-agent explanations, independent form drafts and saved commands.

Keep dialog search focused through opening and host-preference reloads, and avoid setup-only cleanup for agent-only menus.
This commit is contained in:
Brennan Benson
2026-11-04 19:27:44 -08:00
committed by GitHub
parent 5f294246e9
commit 43d7555343
8 changed files with 571 additions and 268 deletions
@@ -0,0 +1,127 @@
// @vitest-environment happy-dom
import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { fireEvent, getByRole, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { beforeEach, afterEach, expect, it, vi } from 'vitest'
import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog'
import { TooltipProvider } from '@/components/ui/tooltip'
import { AgentLaunchPicker } from './AgentLaunchPicker'
import type { NewTabAgentMenuModel } from '../tab-bar/use-new-tab-agent-menu-rows'
import * as inventoryModule from './use-agent-launch-picker-inventory'
import type { AgentPickerEntry } from '../../../../shared/agent-launch/picker/agent-launch-picker'
vi.mock('@/hooks/useShortcutLabel', () => ({ useOptionalShortcutLabel: () => null }))
const entry: AgentPickerEntry = {
agent: 'codex',
label: 'Codex',
rows: [{ agent: 'codex', kind: 'chat' }],
surfaces: ['chat', 'terminal'],
defaultSurface: 'chat',
terminal: {},
structured: {},
catalog: {
origin: 'probe',
fetchedAt: 1,
models: [
{ id: 'astra', label: 'Astra', isDefault: false, efforts: [{ value: 'low', label: 'Low' }] }
]
}
}
let root: Root
let host: HTMLDivElement
let owner = 0
const launch = vi.fn()
beforeEach(() => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
entry.ownerKey = `trigger-test-${owner++}`
launch.mockClear()
host = document.createElement('div')
document.body.append(host)
root = createRoot(host)
})
afterEach(() => {
act(() => root.unmount())
host.remove()
vi.restoreAllMocks()
vi.unstubAllGlobals()
})
it('keeps continuous trigger typing in the search inside a dialog', async () => {
const claude: AgentPickerEntry = {
...entry,
agent: 'claude',
label: 'Claude',
rows: [{ agent: 'claude', kind: 'cli' }]
}
const entries = [entry, claude]
vi.spyOn(inventoryModule, 'useAgentLaunchPickerInventory').mockReturnValue([])
const model = {
state: 'settled' as const,
rows: entries.flatMap((entry) => entry.rows),
detection: {
detectedIds: entries.map((entry) => entry.agent),
isLoading: false,
isRefreshing: false,
detectionFailed: false,
needsServerUpdate: false,
refresh: async () => entries.map((entry) => entry.agent)
}
}
const show = (currentModel: NewTabAgentMenuModel = model, currentEntries = entries) =>
act(() =>
root.render(
<TooltipProvider>
<Dialog open>
<DialogContent>
<DialogTitle>Agent selection</DialogTitle>
<AgentLaunchPicker
model={currentModel}
entries={currentEntries}
agent="codex"
onPick={launch}
/>
</DialogContent>
</Dialog>
</TooltipProvider>
)
)
show()
const trigger = getByRole(document.body, 'button', { name: 'Select agent' })
const search = () =>
document.querySelector<HTMLInputElement>('input[placeholder="Search agents and setups…"]')
fireEvent.keyDown(trigger, { key: 'c', metaKey: true })
expect(search()).toBeNull()
const user = userEvent.setup()
act(() => trigger.focus())
await user.keyboard('c')
expect(document.activeElement).toBe(search())
expect(search()?.value).toBe('c')
expect(search()?.selectionStart).toBe(1)
await user.keyboard('o')
expect(document.activeElement).toBe(search())
expect(search()?.value).toBe('co')
expect(search()?.selectionStart).toBe(2)
expect(document.querySelector('[aria-label="Configure Claude"]')).toBeNull()
expect(launch).not.toHaveBeenCalled()
await user.keyboard('{Enter}')
expect(launch).toHaveBeenCalledOnce()
expect(launch.mock.calls[0][0]).toBe('codex')
await waitFor(() => expect(document.activeElement).toBe(trigger))
await user.click(trigger)
expect(document.activeElement).toBe(search())
expect(search()?.value).toBe('')
await user.keyboard('{ArrowDown}')
expect(document.activeElement).toBe(getByRole(document.body, 'menuitem', { name: /^Codex/ }))
show(model, [...entries])
expect(document.activeElement).toBe(getByRole(document.body, 'menuitem', { name: /^Codex/ }))
show({ state: 'loading', placeholderCount: 2 })
expect(search()).toBeNull()
show()
await waitFor(() => expect(document.activeElement).toBe(search()))
await user.keyboard('co')
expect(search()?.value).toBe('co')
expect(search()?.selectionStart).toBe(2)
await user.keyboard('{Escape}')
await waitFor(() => expect(document.activeElement).toBe(trigger))
})
@@ -89,12 +89,21 @@ export const AgentLaunchPicker = memo(
const ownDraftScope = useAgentPickerDraftScope()
const [settingsOpen, setSettingsOpen] = useState(false)
const trigger = useRef<HTMLButtonElement>(null)
const searchInput = useRef<HTMLInputElement>(null)
const popupFocusReady = useRef(false)
const setSearchInput = useCallback((input: HTMLInputElement | null) => {
searchInput.current = input
if (popupFocusReady.current) {
input?.focus({ preventScroll: true })
}
}, [])
const [open, setOpenState] = useState(false)
// Why: typing on the closed trigger opens the menu with that key already in the search field.
const [initialQuery, setInitialQuery] = useState('')
const setOpen = useCallback(
(next: boolean) => {
if (!next) {
popupFocusReady.current = false
setInitialQuery('')
}
setOpenState(next)
@@ -209,6 +218,13 @@ export const AgentLaunchPicker = memo(
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
onOpenAutoFocus={(event) => {
popupFocusReady.current = true
if (searchInput.current) {
event.preventDefault()
searchInput.current.focus({ preventScroll: true })
}
}}
className={
matchTriggerWidth
? 'w-(--radix-dropdown-menu-trigger-width) min-w-(--radix-dropdown-menu-trigger-width)'
@@ -222,6 +238,7 @@ export const AgentLaunchPicker = memo(
model={model}
entries={entries}
initialQuery={initialQuery}
searchInputRef={setSearchInput}
value={value}
draftForEntry={draftForEntry}
mode="select"
@@ -1,57 +1,23 @@
import { selectWorktreesByRepo, selectFolderWorkspaces } from '@/store/workspace-record-selectors'
import { memo, useEffect, useId, useRef, useState, type ComponentProps } from 'react'
import { useEffect, useRef, useState, type ComponentProps, type Ref } from 'react'
import { useAppStore } from '@/store/index'
import { parseExecutionHostId } from '../../../../shared/execution-host'
import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
import {
DropdownMenuItem,
DropdownMenuShortcut,
useDropdownMenuClose
} from '@/components/ui/dropdown-menu'
import { AgentIcon } from '@/lib/agent-catalog'
import { AgentPickerMenuRow, type AgentPickerMenuLaunch } from './AgentLaunchPickerMenuRow'
import { DropdownMenuItem } from '@/components/ui/dropdown-menu'
import { translate } from '@/i18n/i18n'
import { useUiLanguageRevision } from '@/i18n/use-ui-language-revision'
import {
AgentLaunchSetupButton,
retireAgentPickerDrafts,
type AgentPickerSetupIdentity
} from './AgentLaunchSetupButton'
import { applyAgentPickerEdit } from '../../../../shared/agent-launch/picker/agent-launch-picker-edits'
import { retireAgentPickerDrafts, type AgentPickerSetupIdentity } from './AgentLaunchSetupButton'
import type { NewTabAgentMenuModel } from '../tab-bar/use-new-tab-agent-menu-rows'
import { chatUnavailableLabel } from '../tab-bar/tab-agent-row-presentation'
import type { NewTabAgentMenuRow } from '../../../../shared/agent-launch/picker/agent-launch-picker-rows'
import {
useAgentPickerInventoryEntries,
type AgentPickerInventorySource
} from './use-agent-launch-picker-inventory'
import { searchAgentPickerSetups } from '../../../../shared/agent-launch/picker/agent-launch-picker-search'
import {
agentPickerDraft,
type AgentPickerDraft,
type AgentPickerEntry,
type AgentPickerSurface
import type {
AgentPickerDraft,
AgentPickerEntry
} from '../../../../shared/agent-launch/picker/agent-launch-picker'
export function pickerLaunchRow(
entry: AgentPickerEntry,
surface: AgentPickerSurface
): NewTabAgentMenuRow {
return {
agent: entry.agent,
kind: surface === 'chat' ? 'chat' : 'cli',
executionHostId: entry.rows[0]?.executionHostId
}
}
type Launch = (
row: NewTabAgentMenuRow,
draft?: AgentPickerDraft,
entry?: AgentPickerEntry,
/** Select mode: the surface explicitly accepted by a named launch action. */
surface?: AgentPickerSurface
) => boolean | void | Promise<boolean>
export function AgentLaunchPickerMenu({
draftScope,
shortcutLabels,
@@ -64,6 +30,7 @@ export function AgentLaunchPickerMenu({
entries,
hideSearch,
initialQuery = '',
searchInputRef,
onSelectAgent
}: {
model?: NewTabAgentMenuModel
@@ -74,14 +41,19 @@ export function AgentLaunchPickerMenu({
mode?: 'launch' | 'select'
value?: AgentPickerDraft
draftForEntry?: (entry: AgentPickerEntry) => AgentPickerDraft
runLaunch: Launch
runLaunch: AgentPickerMenuLaunch
entries: AgentPickerEntry[]
hideSearch?: boolean
/** Seeds the search field; read once when the menu mounts. */
initialQuery?: string
/** A supplied ref gives the owning popup control of initial focus. */
searchInputRef?: Ref<HTMLInputElement>
onSelectAgent?: (agent: AgentPickerEntry['agent']) => void
}) {
useEffect(() => {
if (setupDisabled) {
return
}
const state = useAppStore.getState()
const workspaces = new Set([
...Object.values(selectWorktreesByRepo(state) ?? {})
@@ -105,7 +77,7 @@ export function AgentLaunchPickerMenu({
},
mode
)
}, [entries, draftScope, mode])
}, [entries, draftScope, mode, setupDisabled])
const [query, setQuery] = useState(initialQuery)
const [activeSetup, setActiveSetup] = useState<{
id: string
@@ -156,9 +128,10 @@ export function AgentLaunchPickerMenu({
{!hideSearch ? (
<div className="px-1 py-0.5">
<Input
ref={searchInputRef}
value={query}
density="compact"
autoFocus
autoFocus={!searchInputRef}
onFocus={(event) => {
// Why: a seeded query must keep the caret after the typed key.
const end = event.currentTarget.value.length
@@ -253,159 +226,3 @@ export function SubscribedAgentLaunchPickerMenu({
const entries = useAgentPickerInventoryEntries(inventory)
return <AgentLaunchPickerMenu {...props} entries={entries} />
}
// Why: catalog and seed reads land one agent at a time after the menu opens; only that row re-renders.
const AgentPickerMenuRow = memo(function AgentPickerMenuRow({
entry,
draftScope,
draftId,
initialModel,
initialEffort,
openSetup,
onSetupOpenChange,
label = entry.label,
source,
selected,
mode,
setupDisabled,
disabled,
runLaunch,
shortcut,
onSelectAgent
}: {
entry: AgentPickerEntry
draftScope?: object
draftId?: string
initialModel?: string
initialEffort?: string
openSetup: string | null
onSetupOpenChange: (
setup: {
id: string
agent: AgentPickerEntry['agent']
preset?: AgentPickerSetupIdentity
label: string
} | null
) => void
label?: string
source?: AgentPickerDraft
selected?: AgentPickerDraft
mode?: 'launch' | 'select'
setupDisabled?: boolean
disabled?: boolean
runLaunch: Launch
shortcut?: string
onSelectAgent?: (agent: AgentPickerEntry['agent']) => void
}) {
useUiLanguageRevision()
const setupId = useId()
const preset = initialModel ? { model: initialModel, effort: initialEffort } : undefined
const initial = preset
? applyAgentPickerEdit(entry, selected ?? source ?? agentPickerDraft(entry), {
kind: 'choice',
patch: {
model: preset.model,
...(preset.effort ? { options: { effort: preset.effort } } : {})
}
})
: undefined
const closeMenu = useDropdownMenuClose()
const unavailable = entry.rows.find((row) => row.kind === 'chat-unavailable')
if (setupDisabled) {
return (
<DropdownMenuItem
disabled={disabled || !entry.surfaces.length}
onSelect={() => {
onSelectAgent?.(entry.agent)
runLaunch(pickerLaunchRow(entry, selected?.surface ?? entry.defaultSurface))
}}
>
<AgentIcon agent={entry.agent} size={14} />
<span className="min-w-0 flex-1 truncate">{label}</span>
{shortcut ? <DropdownMenuShortcut>{shortcut}</DropdownMenuShortcut> : null}
</DropdownMenuItem>
)
}
return (
<AgentLaunchSetupButton
submenu
draftScope={draftScope}
draftId={draftId}
preset={preset}
entry={entry}
initial={source}
seed={initial ?? selected ?? source}
surface={selected?.surface}
mode={mode}
open={openSetup === setupId}
replacedByAnotherSetup={openSetup !== null && openSetup !== setupId}
disabled={disabled || setupDisabled || !entry.surfaces.length}
onOpenChange={(next) => {
if (next || openSetup === setupId) {
onSetupOpenChange(next ? { id: setupId, agent: entry.agent, preset, label } : null)
}
if (next && openSetup !== setupId) {
onSelectAgent?.(entry.agent)
}
}}
onUse={(draft) => {
const chosen = mode === 'select' ? draft.surface : undefined
const result =
chosen === undefined
? runLaunch(pickerLaunchRow(entry, draft.surface), draft, entry)
: runLaunch(pickerLaunchRow(entry, draft.surface), draft, entry, chosen)
if (result !== false) {
closeMenu()
}
return result
}}
renderAnchor={(trigger) => (
<div
data-agent-launch-row
onClick={(event) => {
event.preventDefault()
if (disabled || !entry.surfaces.length) {
return
}
const draft = initial ?? (mode === 'select' ? selected : undefined)
const surface = draft?.surface ?? entry.defaultSurface
const row = draft
? pickerLaunchRow(entry, surface)
: (entry.rows.find((row) =>
surface === 'chat'
? row.kind === 'chat'
: row.kind === 'terminal' || row.kind === 'cli'
) ?? pickerLaunchRow(entry, surface))
const result = draft ? runLaunch(row, draft, entry) : runLaunch(row, undefined)
if (result !== false) {
closeMenu()
}
}}
onKeyDown={(event) => {
if (
event.target === event.currentTarget &&
(event.key === 'Enter' || event.key === ' ')
) {
event.preventDefault()
event.currentTarget.click()
}
}}
>
<Button asChild variant="menu-launch" size="menu">
<span>
<AgentIcon agent={entry.agent} size={14} />
<span className="min-w-0 flex-1 truncate">{label}</span>
{shortcut ? <DropdownMenuShortcut>{shortcut}</DropdownMenuShortcut> : null}
{unavailable?.kind === 'chat-unavailable' && !entry.surfaces.includes('chat') ? (
<span className="text-xs text-muted-foreground">
{chatUnavailableLabel(unavailable.reason)}
</span>
) : null}
</span>
</Button>
{trigger}
</div>
)}
/>
)
})
@@ -0,0 +1,196 @@
import { memo, useId } from 'react'
import { Button } from '@/components/ui/button'
import {
DropdownMenuItem,
DropdownMenuShortcut,
useDropdownMenuClose
} from '@/components/ui/dropdown-menu'
import { AgentIcon } from '@/lib/agent-catalog'
import { useUiLanguageRevision } from '@/i18n/use-ui-language-revision'
import { AgentLaunchSetupButton, type AgentPickerSetupIdentity } from './AgentLaunchSetupButton'
import { applyAgentPickerEdit } from '../../../../shared/agent-launch/picker/agent-launch-picker-edits'
import { chatUnavailableLabel } from '../tab-bar/tab-agent-row-presentation'
import type { NewTabAgentMenuRow } from '../../../../shared/agent-launch/picker/agent-launch-picker-rows'
import {
agentPickerDraft,
type AgentPickerDraft,
type AgentPickerEntry,
type AgentPickerSurface
} from '../../../../shared/agent-launch/picker/agent-launch-picker'
function pickerLaunchRow(entry: AgentPickerEntry, surface: AgentPickerSurface): NewTabAgentMenuRow {
return {
agent: entry.agent,
kind: surface === 'chat' ? 'chat' : 'cli',
executionHostId: entry.rows[0]?.executionHostId
}
}
export type AgentPickerMenuLaunch = (
row: NewTabAgentMenuRow,
draft?: AgentPickerDraft,
entry?: AgentPickerEntry,
/** Select mode: the surface explicitly accepted by a named launch action. */
surface?: AgentPickerSurface
) => boolean | void | Promise<boolean>
// Why: catalog and seed reads land one agent at a time after the menu opens; only that row re-renders.
export const AgentPickerMenuRow = memo(function AgentPickerMenuRow({
entry,
draftScope,
draftId,
initialModel,
initialEffort,
openSetup,
onSetupOpenChange,
label = entry.label,
source,
selected,
mode,
setupDisabled,
disabled,
runLaunch,
shortcut,
onSelectAgent
}: {
entry: AgentPickerEntry
draftScope?: object
draftId?: string
initialModel?: string
initialEffort?: string
openSetup: string | null
onSetupOpenChange: (
setup: {
id: string
agent: AgentPickerEntry['agent']
preset?: AgentPickerSetupIdentity
label: string
} | null
) => void
label?: string
source?: AgentPickerDraft
selected?: AgentPickerDraft
mode?: 'launch' | 'select'
setupDisabled?: boolean
disabled?: boolean
runLaunch: AgentPickerMenuLaunch
shortcut?: string
onSelectAgent?: (agent: AgentPickerEntry['agent']) => void
}) {
useUiLanguageRevision()
const setupId = useId()
const preset = initialModel ? { model: initialModel, effort: initialEffort } : undefined
const initial = preset
? applyAgentPickerEdit(entry, selected ?? source ?? agentPickerDraft(entry), {
kind: 'choice',
patch: {
model: preset.model,
...(preset.effort ? { options: { effort: preset.effort } } : {})
}
})
: undefined
const closeMenu = useDropdownMenuClose()
const unavailable = entry.rows.find((row) => row.kind === 'chat-unavailable')
if (setupDisabled) {
return (
<DropdownMenuItem
disabled={disabled || !entry.surfaces.length}
onSelect={() => {
onSelectAgent?.(entry.agent)
runLaunch(pickerLaunchRow(entry, selected?.surface ?? entry.defaultSurface))
}}
>
<AgentIcon agent={entry.agent} size={14} />
<span className="flex min-w-0 flex-1 flex-col">
<span className="truncate">{label}</span>
{entry.description ? (
<span className="truncate text-xs text-muted-foreground">{entry.description}</span>
) : null}
</span>
{shortcut ? <DropdownMenuShortcut>{shortcut}</DropdownMenuShortcut> : null}
</DropdownMenuItem>
)
}
return (
<AgentLaunchSetupButton
submenu
draftScope={draftScope}
draftId={draftId}
preset={preset}
entry={entry}
initial={source}
seed={initial ?? selected ?? source}
surface={selected?.surface}
mode={mode}
open={openSetup === setupId}
replacedByAnotherSetup={openSetup !== null && openSetup !== setupId}
disabled={disabled || setupDisabled || !entry.surfaces.length}
onOpenChange={(next) => {
if (next || openSetup === setupId) {
onSetupOpenChange(next ? { id: setupId, agent: entry.agent, preset, label } : null)
}
if (next && openSetup !== setupId) {
onSelectAgent?.(entry.agent)
}
}}
onUse={(draft) => {
const chosen = mode === 'select' ? draft.surface : undefined
const result =
chosen === undefined
? runLaunch(pickerLaunchRow(entry, draft.surface), draft, entry)
: runLaunch(pickerLaunchRow(entry, draft.surface), draft, entry, chosen)
if (result !== false) {
closeMenu()
}
return result
}}
renderAnchor={(trigger) => (
<div
data-agent-launch-row
onClick={(event) => {
event.preventDefault()
if (disabled || !entry.surfaces.length) {
return
}
const draft = initial ?? (mode === 'select' ? selected : undefined)
const surface = draft?.surface ?? entry.defaultSurface
const row = draft
? pickerLaunchRow(entry, surface)
: (entry.rows.find((row) =>
surface === 'chat'
? row.kind === 'chat'
: row.kind === 'terminal' || row.kind === 'cli'
) ?? pickerLaunchRow(entry, surface))
const result = draft ? runLaunch(row, draft, entry) : runLaunch(row, undefined)
if (result !== false) {
closeMenu()
}
}}
onKeyDown={(event) => {
if (
event.target === event.currentTarget &&
(event.key === 'Enter' || event.key === ' ')
) {
event.preventDefault()
event.currentTarget.click()
}
}}
>
<Button asChild variant="menu-launch" size="menu">
<span>
<AgentIcon agent={entry.agent} size={14} />
<span className="min-w-0 flex-1 truncate">{label}</span>
{shortcut ? <DropdownMenuShortcut>{shortcut}</DropdownMenuShortcut> : null}
{unavailable?.kind === 'chat-unavailable' && !entry.surfaces.includes('chat') ? (
<span className="text-xs text-muted-foreground">
{chatUnavailableLabel(unavailable.reason)}
</span>
) : null}
</span>
</Button>
{trigger}
</div>
)}
/>
)
})
@@ -1,4 +1,11 @@
import { useId, type Dispatch, type MutableRefObject, type SetStateAction } from 'react'
import {
useCallback,
useId,
useMemo,
type Dispatch,
type MutableRefObject,
type SetStateAction
} from 'react'
import type { TerminalQuickCommand } from '../../../../shared/terminal/quick-commands/terminal-quick-command-types'
import type { TuiAgent } from '../../../../shared/agent-launch/catalog/tui-agent'
import {
@@ -10,14 +17,10 @@ import {
type TerminalQuickCommandBodySize
} from '../../../../shared/terminal/quick-commands/terminal-quick-command-prompt-limit'
import { Label } from '@/components/ui/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from '@/components/ui/select'
import { AgentIcon } from '@/lib/agent-catalog'
import { AgentLaunchPicker } from '@/components/agent/AgentLaunchPicker'
import type { AgentPickerEntry } from '../../../../shared/agent-launch/picker/agent-launch-picker'
import type { NewTabAgentMenuModel } from '../tab-bar/new-tab-agent-menu-model'
import { useUiLanguageRevision } from '@/i18n/use-ui-language-revision'
import { cn } from '@/lib/utils'
import { getIntlLocale, translate } from '@/i18n/i18n'
import { getTerminalQuickCommandAgentOptions } from './terminal-quick-command-agent-options'
@@ -25,6 +28,18 @@ import type { TerminalQuickCommandDialogDraftMemory } from './terminal-quick-com
import { TerminalQuickCommandAppendEnterSwitch } from './TerminalQuickCommandAppendEnterSwitch'
const QUICK_COMMAND_AGENT_OPTIONS = getTerminalQuickCommandAgentOptions()
const QUICK_COMMAND_PICKER_MODEL: NewTabAgentMenuModel = {
state: 'settled',
rows: QUICK_COMMAND_AGENT_OPTIONS.map(({ id }) => ({ agent: id, kind: 'cli' })),
detection: {
detectedIds: [],
isLoading: false,
isRefreshing: false,
detectionFailed: false,
needsServerUpdate: false,
refresh: async () => []
}
}
// Why: long pastes are the common way to hit the cap, so the count appears before Save disables.
const BODY_LENGTH_COUNTER_THRESHOLD = 0.8
@@ -79,6 +94,38 @@ export function TerminalQuickCommandContentSection({
setDraft,
toggleAppendEnter
}: TerminalQuickCommandContentSectionProps): React.JSX.Element {
useUiLanguageRevision()
const unsupportedLabel = translate(
'auto.components.terminal.quick.commands.TerminalQuickCommandDialog.026cfb232a',
'Does not support prompt commands'
)
const entries = useMemo<AgentPickerEntry[]>(
() =>
QUICK_COMMAND_AGENT_OPTIONS.map(({ id, label }) => {
const supported = supportsTerminalAgentQuickCommand(id)
return {
agent: id,
label,
description: supported ? undefined : unsupportedLabel,
rows: [{ agent: id, kind: 'cli' }],
surfaces: supported ? ['terminal'] : [],
defaultSurface: 'terminal',
terminal: {},
structured: {},
catalog: { origin: 'unknown' }
}
}),
[unsupportedLabel]
)
const selectAgent = useCallback(
(nextAgent: TuiAgent) => {
draftMemoryRef.current = { ...draftMemoryRef.current, agent: nextAgent }
setDraft((current) =>
isTerminalAgentQuickCommand(current) ? { ...current, agent: nextAgent } : current
)
},
[draftMemoryRef, setDraft]
)
const bodyTooLong = bodySize.used > bodySize.max
const showBodyLength = bodySize.used >= bodySize.max * BODY_LENGTH_COUNTER_THRESHOLD
const lengthId = useId()
@@ -124,58 +171,18 @@ export function TerminalQuickCommandContentSection({
'Agent'
)}
</Label>
<Select
value={selectedAgent}
<AgentLaunchPicker
model={QUICK_COMMAND_PICKER_MODEL}
entries={entries}
agent={selectedAgent}
emptyLabel={translate(
'auto.components.terminal.quick.commands.TerminalQuickCommandDialog.346d409ab2',
'Choose agent'
)}
setupDisabled
disabled={disabled || !isAgentAction}
onValueChange={(agent) => {
const nextAgent = agent as TuiAgent
draftMemoryRef.current = {
...draftMemoryRef.current,
agent: nextAgent
}
setDraft((current) =>
isTerminalAgentQuickCommand(current) ? { ...current, agent: nextAgent } : current
)
}}
>
<SelectTrigger>
<SelectValue
placeholder={translate(
'auto.components.terminal.quick.commands.TerminalQuickCommandDialog.346d409ab2',
'Choose agent'
)}
/>
</SelectTrigger>
<SelectContent
position="popper"
side="bottom"
align="start"
sideOffset={4}
className="max-h-[min(20rem,var(--radix-select-content-available-height))] w-[--radix-select-trigger-width]"
>
{QUICK_COMMAND_AGENT_OPTIONS.map((entry) => {
const supported = supportsTerminalAgentQuickCommand(entry.id)
return (
<SelectItem key={entry.id} value={entry.id} disabled={disabled || !supported}>
<span className="flex min-w-0 items-center gap-2">
<AgentIcon agent={entry.id} size={16} />
<span className="flex min-w-0 flex-col">
<span className="truncate">{entry.label}</span>
{!supported ? (
<span className="truncate text-xs text-muted-foreground">
{translate(
'auto.components.terminal.quick.commands.TerminalQuickCommandDialog.026cfb232a',
'Does not support prompt commands'
)}
</span>
) : null}
</span>
</span>
</SelectItem>
)
})}
</SelectContent>
</Select>
onPick={selectAgent}
/>
</div>
</div>
</div>
@@ -2,10 +2,18 @@
import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { fireEvent, getByRole } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { TerminalQuickCommand } from '../../../../shared/terminal/quick-commands/terminal-quick-command-types'
import { LEGACY_MAX_QUICK_COMMAND_AGENT_PROMPT_LENGTH } from '../../../../shared/terminal/quick-commands/terminal-quick-command-prompt-limit'
import { TerminalQuickCommandDialog } from './TerminalQuickCommandDialog'
import { useAppStore } from '@/store'
import {
makeFolderWorkspace,
makeWorktree,
TEST_REPO
} from '@/store/slices/worktrees-slice-test-fixtures'
const mountedRoots: Root[] = []
@@ -192,13 +200,13 @@ describe('TerminalQuickCommandDialog animation structure', () => {
})
}
const agentSelect = document.body.querySelector<HTMLButtonElement>('[role="combobox"]')!
const agentSelect = getByRole<HTMLButtonElement>(document.body, 'button', {
name: 'Select agent'
})
await submit(agentSelect)
expect(agentSelect.getAttribute('data-state')).toBe('open')
expect(onSave).not.toHaveBeenCalled()
const codex = Array.from(document.body.querySelectorAll<HTMLElement>('[role="option"]')).find(
(option) => option.textContent?.trim() === 'Codex'
)!
const codex = getByRole(document.body, 'menuitem', { name: 'Codex' })
expect(textarea.closest('fieldset')?.contains(codex)).toBe(false)
await submit(codex)
expect(onSave).not.toHaveBeenCalled()
@@ -208,9 +216,12 @@ describe('TerminalQuickCommandDialog animation structure', () => {
await submit()
expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ agent: 'codex', prompt }))
expect(agentSelect.disabled).toBe(true)
const options = Array.from(document.body.querySelectorAll<HTMLElement>('[role="option"]'))
expect(options.length).toBeGreaterThan(0)
expect(options.every((option) => option.getAttribute('aria-disabled') === 'true')).toBe(true)
const options = Array.from(document.body.querySelectorAll<HTMLElement>('[role="menuitem"]'))
expect(
options
.filter((option) => !option.textContent?.includes('Agent settings'))
.every((option) => option.getAttribute('aria-disabled') === 'true')
).toBe(true)
await act(async () => options.find((option) => option.textContent?.includes('Claude'))?.click())
expect(agentSelect.textContent).toContain('Codex')
expect(textarea.closest('fieldset')?.disabled).toBe(true)
@@ -246,4 +257,128 @@ describe('TerminalQuickCommandDialog animation structure', () => {
expect(onSave).toHaveBeenLastCalledWith(expect.objectContaining({ prompt: updatedPrompt }))
expect(onOpenChange).toHaveBeenCalledWith(false)
})
it('keeps shell and agent drafts independent and saves only the selected agent and prompt', async () => {
const onSave = vi.fn(() => false)
await renderDialog(
{
id: 'qc-picker',
label: 'Review',
action: 'terminal-command',
command: 'git diff\n',
appendEnter: false,
scope: { type: 'global' }
},
{ onSave }
)
await act(async () =>
fireEvent.click(getByRole(document.body, 'radio', { name: 'Agent Prompt' }))
)
const prompt = getByRole(document.body, 'textbox', { name: 'Prompt' })
await act(async () =>
fireEvent.change(prompt, { target: { value: 'Review @src\n/goal careful' } })
)
const trigger = getByRole(document.body, 'button', { name: 'Select agent' })
await act(async () => fireEvent.keyDown(trigger, { key: 'Enter' }))
const unsupported = getByRole(document.body, 'menuitem', { name: /^Aider/ })
expect(unsupported.getAttribute('aria-disabled')).toBe('true')
await act(async () => fireEvent.click(unsupported))
expect(onSave).not.toHaveBeenCalled()
await act(async () => fireEvent.keyDown(getByRole(document.body, 'menu'), { key: 'Escape' }))
const user = userEvent.setup()
act(() => trigger.focus())
await user.keyboard('co')
const search = document.querySelector<HTMLInputElement>(
'input[placeholder="Search agents and setups…"]'
)!
expect(document.activeElement).toBe(search)
expect(search.value).toBe('co')
expect(search.selectionStart).toBe(2)
await user.keyboard('{Enter}')
expect(onSave).not.toHaveBeenCalled()
await act(async () =>
fireEvent.click(getByRole(document.body, 'radio', { name: 'Terminal Command' }))
)
expect(
getByRole<HTMLTextAreaElement>(document.body, 'textbox', { name: 'Command' }).value
).toBe('git diff\n')
expect(
getByRole(document.body, 'switch', { name: 'Toggle append Enter' }).getAttribute(
'aria-checked'
)
).toBe('false')
await act(async () =>
fireEvent.click(getByRole(document.body, 'radio', { name: 'Agent Prompt' }))
)
await act(async () => fireEvent.click(getByRole(document.body, 'button', { name: /^Save/ })))
expect(onSave).toHaveBeenCalledExactlyOnceWith({
id: 'qc-picker',
label: 'Review',
action: 'agent-prompt',
agent: 'codex',
prompt: 'Review @src\n/goal careful',
scope: { type: 'global' }
})
})
it.each([100, 1000])(
'opens the agent-only picker without visiting %i unrelated records',
async (count) => {
const before = useAppStore.getState()
let visits = 0
const tracked = <T extends { id: string }>(record: T): T => {
const id = record.id
return Object.defineProperty(record, 'id', {
get: () => {
visits++
return id
}
})
}
useAppStore.setState({
worktreesByRepo: {
'repo-1': Array.from({ length: count }, (_, i) =>
tracked(makeWorktree({ id: `wt-${i}`, repoId: 'repo-1' }))
)
},
folderWorkspaces: Array.from({ length: count }, (_, i) =>
tracked(makeFolderWorkspace({ id: `folder-${i}` }))
),
repos: Array.from({ length: count }, (_, i) => tracked({ ...TEST_REPO, id: `repo-${i}` }))
})
try {
await renderDialog({
id: 'qc-perf',
label: 'Review',
action: 'agent-prompt',
agent: 'claude',
prompt: 'Review',
scope: { type: 'global' }
})
visits = 0
await act(async () =>
fireEvent.keyDown(getByRole(document.body, 'button', { name: 'Select agent' }), {
key: 'Enter'
})
)
expect(visits).toBe(0)
await act(async () =>
fireEvent.keyDown(getByRole(document.body, 'menuitem', { name: 'Codex' }), {
key: 'Enter'
})
)
expect(getByRole(document.body, 'button', { name: 'Select agent' }).textContent).toContain(
'Codex'
)
} finally {
useAppStore.setState({
worktreesByRepo: before.worktreesByRepo,
folderWorkspaces: before.folderWorkspaces,
repos: before.repos
})
}
}
)
})
@@ -59,7 +59,10 @@ function DropdownMenuContent({
sideOffset = 4,
style,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content> & {
// Radix consumes this at its opening focus scope but omits it from the public Menu props.
onOpenAutoFocus?: (event: Event) => void
}) {
const pointerHighlight = useMenuPointerHighlight(props)
const { ref: focusScopeRef, scope } = useOverlayFocusScope(pointerHighlight.ref)
return (
@@ -29,6 +29,7 @@ export type AgentPickerEntry = {
textGeneration?: TextActionPickerCatalog
agent: TuiAgent
label: string
description?: string
aliases?: string[]
rows: NewTabAgentMenuRow[]
surfaces: AgentPickerSurface[]