Choose an agent setup from the new tab menu (#34728)

* Unify dedicated agent launch choices and argument badges

* Enable action model and effort menus with argument migration

* Bound argument choice edit range processing

* Preserve independent action effort through launch and migration

* Preserve literal config arguments and explicit launch permissions

* Respect repository action argument groups and share launch controls

* Remove duplicate action launch command coverage

* Add agent setup search and controls to the new tab menu

* Show inherited choices beside picker reset options

* Keep terminal Arguments separate from shared launch choices

* Launch default agents from rows and select resolved setup values

* Keep terminal settings available while Chat defaults load

* Reuse existing launch setup controls and Arguments editor

* Unify Source Control action choices and repository overrides

* Use precise types in action choice contracts

* Consolidate action choice test imports

* Correct action choice types and integration test boundaries

* Complete prepared terminal test result contract

* Align action integration expectations with canonical choices

* Keep launch defaults authoritative and couple CLI controls

* Assert production Chat preparation runs in launch contract

* Preserve alternate Chat launches and bound picker argument derivation

* Publish committed picker priority and satisfy control contracts

* test(mobile): type the file preview retry test helpers so the test typecheck passes (#35431)

(cherry picked from commit 0774fc9373)

* fix: keep picker integration tests within compiler boundaries

* fix: revalidate completed picker defaults on reopen

* test: give exported launch fixture mocks portable signatures

* fix: keep fallback launch carriers out of action mode controls

* Preserve Fast argument badges outside dialog mode controls

* Project existing Fast carriers independently of model capabilities

* Keep setup focus during launch row pointer transitions

* Allow declared tier choices without an inherited selection

* fix: place agent setup beside the launch menu

* Commit edited action arguments before planning and launch

Reconcile deliberate raw edits once into the submitted action recipe so replacement model and effort flags reach Save and Start together. Preserve captured sources and untouched load conflicts.

* Name portable Source Control dialog fixture results

* Keep agent setup contained and preserve known argument policy

* Use checked style properties in popup geometry fixture

* Keep setup scrolling inside a plain content wrapper

* fix(agent-picker): match the setup panel to the approved layout

Flush model search and list, one settings section with label-left rows
(Effort, compact Permissions menu, Fast mode), a fixed Arguments
disclosure row with its hint, and a summary footer with Launch chat /
Launch terminal. Settled defaults with nothing chosen keep the model
list and Effort pickable. Hovering another menu row no longer closes
the open panel.

* fix(agent-picker): use primitive-owned spacing and update launch labels in tests

* fix(agent-picker): add panel labels to localization catalogs

* Keep one launch setup open and align picker spacing

* Tighten launch model rows and clarify terminal hint

* Keep keyboard focus in replacement launch setup

* fix(tab-bar): keep a long agent setup result's chevron inside the menu

* test(agent-picker): wait for outgoing focus cleanup event

* Show empty model search feedback and name setup dialog

* test: register selection catalog in launch guard fixture

* fix(agent-picker): retain setup drafts and contain Arguments

* fix(agent-picker): retain authored setup route after alternate launch

* style(agent-picker): use a ghost setup chevron with divider

* style(agent-picker): apply muted color to setup glyph

* test(agent-picker): declare retained catalog default
This commit is contained in:
Brennan Benson
2026-10-29 20:17:26 -07:00
committed by GitHub
parent 9a7f76ef28
commit abc8850180
76 changed files with 9176 additions and 886 deletions
@@ -0,0 +1,818 @@
// @vitest-environment happy-dom
import { act } from 'react'
import { fireEvent, getByRole, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { TooltipProvider } from '@/components/ui/tooltip'
import { AgentLaunchSetupButton } from './AgentLaunchSetupButton'
import { AgentLaunchPickerMenu } from './AgentLaunchPickerMenu'
import {
agentPickerChoiceSeeds,
agentPickerLaunchInputs,
type AgentPickerEntry
} from '../../../../shared/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: true, efforts: [{ value: 'low', label: 'Low' }] }
]
}
}
let root: Root
let host: HTMLDivElement
let owner = 0
const launch = vi.fn()
function show(value: AgentPickerEntry, others: AgentPickerEntry[] = []) {
const entries = [value, ...others]
act(() =>
root.render(
<TooltipProvider>
<DropdownMenu defaultOpen modal={false}>
<DropdownMenuTrigger>New tab</DropdownMenuTrigger>
<DropdownMenuContent>
<AgentLaunchPickerMenu
entries={entries}
model={{
state: 'settled',
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)
}
}}
runLaunch={launch}
/>
</DropdownMenuContent>
</DropdownMenu>
</TooltipProvider>
)
)
}
beforeEach(() => {
entry.ownerKey = `menu-test-${owner++}`
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
launch.mockClear()
host = document.createElement('div')
document.body.append(host)
root = createRoot(host)
show(entry)
})
afterEach(() => {
act(() => root.unmount())
host.remove()
vi.restoreAllMocks()
vi.unstubAllGlobals()
})
it.each(['chat', 'terminal'] as const)(
'keeps preferred %s defaults and sparse primary/Enter intent after alternate launch and reopen',
async (preferred) => {
const own: AgentPickerEntry = {
...entry,
agent: 'claude',
label: 'Claude',
ownerKey: `${entry.ownerKey}/preferred-${preferred}`,
defaultSurface: preferred,
arguments: '',
terminal: { permissionMode: 'full-access' },
structured: { model: 'sonnet', options: { effort: 'high' }, permissionMode: 'ask' },
catalog: {
origin: 'probe',
fetchedAt: 1,
listingNamesConfiguredModel: true,
models: ['opus', 'sonnet'].map((id) => ({
id,
label: id,
isDefault: id === 'opus',
defaultEffort: id === 'opus' ? 'low' : 'high',
efforts: [
{ value: 'low', label: 'Low' },
{ value: 'high', label: 'High' }
]
}))
}
}
act(() =>
root.render(
<TooltipProvider>
<AgentLaunchSetupButton entry={own} onUse={launch} />
</TooltipProvider>
)
)
const user = userEvent.setup()
const open = () => user.click(getByRole(document.body, 'button', { name: 'Configure Claude' }))
const expectDefaults = () => {
expect(document.querySelector('[cmdk-item][data-current="true"]')?.textContent).toBe(
preferred === 'chat' ? 'sonnet' : 'opus'
)
expect(
getByRole(document.body, 'radio', {
name: preferred === 'chat' ? 'High' : 'Low'
}).getAttribute('data-state')
).toBe('on')
expect(getByRole(document.body, 'button', { name: 'Claude permissions' }).textContent).toBe(
preferred === 'chat' ? 'Ask for approval' : 'Full access'
)
}
const expectSparse = (surface: 'chat' | 'terminal') => {
const draft = launch.mock.lastCall![0]
expect(draft).toEqual({ agent: 'claude', surface, choices: {}, arguments: undefined })
expect(agentPickerLaunchInputs(draft, own)).toEqual({})
}
await open()
expectDefaults()
const alternate = preferred === 'chat' ? 'terminal' : 'chat'
await user.click(getByRole(document.body, 'button', { name: `Launch ${alternate}` }))
expectSparse(alternate)
for (const method of ['primary', 'Enter']) {
await waitFor(() => expect(document.querySelector('[role="dialog"]')).toBeNull())
await open()
expectDefaults()
if (method === 'primary') {
await user.click(getByRole(document.body, 'button', { name: `Launch ${preferred}` }))
} else {
if (!document.querySelector('textarea')) {
await user.click(getByRole(document.body, 'button', { name: 'Toggle Arguments' }))
}
await user.click(document.querySelector<HTMLTextAreaElement>('textarea')!)
await user.keyboard('{Enter}')
}
expectSparse(preferred)
}
expect(launch).toHaveBeenCalledTimes(3)
}
)
it.each([
{ menuLeft: 700, menuWidth: 280, viewport: 1000, height: 800, side: 'left' },
{ menuLeft: 20, menuWidth: 280, viewport: 1000, height: 800, side: 'right' },
{ menuLeft: 300, menuWidth: 288, viewport: 700, height: 800, side: 'left' },
{ menuLeft: 156, menuWidth: 288, viewport: 600, height: 800, side: 'bottom' },
{ menuLeft: 156, menuWidth: 288, viewport: 600, height: 600, side: 'bottom' }
])(
'contains setup in a $viewport viewport at x=$menuLeft, preferring $side',
async ({ menuLeft, menuWidth, viewport, height, side }) => {
const hydrated: AgentPickerEntry = {
...entry,
arguments: '--add-dir docs',
terminal: { model: 'astra', permissionMode: 'ask' },
structured: { model: 'astra', permissionMode: 'ask' }
}
show(hydrated)
let viewportWidth = viewport
let placementReads = 0
const observers = new Set<ResizeObserverFixture>()
class ResizeObserverFixture implements ResizeObserver {
targets = new Set<Element>()
constructor(readonly callback: ResizeObserverCallback) {
observers.add(this)
}
observe(target: Element) {
this.targets.add(target)
}
unobserve(target: Element) {
this.targets.delete(target)
}
disconnect() {
this.targets.clear()
observers.delete(this)
}
}
vi.stubGlobal('ResizeObserver', ResizeObserverFixture)
// Supply missing browser layout only; the real Popover/Popper computes collision and placement.
const menu = new DOMRect(menuLeft, 80, menuWidth, 240)
const row = new DOMRect(menuLeft + 5, 128, menuWidth - 10, 32)
const trigger = new DOMRect(menuLeft + menuWidth - 32, 132, 24, 24)
const panel = new DOMRect(0, 0, 416, 480)
function geometry(element: Element): DOMRect {
if (
element.matches('[data-slot="popover-content"]') ||
(element.hasAttribute('data-radix-popper-content-wrapper') &&
element.querySelector('[data-slot="popover-content"]'))
) {
const content = element.matches('[data-slot="popover-content"]')
? element
: element.querySelector('[data-slot="popover-content"]')!
const wrapper = content.parentElement!
const available = Number.parseFloat(
wrapper.style.getPropertyValue('--radix-popper-available-width')
)
const capped = content.classList.contains('max-w-(--radix-popover-content-available-width)')
const minimum = content.classList.contains('min-w-64') ? 256 : 0
const availableHeight = Number.parseFloat(
wrapper.style.getPropertyValue('--radix-popper-available-height')
)
const cappedHeight = content.classList.contains(
'max-h-(--radix-popover-content-available-height)'
)
return new DOMRect(
0,
0,
Math.max(
minimum,
Math.min(panel.width, capped && Number.isFinite(available) ? available : panel.width)
),
Math.min(
panel.height,
cappedHeight && Number.isFinite(availableHeight) ? availableHeight : panel.height
)
)
}
if (element.matches('[role="menuitem"]')) {
return element.isConnected ? row : new DOMRect()
}
if (element.matches('[data-slot="popover-trigger"]')) {
return element.isConnected ? trigger : new DOMRect()
}
if (
element.matches('[role="menu"]') ||
element.hasAttribute('data-radix-popper-content-wrapper')
) {
return menu
}
if (element === document.documentElement || element === document.body) {
return new DOMRect(0, 0, viewportWidth, height)
}
return new DOMRect()
}
vi.spyOn(Element.prototype, 'getBoundingClientRect').mockImplementation(function (
this: Element
) {
return geometry(this)
})
vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockImplementation(function (
this: HTMLElement
) {
return geometry(this).width
})
vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockImplementation(function (
this: HTMLElement
) {
return geometry(this).height
})
vi.spyOn(document.documentElement, 'clientWidth', 'get').mockImplementation(() => viewportWidth)
vi.spyOn(document.documentElement, 'clientHeight', 'get').mockReturnValue(height)
const computeStyle = window.getComputedStyle.bind(window)
vi.spyOn(window, 'getComputedStyle').mockImplementation((element, pseudo) => {
const style = computeStyle(element, pseudo)
if (!element.matches('[data-slot="popover-content"]')) {
return style
}
// happy-dom has no Tailwind layout; resolve only the consumer's sizing rules and CSS alias.
const isStyleProperty = (key: PropertyKey): key is keyof CSSStyleDeclaration => key in style
return new Proxy(style, {
get(target, property) {
if (property === 'minWidth' && element.classList.contains('min-w-64')) {
return '256px'
}
if (property === 'getPropertyValue') {
return (name: string) => {
if (name === '--radix-popover-content-available-width') {
placementReads++
return element.parentElement!.style.getPropertyValue(
'--radix-popper-available-width'
)
}
return target.getPropertyValue(name)
}
}
if (!isStyleProperty(property)) {
return undefined
}
const value = target[property]
return typeof value === 'function' ? value.bind(target) : value
}
})
})
act(() =>
document.querySelector<HTMLButtonElement>('button[aria-label="Configure Codex"]')!.click()
)
const content = document.querySelector<HTMLElement>('[data-slot="popover-content"]')!
expect(content.classList.contains('w-104')).toBe(true)
await waitFor(() => {
expect(content.dataset.side).toBe(side)
const transform = content.parentElement!.style.transform.match(
/translate\(([-\d.]+)px, ([-\d.]+)px\)/
)
expect(transform).not.toBeNull()
const x = Number(transform![1])
const y = Number(transform![2])
expect(x).toBeGreaterThanOrEqual(0)
const width = content.offsetWidth
expect(x + width).toBeLessThanOrEqual(viewport)
expect(y).toBeGreaterThanOrEqual(0)
expect(y + content.offsetHeight).toBeLessThanOrEqual(height)
if (side === 'left') {
expect(x + width).toBeLessThanOrEqual(menu.left)
} else if (side === 'right') {
expect(x).toBeGreaterThanOrEqual(menu.right)
} else {
expect(y).toBeGreaterThanOrEqual(row.bottom)
}
if (viewport === 700) {
expect(width).toBe(299)
}
if (viewport === 600) {
expect(width).toBe(panel.width)
}
})
await waitFor(() => expect(placementReads).toBe(1))
const originalTransform = content.parentElement!.style.transform
fireEvent.click(content.querySelector('button[aria-label="Toggle Arguments"]')!)
fireEvent.change(content.querySelector('textarea')!, { target: { value: '--add-dir src' } })
fireEvent.keyDown(content.querySelector('button[aria-label="Codex permissions"]')!, {
key: 'ArrowDown'
})
fireEvent.click(
[...document.querySelectorAll<HTMLElement>('[role="menuitemradio"]')].find((item) =>
item.textContent?.startsWith('Full access')
)!
)
expect(content.querySelector('button[aria-label="Codex permissions"]')?.textContent).toContain(
'Full access'
)
show({ ...hydrated })
expect(document.querySelector('[role="menuitem"]')?.isConnected).toBe(true)
await waitFor(() => {
expect(content.parentElement!.style.transform).toBe(originalTransform)
expect(content.parentElement!.style.getPropertyValue('--radix-popper-anchor-width')).toBe(
`${row.width}px`
)
expect(content.querySelector<HTMLTextAreaElement>('textarea')?.value).toBe('--add-dir src')
})
expect(placementReads).toBe(1)
if (viewport <= 700) {
process.stdout.write(
`${JSON.stringify({ metric: 'picker-contained-layout', viewport, height, menuLeft, side: content.dataset.side, transform: content.parentElement!.style.transform, width: content.offsetWidth, contentHeight: content.offsetHeight, availableWidth: content.parentElement!.style.getPropertyValue('--radix-popper-available-width'), placementReads })}\n`
)
}
function externalResize(width: number, menuX = menu.x) {
act(() => {
viewportWidth = width
menu.x = menuX
row.x = menuX + 5
trigger.x = menuX + menuWidth - 32
fireEvent(window, new Event('resize'))
for (const observer of observers) {
if (
[...observer.targets].some(
(target) => target === document.documentElement || target.matches('[role="menuitem"]')
)
) {
observer.callback([], observer)
}
}
})
}
if (viewport === 700 || (viewport === 600 && height === 800)) {
externalResize(600, 156)
await waitFor(() => {
expect(placementReads).toBe(2)
expect(content.dataset.side).toBe('bottom')
const position = content.parentElement!.style.transform.match(/translate\(([-\d.]+)px,/)
expect(position).not.toBeNull()
expect(Number(position![1])).toBeGreaterThanOrEqual(0)
expect(Number(position![1]) + content.offsetWidth).toBeLessThanOrEqual(viewportWidth)
})
externalResize(1000)
await waitFor(() => {
expect(placementReads).toBe(3)
expect(content.dataset.side).toBe('right')
})
// A panel's own resize must not restart the placement decision.
act(() => {
for (const observer of observers) {
if (observer.targets.has(content) || observer.targets.has(content.parentElement!)) {
observer.callback([], observer)
}
}
})
await waitFor(() => {
expect(placementReads).toBe(3)
expect(content.dataset.side).toBe('right')
})
fireEvent.keyDown(content, { key: 'Escape' })
expect(document.querySelector('[data-slot="popover-content"]')).toBeNull()
expect(
[...observers].some((observer) => observer.targets.has(document.documentElement))
).toBe(false)
fireEvent.click(document.querySelector('button[aria-label="Configure Codex"]')!)
await waitFor(() =>
expect(
document.querySelector<HTMLElement>('[data-slot="popover-content"]')?.dataset.side
).toBe('right')
)
await waitFor(() => expect(placementReads).toBe(4))
}
expect(launch).not.toHaveBeenCalled()
}
)
it('opens setup with a clicked chevron without launching, then dispatches its selected surface once', () => {
const configure = getByRole(document.body, 'button', { name: 'Configure Codex' })
expect(configure?.dataset.variant).toBe('ghost')
expect(configure?.previousElementSibling?.getAttribute('data-orientation')).toBe('vertical')
act(() => configure?.click())
expect(launch).not.toHaveBeenCalled()
expect(document.body.textContent).toContain('Arguments')
expect(getByRole(document.body, 'dialog', { name: 'Configure Codex' })).not.toBeNull()
const terminal = [...document.querySelectorAll<HTMLButtonElement>('button')].find(
(button) => button.textContent === 'Launch terminal'
)
act(() => terminal?.click())
expect(launch).toHaveBeenCalledTimes(1)
expect(launch.mock.calls[0]).toMatchObject([
{ agent: 'codex', kind: 'cli' },
{ agent: 'codex', surface: 'terminal', choices: {} },
entry
])
})
it('keeps setup open while the pointer expands and collapses Arguments', async () => {
const user = userEvent.setup()
await user.click(
document.querySelector<HTMLButtonElement>('button[aria-label="Configure Codex"]')!
)
const panel = document.querySelector('[role="dialog"]')!
expect(panel).not.toBeNull()
expect(panel.querySelector('textarea[aria-label="Arguments"]')).toBeNull()
const toggle = panel.querySelector<HTMLButtonElement>('button[aria-label="Toggle Arguments"]')!
await user.hover(toggle)
expect(document.querySelector('[role="dialog"]')).toBe(panel)
await user.click(toggle)
expect(document.querySelector('[role="dialog"]')).toBe(panel)
expect(panel.querySelector('textarea[aria-label="Arguments"]')).not.toBeNull()
await user.click(toggle)
expect(document.querySelector('[role="dialog"]')).toBe(panel)
expect(panel.querySelector('textarea[aria-label="Arguments"]')).toBeNull()
expect(launch).not.toHaveBeenCalled()
})
it.each(['{Enter}', ' '])(
'keeps setup on hover, replaces it on keyboard %s, and dismisses without launching',
async (key) => {
show(entry, [
{
...entry,
agent: 'claude',
label: 'Claude',
rows: [{ agent: 'claude', kind: 'chat' }]
}
])
const user = userEvent.setup()
await user.click(
document.querySelector<HTMLButtonElement>('button[aria-label="Configure Codex"]')!
)
const panel = document.querySelector('[role="dialog"]')!
expect(panel).not.toBeNull()
const closeAutoFocus = vi.fn()
panel.addEventListener('focusScope.autoFocusOnUnmount', closeAutoFocus, { once: true })
const otherRow = [...document.querySelectorAll<HTMLElement>('[role="menuitem"]')].find(
(row) => row.textContent === 'Claude'
)!
fireEvent.pointerMove(otherRow, { pointerType: 'mouse' })
await waitFor(() => expect(document.activeElement).toBe(otherRow))
expect(document.querySelector('[role="dialog"]')).toBe(panel)
await user.keyboard('{ArrowRight}')
const otherTrigger = document.querySelector<HTMLButtonElement>(
'button[aria-label="Configure Claude"]'
)!
expect(document.activeElement).toBe(otherTrigger)
await user.keyboard(key)
await waitFor(() => expect(document.querySelectorAll('[role="dialog"]')).toHaveLength(1))
const replacement = document.querySelector('[role="dialog"]')!
const search = replacement.querySelector<HTMLInputElement>(
'input[placeholder="Search Claude models…"]'
)!
expect(search).not.toBeNull()
expect(replacement).not.toBe(panel)
await waitFor(() => expect(panel.isConnected).toBe(false))
await waitFor(() => expect(closeAutoFocus).toHaveBeenCalledOnce())
expect(document.activeElement).toBe(search)
await user.keyboard('astra')
expect(search.value).toBe('astra')
expect(document.querySelectorAll('[role="dialog"]')).toHaveLength(1)
await user.keyboard('{Escape}')
await waitFor(() => expect(document.querySelector('[role="dialog"]')).toBeNull())
await waitFor(() => expect(document.activeElement).toBe(otherTrigger))
await user.click(otherTrigger)
expect(document.querySelectorAll('[role="dialog"]')).toHaveLength(1)
fireEvent.pointerDown(document.body, { pointerType: 'mouse', button: 0 })
await waitFor(() => expect(document.querySelector('[role="dialog"]')).toBeNull())
expect(launch).not.toHaveBeenCalled()
}
)
it('keeps model, permission and Fast badge pointer interactions inside setup, but dismisses outside', async () => {
show({
...entry,
agent: 'claude',
label: 'Claude',
rows: [{ agent: 'claude', kind: 'chat' }],
...agentPickerChoiceSeeds('claude', {
agentPermissionDefault: 'own',
agentDefaultArgs: {
claude: `--model opus --settings '{"fastMode":true,"theme":"light"}' --add-dir docs`
}
}),
structured: { model: 'opus', permissionMode: 'own', options: { fastMode: 'true' } },
catalog: {
origin: 'probe',
fetchedAt: 1,
models: [{ id: 'opus', label: 'Opus', isDefault: true, efforts: [], supportsFastMode: true }]
}
})
const user = userEvent.setup()
await user.click(
document.querySelector<HTMLButtonElement>('button[aria-label="Configure Claude"]')!
)
const panel = document.querySelector('[role="dialog"]')!
const model = panel.querySelector<HTMLElement>('[cmdk-item]')!
await user.hover(model)
await user.click(model)
expect(document.querySelector('[role="dialog"]')).toBe(panel)
await user.click(
panel.querySelector<HTMLButtonElement>('button[aria-label="Claude permissions"]')!
)
await user.click(
[...document.querySelectorAll<HTMLElement>('[role="menuitemradio"]')].find((item) =>
item.textContent?.startsWith('Full access')
)!
)
expect(document.querySelector('[role="dialog"]')).toBe(panel)
await user.click(panel.querySelector<HTMLButtonElement>('button[aria-label="Toggle Arguments"]')!)
const chip = panel.querySelector<HTMLButtonElement>('button[aria-label*="Remove --settings"]')!
expect(chip).not.toBeNull()
await user.click(chip)
expect(document.querySelector('[role="dialog"]')).toBe(panel)
expect(panel.querySelector('button[aria-label*="Remove --settings"]')).toBeNull()
expect(panel.querySelector<HTMLTextAreaElement>('textarea')?.value).toContain('"theme":"light"')
await user.click(document.body)
expect(document.querySelector('[role="dialog"]')).toBeNull()
expect(launch).not.toHaveBeenCalled()
})
it('opens a searched setup only from its chevron and launches the alternate way from its footer', () => {
const input = document.querySelector('input')
act(() => {
Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')?.set?.call(
input,
'astra low'
)
input?.dispatchEvent(new Event('input', { bubbles: true }))
})
const row = document.querySelector<HTMLElement>('[role="menuitem"]')!
expect(row.querySelectorAll('button')).toHaveLength(1)
expect(document.querySelector('button[aria-label="Launch terminal"]')).toBeNull()
act(() => row.querySelector<HTMLButtonElement>('button')?.click())
expect(launch).not.toHaveBeenCalled()
const alternate = [...document.querySelectorAll<HTMLButtonElement>('button')].find(
(button) => button.textContent === 'Launch terminal'
)
act(() => alternate?.click())
expect(launch).toHaveBeenCalledTimes(1)
expect(launch.mock.calls[0][1]).toMatchObject({
surface: 'terminal',
choices: { model: 'astra', options: { effort: 'low' } }
})
expect(document.querySelector('[role="menu"]')).toBeNull()
})
it('shows only the chevron and keeps a plain row click free of setup overrides', () => {
const row = document.querySelector<HTMLElement>('[role="menuitem"]')!
expect(
[...row.querySelectorAll('button')].map((button) => button.getAttribute('aria-label'))
).toEqual(['Configure Codex'])
act(() => row.click())
expect(launch).toHaveBeenCalledExactlyOnceWith(entry.rows[0], undefined)
expect(document.querySelector('[role="menu"]')).toBeNull()
})
it('launches the row default with Enter once, without opening setup', () => {
const row = document.querySelector<HTMLElement>('[role="menuitem"]')!
act(() => {
row.focus()
row.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))
})
expect(launch).toHaveBeenCalledExactlyOnceWith(entry.rows[0], undefined)
expect(document.querySelector('input[aria-label="Search models"]')).toBeNull()
expect(document.querySelector('[role="menu"]')).toBeNull()
})
it('still launches today’s default when the row is clicked while its setup is open', async () => {
const user = userEvent.setup()
const row = document.querySelector<HTMLElement>('[role="menuitem"]')!
await user.click(row.querySelector<HTMLButtonElement>('button')!)
expect(launch).not.toHaveBeenCalled()
await user.click(row)
expect(launch).toHaveBeenCalledExactlyOnceWith(entry.rows[0], undefined)
})
it('lets the chevron return keyboard focus to its row without opening or launching', () => {
const configure = document.querySelector<HTMLButtonElement>(
'button[aria-label="Configure Codex"]'
)
act(() => {
configure?.focus()
configure?.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft', bubbles: true }))
})
expect(document.activeElement?.getAttribute('role')).toBe('menuitem')
expect(document.body.textContent).not.toContain('Arguments')
expect(launch).not.toHaveBeenCalled()
})
it('launches the searched default once from Enter', () => {
const input = document.querySelector('input')!
act(() => {
Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')?.set?.call(
input,
'astra low'
)
input.dispatchEvent(new Event('input', { bubbles: true }))
})
act(() =>
input.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true })
)
)
expect(launch).toHaveBeenCalledOnce()
expect(launch.mock.calls[0][1]).toMatchObject({
surface: 'chat',
choices: { model: 'astra', options: { effort: 'low' } }
})
})
it('closes Configure with Escape without dispatch and returns focus to its trigger', async () => {
const configure = document.querySelector<HTMLButtonElement>(
'button[aria-label="Configure Codex"]'
)!
act(() => configure.click())
const search = document.querySelector<HTMLInputElement>('input[aria-label="Search models"]')!
act(() => {
search.focus()
search.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true })
)
})
expect(launch).not.toHaveBeenCalled()
expect(document.querySelector('input[aria-label="Search models"]')).toBeNull()
await waitFor(() => expect(document.activeElement).toBe(configure))
})
it('retains the setup draft on close/reopen and resets an open draft when its owner is replaced', () => {
const own = {
...entry,
ownerKey: 'pair7/runtime1',
defaultSurface: 'terminal' as const,
arguments: '--add-dir docs',
terminal: { permissionMode: 'ask' as const }
}
const show = (value = own) =>
act(() =>
root.render(
<TooltipProvider>
<AgentLaunchSetupButton entry={value} onUse={launch} />
</TooltipProvider>
)
)
show()
const configure = () =>
document.querySelector<HTMLButtonElement>('button[aria-label="Configure Codex"]')!
fireEvent.click(configure())
fireEvent.change(document.querySelector('textarea')!, { target: { value: '--add-dir src' } })
expect(document.querySelector<HTMLTextAreaElement>('textarea')?.value).toBe('--add-dir src')
fireEvent.keyDown(document.querySelector('textarea')!, { key: 'Escape' })
expect(document.querySelector('textarea')).toBeNull()
fireEvent.click(configure())
expect(document.querySelector<HTMLTextAreaElement>('textarea')?.value).toBe('--add-dir src')
fireEvent.change(document.querySelector('textarea')!, {
target: { value: '--add-dir old-owner' }
})
show({ ...own, ownerKey: 'pair8/runtime2', arguments: '--add-dir new-owner' })
expect(document.querySelector<HTMLTextAreaElement>('textarea')?.value).toBe('--add-dir new-owner')
fireEvent.click(
[...document.querySelectorAll('button')].find((b) => b.textContent === 'Launch terminal')!
)
expect(launch).toHaveBeenCalledExactlyOnceWith(
expect.objectContaining({ choices: {}, arguments: undefined })
)
})
it('retains the whole row draft after outside dismissal, menu remount and launch', async () => {
const own: AgentPickerEntry = {
...entry,
agent: 'claude',
label: 'Claude',
ownerKey: 'retained-claude',
defaultSurface: 'terminal',
rows: [{ agent: 'claude', kind: 'cli' }],
arguments: '--add-dir docs',
terminal: { model: 'opus', permissionMode: 'ask' },
catalog: {
origin: 'probe',
fetchedAt: 1,
models: ['opus', 'sonnet'].map((id) => ({
id,
label: id,
isDefault: id === 'opus',
supportsFastMode: true,
efforts: [
{ value: 'low', label: 'Low' },
{ value: 'high', label: 'High' }
]
}))
}
}
const showButton = (value = own) =>
act(() =>
root.render(
<TooltipProvider>
<AgentLaunchSetupButton entry={value} onUse={launch} />
</TooltipProvider>
)
)
showButton()
const user = userEvent.setup()
const configure = () =>
document.querySelector<HTMLButtonElement>('button[aria-label="Configure Claude"]')!
await user.click(configure())
await user.click(
[...document.querySelectorAll<HTMLElement>('[cmdk-item]')].find(
(item) => item.textContent === 'sonnet'
)!
)
await user.click(getByRole(document.body, 'radio', { name: 'High' }))
await user.click(
document.querySelector<HTMLButtonElement>('button[aria-label="Claude permissions"]')!
)
await user.click(
[...document.querySelectorAll<HTMLElement>('[role="menuitemradio"]')].find((item) =>
item.textContent?.startsWith('Full access')
)!
)
await user.click(getByRole(document.body, 'switch'))
const token = 'fdsafjdkslafjkdls;ajfkld;sjaklfd;'.repeat(40)
fireEvent.change(document.querySelector('textarea')!, { target: { value: token } })
const argumentsText = document.querySelector<HTMLTextAreaElement>('textarea')!.value
expect(argumentsText).toContain(token)
const assertDraft = () => {
expect(document.querySelector('[cmdk-item][data-current="true"]')?.textContent).toBe('sonnet')
expect(getByRole(document.body, 'radio', { name: 'High' }).getAttribute('data-state')).toBe(
'on'
)
expect(
document.querySelector('button[aria-label="Claude permissions"]')?.textContent
).toContain('Full access')
expect(getByRole(document.body, 'switch').getAttribute('aria-checked')).toBe('true')
expect(document.querySelector<HTMLTextAreaElement>('textarea')?.value).toBe(argumentsText)
expect(
document.querySelector('button[aria-label="Remove --dangerously-skip-permissions"]')
).not.toBeNull()
expect(document.querySelector('button[aria-label*="Remove --settings"]')).not.toBeNull()
}
assertDraft()
await user.click(document.body)
await waitFor(() => expect(document.querySelector('textarea')).toBeNull())
act(() => root.render(null))
showButton()
await user.click(configure())
assertDraft()
await user.click(getByRole(document.body, 'button', { name: 'Launch terminal' }))
expect(launch).toHaveBeenCalledExactlyOnceWith(
expect.objectContaining({
surface: 'terminal',
choices: expect.objectContaining({
model: 'sonnet',
options: expect.objectContaining({ effort: 'high' })
}),
arguments: expect.stringContaining(token)
})
)
act(() => root.render(null))
showButton()
await user.click(configure())
assertDraft()
showButton({ ...own, ownerKey: 'replacement-claude' })
expect(document.querySelector<HTMLTextAreaElement>('textarea')?.value).toBe('--add-dir docs')
expect(getByRole(document.body, 'switch').getAttribute('aria-checked')).toBe('false')
})
@@ -0,0 +1,222 @@
import { useId, useRef, useState, type PointerEvent } from 'react'
import { Input } from '@/components/ui/input'
import { DropdownMenuItem, DropdownMenuShortcut } from '@/components/ui/dropdown-menu'
import { AgentIcon } from '@/lib/agent-catalog'
import { useOptionalShortcutLabel } from '@/hooks/useShortcutLabel'
import { translate } from '@/i18n/i18n'
import { AgentLaunchSetupButton } 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-rows'
import {
searchAgentPickerSetups,
type AgentPickerDraft,
type AgentPickerEntry,
type AgentPickerSurface
} from '../../../../shared/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) => void
function keepSetupFocus(event: PointerEvent<HTMLDivElement>) {
// Radix row hover must not steal focus from the row's portaled setup or its nested controls.
if (event.currentTarget.querySelector('[data-slot="popover-trigger"][aria-expanded="true"]')) {
event.preventDefault()
}
}
export function AgentLaunchPickerMenu({
model,
disabled,
runLaunch,
entries,
hideSearch,
onSelectAgent
}: {
model: NewTabAgentMenuModel
disabled?: boolean
runLaunch: Launch
entries: AgentPickerEntry[]
hideSearch?: boolean
onSelectAgent?: (agent: AgentPickerEntry['agent']) => void
}) {
const [query, setQuery] = useState('')
const [openSetup, setOpenSetup] = useState<string | null>(null)
const root = useRef<HTMLDivElement>(null)
const shortcut = useOptionalShortcutLabel('tab.newAgent')
const setups = searchAgentPickerSetups(entries, query)
const words = query.trim().toLowerCase().split(/\s+/).filter(Boolean)
const visible = entries.filter((entry) =>
words.every((word) => `${entry.agent} ${entry.label}`.toLowerCase().includes(word))
)
return (
<div ref={root} data-agent-launch-picker="true">
{!hideSearch ? (
<div className="p-1">
<Input
value={query}
autoFocus
placeholder={translate('agentPicker.search', 'Search agents and setups…')}
onChange={(event) => setQuery(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Escape') {
return
}
event.stopPropagation()
if (event.key === 'Enter' || event.key === 'ArrowDown') {
event.preventDefault()
const first = root.current?.querySelector<HTMLElement>(
'[role="menuitem"]:not([data-disabled])'
)
if (event.key === 'Enter') {
first?.click()
} else {
first?.focus()
}
}
}}
/>
</div>
) : null}
{setups.map((setup) => (
<AgentPickerMenuRow
key={`${setup.entry.agent}:${setup.model.id}:${setup.choices.options?.effort ?? ''}`}
entry={setup.entry}
openSetup={openSetup}
onSetupOpenChange={setOpenSetup}
label={setup.label}
initial={{
agent: setup.entry.agent,
surface: setup.entry.defaultSurface,
choices: setup.choices
}}
onSelectAgent={onSelectAgent}
disabled={disabled}
runLaunch={runLaunch}
/>
))}
{visible.map((entry) => (
<AgentPickerMenuRow
key={entry.agent}
entry={entry}
openSetup={openSetup}
onSetupOpenChange={setOpenSetup}
disabled={disabled}
runLaunch={runLaunch}
onSelectAgent={onSelectAgent}
shortcut={
model.state === 'settled' && model.defaultTuiAgent === entry.agent
? (shortcut ?? undefined)
: undefined
}
/>
))}
{!visible.length && !setups.length ? (
<DropdownMenuItem disabled>
{translate(
'auto.components.agent.AgentCombobox.579c768bde',
'No agents match your search.'
)}
</DropdownMenuItem>
) : null}
</div>
)
}
function AgentPickerMenuRow({
entry,
openSetup,
onSetupOpenChange,
label = entry.label,
initial,
disabled,
runLaunch,
shortcut,
onSelectAgent
}: {
entry: AgentPickerEntry
openSetup: string | null
onSetupOpenChange: (id: string | null) => void
label?: string
initial?: AgentPickerDraft
disabled?: boolean
runLaunch: Launch
shortcut?: string
onSelectAgent?: (agent: AgentPickerEntry['agent']) => void
}) {
const setupId = useId()
const item = useRef<HTMLDivElement>(null)
const configured = useRef<AgentPickerDraft | undefined>(undefined)
const unavailable = entry.rows.find((row) => row.kind === 'chat-unavailable')
return (
<AgentLaunchSetupButton
entry={entry}
initial={initial}
open={openSetup === setupId}
replacedByAnotherSetup={openSetup !== null && openSetup !== setupId}
disabled={disabled}
onOpenChange={(next) => {
onSetupOpenChange(next ? setupId : null)
if (next) {
onSelectAgent?.(entry.agent)
}
}}
onUse={(draft) => {
configured.current = draft
item.current?.click()
}}
renderAnchor={(trigger) => (
<DropdownMenuItem
ref={item}
variant="launch"
disabled={disabled || !entry.surfaces.length}
onSelect={() => {
const draft = configured.current ?? initial
configured.current = 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))
if (draft) {
runLaunch(row, draft, entry)
} else {
runLaunch(row, undefined)
}
}}
onKeyDown={(event) => {
if (event.key === 'ArrowRight') {
event.preventDefault()
item.current?.querySelector<HTMLButtonElement>('button')?.focus()
}
}}
onPointerMove={keepSetupFocus}
onPointerLeave={keepSetupFocus}
>
<AgentIcon agent={entry.agent} size={14} />
<span className="flex-1">{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}
{trigger}
</DropdownMenuItem>
)}
/>
)
}
@@ -0,0 +1,173 @@
import { useLayoutEffect, useRef, useState, type KeyboardEvent, type ReactElement } from 'react'
import { flushSync } from 'react-dom'
import { ChevronRight } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Separator } from '@/components/ui/separator'
import { Popover, PopoverAnchor, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { translate } from '@/i18n/i18n'
import { AgentLaunchSetupPanel } from './AgentLaunchSetupPanel'
import type { AgentPickerDraft, AgentPickerEntry } from '../../../../shared/agent-launch-picker'
// The menu and its popovers unmount on dismissal; authored setups live for this app session.
const sessionDrafts = new Map<string, AgentPickerDraft>()
function setupKeyDown(event: KeyboardEvent<HTMLButtonElement>) {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
event.stopPropagation()
event.currentTarget.click()
} else if (['ArrowLeft', 'ArrowUp', 'ArrowDown', 'Home', 'End'].includes(event.key)) {
const row = event.currentTarget.closest<HTMLElement>('[role="menuitem"]')
if (row) {
event.preventDefault()
event.stopPropagation()
row.focus()
if (event.key !== 'ArrowLeft') {
row.dispatchEvent(new window.KeyboardEvent('keydown', { key: event.key, bubbles: true }))
}
}
}
}
export function AgentLaunchSetupButton({
entry,
initial,
renderAnchor,
disabled,
open: controlledOpen,
replacedByAnotherSetup = false,
onOpenChange,
onUse
}: {
entry: AgentPickerEntry
initial?: AgentPickerDraft
renderAnchor?: (trigger: ReactElement) => ReactElement
disabled?: boolean
open?: boolean
replacedByAnotherSetup?: boolean
onOpenChange?: (open: boolean) => void
onUse: (draft: AgentPickerDraft) => void
}) {
const draftKey = JSON.stringify([entry.ownerKey, entry.agent, initial])
const [localOpen, setOpen] = useState(false)
const open = controlledOpen ?? localOpen
const closeFocus = useRef({ open, restore: true })
useLayoutEffect(() => {
if (closeFocus.current.open && !open) {
// Preserve the close cause until Radix's deferred unmount autofocus runs.
closeFocus.current.restore = !replacedByAnotherSetup
}
closeFocus.current.open = open
}, [open, replacedByAnotherSetup])
const [placement, setPlacement] = useState<'right' | 'bottom'>('right')
const content = useRef<HTMLDivElement>(null)
const anchor = useRef<HTMLDivElement>(null)
useLayoutEffect(() => {
if (!open) {
return
}
let frame = 0
const measure = () => {
cancelAnimationFrame(frame)
// Let Radix publish collision space after the external resize and horizontal placement.
frame = requestAnimationFrame(() => {
flushSync(() => setPlacement('right'))
frame = requestAnimationFrame(() => {
if (!content.current) {
return
}
const style = window.getComputedStyle(content.current)
const available = Number.parseFloat(
style.getPropertyValue('--radix-popover-content-available-width')
)
if (available < Number.parseFloat(style.minWidth)) {
setPlacement('bottom')
}
})
})
}
const observer = new ResizeObserver(measure)
observer.observe(document.documentElement)
if (anchor.current) {
observer.observe(anchor.current)
}
measure()
return () => {
observer.disconnect()
cancelAnimationFrame(frame)
}
}, [open])
const changeOpen = (next: boolean) => {
if (next) {
setPlacement('right')
}
if (controlledOpen === undefined) {
setOpen(next)
}
onOpenChange?.(next)
}
const trigger = (
<div className="flex shrink-0 items-center gap-1">
<Separator orientation="vertical" className="data-[orientation=vertical]:h-4" />
<PopoverTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-xs"
disabled={disabled}
aria-label={translate('agentPicker.configure', 'Configure {{agent}}', {
agent: entry.label
})}
onClick={(event) => event.stopPropagation()}
onKeyDown={setupKeyDown}
>
<ChevronRight className="text-muted-foreground" />
</Button>
</PopoverTrigger>
</div>
)
return (
<Popover open={open} onOpenChange={changeOpen}>
{renderAnchor ? (
<PopoverAnchor asChild ref={anchor}>
{renderAnchor(trigger)}
</PopoverAnchor>
) : (
trigger
)}
<PopoverContent
ref={content}
side={placement}
sticky="always"
align="start"
sideOffset={6}
aria-label={translate('agentPicker.configure', 'Configure {{agent}}', {
agent: entry.label
})}
className="flex w-104 min-w-64 max-w-(--radix-popover-content-available-width) max-h-(--radix-popover-content-available-height) flex-col"
onClick={(event) => event.stopPropagation()}
// Hovering another menu row moves focus there; only a click outside or Escape closes the panel.
onFocusOutside={(event) => event.preventDefault()}
onCloseAutoFocus={(event) => {
if (!closeFocus.current.restore) {
event.preventDefault()
}
}}
>
<div className="min-h-0 overflow-y-auto scrollbar-sleek">
<AgentLaunchSetupPanel
key={draftKey}
entry={entry}
initial={sessionDrafts.get(draftKey) ?? initial}
disabled={disabled}
onDraftChange={(draft) => sessionDrafts.set(draftKey, draft)}
onUse={(draft) => {
onUse(draft)
changeOpen(false)
}}
/>
</div>
</PopoverContent>
</Popover>
)
}
@@ -0,0 +1,193 @@
// @vitest-environment happy-dom
import { entry, host, root, onUse } from './agent-launch-setup-panel-test-fixture'
import { act } from 'react'
import { fireEvent } from '@testing-library/react'
import { expect, it } from 'vitest'
import { TooltipProvider } from '@/components/ui/tooltip'
import { AgentLaunchSetupPanel } from './AgentLaunchSetupPanel'
import * as shell from '../../../../shared/tui-agent-startup-shell'
import { resolveAgentLaunchCommand } from '../../../../shared/tui-agent-launch-command'
import {
agentPickerLaunchInputs,
type AgentPickerEntry
} from '../../../../shared/agent-launch-picker'
it('uses chat-style permission icons and separated own settings while keeping raw CLI text selected', () => {
act(() =>
root.render(
<TooltipProvider>
<AgentLaunchSetupPanel entry={entry} onUse={onUse} />
</TooltipProvider>
)
)
const trigger = host.querySelector<HTMLButtonElement>('button[aria-label="Claude permissions"]')!
expect(trigger.textContent).toBe('plan')
expect(trigger.querySelector('svg.lucide-terminal')).not.toBeNull()
fireEvent.keyDown(trigger, { key: 'ArrowDown' })
const rows = [...document.querySelectorAll<HTMLElement>('[role="menuitemradio"]')]
const raw = rows.find((row) => row.textContent === 'plan')!
expect(raw.getAttribute('aria-checked')).toBe('true')
expect(raw.querySelector('svg.lucide-terminal')).not.toBeNull()
const full = rows.find((row) => row.textContent?.startsWith('Full access'))!
expect(full.querySelector('.text-status-warning svg')).not.toBeNull()
expect(full.textContent).toContain('Never asks before edits or commands')
const own = rows.find((row) => row.textContent?.includes("Use Claude's own settings"))!
expect(own.previousElementSibling?.getAttribute('role')).toBe('separator')
expect(own.querySelector('svg.lucide-terminal')).not.toBeNull()
expect(rows.some((row) => row.textContent?.includes('Same as all agents'))).toBe(false)
fireEvent.click(full)
expect(trigger.querySelector('.text-status-warning svg')).not.toBeNull()
expect(onUse).not.toHaveBeenCalled()
})
it.each(['missing-default', 'pending', 'known-default', 'explicit'] as const)(
'keeps Effort authority separate from declared choices: %s',
(state) => {
const own: AgentPickerEntry = {
...entry,
arguments: '--model opus',
defaultsPending: state === 'pending',
structuredDefaultsPending: state === 'pending',
terminal: {
model: 'opus',
permissionMode: 'ask',
...(state === 'explicit' || state === 'pending' ? { options: { effort: 'high' } } : {})
},
catalog: {
origin: 'probe',
fetchedAt: 1,
models: [
{
id: 'opus',
label: 'Opus',
isDefault: true,
supportsFastMode: true,
efforts: [
{ value: 'low', label: 'Low' },
{ value: 'high', label: 'High' }
],
...(state === 'known-default' ? { defaultEffort: 'low' } : {})
}
]
}
}
act(() =>
root.render(
<TooltipProvider>
<AgentLaunchSetupPanel entry={own} onUse={onUse} />
</TooltipProvider>
)
)
const radios = [
...host.querySelectorAll<HTMLButtonElement>('[data-option-id="effort"] [role="radio"]')
]
expect(radios).toHaveLength(2)
expect(radios.every((radio) => radio.disabled)).toBe(state === 'pending')
expect(
host.querySelector<HTMLButtonElement>('[data-option-id="fastMode"] [role="switch"]')?.disabled
).toBe(state === 'pending')
if (state === 'known-default' || state === 'explicit') {
expect(
radios.find((radio) => radio.getAttribute('aria-checked') === 'true')?.textContent
).toBe(state === 'known-default' ? 'Low' : 'High')
}
if (state === 'missing-default') {
expect(radios.every((radio) => radio.getAttribute('aria-checked') === 'false')).toBe(true)
}
for (const way of ['Launch chat', 'Launch terminal']) {
onUse.mockClear()
fireEvent.click(
[...host.querySelectorAll('button')].find((button) => button.textContent === way)!
)
expect(agentPickerLaunchInputs(onUse.mock.calls[0][0], own)).toEqual({})
}
}
)
it('edits the QA inline settings carrier after verbose and compiles the same effective JSON', () => {
const own: AgentPickerEntry = {
...entry,
terminal: { model: 'opus', permissionMode: 'ask' },
arguments: `--verbose --settings '{"language":"English","fastMode":false}'`,
catalog: {
origin: 'probe',
fetchedAt: 1,
models: [{ id: 'opus', label: 'Opus', isDefault: true, efforts: [], supportsFastMode: true }]
}
}
act(() =>
root.render(
<TooltipProvider>
<AgentLaunchSetupPanel entry={own} onUse={onUse} />
</TooltipProvider>
)
)
const fast = host.querySelector<HTMLButtonElement>('[role="switch"]')
expect(fast).not.toBeNull()
expect(fast?.disabled).toBe(false)
fireEvent.click(fast!)
fireEvent.click(
[...host.querySelectorAll('button')].find((button) => button.textContent === 'Launch terminal')!
)
const launch = agentPickerLaunchInputs(onUse.mock.calls[0][0], own)
const compiled = resolveAgentLaunchCommand({
agent: 'claude',
cmdOverrides: {},
platform: 'darwin',
shell: 'posix',
...launch
})
expect(compiled.ok).toBe(true)
if (!compiled.ok) {
throw new Error(compiled.error)
}
const argv = shell.tokenizeStartupCommand(compiled.command, 'posix')
expect(argv.ok).toBe(true)
if (!argv.ok) {
throw new Error(argv.error)
}
expect(argv.tokens.filter((token) => token === '--settings')).toHaveLength(1)
expect(JSON.parse(argv.tokens[argv.tokens.indexOf('--settings') + 1])).toEqual({
language: 'English',
fastMode: true
})
expect(argv.tokens).toContain('--verbose')
})
it('keeps the QA unclosed permission literal once after Ask and preserves actual Terminal refusal', () => {
const raw = '--verbose --permission-mode "plan'
const own: AgentPickerEntry = { ...entry, arguments: raw }
act(() =>
root.render(
<TooltipProvider>
<AgentLaunchSetupPanel entry={own} onUse={onUse} />
</TooltipProvider>
)
)
const permission = host.querySelector<HTMLButtonElement>('button[aria-label$=" permissions"]')!
fireEvent.keyDown(permission, { key: 'ArrowDown' })
fireEvent.click(
[...document.querySelectorAll<HTMLElement>('[role="menuitemradio"]')].find((item) =>
item.textContent?.startsWith('Ask for approval')
)!
)
expect(host.querySelector<HTMLTextAreaElement>('textarea[aria-label="Arguments"]')?.value).toBe(
raw
)
expect(host.querySelector('button[aria-label^="Remove --permission"]')).toBeNull()
fireEvent.click(
[...host.querySelectorAll('button')].find((button) => button.textContent === 'Launch terminal')!
)
const launch = agentPickerLaunchInputs(onUse.mock.calls[0][0], own)
expect(launch.agentArgs).toBe(raw)
expect(launch.sessionOptions?.permissionMode).toBe('ask')
expect(
resolveAgentLaunchCommand({
agent: 'claude',
cmdOverrides: {},
platform: 'darwin',
shell: 'posix',
...launch
})
).toEqual({ ok: false, error: 'CLI arguments are invalid: Unclosed quote in command template.' })
})
@@ -0,0 +1,300 @@
// @vitest-environment happy-dom
import { act } from 'react'
import { fireEvent } from '@testing-library/react'
import { expect, it, vi } from 'vitest'
import { TooltipProvider } from '@/components/ui/tooltip'
import { tokenizeStartupCommand } from '../../../../shared/tui-agent-startup-shell'
import * as choiceAnalysis from '../../../../shared/agent-choice-args'
import * as choiceSettings from '../../../../shared/agent-choice-settings'
import * as optionScanner from '../../../../shared/command-option-occurrence'
import * as pickerCatalog from '../../../../shared/agent-launch-picker-catalog'
import { AgentLaunchSetupPanel } from './AgentLaunchSetupPanel'
import {
agentPickerDraft,
agentPickerLaunchInputs,
type AgentPickerEntry
} from '../../../../shared/agent-launch-picker'
import { entry, host, root, onUse } from './agent-launch-setup-panel-test-fixture'
import { fetchCodexModelCatalogListing } from '../../lib/codex-model-catalog-reader-test-fixture'
function observeSettingsWork() {
let depth = 0
const blank = () => ({ inventories: 0, records: 0, scanners: 0 })
let counts = { analyses: 0, analyzer: blank(), compiler: blank() }
const analyze = choiceAnalysis.analyzeAgentChoiceArgs
const inventory = choiceSettings.agentChoiceSettingsOccurrences
const scan = optionScanner.findOptionOccurrence
const analyzer = vi
.spyOn(choiceAnalysis, 'analyzeAgentChoiceArgs')
.mockImplementation((...args) => {
counts.analyses++
depth++
try {
return analyze(...args)
} finally {
depth--
}
})
let inventoryDepth = false
let inAnalysis = false
const inventories = vi
.spyOn(choiceSettings, 'agentChoiceSettingsOccurrences')
.mockImplementation((...args) => {
const bucket = depth ? counts.analyzer : counts.compiler
bucket.inventories++
inventoryDepth = true
inAnalysis = depth > 0
try {
const result = inventory(...args)
bucket.records += result.length
return result
} finally {
inventoryDepth = false
}
})
const scanners = vi.spyOn(optionScanner, 'findOptionOccurrence').mockImplementation((...args) => {
if (inventoryDepth) {
;(inAnalysis ? counts.analyzer : counts.compiler).scanners++
}
return scan(...args)
})
return {
read: () => structuredClone(counts),
reset: () => {
counts = { analyses: 0, analyzer: blank(), compiler: blank() }
},
stop: () => {
analyzer.mockRestore()
inventories.mockRestore()
scanners.mockRestore()
}
}
}
it('bounds effective-settings work for direct analysis, collapsed Chat render and one actual Args edit at 30/300 carriers', async () => {
const work = observeSettingsWork()
try {
for (const count of [0, 30, 300]) {
work.reset()
const result = choiceAnalysis.analyzeAgentChoiceArgs(
'claude',
count ? Array(count).fill('--settings {}').join(' ') : '--add-dir docs --verbose'
)
expect(result.opaqueKeys.has('fastMode')).toBe(false)
expect(result.occurrences).toEqual([])
expect(work.read()).toEqual({
analyses: 1,
analyzer: { inventories: 1, records: count, scanners: count + 1 },
compiler: { inventories: 0, records: 0, scanners: 0 }
})
}
const previous = new Map<string, ReturnType<typeof work.read>>()
for (const count of [30, 300]) {
for (const value of ['empty', 'true', 'false']) {
const text = [
...Array(count - 1).fill('--settings {}'),
value === 'empty'
? '--settings {}'
: `--settings '{"fastMode":${value},"env":{"KEEP":"yes"}}'`
].join(' ')
const own: AgentPickerEntry = {
...entry,
defaultSurface: 'chat',
arguments: text,
terminal: { model: 'opus', permissionMode: 'own' },
structured: { model: 'opus', permissionMode: 'own', options: { fastMode: 'false' } },
catalog: {
origin: 'probe',
fetchedAt: 1,
models: [
{ id: 'opus', label: 'Opus', isDefault: true, efforts: [], supportsFastMode: true }
]
}
}
act(() => root.render(null))
work.reset()
await act(async () =>
root.render(
<TooltipProvider>
<AgentLaunchSetupPanel
entry={own}
initial={{ ...agentPickerDraft(own), arguments: text }}
onUse={onUse}
/>
</TooltipProvider>
)
)
const render = work.read()
expect(host.querySelector('textarea')).toBeNull()
expect(host.querySelector('[role="switch"]')?.getAttribute('aria-checked')).toBe(
value === 'true' ? 'true' : 'false'
)
fireEvent.click(host.querySelector('button[aria-label="Toggle Arguments"]')!)
const input = host.querySelector<HTMLTextAreaElement>('textarea')!
work.reset()
fireEvent.change(input, { target: { value: `${input.value} --add-dir src` } })
const edit = work.read()
expect(tokenizeStartupCommand(input.value, 'posix')).toMatchObject({
ok: true,
tokens: expect.arrayContaining(['--add-dir', 'src'])
})
for (const [phase, measured] of Object.entries({ render, edit })) {
expect(measured.analyses).toBeGreaterThan(0)
expect(measured.analyses).toBe(phase === 'render' ? 8 : value === 'empty' ? 18 : 19)
expect(measured.compiler.inventories).toBe(
value === 'empty' ? 0 : phase === 'render' ? 2 : 6
)
expect(measured.analyzer.inventories).toBeLessThanOrEqual(measured.analyses)
const max = count + 1
for (const bucket of [measured.analyzer, measured.compiler]) {
expect(bucket.records).toBeLessThanOrEqual(bucket.inventories * max)
expect(bucket.scanners).toBeLessThanOrEqual(bucket.inventories * (max + 1))
}
const key = `${value}:${phase}`
if (count === 300) {
const small = previous.get(key)!
expect(measured.analyses).toBe(small.analyses)
expect(measured.compiler.inventories).toBe(small.compiler.inventories)
expect(measured.analyzer.records + measured.compiler.records).toBeLessThanOrEqual(
10 * (small.analyzer.records + small.compiler.records)
)
expect(measured.analyzer.scanners + measured.compiler.scanners).toBeLessThanOrEqual(
10 * (small.analyzer.scanners + small.compiler.scanners)
)
}
previous.set(key, measured)
process.stdout.write(
`${JSON.stringify({ metric: 'settings-work', count, value, phase, ...measured })}\n`
)
}
fireEvent.click(
[...host.querySelectorAll('button')].find((b) => b.textContent === 'Launch terminal')!
)
const launch = agentPickerLaunchInputs(onUse.mock.calls.at(-1)![0], own)
expect(tokenizeStartupCommand(launch.agentArgs ?? '', 'posix')).toMatchObject({
ok: true,
tokens: expect.arrayContaining(['--add-dir', 'src'])
})
if (value !== 'empty') {
expect(launch.agentArgs).toContain(`"fastMode":${value}`)
expect(launch.agentArgs).toContain('"KEEP":"yes"')
}
}
}
} finally {
work.stop()
}
})
it('bounds catalog projection independently of raw model operands and retains Codex tier and Cursor composition through the real editor', async () => {
const { default: capture } =
await import('../../../../shared/fixtures/agent-picker-cli-recordings.json')
const listing = await fetchCodexModelCatalogListing({
connection: { request: vi.fn().mockResolvedValue(capture.modelList) }
})
const captured = listing.models[0]
const tier = captured.serviceTiers![0].value
const projection = vi.spyOn(pickerCatalog, 'agentPickerEntryOptions')
const previous = new Map<number, { calls: number; reads: number }>()
try {
for (const models of [30, 300]) {
for (const operands of [30, 300]) {
let reads = 0
const own: AgentPickerEntry = {
...entry,
agent: 'codex',
label: 'Codex',
defaultSurface: 'terminal',
arguments: '--add-dir docs',
terminal: { model: captured.id, permissionMode: 'own' },
structured: {},
catalog: {
origin: 'probe',
fetchedAt: 1,
models: Array.from({ length: models }, (_, i) => ({
...captured,
get id() {
reads++
return i === models - 1 ? captured.id : `model-${i}`
},
label: `Model ${i}`,
isDefault: i === models - 1,
efforts: captured.efforts
}))
}
}
act(() => root.render(null))
act(() =>
root.render(
<TooltipProvider>
<AgentLaunchSetupPanel
entry={own}
initial={{
...agentPickerDraft(own),
choices: { options: { serviceTier: tier } }
}}
onUse={onUse}
/>
</TooltipProvider>
)
)
const input = host.querySelector<HTMLTextAreaElement>('textarea')!
const text = Array(operands).fill('--model raw').join(' ')
projection.mockClear()
reads = 0
fireEvent.change(input, { target: { value: text } })
const calls = projection.mock.calls.length
expect(calls).toBe(7)
expect(reads).toBeLessThanOrEqual((7 + 8) * models + 8)
if (operands === 300) {
expect({ calls, reads }).toEqual(previous.get(models))
}
previous.set(models, { calls, reads })
process.stdout.write(
`${JSON.stringify({ metric: 'catalog-edit', models, operands, calls, reads })}\n`
)
expect(input.value).toBe(text)
expect(
host.querySelector(`button[aria-label="Remove -c service_tier=${tier}"]`)
).not.toBeNull()
fireEvent.click(
[...host.querySelectorAll('button')].find((b) => b.textContent === 'Launch terminal')!
)
const launch = agentPickerLaunchInputs(onUse.mock.calls.at(-1)![0], own)
expect(launch.agentArgs).toContain(`service_tier=${tier}`)
expect(launch.agentArgs?.match(/--model raw/g)).toHaveLength(operands)
}
}
const own: AgentPickerEntry = {
...entry,
agent: 'cursor',
label: 'Cursor',
defaultSurface: 'terminal',
arguments: '--verbose',
terminal: { model: 'claude-opus-4-8', permissionMode: 'own', options: { fastMode: 'true' } },
structured: {},
catalog: { origin: 'unknown' }
}
act(() => root.render(null))
act(() =>
root.render(
<TooltipProvider>
<AgentLaunchSetupPanel entry={own} onUse={onUse} />
</TooltipProvider>
)
)
fireEvent.change(host.querySelector('textarea')!, {
target: { value: '--model claude-opus-4-8 --verbose' }
})
fireEvent.click(
[...host.querySelectorAll('button')].find((b) => b.textContent === 'Launch terminal')!
)
const launch = agentPickerLaunchInputs(onUse.mock.calls.at(-1)![0], own)
expect(launch.agentArgs).toContain('--model claude-opus-4-8')
expect(launch.agentArgs).not.toContain('-fast')
expect(launch.agentArgs?.match(/--model/g)).toHaveLength(1)
} finally {
projection.mockRestore()
}
})
@@ -0,0 +1,818 @@
// @vitest-environment happy-dom
import { entry, host, root, onUse } from './agent-launch-setup-panel-test-fixture'
import { act } from 'react'
import { fireEvent, getByText, isInaccessible, waitFor } from '@testing-library/react'
import { expect, it, vi } from 'vitest'
import { TooltipProvider } from '@/components/ui/tooltip'
import { AgentLaunchSetupPanel } from './AgentLaunchSetupPanel'
import * as shell from '../../../../shared/tui-agent-startup-shell'
import { fetchCodexModelCatalogListing } from '../../lib/codex-model-catalog-reader-test-fixture'
import {
agentPickerDraft,
agentPickerChoiceSeeds,
agentPickerLaunchInputs,
type AgentPickerEntry
} from '../../../../shared/agent-launch-picker'
it('shows visible no-match feedback for a nonempty model query', async () => {
act(() =>
root.render(
<TooltipProvider>
<AgentLaunchSetupPanel
entry={{
...entry,
terminal: { model: 'opus', permissionMode: 'ask' },
catalog: {
origin: 'probe',
fetchedAt: 1,
models: [{ id: 'opus', label: 'Opus', isDefault: true, efforts: [] }]
}
}}
onUse={onUse}
/>
</TooltipProvider>
)
)
fireEvent.change(host.querySelector('input[aria-label="Search models"]')!, {
target: { value: 'zzzzzz' }
})
await waitFor(() => {
const message = getByText(host, 'No models match your search.')
expect(isInaccessible(message)).toBe(false)
})
})
it('shows a raw Arguments value in the shared Permissions menu; removing its chip selects own and preserves other text', () => {
act(() =>
root.render(
<TooltipProvider>
<AgentLaunchSetupPanel entry={entry} onUse={onUse} />
</TooltipProvider>
)
)
const permission = () =>
host.querySelector<HTMLButtonElement>('button[aria-label$=" permissions"]')
expect(permission()?.disabled).toBe(false)
expect(permission()?.textContent).toContain('plan')
expect(host.querySelector<HTMLTextAreaElement>('textarea[aria-label="Arguments"]')?.value).toBe(
'--add-dir docs'
)
act(() =>
host
.querySelector<HTMLButtonElement>('button[aria-label="Remove --permission-mode plan"]')
?.click()
)
expect(permission()?.disabled).toBe(false)
expect(host.querySelector<HTMLTextAreaElement>('textarea[aria-label="Arguments"]')?.value).toBe(
'--add-dir docs'
)
act(() =>
[...host.querySelectorAll('button')]
.find((button) => button.textContent === 'Launch terminal')
?.click()
)
expect(onUse).toHaveBeenCalledExactlyOnceWith(
expect.objectContaining({ surface: 'terminal', arguments: '--add-dir docs' })
)
expect(entry.arguments).toBe('--permission-mode plan --add-dir docs')
expect(host.textContent).not.toContain('Reset to default')
expect(host.textContent).toContain('Only applies to terminal (CLI) launches')
})
it('leaves chat Permissions enabled even with launch-owned raw CLI permission text', () => {
act(() =>
root.render(
<TooltipProvider>
<AgentLaunchSetupPanel
entry={entry}
initial={{ ...agentPickerDraft(entry, 'chat'), arguments: '--permission-mode plan' }}
onUse={onUse}
/>
</TooltipProvider>
)
)
expect(
host.querySelector<HTMLButtonElement>('button[aria-label$=" permissions"]')?.disabled
).toBe(false)
expect(host.querySelector('button[aria-label="Remove --permission-mode plan"]')).not.toBeNull()
})
it('shows remembered chat defaults without turning displayed values into launch intent', () => {
const chat: AgentPickerEntry = {
...entry,
agent: 'codex',
defaultSurface: 'chat',
arguments: '--add-dir docs',
structured: { model: 'astra', options: { effort: 'low' }, permissionMode: 'ask' },
catalog: {
origin: 'probe',
fetchedAt: 1,
models: [
{ id: 'astra', label: 'Astra', isDefault: true, efforts: [{ value: 'low', label: 'Low' }] }
]
}
}
act(() =>
root.render(
<TooltipProvider>
<AgentLaunchSetupPanel entry={chat} onUse={onUse} />
</TooltipProvider>
)
)
expect(host.querySelector('[role="radio"][data-state="on"]')?.textContent).toBe('Low')
expect(host.querySelector('button[aria-label$=" permissions"]')?.textContent).toBe(
'Ask for approval'
)
expect(host.textContent).not.toMatch(/Agents? defaults?|Reset to default/)
expect(host.querySelector('[cmdk-item][data-current="true"]')?.textContent).toBe('Astra')
act(() =>
[...host.querySelectorAll('button')]
.find((button) => button.textContent === 'Launch chat')
?.click()
)
expect(onUse).toHaveBeenCalledExactlyOnceWith({
agent: 'codex',
surface: 'chat',
choices: {},
arguments: undefined
})
})
it('keeps pending defaults disabled, selects resolved values without overrides, and enables settled unselected models', () => {
const pending: AgentPickerEntry = {
...entry,
agent: 'codex',
defaultSurface: 'chat',
arguments: undefined,
structured: {},
defaultsPending: true,
structuredDefaultsPending: true
}
const render = (value: AgentPickerEntry) =>
act(() =>
root.render(
<TooltipProvider>
<AgentLaunchSetupPanel entry={value} onUse={onUse} />
</TooltipProvider>
)
)
render(pending)
fireEvent.click(host.querySelector('button[aria-label="Toggle Arguments"]')!)
expect(
[...host.querySelectorAll('button')].find((button) => button.textContent === 'Launch chat')
?.disabled
).toBe(false)
expect(host.querySelector<HTMLInputElement>('input[aria-label="Search models"]')?.disabled).toBe(
true
)
expect(
host.querySelector<HTMLButtonElement>('button[aria-label$=" permissions"]')?.disabled
).toBe(true)
expect(host.querySelector('[cmdk-item][data-current="true"]')).toBeNull()
expect(
host.querySelector<HTMLTextAreaElement>('textarea[aria-label="Arguments"]')?.disabled
).toBe(true)
render({
...pending,
defaultsPending: false,
structuredDefaultsPending: true,
arguments: '--host-only'
})
expect(host.querySelector<HTMLInputElement>('input[aria-label="Search models"]')?.disabled).toBe(
true
)
expect(
host.querySelector<HTMLTextAreaElement>('textarea[aria-label="Arguments"]')?.disabled
).toBe(false)
expect(host.querySelector<HTMLTextAreaElement>('textarea[aria-label="Arguments"]')?.value).toBe(
'--host-only'
)
const settled: AgentPickerEntry = {
...pending,
defaultsPending: false,
structuredDefaultsPending: false,
structured: { permissionMode: 'ask' },
catalog: {
origin: 'probe',
fetchedAt: 1,
listingNamesConfiguredModel: true,
models: [
{
id: 'host-model',
label: 'Host model',
isDefault: true,
defaultEffort: 'low',
efforts: [
{ value: 'low', label: 'Low' },
{ value: 'high', label: 'High' }
]
}
]
}
}
render(settled)
expect(host.querySelector<HTMLInputElement>('input[aria-label="Search models"]')?.disabled).toBe(
false
)
expect(host.querySelector('[cmdk-item][data-current="true"]')?.textContent).toBe('Host model')
expect(host.querySelector('[role="radio"][data-state="on"]')?.textContent).toBe('Low')
expect(host.querySelector('button[aria-label$=" permissions"]')?.textContent).toBe(
'Ask for approval'
)
expect(host.textContent).not.toMatch(/Agents? defaults?|Reset to default/)
act(() =>
[...host.querySelectorAll('button')]
.find((button) => button.textContent === 'Launch chat')
?.click()
)
expect(onUse).toHaveBeenCalledExactlyOnceWith(
expect.objectContaining({ choices: {}, arguments: undefined })
)
render({ ...settled, catalog: { origin: 'unknown' }, structured: { permissionMode: 'ask' } })
expect(host.querySelector<HTMLInputElement>('input[aria-label="Search models"]')?.disabled).toBe(
false
)
expect(host.querySelector('[cmdk-item][data-current="true"]')).toBeNull()
expect(
[...host.querySelectorAll('button')].find((button) => button.textContent === 'Launch chat')
?.disabled
).toBe(false)
})
it('launches explicit model and effort picks when settled defaults have neither selected', () => {
const own: AgentPickerEntry = {
...entry,
agent: 'codex',
defaultSurface: 'chat',
arguments: undefined,
structured: { permissionMode: 'ask' },
catalog: {
origin: 'probe',
fetchedAt: 1,
listingNamesConfiguredModel: false,
models: [
{
id: 'host-model',
label: 'Host model',
isDefault: false,
efforts: [
{ value: 'low', label: 'Low' },
{ value: 'high', label: 'High' }
]
}
]
}
}
act(() =>
root.render(
<TooltipProvider>
<AgentLaunchSetupPanel entry={own} onUse={onUse} />
</TooltipProvider>
)
)
expect(host.querySelector('[cmdk-item][data-current="true"]')).toBeNull()
expect(host.querySelector('[role="radio"][data-state="on"]')).toBeNull()
fireEvent.click(host.querySelector('[cmdk-item][data-value="host-model"]')!)
expect(host.querySelector('[cmdk-item][data-current="true"]')?.textContent).toBe('Host model')
expect(host.querySelector('[role="radio"][data-state="on"]')).toBeNull()
fireEvent.click(
[...host.querySelectorAll('[role="radio"]')].find((radio) => radio.textContent === 'High')!
)
expect(host.querySelector('[role="radio"][data-state="on"]')?.textContent).toBe('High')
fireEvent.click(
[...host.querySelectorAll('button')].find((button) => button.textContent === 'Launch chat')!
)
expect(onUse).toHaveBeenCalledExactlyOnceWith(
expect.objectContaining({
surface: 'chat',
choices: { model: 'host-model', options: { effort: 'high' } }
})
)
expect(agentPickerLaunchInputs(onUse.mock.calls[0][0], own).sessionOptions).toMatchObject({
model: 'host-model',
effort: 'high'
})
const footer = [...host.querySelectorAll('button')].find(
(button) => button.textContent === 'Launch chat'
)!.parentElement!
expect(footer.textContent).toBe('Launch terminalLaunch chat')
})
it('returns to concrete default model, effort and permission values by selecting them directly', () => {
const own: AgentPickerEntry = {
...entry,
agent: 'codex',
defaultSurface: 'chat',
arguments: undefined,
structured: { model: 'astra', options: { effort: 'low' }, permissionMode: 'ask' },
catalog: {
origin: 'probe',
fetchedAt: 1,
models: [
{
id: 'astra',
label: 'Astra',
isDefault: true,
defaultEffort: 'low',
efforts: [
{ value: 'low', label: 'Low' },
{ value: 'high', label: 'High' }
]
},
{
id: 'other',
label: 'Other',
isDefault: false,
defaultEffort: 'low',
efforts: [
{ value: 'low', label: 'Low' },
{ value: 'high', label: 'High' }
]
}
]
}
}
act(() =>
root.render(
<TooltipProvider>
<AgentLaunchSetupPanel entry={own} onUse={onUse} />
</TooltipProvider>
)
)
const model = (name: string) =>
act(() =>
[...host.querySelectorAll<HTMLElement>('[cmdk-item]')]
.find((row) => row.textContent === name)
?.click()
)
model('Other')
model('Astra')
const select = (_label: string, value: string) => {
fireEvent.keyDown(host.querySelector('button[aria-label$=" permissions"]')!, {
key: 'ArrowDown'
})
fireEvent.click(
[...document.querySelectorAll<HTMLElement>('[role="menuitemradio"]')].find((row) =>
row.textContent?.startsWith(value)
)!
)
}
fireEvent.click(
[...host.querySelectorAll<HTMLElement>('[role="radio"]')].find(
(row) => row.textContent === 'High'
)!
)
fireEvent.click(
[...host.querySelectorAll<HTMLElement>('[role="radio"]')].find(
(row) => row.textContent === 'Low'
)!
)
select('Permissions', 'Full access')
select('Permissions', 'Ask')
act(() =>
[...host.querySelectorAll('button')]
.find((button) => button.textContent === 'Launch chat')
?.click()
)
expect(onUse).toHaveBeenCalledExactlyOnceWith(
expect.objectContaining({
choices: { model: 'astra', options: { effort: 'low' }, permissionMode: 'ask' }
})
)
expect(host.textContent).not.toMatch(/Agents? defaults?|Reset to default/)
})
it('searches current and supplied legacy models, keeps unknown explicit choices, and hides CLI Arguments for chat-only entries', () => {
const catalog = {
origin: 'probe' as const,
fetchedAt: 1,
models: [
{ id: 'current-model', label: 'Current model', isDefault: true, efforts: [] },
{
id: 'retired',
label: 'Retired model',
isDefault: false,
efforts: [],
isLegacy: true as const
}
]
}
const chat: AgentPickerEntry = { ...entry, surfaces: ['chat'], defaultSurface: 'chat', catalog }
act(() =>
root.render(
<TooltipProvider>
<AgentLaunchSetupPanel
entry={chat}
initial={{ ...agentPickerDraft(chat), choices: { model: 'private-model' } }}
onUse={onUse}
/>
</TooltipProvider>
)
)
expect(host.querySelector('textarea[aria-label="Arguments"]')).toBeNull()
expect(host.textContent).not.toContain('Only applies to terminal (CLI) launches')
expect(host.querySelector('[cmdk-item][data-current="true"]')?.textContent).toBe('private-model')
expect(host.textContent).toContain('Legacy models')
expect(host.textContent).not.toContain('Retired model')
const search = host.querySelector('input[aria-label="Search models"]')!
fireEvent.change(search, { target: { value: 'Retired' } })
expect(host.textContent).toContain('Retired model')
act(() =>
[...host.querySelectorAll<HTMLElement>('[cmdk-item]')]
.find((row) => row.textContent === 'Retired model')
?.click()
)
act(() =>
[...host.querySelectorAll('button')]
.find((button) => button.textContent === 'Launch chat')
?.click()
)
expect(onUse).toHaveBeenCalledExactlyOnceWith(
expect.objectContaining({ choices: { model: 'retired' } })
)
})
it.each(['chat', 'terminal'] as const)(
'uses the %s default exactly once from Enter in CLI Arguments, keeping CLI changes out of Chat intent',
(surface) => {
const own = { ...entry, defaultSurface: surface }
act(() =>
root.render(
<TooltipProvider>
<AgentLaunchSetupPanel
entry={own}
initial={{
...agentPickerDraft(own),
choices: { model: 'opus', permissionMode: 'ask' }
}}
onUse={onUse}
/>
</TooltipProvider>
)
)
if (surface === 'chat') {
fireEvent.click(host.querySelector('button[aria-label="Toggle Arguments"]')!)
}
const input = host.querySelector('textarea[aria-label="Arguments"]')!
fireEvent.change(input, { target: { value: '--model haiku --dangerously-skip-permissions' } })
for (const way of ['chat', 'terminal'] as const) {
onUse.mockClear()
act(() =>
[...host.querySelectorAll('button')]
.find(
(button) => button.textContent === (way === 'chat' ? 'Launch chat' : 'Launch terminal')
)
?.click()
)
expect(onUse).toHaveBeenCalledOnce()
const launch = agentPickerLaunchInputs(onUse.mock.calls[0][0], own)
if (way === 'chat') {
expect(launch).toEqual({ sessionOptions: { model: 'opus', permissionMode: 'full-access' } })
} else {
expect(launch.agentArgs).toContain('--dangerously-skip-permissions')
}
}
onUse.mockClear()
fireEvent.keyDown(input, { key: 'Enter' })
expect(onUse).toHaveBeenCalledExactlyOnceWith(
expect.objectContaining({
surface,
choices: { model: 'opus', permissionMode: 'full-access' }
})
)
}
)
it.each(['chat', 'terminal'] as const)(
'reuses the Arguments editor inside its %s-default disclosure, with permission badge in the input frame',
(surface) => {
act(() =>
root.render(
<TooltipProvider>
<AgentLaunchSetupPanel entry={{ ...entry, defaultSurface: surface }} onUse={onUse} />
</TooltipProvider>
)
)
const disclosure = host.querySelector<HTMLButtonElement>(
'button[aria-label="Toggle Arguments"]'
)!
expect(disclosure.getAttribute('aria-expanded')).toBe(String(surface === 'terminal'))
if (surface === 'chat') {
expect(host.querySelector('textarea')).toBeNull()
fireEvent.click(disclosure)
}
const text = host.querySelector<HTMLTextAreaElement>('textarea[aria-label="Arguments"]')!
const chip = host.querySelector(
surface === 'chat'
? 'button[aria-label="Remove --permission-mode default"]'
: 'button[aria-label="Remove --permission-mode plan"]'
)!
expect(text.parentElement?.contains(chip)).toBe(true)
expect(text.value).toBe('--add-dir docs')
expect(host.textContent).not.toContain('Reset')
expect(host.textContent).not.toContain('Default from Agents settings')
fireEvent.click(disclosure)
expect(host.querySelector('textarea')).toBeNull()
}
)
it('keeps uncertain permission text literal instead of offering a badge that removes the whole Arguments field', () => {
const raw = '--unknown-option value --permission-mode auto --add-dir docs'
act(() =>
root.render(
<TooltipProvider>
<AgentLaunchSetupPanel entry={{ ...entry, arguments: raw }} onUse={onUse} />
</TooltipProvider>
)
)
expect(host.querySelector<HTMLTextAreaElement>('textarea[aria-label="Arguments"]')?.value).toBe(
raw
)
expect(host.querySelector('button[aria-label^="Remove --unknown-option"]')).toBeNull()
act(() =>
[...host.querySelectorAll('button')]
.find((button) => button.textContent === 'Launch terminal')
?.click()
)
expect(onUse).toHaveBeenCalledExactlyOnceWith(
expect.objectContaining({ arguments: undefined, choices: {} })
)
})
it('uses the shared permission menu without its inheritance row and couples raw, selected and removed flags in both directions', () => {
act(() =>
root.render(
<TooltipProvider>
<AgentLaunchSetupPanel entry={entry} onUse={onUse} />
</TooltipProvider>
)
)
const permission = () =>
host.querySelector<HTMLButtonElement>('button[aria-label="Claude permissions"]')!
expect(permission().textContent).toBe('plan')
fireEvent.keyDown(permission(), { key: 'ArrowDown' })
const items = [...document.querySelectorAll<HTMLElement>('[role="menuitemradio"]')]
expect(items.some((item) => item.textContent?.includes('Same as all agents'))).toBe(false)
expect(items.some((item) => item.textContent === 'plan')).toBe(true)
fireEvent.click(items.find((item) => item.textContent?.startsWith('Full access'))!)
expect(
host.querySelector('button[aria-label="Remove --dangerously-skip-permissions"]')
).not.toBeNull()
const input = host.querySelector<HTMLTextAreaElement>('textarea[aria-label="Arguments"]')!
expect(input.value).toBe('--add-dir docs')
fireEvent.change(input, {
target: { value: '--permission-mode acceptEdits --add-dir "keep spaces"' }
})
expect(permission().textContent).toBe('Accept edits')
expect(input.value).toBe('--add-dir "keep spaces"')
for (const way of ['Launch chat', 'Launch terminal']) {
onUse.mockClear()
fireEvent.click(
[...host.querySelectorAll('button')].find((button) => button.textContent === way)!
)
expect(onUse).toHaveBeenCalledOnce()
const launch = agentPickerLaunchInputs(onUse.mock.calls[0][0], entry)
expect(launch.sessionOptions?.permissionMode).toBe('accept-edits')
if (way === 'Launch terminal') {
expect(launch.agentArgs).toContain('--permission-mode acceptEdits')
expect(launch.agentArgs).toContain('keep spaces')
expect(launch.agentArgs).not.toContain('skip-permissions')
}
}
fireEvent.click(host.querySelector('button[aria-label="Remove --permission-mode acceptEdits"]')!)
expect(permission().textContent).toContain("Claude's own settings")
expect(input.value).toBe('--add-dir "keep spaces"')
})
it.each(['chat', 'terminal'] as const)(
'couples Claude Fast and its in-box Args badge for a %s default, both footers and Enter',
(surface) => {
const own: AgentPickerEntry = {
...entry,
defaultSurface: surface,
terminal: { model: 'opus', permissionMode: 'ask' },
structured: { model: 'opus', permissionMode: 'ask', options: { fastMode: 'false' } },
arguments: '--settings \'{"theme":"dark"}\' --add-dir docs',
catalog: {
origin: 'probe',
fetchedAt: 1,
models: [
{ id: 'opus', label: 'Opus', isDefault: true, efforts: [], supportsFastMode: true }
]
}
}
act(() =>
root.render(
<TooltipProvider>
<AgentLaunchSetupPanel entry={own} onUse={onUse} />
</TooltipProvider>
)
)
if (surface === 'chat') {
fireEvent.click(host.querySelector('button[aria-label="Toggle Arguments"]')!)
}
const fast = host.querySelector<HTMLButtonElement>('[role="switch"]')!
expect(fast.getAttribute('aria-checked')).toBe('false')
fireEvent.click(fast)
expect(fast.getAttribute('aria-checked')).toBe('true')
const chip = host.querySelector<HTMLButtonElement>("button[aria-label*='Remove --settings']")!
const input = host.querySelector<HTMLTextAreaElement>('textarea[aria-label="Arguments"]')!
expect(input.parentElement?.contains(chip)).toBe(true)
expect(input.value).toContain('"theme":"dark"')
for (const way of ['Launch chat', 'Launch terminal']) {
onUse.mockClear()
fireEvent.click(
[...host.querySelectorAll('button')].find((button) => button.textContent === way)!
)
const launch = agentPickerLaunchInputs(onUse.mock.calls[0][0], own)
expect(launch.sessionOptions?.fastMode).toBe('true')
if (way === 'Launch terminal') {
expect(launch.agentArgs).toContain('"fastMode":true')
expect(launch.agentArgs).toContain('"theme":"dark"')
}
}
fireEvent.click(chip)
expect(fast.getAttribute('aria-checked')).toBe('false')
fireEvent.change(input, {
target: { value: '--settings \'{"fastMode":true,"theme":"light"}\' --add-dir src' }
})
expect(fast.getAttribute('aria-checked')).toBe('true')
onUse.mockClear()
fireEvent.keyDown(input, { key: 'Enter' })
expect(onUse).toHaveBeenCalledOnce()
expect(onUse.mock.calls[0][0].surface).toBe(surface)
expect(agentPickerLaunchInputs(onUse.mock.calls[0][0], own).sessionOptions?.fastMode).toBe(
'true'
)
}
)
it.each([
{ surface: 'chat', model: 'host-private' },
{ surface: 'terminal', model: 'host-private' },
{ surface: 'terminal', model: 'sonnet' },
{ surface: 'terminal', model: 'haiku' }
] as const)(
'keeps saved Fast Args removable for $model with a $surface default',
({ surface, model }) => {
const args = `--model ${model} --settings '{"fastMode":true,"theme":"light"}' --add-dir docs`
const own: AgentPickerEntry = {
...entry,
defaultSurface: surface,
...agentPickerChoiceSeeds('claude', {
agentPermissionDefault: 'own',
agentDefaultArgs: { claude: args }
})
}
act(() =>
root.render(
<TooltipProvider>
<AgentLaunchSetupPanel entry={own} onUse={onUse} />
</TooltipProvider>
)
)
if (surface === 'chat') {
fireEvent.click(host.querySelector('button[aria-label="Toggle Arguments"]')!)
}
const chip = host.querySelector<HTMLButtonElement>('button[aria-label*="Remove --settings"]')
expect(chip).not.toBeNull()
const input = host.querySelector<HTMLTextAreaElement>('textarea[aria-label="Arguments"]')!
expect(input.parentElement?.contains(chip)).toBe(true)
expect(input.value).toContain(`--model ${model}`)
expect(input.value).toContain('"theme":"light"')
expect(input.value).toContain('--add-dir docs')
for (const way of ['Launch chat', 'Launch terminal']) {
onUse.mockClear()
fireEvent.click(
[...host.querySelectorAll('button')].find((button) => button.textContent === way)!
)
expect(agentPickerLaunchInputs(onUse.mock.calls[0][0], own)).not.toHaveProperty('agentArgs')
}
fireEvent.click(chip!)
expect(host.querySelector('button[aria-label*="Remove --settings"]')).toBeNull()
for (const way of ['Launch chat', 'Launch terminal']) {
onUse.mockClear()
fireEvent.click(
[...host.querySelectorAll('button')].find((button) => button.textContent === way)!
)
const launch = agentPickerLaunchInputs(onUse.mock.calls[0][0], own)
expect(launch.sessionOptions?.fastMode).toBe('false')
if (way === 'Launch terminal') {
expect(shell.tokenizeStartupCommand(launch.agentArgs ?? '', 'posix')).toMatchObject({
ok: true,
tokens: [
'--model',
model,
'--settings',
'{"theme":"light","fastMode":false}',
'--add-dir',
'docs'
]
})
}
}
}
)
it('enables recorded Codex tier choices after defaults become known without inventing an inherited tier', async () => {
const { default: capture } =
await import('../../../../shared/fixtures/agent-picker-cli-recordings.json')
const listing = await fetchCodexModelCatalogListing({
connection: { request: vi.fn().mockResolvedValue(capture.modelList) }
})
const model = listing.models[0]
const tier = model.serviceTiers![0]
const own: AgentPickerEntry = {
...entry,
agent: 'codex',
label: 'Codex',
defaultSurface: 'terminal',
arguments: '',
terminal: { model: model.id, permissionMode: 'ask' },
structured: { model: model.id, permissionMode: 'ask' },
catalog: { origin: 'probe', fetchedAt: 1, models: [model] }
}
const show = (value: AgentPickerEntry) =>
act(() =>
root.render(
<TooltipProvider>
<AgentLaunchSetupPanel entry={value} onUse={onUse} />
</TooltipProvider>
)
)
const radios = () => [...host.querySelectorAll<HTMLButtonElement>('[role="radio"]')]
const fast = () => radios().find((radio) => radio.textContent === tier.label)!
const standard = () => radios().find((radio) => radio.textContent === 'Standard')!
show({ ...own, arguments: undefined, defaultsPending: true, structuredDefaultsPending: true })
expect(fast().disabled).toBe(true)
expect(standard().disabled).toBe(true)
show(own)
expect(fast().disabled).toBe(false)
expect(standard().disabled).toBe(false)
expect(fast().getAttribute('aria-checked')).toBe('false')
expect(standard().getAttribute('aria-checked')).toBe('false')
const launch = (way: string) => {
onUse.mockClear()
fireEvent.click(
[...host.querySelectorAll('button')].find((button) => button.textContent === way)!
)
return agentPickerLaunchInputs(onUse.mock.calls[0][0], own)
}
for (const way of ['Launch chat', 'Launch terminal']) {
expect(launch(way)).toEqual({})
}
fireEvent.click(fast())
expect(fast().getAttribute('aria-checked')).toBe('true')
expect(launch('Launch chat').sessionOptions?.serviceTier).toBe(tier.value)
const terminal = launch('Launch terminal')
expect(terminal.sessionOptions?.serviceTier).toBe(tier.value)
expect(terminal.agentArgs).toContain(`service_tier=${tier.value}`)
expect(terminal.sessionOptions?.permissionMode).toBe('ask')
fireEvent.click(standard())
expect(standard().getAttribute('aria-checked')).toBe('true')
expect(launch('Launch chat').sessionOptions?.serviceTier).toBe('default')
expect(launch('Launch terminal').agentArgs).toContain('service_tier=default')
})
it.each([`--settings '{"fastMode":true}' --settings ./team.json`, `--settings '{broken'`])(
'keeps opaque effective settings literal and Fast unclaimed: %s',
(args) => {
const own: AgentPickerEntry = {
...entry,
defaultSurface: 'terminal',
terminal: { model: 'opus', permissionMode: 'ask' },
structured: { model: 'opus', permissionMode: 'ask', options: { fastMode: 'true' } },
arguments: args,
catalog: {
origin: 'probe',
fetchedAt: 1,
models: [
{ id: 'opus', label: 'Opus', isDefault: true, efforts: [], supportsFastMode: true }
]
}
}
act(() =>
root.render(
<TooltipProvider>
<AgentLaunchSetupPanel entry={own} onUse={onUse} />
</TooltipProvider>
)
)
expect(host.textContent).toContain('Set by your Arguments')
expect(host.querySelector('[role="switch"]')).toBeNull()
expect(host.querySelector('button[aria-label*="Remove --settings"]')).toBeNull()
expect(host.querySelector<HTMLTextAreaElement>('textarea[aria-label="Arguments"]')?.value).toBe(
args
)
for (const way of ['Launch chat', 'Launch terminal']) {
onUse.mockClear()
fireEvent.click(
[...host.querySelectorAll('button')].find((button) => button.textContent === way)!
)
const input = agentPickerLaunchInputs(onUse.mock.calls[0][0], own)
expect(input.sessionOptions?.fastMode).toBeUndefined()
expect(input).not.toHaveProperty('agentArgs')
}
}
)
@@ -0,0 +1,384 @@
import { useState } from 'react'
import { Check, ChevronDown, ChevronLeft, ChevronRight, CornerDownLeft } from 'lucide-react'
import { getAgentSessionOptionLaunchCatalog } from '../../../../shared/agent-session-option-catalog'
import { Button } from '@/components/ui/button'
import {
Command,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem
} from '@/components/ui/command'
import { translate } from '@/i18n/i18n'
import { AgentDefaultArgsInput } from '../settings/AgentLaunchDefaultsEditor'
import { NativeChatSessionOptionRows } from '../native-chat/NativeChatSessionOptionRows'
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '@/components/ui/collapsible'
import type { SessionOptionDescriptor } from '../../../../shared/native-chat/input/native-chat-session-options'
import { AgentPermissionMenu } from '../settings/AgentPermissionChoiceControls'
import {
agentPickerPanelArguments,
agentPickerSpeedValue,
editAgentPickerSpeed,
editAgentPickerPanelText
} from '../../../../shared/agent-launch-picker-arguments'
import { agentPickerSpeedIsOpaque } from '../../../../shared/agent-launch-picker-speed'
import { editAgentPickerPermission } from '../../../../shared/agent-launch-picker-permission'
import {
agentPickerButtonOrder,
agentPickerArgumentsKnown,
agentPickerDraft,
agentPickerEffectiveChoices,
agentPickerModels,
agentPickerEntryOptions,
agentPickerPermissions,
editAgentPickerDraft,
type AgentPickerEntry,
type AgentPickerDraft,
type AgentPickerSurface
} from '../../../../shared/agent-launch-picker'
export function AgentLaunchSetupPanel({
entry,
initial,
disabled,
onDraftChange,
onUse
}: {
entry: AgentPickerEntry
initial?: AgentPickerDraft
disabled?: boolean
onDraftChange?: (draft: AgentPickerDraft) => void
onUse: (draft: AgentPickerDraft) => void
}) {
const [draft, updateDraft] = useState(() => initial ?? agentPickerDraft(entry))
const setDraft = (next: AgentPickerDraft) => {
updateDraft(next)
onDraftChange?.(next)
}
const [modelQuery, setModelQuery] = useState('')
const [legacy, setLegacy] = useState(false)
const [argumentsOpen, setArgumentsOpen] = useState(entry.defaultSurface === 'terminal')
const argumentsKnown = agentPickerArgumentsKnown(entry, draft)
const effective = agentPickerEffectiveChoices(entry, draft)
const models = agentPickerModels(entry)
const defaultsPending =
draft.surface === 'chat' ? entry.structuredDefaultsPending : entry.defaultsPending
const seed = getAgentSessionOptionLaunchCatalog(entry.agent)
const listedDefaultKnown =
entry.catalog.origin !== 'unknown' &&
(entry.catalog.listingNamesConfiguredModel ?? seed?.hostListingNamesConfiguredModel === true)
const currentModel =
effective.model ??
(!defaultsPending && listedDefaultKnown
? models.find((model) => model.isDefault)?.id
: undefined)
// Settled with nothing chosen still lets the user pick; nothing is checked and the agent keeps its own default.
const modelDisabled = disabled || defaultsPending
const options = agentPickerEntryOptions(entry, currentModel, draft.surface)
const argumentsView = agentPickerPanelArguments(entry, draft, currentModel)
const optionRows: SessionOptionDescriptor[] = options.map((option) => {
const liveModel = models.find((model) => model.id === currentModel)
const current =
(option.id === 'fastMode' || option.id === 'serviceTier'
? agentPickerSpeedValue(entry, draft, option.id)
: effective.options?.[option.id]) ??
(currentModel && !defaultsPending
? option.kind.type === 'boolean'
? String(option.kind.defaultValue)
: option.id === 'effort'
? liveModel?.defaultEffort
: option.kind.defaultIsCliDefault
? option.kind.defaultValue
: undefined
: undefined)
if (agentPickerSpeedIsOpaque(entry, draft, option.id)) {
return {
id: option.id,
label: option.label,
category: option.category,
kind: {
type: 'select',
choices: [
{
value: 'arguments',
label: translate('agentPicker.setByArguments', 'Set by your Arguments')
}
],
currentValue: 'arguments'
},
valueSource: 'default',
transport: 'agent-session',
settable: false
}
}
const valueKnown =
current !== undefined ||
(option.id === 'serviceTier' &&
entry.catalog.origin !== 'unknown' &&
Boolean(liveModel?.serviceTiers?.length))
return {
id: option.id,
label: option.label,
category: option.category,
kind:
option.kind.type === 'boolean'
? { type: 'boolean', currentValue: current === 'true' }
: {
type: 'select',
choices:
current && !option.kind.choices.some((choice) => choice.value === current)
? [...option.kind.choices, { value: current, label: current }]
: option.kind.choices,
currentValue: current
},
valueSource: valueKnown ? 'default' : 'unknown',
transport: 'agent-session',
settable:
!disabled &&
!defaultsPending &&
// A settled level the agent picks itself shows unselected but stays pickable.
(valueKnown || option.kind.type === 'select') &&
(!(option.id === 'fastMode' || option.id === 'serviceTier') ||
!entry.surfaces.includes('terminal') ||
argumentsKnown)
}
})
const renderOptions = (rows: SessionOptionDescriptor[]) => (
<NativeChatSessionOptionRows
options={rows}
shownReason={null}
pendingIds={new Set()}
divided={false}
layout="settings"
onSet={(option, value) =>
setDraft(
option.id === 'fastMode' || option.id === 'serviceTier'
? editAgentPickerSpeed(entry, draft, option.id, String(value), currentModel)
: editAgentPickerDraft(draft, { options: { [option.id]: String(value) } })
)
}
onAction={() => {}}
/>
)
const use = (surface: AgentPickerSurface) => onUse({ ...draft, surface })
const legacyCount = models.filter((model) => model.isLegacy && !model.hidden).length
return (
<div
className="flex w-full flex-col"
onKeyDown={(event) => {
event.stopPropagation()
if (
!disabled &&
event.key === 'Enter' &&
(event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement)
) {
event.preventDefault()
use(entry.defaultSurface)
}
}}
>
<Command
surface="inline"
density="model"
onKeyDownCapture={(event) => {
if (
!disabled &&
event.key === 'Enter' &&
event.currentTarget.querySelector(
'[cmdk-item][data-selected="true"][data-current="true"]'
)
) {
event.preventDefault()
event.stopPropagation()
use(entry.defaultSurface)
}
}}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.stopPropagation()
}
}}
>
<CommandInput
disabled={modelDisabled}
value={modelQuery}
onValueChange={setModelQuery}
aria-label={translate('agentPicker.searchModels', 'Search models')}
placeholder={translate('agentPicker.searchAgentModels', 'Search {{agent}} models…', {
agent: entry.label
})}
/>
<CommandList className="max-h-56">
<CommandEmpty>
{translate('agentPicker.noModels', 'No models match your search.')}
</CommandEmpty>
<CommandGroup>
{legacy && !modelQuery ? (
<CommandItem value="__back__" disabled={disabled} onSelect={() => setLegacy(false)}>
<ChevronLeft className="size-3.5 text-muted-foreground" />
{translate('agentPicker.legacyModels', 'Legacy models')}
</CommandItem>
) : null}
{models
.filter(
(model) =>
!model.hidden &&
(model.id === currentModel || modelQuery || Boolean(model.isLegacy) === legacy)
)
.map((model) => (
<CommandItem
key={model.id}
value={model.id}
keywords={[model.label]}
disabled={modelDisabled}
data-current={currentModel === model.id}
onSelect={() => setDraft(editAgentPickerDraft(draft, { model: model.id }))}
>
{model.label}
<span className="ml-auto flex size-3.5 shrink-0 items-center justify-center">
{currentModel === model.id ? (
<Check className="size-3.5 text-muted-foreground" />
) : null}
</span>
</CommandItem>
))}
{!legacy && !modelQuery && legacyCount > 0 ? (
<CommandItem value="__legacy__" disabled={disabled} onSelect={() => setLegacy(true)}>
<span className="flex min-w-0 flex-col">
<span>{translate('agentPicker.legacyModels', 'Legacy models')}</span>
<span className="text-muted-foreground">
{translate('agentPicker.legacyModelCount', '{{total}} models', {
total: legacyCount
})}
</span>
</span>
<ChevronRight className="ml-auto size-3.5 text-muted-foreground" />
</CommandItem>
) : null}
{currentModel && !models.some((model) => model.id === currentModel) ? (
<CommandItem
value={currentModel}
disabled={modelDisabled}
onSelect={() => {}}
data-current="true"
>
{currentModel}
<Check className="ml-auto size-3.5 text-muted-foreground" />
</CommandItem>
) : null}
</CommandGroup>
</CommandList>
</Command>
<div className="grid min-w-0 grid-cols-1 gap-2.5 border-t border-border px-3 pt-2.5 pb-1">
{renderOptions(
optionRows.filter((row) => row.id !== 'fastMode' && row.id !== 'serviceTier')
)}
<div>
<AgentPermissionMenu
agentId={entry.agent}
label={entry.label}
layout="row"
showInherited={false}
rawValue={argumentsView.selectedPermission ? undefined : argumentsView.permission}
disabled={
disabled ||
defaultsPending ||
!argumentsView.permission ||
(entry.surfaces.includes('terminal') && !argumentsKnown)
}
permission={{
choice: argumentsView.selectedPermission ?? 'own',
defaultChoice: argumentsView.selectedPermission ?? 'own',
choices: agentPickerPermissions(entry.agent, draft.surface),
source: 'agent',
flag: '',
argsSetPermission: false,
onSelect: (choice) => {
if (choice !== 'default') {
setDraft(editAgentPickerPermission(entry, draft, choice))
}
}
}}
/>
</div>
{renderOptions(
optionRows.filter((row) => row.id === 'fastMode' || row.id === 'serviceTier')
)}
{entry.surfaces.includes('terminal') ? (
<div>
<Collapsible open={argumentsOpen} onOpenChange={setArgumentsOpen}>
<CollapsibleTrigger asChild>
<button
type="button"
aria-label={translate('agentPicker.toggleArguments', 'Toggle Arguments')}
className="flex min-h-7 w-full items-center gap-1.5 text-left text-xs"
>
<span className="flex size-3.5 shrink-0 items-center justify-center text-muted-foreground">
{argumentsOpen ? (
<ChevronDown className="size-3.5" />
) : (
<ChevronRight className="size-3.5" />
)}
</span>
<span className="text-muted-foreground">
{translate('agentPicker.arguments', 'Arguments')}
</span>
<span className="ml-auto truncate text-muted-foreground/70">
{translate(
'agentPicker.argumentsTerminalHint',
'Only applies to terminal (CLI) launches'
)}
</span>
</button>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="pb-1">
<AgentDefaultArgsInput
hideLabel
defaultArgs={argumentsView.text}
argsOverride={argumentsView.text}
disabled={disabled || !argumentsKnown}
onSaveArgs={() => {}}
onDraftChange={(text) =>
setDraft(editAgentPickerPanelText(entry, draft, text, currentModel))
}
choiceChips={argumentsView.chips.map((chip) => ({
key: chip.key,
label: entry.label,
source: 'arguments',
flag: chip.flag,
onRemove: (text) => {
setDraft(chip.remove())
return text
}
}))}
/>
</div>
</CollapsibleContent>
</Collapsible>
</div>
) : null}
</div>
<div className="flex items-center justify-end gap-2 border-t border-border px-2.5 py-2">
{agentPickerButtonOrder(entry).map((surface) => (
<Button
key={surface}
type="button"
size="xs"
disabled={disabled}
variant={surface === entry.defaultSurface ? 'default' : 'outline'}
onClick={() => use(surface)}
>
{surface === 'chat'
? translate('agentPicker.launchChat', 'Launch chat')
: translate('agentPicker.launchTerminal', 'Launch terminal')}
{surface === entry.defaultSurface ? (
<CornerDownLeft className="size-3 opacity-60" />
) : null}
</Button>
))}
</div>
</div>
)
}
@@ -0,0 +1,202 @@
import type { AgentPickerEntry } from '../../../../shared/agent-launch-picker'
import type { TuiAgent } from '../../../../shared/tui-agent'
/** Pending local IPC outlives observers; matching menus must retain its physical owner. */
const pending = new Map<string, AgentPickerAcquisition>()
type Observer = {
publish: (entries: AgentPickerEntry[]) => void
selected: () => TuiAgent | undefined
}
type Reads = {
settings?: () => Promise<AgentPickerEntry[] | undefined>
catalog: (entry: AgentPickerEntry) => Promise<AgentPickerEntry['catalog'] | undefined>
support: (entry: AgentPickerEntry) => Promise<AgentPickerEntry['structured'] | undefined>
}
type SupportRead = {
state: 'queued' | 'pending' | 'settled'
value?: AgentPickerEntry['structured']
}
class AgentPickerAcquisition {
readonly observers = new Set<Observer>()
entries: AgentPickerEntry[]
settingsPending = false
private outstanding = { catalog: 0, support: 0 }
private queued: Record<'catalog' | 'support', Set<number>>
private supports = new Map<number, SupportRead>()
private defaults: AgentPickerEntry[]
constructor(
readonly key: string,
private initial: AgentPickerEntry[],
private reads: Reads
) {
this.entries = initial
this.defaults = initial
for (const [i, entry] of initial.entries()) {
if (entry.structuredDefaultsPending) {
this.supports.set(i, { state: 'queued' })
}
}
this.queued = {
catalog: new Set(initial.flatMap((e, i) => (e.surfaces.length ? [i] : []))),
support: new Set(this.supports.keys())
}
}
observe(observer: Observer) {
const first = this.observers.size === 0
this.observers.add(observer)
if (first) {
for (const [i, read] of this.supports) {
if (read.state === 'settled') {
this.supports.set(i, { state: 'queued' })
this.queued.support.add(i)
}
}
if (!this.settingsPending && this.reads.settings) {
this.defaults = this.initial
this.readSettings(this.reads.settings)
}
this.entries = this.project()
}
observer.publish(this.entries)
this.admit('catalog')
this.admit('support')
return () => {
this.observers.delete(observer)
this.retire()
}
}
private publish() {
for (const observer of this.observers) {
observer.publish(this.entries)
}
}
private project(reopen = false) {
const defaults =
reopen && this.reads.settings && !this.settingsPending ? this.initial : this.defaults
return defaults.map((entry, i) => {
const read = this.supports.get(i)
const current = read && !(reopen && read.state === 'settled')
return {
...entry,
catalog: this.entries[i].catalog,
...(read
? {
structured: (current && read.value) || entry.structured,
structuredDefaultsPending:
!current ||
read.state !== 'settled' ||
(!read.value && entry.structuredDefaultsPending !== false)
}
: {})
}
})
}
currentEntries() {
// Pending sibling I/O owns its request, never renewed authority for completed defaults.
return this.observers.size ? this.entries : this.project(true)
}
private readSettings(read: NonNullable<Reads['settings']>) {
this.settingsPending = true
void read()
.catch(() => undefined)
.then((entries) => {
if (entries) {
this.defaults = entries
}
this.settingsPending = false
this.entries = this.project()
this.publish()
this.retire()
})
}
private admit(kind: 'catalog' | 'support') {
while (this.observers.size && this.outstanding[kind] < 2 && this.queued[kind].size) {
const priorities = [...this.observers].map((o) => o.selected())
const preferred = this.entries.findIndex((e) => priorities.includes(e.agent))
const index = this.queued[kind].has(preferred)
? preferred
: this.queued[kind].values().next().value
if (index === undefined) {
break
}
this.queued[kind].delete(index)
this.outstanding[kind]++
const entry = this.entries[index]
if (kind === 'support') {
this.supports.set(index, { state: 'pending' })
}
const read =
kind === 'catalog'
? this.reads.catalog(entry).then((catalog) => {
if (catalog) {
this.entries = this.entries.map((e, i) => (i === index ? { ...e, catalog } : e))
}
})
: this.reads
.support(entry)
.catch(() => undefined)
.then((value) => {
this.supports.set(index, { state: 'settled', value })
this.entries = this.project()
})
void read
.catch(() => undefined)
.then(() => {
this.outstanding[kind]--
this.publish()
this.admit(kind)
this.retire()
})
}
}
private retire() {
if (
!this.observers.size &&
!this.settingsPending &&
!this.outstanding.catalog &&
!this.outstanding.support
) {
pending.delete(this.key)
}
}
diagnostics() {
return {
observers: this.observers.size,
settings: Number(this.settingsPending),
...this.outstanding
}
}
}
export function observeAgentPickerAcquisition(
key: string,
entries: AgentPickerEntry[],
reads: Reads,
observer: Observer
) {
let resource = pending.get(key)
if (!resource) {
resource = new AgentPickerAcquisition(key, entries, reads)
pending.set(key, resource)
}
return resource.observe(observer)
}
export function currentAgentPickerAcquisition(key: string) {
const resource = pending.get(key)
return resource?.currentEntries()
}
export function agentPickerAcquisitionDiagnostics() {
return [...pending.values()].map((resource) => ({ key: resource.key, ...resource.diagnostics() }))
}
@@ -0,0 +1,190 @@
import { createElement } from 'react'
import { act, fireEvent, render } from '@testing-library/react'
import { expect, vi } from 'vitest'
import { TooltipProvider } from '@/components/ui/tooltip'
import { AgentLaunchSetupPanel } from './AgentLaunchSetupPanel'
import { useAgentLaunchPickerInventory } from './use-agent-launch-picker-inventory'
import { agentPickerAcquisitionDiagnostics } from './agent-launch-picker-acquisition'
import type { NewTabAgentMenuModel } from '../tab-bar/new-tab-agent-menu-model'
import * as picker from '../../../../shared/agent-launch-picker'
import {
applyNativeChatSessionOptionPicks,
structuredLaunchSeedOptions
} from '../../../../shared/native-chat/input/native-chat-session-option-defaults'
export async function exerciseAgentPickerFreshness({
menu,
session,
catalog
}: {
menu: Extract<NewTabAgentMenuModel, { state: 'settled' }>
session: ReturnType<typeof vi.fn>
catalog: picker.AgentPickerEntry['catalog']
}) {
let saved = applyNativeChatSessionOptionPicks({
persisted: {},
agent: 'claude',
picks: [{ modelId: 'opus', optionId: 'effort', value: 'low' }]
})
const seed = () =>
structuredLaunchSeedOptions(
{ nativeChatSessionOptions: saved, nativeChatPermissionMode: 'ask' },
'claude'
)
const firstSeed = seed()
const reads: {
host: string
method: string
resolve: (value: unknown) => void
reject: (error: Error) => void
}[] = []
session.mockImplementation((target, method) => {
const read = Promise.withResolvers<unknown>()
reads.push({ host: target.environmentId, method, resolve: read.resolve, reject: read.reject })
return read.promise
})
const supports = () => reads.filter((r) => r.method === 'agentSession.createSupport')
const launches = vi.fn()
const snapshots: picker.AgentPickerEntry[] = []
const current: { entry?: picker.AgentPickerEntry } = {}
function Setup({ value }: { value: typeof menu }) {
const [entry] = useAgentLaunchPickerInventory('wt', value, true)
current.entry = entry
snapshots.push(entry)
return createElement(
TooltipProvider,
null,
createElement(AgentLaunchSetupPanel, {
key: entry.ownerKey,
entry,
onUse: (draft) => launches(picker.agentPickerLaunchInputs(draft, entry))
})
)
}
const mount = () => render(createElement(Setup, { value: menu }))
let view = mount()
let second: ReturnType<typeof mount> | undefined
const search = () =>
view.container.querySelector<HTMLInputElement>('input[aria-label="Search models"]')!
const choice = () => view.container.querySelector('[cmdk-item][data-current="true"]')?.textContent
const effortControls = () => [
...view.container.querySelectorAll<HTMLButtonElement>(
'[data-option-id="effort"] [role="radio"]'
)
]
const effort = () =>
view.container.querySelector('[data-option-id="effort"] [data-state="on"]')?.textContent
const launch = () => {
fireEvent.click(
[...view.container.querySelectorAll('button')].find((b) => b.textContent === 'Launch chat')!
)
expect(launches.mock.lastCall).toEqual([{}])
}
const stats = () =>
agentPickerAcquisitionDiagnostics().filter((s) => s.key.includes('source-freshness'))
try {
await act(async () => supports()[0].resolve({ seedOptions: seed() }))
expect(choice()).toContain('Opus')
expect(effort()).toBe('Low')
expect(search().disabled).toBe(false)
expect(effortControls().length).toBeGreaterThan(0)
expect(effortControls().every((control) => !control.disabled)).toBe(true)
expect(current.entry?.structured).toMatchObject({
model: seed()?.model,
options: { effort: 'low' }
})
launch()
view.unmount()
saved = applyNativeChatSessionOptionPicks({
persisted: saved,
agent: 'claude',
picks: [{ modelId: 'sonnet', optionId: 'effort', value: 'high' }]
})
snapshots.length = 0
view = mount()
await act(async () => {})
process.stdout.write(
`${JSON.stringify({ metric: 'source-freshness-reopen', producer: seed(), displayed: current.entry?.structured, supportStarts: supports().length, disabled: search().disabled })}\n`
)
expect(snapshots.every((entry) => entry.structuredDefaultsPending)).toBe(true)
expect(search().disabled).toBe(true)
expect(choice()).toBeUndefined()
expect(supports()).toHaveLength(2)
launch()
second = mount()
expect(supports()).toHaveLength(2)
expect(stats()).toMatchObject([{ observers: 2, catalog: 1, support: 1 }])
await act(async () => supports()[1].resolve({ seedOptions: seed() }))
expect(choice()).toContain('Sonnet')
expect(effort()).toBe('High')
expect(search().disabled).toBe(false)
expect(
second.container.querySelector('[cmdk-item][data-current="true"]')?.textContent
).toContain('Sonnet')
expect(current.entry?.structured).toMatchObject({
model: seed()?.model,
options: { effort: 'high' }
})
launch()
second.unmount()
for (const answer of ['missing', 'rejected'] as const) {
view.unmount()
view = mount()
expect(search().disabled).toBe(true)
const read = supports().at(-1)!
await act(async () =>
answer === 'missing' ? read.resolve({ supported: true }) : read.reject(new Error('offline'))
)
expect(current.entry?.structuredDefaultsPending).toBe(true)
expect(current.entry?.structured.model).toBeUndefined()
expect(search().disabled).toBe(true)
expect(choice()).toBeUndefined()
expect(effortControls().length).toBeGreaterThan(0)
expect(effortControls().every((control) => control.disabled)).toBe(true)
launch()
}
view.unmount()
view = mount()
const starts = supports().length
const pendingRead = supports().at(-1)!
view.unmount()
view = mount()
expect(supports()).toHaveLength(starts)
expect(search().disabled).toBe(true)
await act(async () => pendingRead.resolve({ seedOptions: seed() }))
expect(effort()).toBe('High')
view.unmount()
view = mount()
const oldRead = supports().at(-1)!
if (!menu.pickerContext) {
throw new Error('paired fixture')
}
view.rerender(
createElement(Setup, {
value: {
...menu,
pickerContext: { ...menu.pickerContext, pairingRevision: 8, runtimeId: 'replacement' }
}
})
)
expect(search().disabled).toBe(true)
expect(supports()).toHaveLength(starts + 2)
const newRead = supports().at(-1)!
await act(async () => newRead.resolve({ seedOptions: seed() }))
const owner = current.entry?.ownerKey
await act(async () => oldRead.resolve({ seedOptions: firstSeed }))
expect(current.entry?.ownerKey).toBe(owner)
expect(effort()).toBe('High')
expect(choice()).toContain('Sonnet')
launch()
} finally {
view.unmount()
second?.unmount()
await act(async () =>
reads.forEach((read) =>
read.resolve(read.method === 'agentSession.modelCatalog' ? catalog : {})
)
)
expect(stats()).toEqual([])
}
}
@@ -0,0 +1,31 @@
import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, vi } from 'vitest'
import type { AgentPickerDraft, AgentPickerEntry } from '../../../../shared/agent-launch-picker'
export const entry: AgentPickerEntry = {
agent: 'claude',
label: 'Claude',
rows: [{ agent: 'claude', kind: 'terminal' }],
surfaces: ['chat', 'terminal'],
defaultSurface: 'terminal',
terminal: { permissionMode: 'ask' },
structured: { permissionMode: 'ask' },
arguments: '--permission-mode plan --add-dir docs',
catalog: { origin: 'unknown' }
}
export let root: Root
export let host: HTMLDivElement
export const onUse = vi.fn<(draft: AgentPickerDraft) => void>()
beforeEach(() => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
onUse.mockClear()
host = document.createElement('div')
document.body.append(host)
root = createRoot(host)
})
afterEach(() => {
act(() => root.unmount())
host.remove()
vi.unstubAllGlobals()
})
@@ -0,0 +1,720 @@
// @vitest-environment happy-dom
import { createElement, Suspense, startTransition, useLayoutEffect, useState } from 'react'
import { act, cleanup, fireEvent, render, renderHook } from '@testing-library/react'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import { AgentLaunchPickerMenu } from './AgentLaunchPickerMenu'
import { exerciseAgentPickerFreshness } from './agent-launch-picker-freshness-test-fixture'
import { TooltipProvider } from '@/components/ui/tooltip'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import {
buildNewTabAgentMenuRows,
type HostChatAnswer
} from '../../../../shared/agent-launch-picker-rows'
import * as picker from '../../../../shared/agent-launch-picker'
import { projectAgentLaunchSettings } from '../../../../shared/agent-launch-settings'
import type { AgentSessionModelCatalogResult } from '../../../../shared/agent-session-wire'
import type { NewTabAgentMenuModel } from '../tab-bar/new-tab-agent-menu-model'
import { useAgentLaunchPickerInventory } from './use-agent-launch-picker-inventory'
import { agentPickerAcquisitionDiagnostics } from './agent-launch-picker-acquisition'
vi.mock('@/hooks/useShortcutLabel', () => ({ useOptionalShortcutLabel: () => null }))
const mocks = vi.hoisted(() => ({ settings: vi.fn(), rpc: vi.fn(), session: vi.fn() }))
vi.mock('../settings/agent-launch-settings-transport', () => ({
readHostAgentLaunchSettings: mocks.settings
}))
vi.mock('@/store', () => ({
useAppStore: Object.assign(
(select: (state: unknown) => unknown) =>
select({ settings: { agentDefaultArgs: { codex: '--desktop-only' } } }),
{ getState: () => ({}) }
)
}))
vi.mock('@/lib/agent-launch-route-input', () => ({
buildAgentLaunchRouteInput: () => ({ executionHostId: 'local' }),
workspaceKindForWorktreeId: () => 'git-worktree'
}))
vi.mock('@/runtime/structured-agent-session-owner', () => ({
structuredAgentSessionTargetForHost: (host: string) =>
host.startsWith('runtime:')
? { kind: 'environment', environmentId: host.slice(8) }
: host === 'local'
? { kind: 'local' }
: null
}))
vi.mock('@/runtime/runtime-rpc-client', () => ({ callRuntimeRpc: mocks.rpc }))
vi.mock('@/runtime/structured-agent-session-client', () => ({
callStructuredAgentSession: mocks.session
}))
function model(
host = 'alpha',
agents: picker.AgentPickerEntry['agent'][] = ['codex']
): Extract<NewTabAgentMenuModel, { state: 'settled' }> {
return {
state: 'settled',
rows: agents.map((agent) => ({ agent, kind: 'chat', executionHostId: `runtime:${host}` })),
detection: {
detectedIds: agents,
isLoading: false,
isRefreshing: false,
detectionFailed: false,
needsServerUpdate: false,
refresh: async () => agents
},
pickerContext: {
workspace: { kind: 'git-worktree', worktreeId: 'wt' },
executionHostId: `runtime:${host}`,
pairingRevision: 7,
runtimeId: `process-${host}`,
settings: null,
key: host
}
}
}
const catalog: AgentSessionModelCatalogResult = {
origin: 'probe',
fetchedAt: 1,
models: [
{
id: 'host-model',
label: 'Host model',
isDefault: true,
efforts: [{ value: 'low', label: 'Low' }]
}
]
}
beforeEach(() => {
mocks.settings.mockReset().mockResolvedValue(
projectAgentLaunchSettings({
agentPermissionDefault: 'ask',
agentDefaultArgs: { codex: '--host-only' }
})
)
mocks.rpc.mockReset()
mocks.session
.mockReset()
.mockImplementation(async (_target, method) =>
method === 'agentSession.modelCatalog'
? catalog
: { seedOptions: { model: 'host-model', effort: 'low', permissionMode: 'full-access' } }
)
})
afterEach(cleanup)
it('revalidates completed host Chat defaults behind held siblings through the actual producer, resource and panel', async () => {
await exerciseAgentPickerFreshness({
menu: model('source-freshness', ['claude']),
session: mocks.session,
catalog
})
})
it('publishes only committed selection to physical catalog admission after a suspended render', async () => {
const menu = model('committed-priority', ['codex', 'claude', 'gemini', 'cursor', 'amp'])
const settings = Promise.withResolvers<ReturnType<typeof projectAgentLaunchSettings>>()
mocks.settings.mockReturnValue(settings.promise)
const reads: {
method: string
agent: picker.AgentPickerEntry['agent']
resolve: (value: unknown) => void
}[] = []
mocks.session.mockImplementation((_target, method, params) => {
const read = Promise.withResolvers<unknown>()
reads.push({ method, agent: params.agent, resolve: read.resolve })
return read.promise
})
const suspended = Promise.withResolvers<void>()
const rendered = vi.fn()
const committed = vi.fn()
type Selection = { selected: picker.AgentPickerEntry['agent']; suspend: boolean }
function Inventory({ selected, suspend }: Selection) {
const entries = useAgentLaunchPickerInventory('wt', menu, true, selected)
rendered(selected, suspend)
useLayoutEffect(() => {
committed(selected)
}, [selected])
if (suspend) {
throw suspended.promise
}
return createElement(
'div',
null,
`${selected}:${entries[0]?.defaultsPending ? 'unknown' : 'known'}`
)
}
function Menu() {
const [selection, setSelection] = useState<Selection>({ selected: 'claude', suspend: false })
return createElement(
'div',
null,
createElement(
'button',
{
onClick: () => startTransition(() => setSelection({ selected: 'amp', suspend: true }))
},
'Speculate'
),
createElement(
'button',
{
onClick: () => setSelection({ selected: 'amp', suspend: false })
},
'Commit'
),
createElement(Suspense, { fallback: 'Waiting' }, createElement(Inventory, selection))
)
}
const view = render(createElement(Menu))
const catalogs = () => reads.filter((read) => read.method === 'agentSession.modelCatalog')
const stats = () =>
agentPickerAcquisitionDiagnostics().filter((s) => s.key.includes('committed-priority'))
try {
expect(catalogs().map((read) => read.agent)).toEqual(['claude', 'codex'])
expect(stats()).toMatchObject([{ observers: 1, catalog: 2, support: 2, settings: 1 }])
fireEvent.click(view.getByText('Speculate'))
expect(rendered).toHaveBeenCalledWith('amp', true)
expect(committed.mock.calls).toEqual([['claude']])
expect(view.getByText('claude:unknown')).toBeTruthy()
await act(async () => catalogs()[0].resolve(catalog))
expect(catalogs().map((read) => read.agent)).toEqual(['claude', 'codex', 'gemini'])
expect(committed.mock.calls).toEqual([['claude']])
fireEvent.click(view.getByText('Commit'))
expect(committed.mock.calls).toEqual([['claude'], ['amp']])
expect(catalogs()).toHaveLength(3)
await act(async () => catalogs()[1].resolve(catalog))
expect(catalogs().map((read) => read.agent)).toEqual(['claude', 'codex', 'gemini', 'amp'])
expect(stats()).toMatchObject([{ observers: 1, catalog: 2, support: 2, settings: 1 }])
expect(mocks.settings).toHaveBeenCalledTimes(1)
await act(async () =>
settings.resolve(projectAgentLaunchSettings({ agentPermissionDefault: 'ask' }))
)
expect(view.getByText('amp:known')).toBeTruthy()
expect(stats()).toMatchObject([{ observers: 1, catalog: 2, support: 2, settings: 0 }])
} finally {
view.unmount()
expect(stats()).toMatchObject([{ observers: 0 }])
await act(async () => {
settings.resolve(projectAgentLaunchSettings({}))
suspended.resolve()
for (const read of reads) {
read.resolve(read.method === 'agentSession.modelCatalog' ? catalog : {})
}
})
expect(stats()).toEqual([])
}
expect(catalogs().map((read) => read.agent)).toEqual(['claude', 'codex', 'gemini', 'amp'])
process.stdout.write(
`${JSON.stringify({
metric: 'committed-priority',
suspendedRenderReachedHook: rendered.mock.calls.some(
([agent, suspend]) => agent === 'amp' && suspend
),
committed: committed.mock.calls.map(([agent]) => agent),
catalogStarts: catalogs().map((read) => read.agent),
supportStarts: reads.filter((read) => read.method === 'agentSession.createSupport').length,
settingsStarts: mocks.settings.mock.calls.length,
retainedOwnersAfterSettlement: stats().length
})}\n`
)
})
it('reads host Arguments and remembered chat choices without leaking desktop defaults or refetching for selection', async () => {
const menu = model()
const hook = renderHook(
({ selected }: { selected?: 'codex' }) =>
useAgentLaunchPickerInventory('wt', menu, true, selected),
{ initialProps: {} }
)
expect(hook.result.current[0]).toMatchObject({
defaultsPending: true,
terminal: {},
structured: {}
})
await act(async () => {})
expect(hook.result.current[0]).toMatchObject({
defaultsPending: false,
arguments: '--host-only',
terminal: { permissionMode: 'ask' },
structured: { model: 'host-model', options: { effort: 'low' }, permissionMode: 'full-access' },
catalog
})
expect(mocks.settings).toHaveBeenCalledWith(
{ environmentId: 'alpha', pairingRevision: 7, runtimeId: 'process-alpha' },
expect.any(AbortSignal)
)
hook.rerender({ selected: 'codex' })
expect(mocks.settings).toHaveBeenCalledTimes(1)
expect(mocks.session).toHaveBeenCalledTimes(2)
expect(
mocks.session.mock.calls.every(
(call) => call[0].environmentId === 'alpha' && call[2].worktree === 'id:wt'
)
).toBe(true)
expect(mocks.rpc).not.toHaveBeenCalled()
})
it('uses existing legacy settings and a missing optional seed on an older paired host', async () => {
mocks.settings.mockResolvedValue(null)
mocks.rpc.mockResolvedValue({
settings: { agentDefaultArgs: { codex: '--sandbox workspace-write' } }
})
mocks.session.mockImplementation(async (_target, method) =>
method === 'agentSession.modelCatalog' ? catalog : { supported: true }
)
const hook = renderHook(() => useAgentLaunchPickerInventory('wt', model(), true))
await act(async () => {})
expect(hook.result.current[0]).toMatchObject({
defaultsPending: false,
arguments: '--sandbox workspace-write',
structured: { options: {} }
})
expect(mocks.rpc).toHaveBeenCalledExactlyOnceWith(
{ kind: 'environment', environmentId: 'alpha' },
'settings.get',
undefined,
expect.objectContaining({
expectedEnvironmentPairingRevision: 7,
expectedEnvironmentRuntimeId: 'process-alpha'
})
)
})
it('bounds open-time catalog hydration to two agents and discards responses from the previous host', async () => {
const old = Promise.withResolvers<AgentSessionModelCatalogResult>()
mocks.session.mockImplementation((_target, method) =>
method === 'agentSession.modelCatalog' ? old.promise : Promise.resolve({ supported: true })
)
const hook = renderHook(({ menu }) => useAgentLaunchPickerInventory('wt', menu, true), {
initialProps: { menu: model('alpha', ['codex', 'claude', 'gemini']) }
})
await act(async () => {})
const catalogs = () =>
mocks.session.mock.calls.filter((call) => call[1] === 'agentSession.modelCatalog')
expect(catalogs()).toHaveLength(2)
mocks.session.mockImplementation(async (_target, method) =>
method === 'agentSession.modelCatalog' ? catalog : { supported: true }
)
hook.rerender({ menu: model('beta') })
await act(async () => {})
expect(mocks.settings.mock.calls[0][1].aborted).toBe(false)
await act(async () => old.resolve({ ...catalog, fetchedAt: 2 }))
expect(catalogs()).toHaveLength(3)
expect(hook.result.current).toHaveLength(1)
expect(hook.result.current[0].catalog).toEqual(catalog)
expect(hook.result.current[0].rows[0].executionHostId).toBe('runtime:beta')
})
it.each(['slow', 'failed'] as const)(
'publishes known terminal defaults independently of %s optional catalog/support reads',
async (kind) => {
const listing = Promise.withResolvers<AgentSessionModelCatalogResult>()
mocks.session.mockImplementation((_target, method) =>
method === 'agentSession.modelCatalog'
? listing.promise
: kind === 'failed'
? Promise.reject(new Error('unavailable'))
: Promise.resolve({ seedOptions: { model: 'host-model', permissionMode: 'ask' } })
)
const hook = renderHook(() => useAgentLaunchPickerInventory('wt', model(), true))
await act(async () => {})
expect(hook.result.current[0]).toMatchObject({
defaultsPending: false,
structuredDefaultsPending: kind === 'failed',
arguments: '--host-only',
terminal: { permissionMode: 'ask' },
catalog: { origin: 'unknown' }
})
if (kind === 'slow') {
expect(hook.result.current[0].structured.model).toBe('host-model')
}
await act(async () => listing.resolve(catalog))
expect(hook.result.current[0].defaultsPending).toBe(false)
}
)
it('retains legacy remembered values when createSupport does not supply optional seeds', async () => {
mocks.settings.mockResolvedValue(null)
mocks.rpc.mockResolvedValue({
settings: {
agentDefaultArgs: { codex: '--host-only' },
nativeChatSessionOptions: {
codex: { model: 'host-private', valuesByModel: { 'host-private': { effort: 'low' } } }
},
nativeChatPermissionMode: 'full-access'
}
})
mocks.session.mockImplementation(async (_target, method) =>
method === 'agentSession.modelCatalog' ? catalog : { supported: true }
)
const hook = renderHook(() => useAgentLaunchPickerInventory('wt', model(), true))
await act(async () => {})
expect(hook.result.current[0]).toMatchObject({
defaultsPending: false,
structured: { model: 'host-private', options: { effort: 'low' }, permissionMode: 'full-access' }
})
})
it('keeps Chat default controls pending until their independent seed read settles, even after catalog publication', async () => {
const support = Promise.withResolvers<{ seedOptions?: Record<string, string> }>()
mocks.session.mockImplementation((_target, method) =>
method === 'agentSession.modelCatalog' ? Promise.resolve(catalog) : support.promise
)
const hook = renderHook(() => useAgentLaunchPickerInventory('wt', model(), true))
await act(async () => {})
expect(hook.result.current[0]).toMatchObject({
defaultsPending: false,
structuredDefaultsPending: true,
catalog
})
expect(hook.result.current[0].terminal.permissionMode).toBe('ask')
await act(async () =>
support.resolve({ seedOptions: { model: 'remembered', permissionMode: 'ask' } })
)
expect(hook.result.current[0]).toMatchObject({
defaultsPending: false,
structuredDefaultsPending: false,
structured: { model: 'remembered', permissionMode: 'ask' }
})
})
it('acquires each host agent once per open batch at N and 10N, independently of catalog size and selection', async () => {
const { TUI_AGENT_CONFIG } = await import('../../../../shared/tui-agent-config')
const agents = Object.keys(TUI_AGENT_CONFIG).filter(
(agent): agent is keyof typeof TUI_AGENT_CONFIG => Object.hasOwn(TUI_AGENT_CONFIG, agent)
)
const seeds = vi.spyOn(picker, 'agentPickerChoiceSeeds')
for (const count of [3, 30]) {
seeds.mockClear()
mocks.settings.mockClear()
mocks.session.mockClear()
const menu = model()
if (menu.state !== 'settled') {
throw new Error('settled fixture')
}
menu.rows = agents
.slice(0, count)
.map((agent) => ({ agent, kind: 'chat', executionHostId: 'runtime:alpha' }))
menu.detection.detectedIds = agents.slice(0, count)
const hook = renderHook(
({ open, selected }) => useAgentLaunchPickerInventory('wt', menu, open, selected),
{ initialProps: { open: true, selected: agents[0] } }
)
await act(async () => {})
expect(mocks.settings).toHaveBeenCalledTimes(1)
expect(
mocks.session.mock.calls.filter((call) => call[1] === 'agentSession.modelCatalog')
).toHaveLength(count)
expect(
mocks.session.mock.calls.filter((call) => call[1] === 'agentSession.createSupport')
).toHaveLength(count)
hook.rerender({ open: true, selected: agents[1] })
expect(mocks.session).toHaveBeenCalledTimes(2 * count)
expect(seeds).toHaveBeenCalledTimes(2 * count)
hook.rerender({ open: false, selected: agents[1] })
hook.rerender({ open: true, selected: agents[1] })
await act(async () => {})
expect(mocks.session).toHaveBeenCalledTimes(4 * count)
hook.unmount()
}
seeds.mockRestore()
})
it('updates terminal availability when detection changes while the Chat rows stay the same', async () => {
const menu = model()
if (menu.state !== 'settled') {
throw new Error('settled fixture')
}
const initialProps: { value: Extract<NewTabAgentMenuModel, { state: 'settled' }> } = {
value: { ...menu, detection: { ...menu.detection, detectedIds: [] } }
}
const hook = renderHook(
({ value }: { value: Extract<NewTabAgentMenuModel, { state: 'settled' }> }) =>
useAgentLaunchPickerInventory('wt', value, true),
{ initialProps }
)
await act(async () => {})
expect(hook.result.current[0].surfaces).toEqual(['chat'])
hook.rerender({ value: menu })
await act(async () => {})
expect(hook.result.current[0].surfaces).toEqual(['chat', 'terminal'])
})
it.each([3, 30])(
'shares actual unresolved reads and current listeners across %i opens and matching controllers',
async (count) => {
const { agentPickerAcquisitionDiagnostics } = await import('./agent-launch-picker-acquisition')
const settings = Promise.withResolvers<ReturnType<typeof projectAgentLaunchSettings>>()
mocks.settings.mockReturnValue(settings.promise)
const reads: {
method: string
resolve: (value: unknown) => void
reject: (reason: Error) => void
}[] = []
mocks.session.mockImplementation((_target, method) => {
const read = Promise.withResolvers<unknown>()
reads.push({ method, resolve: read.resolve, reject: read.reject })
return read.promise
})
const menu = model(`shared-${count}`, ['codex', 'claude', 'gemini'])
const mount = (open = true) =>
renderHook(({ open }) => useAgentLaunchPickerInventory('wt', menu, open), {
initialProps: { open }
})
const one = mount(),
two = mount()
for (let i = 0; i < count; i++) {
one.rerender({ open: false })
two.rerender({ open: false })
one.rerender({ open: true })
two.rerender({ open: true })
}
two.unmount()
const successor = mount()
expect(mocks.settings).toHaveBeenCalledTimes(1)
expect(reads).toHaveLength(4)
const stats = () =>
agentPickerAcquisitionDiagnostics().find((s) => s.key.includes(`shared-${count}`))!
expect(stats()).toMatchObject({ observers: 2, settings: 1, catalog: 2, support: 2 })
one.rerender({ open: false })
successor.rerender({ open: false })
expect(stats().observers).toBe(0)
await act(async () =>
reads.find((r) => r.method === 'agentSession.modelCatalog')!.resolve(catalog)
)
expect(reads).toHaveLength(4) // last-close settlement admits no next agent
expect(stats().catalog).toBe(1)
successor.rerender({ open: true })
expect(reads).toHaveLength(5)
await act(async () =>
reads.find((r) => r.method === 'agentSession.createSupport')!.reject(new Error('unavailable'))
)
expect(reads).toHaveLength(6) // rejection releases the actual support slot
await act(async () =>
settings.resolve(
projectAgentLaunchSettings({
agentPermissionDefault: 'ask',
agentDefaultArgs: { codex: '--add-dir docs' }
})
)
)
expect(successor.result.current[0]).toMatchObject({
defaultsPending: false,
arguments: '--add-dir docs',
catalog
})
expect(mocks.settings).toHaveBeenCalledTimes(1)
await act(async () => {
for (const r of reads) {
r.resolve(r.method === 'agentSession.modelCatalog' ? catalog : { supported: true })
}
})
expect(stats()).toMatchObject({ settings: 0, catalog: 0, support: 0, observers: 1 })
successor.unmount()
expect(stats()).toBeUndefined()
one.unmount()
}
)
it.each([
undefined,
null,
[],
'bad',
{ agentDefaultArgs: 'bad' },
{ agentPermission: { default: 'ask', userArgs: 'bad' } }
])(
'does not turn malformed/missing settings %j into known-empty, and recovers on a legitimate open',
async (payload) => {
const optional = Promise.withResolvers<unknown>()
mocks.session.mockReturnValue(optional.promise)
mocks.settings.mockResolvedValue(null)
mocks.rpc.mockResolvedValue({ settings: payload })
const menu = model(`invalid-${JSON.stringify(payload)}`)
const hook = renderHook(({ open }) => useAgentLaunchPickerInventory('wt', menu, open), {
initialProps: { open: true }
})
await act(async () => {})
expect(hook.result.current[0].defaultsPending).toBe(true)
expect(mocks.rpc).toHaveBeenCalledTimes(1)
hook.rerender({ open: false })
mocks.rpc.mockResolvedValue({ settings: {} })
hook.rerender({ open: true })
expect(hook.result.current[0].defaultsPending).toBe(true)
await act(async () => {})
expect(hook.result.current[0].defaultsPending).toBe(false)
expect(mocks.settings).toHaveBeenCalledTimes(2)
expect(mocks.session).toHaveBeenCalledTimes(2) // optional pending work survives settings revalidation
await act(async () => optional.resolve(undefined))
}
)
it('revokes same-key read authority synchronously, retains failure as unknown, then accepts a late valid current answer', async () => {
const menu = model('authority')
const hook = renderHook(({ open }) => useAgentLaunchPickerInventory('wt', menu, open), {
initialProps: { open: true }
})
await act(async () => {})
expect(hook.result.current[0].defaultsPending).toBe(false)
hook.rerender({ open: false })
const next = Promise.withResolvers<ReturnType<typeof projectAgentLaunchSettings>>()
mocks.settings.mockReturnValue(next.promise)
hook.rerender({ open: true })
expect(hook.result.current[0].defaultsPending).toBe(true)
hook.rerender({ open: false })
hook.rerender({ open: true })
expect(mocks.settings).toHaveBeenCalledTimes(2) // rejoins same pending read
mocks.rpc.mockRejectedValue(new Error('legacy offline'))
await act(async () => next.reject(new Error('offline')))
expect(hook.result.current[0].defaultsPending).toBe(true)
hook.rerender({ open: false })
const valid = Promise.withResolvers<ReturnType<typeof projectAgentLaunchSettings>>()
mocks.settings.mockReturnValue(valid.promise)
hook.rerender({ open: true })
await act(async () =>
valid.resolve(projectAgentLaunchSettings({ agentPermissionDefault: 'ask' }))
)
expect(hook.result.current[0]).toMatchObject({
defaultsPending: false,
terminal: { permissionMode: 'ask' }
})
})
it('lets a re-paired owner progress independently of held local-style calls and fences late old publication', async () => {
const oldSettings = Promise.withResolvers<ReturnType<typeof projectAgentLaunchSettings>>()
const oldOptional = Promise.withResolvers<unknown>()
mocks.settings.mockReturnValueOnce(oldSettings.promise)
mocks.session.mockReturnValueOnce(oldOptional.promise).mockReturnValueOnce(oldOptional.promise)
const menu = model('re-pair')
const hook = renderHook(({ menu }) => useAgentLaunchPickerInventory('wt', menu, true), {
initialProps: { menu }
})
if (menu.state !== 'settled' || !menu.pickerContext) {
throw new Error('fixture')
}
const successor = {
...menu,
pickerContext: { ...menu.pickerContext, pairingRevision: 8, runtimeId: 'new-runtime' }
}
hook.rerender({ menu: successor })
await act(async () => {})
expect(mocks.settings).toHaveBeenCalledTimes(2)
expect(hook.result.current[0].defaultsPending).toBe(false)
const current = hook.result.current[0]
await act(async () => {
oldSettings.resolve(projectAgentLaunchSettings({ agentPermissionDefault: 'full-access' }))
oldOptional.resolve(undefined)
})
expect(hook.result.current[0]).toEqual(current)
})
it.each([3, 30])(
'shares uncancelable local catalog ownership across %i remounts and rereads after true settlement',
async (count) => {
const { agentPickerAcquisitionDiagnostics } = await import('./agent-launch-picker-acquisition')
const read = Promise.withResolvers<AgentSessionModelCatalogResult>()
mocks.session.mockReturnValue(read.promise)
const menu = model(`local-${count}`, ['codex', 'claude', 'gemini'])
if (menu.state !== 'settled' || !menu.pickerContext) {
throw new Error('fixture')
}
menu.pickerContext.executionHostId = 'local'
const mount = () => renderHook(() => useAgentLaunchPickerInventory('wt', menu, true))
const surviving = mount()
for (let i = 0; i < count; i++) {
const transient = mount()
transient.unmount()
}
expect(mocks.session).toHaveBeenCalledTimes(2)
expect(mocks.settings).not.toHaveBeenCalled()
expect(
mocks.session.mock.calls.every(
(call) => call[0].kind === 'local' && call[1] === 'agentSession.modelCatalog'
)
).toBe(true)
expect(
agentPickerAcquisitionDiagnostics().find((s) => s.key.includes(`local-${count}`))
).toMatchObject({ observers: 1, catalog: 2, support: 0, settings: 0 })
await act(async () => read.resolve(catalog))
expect(mocks.session).toHaveBeenCalledTimes(3)
expect(surviving.result.current.every((entry) => entry.catalog === catalog)).toBe(true)
surviving.unmount()
expect(agentPickerAcquisitionDiagnostics().some((s) => s.key.includes(`local-${count}`))).toBe(
false
)
const fresh = mount()
await act(async () => {})
expect(mocks.session).toHaveBeenCalledTimes(6)
fresh.unmount()
}
)
it('updates a mounted setup from current host rows without making ordinary actions wait for unknown defaults', async () => {
mocks.settings.mockRejectedValue(new Error('offline'))
mocks.rpc.mockRejectedValue(new Error('offline'))
const optional = Promise.withResolvers<void>()
mocks.session.mockImplementation((_target, method) =>
optional.promise.then(() => (method === 'agentSession.modelCatalog' ? catalog : {}))
)
const launch = vi.fn()
let current: picker.AgentPickerEntry[] = []
function Menu({ answer }: { answer: HostChatAnswer }) {
const menu = model('late-offer')
menu.rows = buildNewTabAgentMenuRows({
settings: { experimentalNativeChat: true, nativeChatProviders: { codex: false } },
detectedOnHost: ['codex'],
chatAgents: ['codex'],
hostChatAnswer: () => answer,
chatProgram: () => 'available'
}).map((row) => ({ ...row, executionHostId: 'runtime:late-offer' as const }))
current = useAgentLaunchPickerInventory('wt', menu, true)
return createElement(
TooltipProvider,
null,
createElement(
DropdownMenu,
{ defaultOpen: true, modal: false },
createElement(DropdownMenuTrigger, null, 'New tab'),
createElement(
DropdownMenuContent,
null,
createElement(AgentLaunchPickerMenu, { model: menu, entries: current, runLaunch: launch })
)
)
)
}
const view = render(createElement(Menu, { answer: 'not-yet-known' }))
await act(async () => {})
const configure = document.querySelector<HTMLButtonElement>(
'button[aria-label="Configure Codex"]'
)!
fireEvent.click(configure)
expect(document.body.textContent).toContain('Launch terminal')
expect(document.body.textContent).not.toContain('Launch chat')
for (const answer of ['yes', 'ssh', 'yes'] as const) {
view.rerender(createElement(Menu, { answer }))
await act(async () => {})
expect(current[0].defaultSurface).toBe('terminal')
expect(current[0].defaultsPending).toBe(true)
expect(current[0].surfaces).toEqual(answer === 'yes' ? ['chat', 'terminal'] : ['terminal'])
expect(document.body.textContent?.includes('Launch chat')).toBe(answer === 'yes')
expect(document.querySelector('textarea')).toHaveProperty('disabled', true)
expect(
[...document.querySelectorAll('button')].find(
(button) => button.textContent === 'Launch terminal'
)
).toHaveProperty('disabled', false)
}
expect(launch).not.toHaveBeenCalled()
fireEvent.keyDown(document.querySelector('textarea')!, { key: 'Escape' })
fireEvent.keyDown(document.querySelector('[role="menuitem"]')!, { key: 'Enter' })
expect(launch).toHaveBeenCalledExactlyOnceWith(
{ agent: 'codex', kind: 'cli', chat: true, executionHostId: 'runtime:late-offer' },
undefined
)
view.unmount()
await act(async () => optional.resolve())
})
@@ -0,0 +1,217 @@
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { readHostAgentLaunchSettings } from '../settings/agent-launch-settings-transport'
import type { NewTabAgentMenuModel } from '../tab-bar/use-new-tab-agent-menu-rows'
import { useAppStore } from '@/store'
import {
buildAgentLaunchRouteInput,
workspaceKindForWorktreeId
} from '@/lib/agent-launch-route-input'
import { structuredAgentSessionTargetForHost } from '@/runtime/structured-agent-session-owner'
import { callStructuredAgentSession } from '@/runtime/structured-agent-session-client'
import { callRuntimeRpc } from '@/runtime/runtime-rpc-client'
import { formatAgentTypeLabel } from '../../../../shared/agent-type-label'
import type { AgentSessionModelCatalogResult } from '../../../../shared/agent-session-wire'
import { parseStructuredLaunchSeedOptions } from '../../../../shared/native-chat/input/native-chat-session-option-defaults'
import {
currentAgentPickerAcquisition,
observeAgentPickerAcquisition
} from './agent-launch-picker-acquisition'
import { isAgentPermissionChoice } from '../../../../shared/tui-agent-permission-choice'
import { isAgentChatPermissionChoice } from '../../../../shared/agent-chat-permission-mode'
import {
agentPickerEntriesFromRows,
agentPickerChoiceSeeds,
type AgentPickerDefaultSettings,
type AgentPickerEntry
} from '../../../../shared/agent-launch-picker'
export function useAgentLaunchPickerInventory(
worktreeId: string | undefined,
model: NewTabAgentMenuModel,
open: boolean,
selected?: AgentPickerEntry['agent']
): AgentPickerEntry[] {
const selectedRef = useRef(selected)
useLayoutEffect(() => {
selectedRef.current = selected
}, [selected])
const context = model.state === 'settled' ? model.pickerContext : undefined
const settings = context ? context.settings : useAppStore.getState().settings
const executionHostId =
context?.executionHostId ??
buildAgentLaunchRouteInput(useAppStore.getState(), {
agent: 'claude',
workspace: context?.workspace ?? {
kind: worktreeId ? workspaceKindForWorktreeId(worktreeId) : 'floating',
...(worktreeId ? { worktreeId } : {})
}
}).executionHostId
const target = structuredAgentSessionTargetForHost(executionHostId)
const key = useMemo(
() =>
JSON.stringify([
context?.key,
context?.workspace,
context?.pairingRevision,
context?.runtimeId,
worktreeId,
executionHostId,
model.state === 'settled' ? model.rows : [],
model.state === 'settled' ? model.detection.detectedIds : [],
settings && {
agentDefaultArgs: settings.agentDefaultArgs,
agentPermissionDefault: settings.agentPermissionDefault,
agentPermissionOverrides: settings.agentPermissionOverrides,
nativeChatSessionOptions: settings.nativeChatSessionOptions,
nativeChatPermissionMode: settings.nativeChatPermissionMode
}
]),
[context, worktreeId, executionHostId, model, settings]
)
const generation = useMemo(() => ({ key, open }), [key, open])
const [snapshot, setSnapshot] = useState<{
generation: object
entries: AgentPickerEntry[]
} | null>(null)
const build = (defaults: AgentPickerDefaultSettings | null | undefined) =>
model.state === 'settled'
? agentPickerEntriesFromRows(
model.rows,
model.detection.detectedIds ?? [],
(agent) => agentPickerChoiceSeeds(agent, defaults),
formatAgentTypeLabel
)
: []
const fallback = useMemo(
() =>
target?.kind === 'environment'
? build({}).map((entry) => ({
...entry,
terminal: {},
structured: {},
arguments: undefined,
defaultsPending: true,
structuredDefaultsPending: entry.surfaces.includes('chat'),
ownerKey: key
}))
: build(settings).map((entry) => ({ ...entry, ownerKey: key })),
// oxlint-disable-next-line react-hooks/exhaustive-deps -- The semantic key covers rows, detection, target and defaults without restarting for object identity.
[key]
)
useEffect(() => {
if (!open || !target || !worktreeId || model.state !== 'settled') {
return
}
const controller = new AbortController()
const record = (value: unknown): value is Record<string, unknown> =>
value !== null && typeof value === 'object' && !Array.isArray(value)
const argsRecord = (value: unknown) =>
value === undefined ||
(record(value) && Object.values(value).every((v) => typeof v === 'string'))
const permissionsRecord = (value: unknown) =>
value === undefined || (record(value) && Object.values(value).every(isAgentPermissionChoice))
const usable = (value: unknown): value is AgentPickerDefaultSettings =>
record(value) &&
argsRecord(value.agentDefaultArgs) &&
permissionsRecord(value.agentPermissionOverrides) &&
(value.agentPermissionDefault === undefined ||
isAgentPermissionChoice(value.agentPermissionDefault)) &&
(value.agentPermission === undefined ||
(record(value.agentPermission) &&
isAgentPermissionChoice(value.agentPermission.default) &&
argsRecord(value.agentPermission.userArgs) &&
permissionsRecord(value.agentPermission.overrides)))
return observeAgentPickerAcquisition(
key,
fallback,
{
settings:
target.kind !== 'environment'
? undefined
: async () => {
const host = await readHostAgentLaunchSettings(
{
environmentId: target.environmentId,
pairingRevision: context?.pairingRevision,
...(context?.runtimeId ? { runtimeId: context.runtimeId } : {})
},
controller.signal
).catch(() => null)
let defaults: AgentPickerDefaultSettings | undefined
let structuredKnown = false
if (usable(host)) {
const permission = host.agentPermission
defaults = {
agentDefaultArgs: permission?.userArgs ?? host.agentDefaultArgs,
...(permission
? {
agentPermissionDefault: permission.default,
agentPermissionOverrides: permission.overrides
}
: {})
}
} else {
const legacy = await callRuntimeRpc<{ settings?: AgentPickerDefaultSettings }>(
target,
'settings.get',
undefined,
{
signal: controller.signal,
timeoutMs: 15_000,
expectedEnvironmentPairingRevision: context?.pairingRevision,
...(context?.runtimeId
? { expectedEnvironmentRuntimeId: context.runtimeId }
: {})
}
).catch(() => null)
if (usable(legacy?.settings)) {
defaults = legacy.settings
structuredKnown = true
}
}
return defaults
? build(defaults).map((entry) => ({
...entry,
defaultsPending: false,
structuredDefaultsPending:
entry.surfaces.includes('chat') && !structuredKnown,
ownerKey: key
}))
: undefined
},
catalog: async (entry) =>
await callStructuredAgentSession<AgentSessionModelCatalogResult>(
target,
'agentSession.modelCatalog',
{ worktree: `id:${worktreeId}`, agent: entry.agent, waitForListing: true }
).catch(() => undefined),
support: async (entry) => {
const support = await callStructuredAgentSession<{
seedOptions?: Record<string, string>
}>(target, 'agentSession.createSupport', {
worktree: `id:${worktreeId}`,
agent: entry.agent
})
const parsed = parseStructuredLaunchSeedOptions(support?.seedOptions)
if (!parsed) {
return undefined
}
const { model, permissionMode, ...options } = parsed
return {
...(model ? { model } : {}),
options,
...(isAgentChatPermissionChoice(permissionMode) ? { permissionMode } : {})
}
}
},
{
selected: () => selectedRef.current,
publish: (entries) => setSnapshot({ generation, entries: [...entries] })
}
)
// oxlint-disable-next-line react-hooks/exhaustive-deps -- Only semantic inputs and visibility own observation; selected priority publishes independently after commit.
}, [key, open])
return snapshot?.generation === generation
? snapshot.entries
: ((open ? currentAgentPickerAcquisition(key) : undefined) ?? fallback)
}
@@ -1,4 +1,5 @@
import { useId, useLayoutEffect, useRef } from 'react'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import { Switch } from '@/components/ui/switch'
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'
@@ -30,6 +31,10 @@ type NativeChatSessionOptionRowsProps = {
onSet: (descriptor: SessionOptionDescriptor, value: SessionOptionValue) => void
/** A value-less action; it has nothing to show in place, so it closes the picker. */
onAction: (descriptor: SessionOptionDescriptor) => void
/** False when the caller already separates these rows from what sits above them. */
divided?: boolean
/** Settings grids provide their own inset and spacing between option blocks. */
layout?: 'menu' | 'settings'
}
// Retain missing rows disabled for this visit so model changes cannot slide the picker under the pointer.
@@ -78,12 +83,13 @@ function LevelSegment(props: {
function OptionRow(props: {
descriptor: SessionOptionDescriptor
layout: 'menu' | 'settings'
/** Kept from an earlier model in this visit; the current one does not offer it. */
gone: boolean
onSet: (value: SessionOptionValue) => void
onAction: () => void
}): React.JSX.Element {
const { descriptor, gone, onSet, onAction } = props
const { descriptor, layout, gone, onSet, onAction } = props
const labelId = useId()
const label = nativeChatSessionOptionLabel(descriptor, say)
const off = gone || !descriptor.settable
@@ -103,7 +109,12 @@ function OptionRow(props: {
}
if (descriptor.action) {
return (
<div className="flex min-h-7 items-center justify-between gap-3 pl-2">
<div
className={cn(
'flex min-h-7 items-center justify-between gap-3',
layout === 'menu' && 'pl-2'
)}
>
<span className="text-xs text-muted-foreground">{label}</span>
<Button type="button" variant="ghost" size="xs" disabled={off} onClick={onAction}>
{nativeChatSessionOptionActionLabel(descriptor, say)}
@@ -113,7 +124,12 @@ function OptionRow(props: {
}
if (descriptor.kind.type === 'boolean') {
return (
<label className="flex min-h-7 items-center justify-between gap-3 px-2 text-xs text-muted-foreground">
<label
className={cn(
'flex min-h-7 items-center justify-between gap-3 text-xs text-muted-foreground',
layout === 'menu' && 'px-2'
)}
>
{label}
<Switch
checked={!gone && descriptor.kind.currentValue}
@@ -128,7 +144,12 @@ function OptionRow(props: {
if (only && isReadOnlySessionOptionValue(descriptor)) {
// A row kept from the previous model names no value under this one, as a switched-off row.
return (
<div className="flex min-h-7 items-center justify-between gap-3 px-2 text-xs">
<div
className={cn(
'flex min-h-7 items-center justify-between gap-3 text-xs',
layout === 'menu' && 'px-2'
)}
>
<span className="text-muted-foreground">{label}</span>
{gone ? null : (
<span className="text-foreground">
@@ -139,7 +160,12 @@ function OptionRow(props: {
)
}
return (
<div className="flex min-h-7 flex-wrap items-center justify-between gap-x-3 gap-y-1 pl-2">
<div
className={cn(
'flex min-h-7 flex-wrap items-center justify-between gap-x-3 gap-y-1',
layout === 'menu' && 'pl-2'
)}
>
<span id={labelId} className="text-xs text-muted-foreground">
{label}
</span>
@@ -175,7 +201,9 @@ export function NativeChatSessionOptionRows({
shownReason,
pendingIds,
onSet,
onAction
onAction,
divided = true,
layout = 'menu'
}: NativeChatSessionOptionRowsProps): React.JSX.Element {
const shown = useRef(options)
const rows = withEarlierRows(shown.current, options)
@@ -203,12 +231,15 @@ export function NativeChatSessionOptionRows({
return (
<div
ref={frame}
className="h-(--option-rows-height) shrink-0 overflow-hidden transition-[height] duration-150 ease-out motion-reduce:transition-none"
className={cn(
'h-(--option-rows-height) shrink-0 overflow-hidden transition-[height] duration-150 ease-out motion-reduce:transition-none',
layout === 'settings' && rows.length === 0 && 'hidden'
)}
>
<div ref={content}>
{rows.length > 0 ? (
<div className="border-t border-border p-1">
<div className="flex flex-col gap-0.5">
<div className={cn(divided && 'border-t border-border', layout === 'menu' && 'p-1')}>
<div className={cn('flex flex-col', layout === 'menu' ? 'gap-0.5' : 'gap-2.5')}>
{rows.map((descriptor) => {
const reason = nativeChatSessionOptionDisabledReason(descriptor.disabledReason, say)
return (
@@ -219,6 +250,7 @@ export function NativeChatSessionOptionRows({
<div data-option-id={descriptor.id}>
<OptionRow
descriptor={descriptor}
layout={layout}
gone={!options.some((option) => option.id === descriptor.id)}
onSet={(value) => onSet(descriptor, value)}
onAction={() => onAction(descriptor)}
@@ -1,11 +1,85 @@
// @vitest-environment happy-dom
import { cleanup, render, screen, waitFor } from '@testing-library/react'
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { AgentCommandOverrideInput, AgentDefaultArgsInput } from './AgentLaunchDefaultsEditor'
import { TooltipProvider } from '../ui/tooltip'
afterEach(cleanup)
afterEach(() => {
cleanup()
vi.restoreAllMocks()
vi.unstubAllGlobals()
})
it.each([false, true])(
'reserves the full chip layout width during popover zoom, hideLabel=%s',
(hideLabel) => {
let resize = () => {}
let width = 280
vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockImplementation(function (
this: HTMLElement
) {
return this.classList.contains('-mb-5') ? width : 0
})
vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue(
new DOMRect(0, 0, width * 0.95, 20)
)
vi.stubGlobal(
'ResizeObserver',
class {
constructor(callback: () => void) {
resize = callback
}
observe() {}
disconnect() {}
}
)
const editor = (withChip: boolean) => (
<TooltipProvider>
<AgentDefaultArgsInput
hideLabel={hideLabel}
defaultArgs=""
argsOverride="fdsa"
onSaveArgs={vi.fn()}
choiceChips={
withChip
? [
{
key: 'permissionMode',
label: 'Claude',
flag: '--dangerously-skip-permissions',
onRemove: (text) => text
}
]
: undefined
}
/>
</TooltipProvider>
)
const view = render(editor(true))
const input = screen.getByRole('textbox', { name: 'Arguments' })
expect(input.style.textIndent).toBe('calc(280px + var(--spacing) * 2)')
width = 320
act(() => resize())
expect(input.style.textIndent).toBe('calc(320px + var(--spacing) * 2)')
view.rerender(editor(false))
expect(input.style.textIndent).toBe('')
}
)
it('bounds content sizing and permits long tokens to wrap inside the scrollable Arguments field', () => {
render(<AgentDefaultArgsInput defaultArgs="" argsOverride="" onSaveArgs={vi.fn()} />)
const input = screen.getByRole('textbox', { name: 'Arguments' })
const longToken = 'fdsafjdkslafjkdls;ajfkld;sjaklfd;'.repeat(80)
fireEvent.change(input, { target: { value: longToken } })
expect(input).toHaveProperty('value', longToken)
// happy-dom has no line layout; keep the browser's wrapping/growth/scroll constraints explicit.
expect(input.classList.contains('[overflow-wrap:anywhere]')).toBe(true)
expect(input.classList.contains('max-w-full')).toBe(true)
expect(input.classList.contains('[field-sizing:content]')).toBe(true)
expect(input.parentElement?.classList.contains('overflow-y-auto')).toBe(true)
expect(input.parentElement?.classList.contains('max-h-[calc(var(--spacing)*41+2px)]')).toBe(true)
})
it.each(['', '--default'])(
'hides the %j placeholder while a permission chip is present',
@@ -92,7 +92,8 @@ export function AgentDefaultArgsInput({
disabled = false,
onDraftChange,
placeholder,
onResetArgs
onResetArgs,
hideLabel = false
}: {
defaultArgs: string
placeholder?: string
@@ -103,6 +104,8 @@ export function AgentDefaultArgsInput({
note?: string
disabled?: boolean
onDraftChange?: (value: string) => void
/** The caller already labels the field, e.g. with a disclosure row. */
hideLabel?: boolean
}): React.JSX.Element {
const [draft, setDraft] = useState<string | null>(null)
const inputRef = useRef<HTMLTextAreaElement>(null)
@@ -122,7 +125,8 @@ export function AgentDefaultArgsInput({
return
}
const measure = (): void => {
input.style.textIndent = `calc(${chip.getBoundingClientRect().width}px + var(--spacing) * 2)`
// Popover transforms change viewport geometry, but text-indent needs layout pixels.
input.style.textIndent = `calc(${chip.offsetWidth}px + var(--spacing) * 2)`
}
measure()
const observer = new ResizeObserver(measure)
@@ -147,9 +151,11 @@ export function AgentDefaultArgsInput({
return (
<div className="flex min-w-0 flex-col gap-1">
<span className="text-xs font-medium leading-none text-muted-foreground">
{translate('auto.components.settings.AgentsPane.cfb3f35775', 'Arguments')}
</span>
{hideLabel ? null : (
<span className="text-xs font-medium leading-none text-muted-foreground">
{translate('auto.components.settings.AgentsPane.cfb3f35775', 'Arguments')}
</span>
)}
<div className="flex min-w-0 items-center gap-2">
<div
className="scrollbar-sleek relative max-h-[calc(var(--spacing)*41+2px)] min-h-7 min-w-0 flex-1 overflow-x-hidden overflow-y-auto rounded-md border border-input px-3 py-0.5 shadow-xs transition-[color,box-shadow] focus-within:border-ring focus-within:ring-[3px] focus-within:ring-ring/50 dark:bg-input/30"
@@ -230,7 +236,7 @@ export function AgentDefaultArgsInput({
)
}
spellCheck={false}
className="block min-h-5 [field-sizing:content]"
className="block min-h-5 max-w-full [field-sizing:content] [overflow-wrap:anywhere]"
/>
</div>
{(argsOverride !== defaultArgs ||
@@ -1,3 +1,4 @@
import { AgentPermissionMenuOptions } from './AgentPermissionMenuOptions'
import { ChevronDown, X } from 'lucide-react'
import type { Ref } from 'react'
import type { AgentChatPermissionMode } from '../../../../shared/agent-chat-permission-mode'
@@ -10,8 +11,6 @@ import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuTrigger
} from '../ui/dropdown-menu'
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip'
@@ -165,7 +164,9 @@ export function AgentPermissionMenu({
inheritLabel,
disabled,
size = 'xs',
layout = 'inline'
layout = 'inline',
showInherited = true,
rawValue
}: {
agentId: string
label: string
@@ -173,7 +174,10 @@ export function AgentPermissionMenu({
inheritLabel?: string
disabled?: boolean
size?: 'xs' | 'sm'
layout?: 'inline' | 'stacked'
/** Row: muted label left and the menu right, like the chat composer's option rows. */
layout?: 'inline' | 'stacked' | 'row'
showInherited?: boolean
rawValue?: string
}): React.JSX.Element {
const followAll =
inheritLabel ?? translate('settings.agents.permissionSameAsAll', 'Same as all agents')
@@ -184,10 +188,16 @@ export function AgentPermissionMenu({
'flex min-w-0',
layout === 'stacked'
? 'flex-col items-stretch gap-2'
: 'flex-wrap items-center gap-x-3 gap-y-1'
: layout === 'row'
? 'min-h-7 items-center justify-between gap-3'
: 'flex-wrap items-center gap-x-3 gap-y-1'
)}
>
{layout === 'stacked' ? (
{layout === 'row' ? (
<span className="text-xs text-muted-foreground">
{translate('settings.agents.rowPermissions', 'Permissions')}
</span>
) : layout === 'stacked' ? (
<Label variant="settings">
{translate('settings.agents.rowPermissions', 'Permissions')}
</Label>
@@ -209,39 +219,25 @@ export function AgentPermissionMenu({
>
<span className="flex min-w-0 items-center gap-1.5 text-xs">
{permission.source === 'all' ? <span className="shrink-0">{followAll} ·</span> : null}
<AgentPermissionChoiceName choice={permission.choice} ownLabel={ownLabel} truncate />
<AgentPermissionChoiceName
choice={rawValue ? 'own' : permission.choice}
ownLabel={rawValue ?? ownLabel}
truncate
/>
</span>
<ChevronDown className="size-3 shrink-0" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" collisionPadding={8} className="w-72">
<DropdownMenuRadioGroup
value={permission.source === 'all' ? 'default' : permission.choice}
>
<DropdownMenuRadioItem
value="default"
disabled={disabled}
onSelect={() => permission.onSelect('default')}
>
<span className="flex min-w-0 items-center gap-1.5 py-0.5">
<span className="shrink-0">{followAll} ·</span>
<AgentPermissionChoiceName
choice={permission.defaultChoice}
ownLabel={ownLabel}
truncate
/>
</span>
</DropdownMenuRadioItem>
<DropdownMenuSeparator />
<AgentPermissionChoiceItems
choices={permission.choices}
agent={agentId}
ownLabel={ownLabel}
chatOwnSupported={permission.chatOwnSupported === true}
disabled={disabled}
onSelect={permission.onSelect}
/>
</DropdownMenuRadioGroup>
<DropdownMenuContent align="start" collisionPadding={8} className="w-80">
<AgentPermissionMenuOptions
agentId={agentId}
permission={permission}
disabled={disabled}
rawValue={rawValue}
showInherited={showInherited}
followAll={followAll}
ownLabel={ownLabel}
/>
</DropdownMenuContent>
</DropdownMenu>
{permission.terminalChoice === 'own' && permission.chatChoice ? (
@@ -264,7 +260,8 @@ function AgentArgsChoiceChip({
disabled,
chipRef,
buttonRef,
choiceKey
choiceKey,
source
}: {
label: string
flag: string
@@ -273,6 +270,7 @@ function AgentArgsChoiceChip({
chipRef?: Ref<HTMLSpanElement>
buttonRef?: Ref<HTMLButtonElement>
choiceKey: string
source?: 'arguments'
}): React.JSX.Element {
return (
<Tooltip>
@@ -302,7 +300,7 @@ function AgentArgsChoiceChip({
}
}}
aria-label={
choiceKey !== 'permissionMode'
source === 'arguments' || choiceKey !== 'permissionMode'
? translate('settings.agents.choiceChipRemove', 'Remove {{flag}}', { flag })
: translate(
'settings.agents.permissionChipRemove',
@@ -317,22 +315,24 @@ function AgentArgsChoiceChip({
</span>
</TooltipTrigger>
<TooltipContent>
{choiceKey !== 'permissionMode'
? translate(
'settings.agents.choiceChipTooltip',
'Added by {{choice}}. Remove it to use the default.',
{
choice:
choiceKey === 'model'
? translate('settings.sourceControl.actionModel.model', 'Model')
: translate('settings.sourceControl.actionModel.effort', 'Effort')
}
)
: translate(
'settings.agents.permissionChipTooltip',
'Added by Permissions. Remove it to use {{agent}}’s own settings.',
{ agent: label }
)}
{source === 'arguments'
? translate('agentPicker.argumentsHint', 'Applies only to CLI launches.')
: choiceKey !== 'permissionMode'
? translate(
'settings.agents.choiceChipTooltip',
'Added by {{choice}}. Remove it to use the default.',
{
choice:
choiceKey === 'model'
? translate('settings.sourceControl.actionModel.model', 'Model')
: translate('settings.sourceControl.actionModel.effort', 'Effort')
}
)
: translate(
'settings.agents.permissionChipTooltip',
'Added by Permissions. Remove it to use {{agent}}’s own settings.',
{ agent: label }
)}
</TooltipContent>
</Tooltip>
)
@@ -343,6 +343,7 @@ export type AgentArgsChoice = {
label: string
flag: string
onRemove: (text: string) => string
source?: 'arguments'
}
export function AgentArgsChoiceChips({
@@ -369,6 +370,7 @@ export function AgentArgsChoiceChips({
choiceKey={chip.key}
label={chip.label}
flag={chip.flag}
source={chip.source}
disabled={disabled}
buttonRef={index === chips.length - 1 ? buttonRef : undefined}
onRemove={() => onRemove(chip)}
@@ -0,0 +1,66 @@
import type { AgentPermissionChoiceRow } from './AgentPermissionChoiceControls'
import { AgentPermissionChoiceItems, AgentPermissionChoiceName } from './AgentPermissionChoiceItems'
import {
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator
} from '../ui/dropdown-menu'
export function AgentPermissionMenuOptions({
agentId,
permission,
disabled,
rawValue,
showInherited,
followAll,
ownLabel
}: {
agentId: string
permission: AgentPermissionChoiceRow
disabled?: boolean
rawValue?: string
showInherited: boolean
followAll: string
ownLabel: string
}) {
return (
<DropdownMenuRadioGroup
value={
rawValue ? `raw:${rawValue}` : permission.source === 'all' ? 'default' : permission.choice
}
>
{showInherited ? (
<>
<DropdownMenuRadioItem
value="default"
disabled={disabled}
onSelect={() => permission.onSelect('default')}
>
<span className="flex min-w-0 items-center gap-1.5 py-0.5">
<span className="shrink-0">{followAll} ·</span>
<AgentPermissionChoiceName
choice={permission.defaultChoice}
ownLabel={ownLabel}
truncate
/>
</span>
</DropdownMenuRadioItem>
<DropdownMenuSeparator />
</>
) : null}
{rawValue ? (
<DropdownMenuRadioItem value={`raw:${rawValue}`}>
<AgentPermissionChoiceName choice="own" ownLabel={rawValue} truncate />
</DropdownMenuRadioItem>
) : null}
<AgentPermissionChoiceItems
choices={permission.choices}
agent={agentId}
ownLabel={ownLabel}
chatOwnSupported={permission.chatOwnSupported === true}
disabled={disabled}
onSelect={permission.onSelect}
/>
</DropdownMenuRadioGroup>
)
}
@@ -106,9 +106,11 @@ export function SourceControlActionArguments({
if (!chosen) {
return []
}
const option = sourceControlActionOptions(agent, selected?.model ?? '').find(
({ option }) => option.id === key
)?.option
const option =
sourceControlActionOptions(agent, selected?.model ?? '').find(
({ option }) => option.id === key
)?.option ??
sourceControlActionOptions(agent, '').find(({ option }) => option.id === key)?.option
const flag = option?.apply.composedIntoModel
? `${option.label} ${chosen}`
: projectAgentLaunchProfile(
@@ -51,11 +51,12 @@ export function AgentChoiceControls({
...normalizeAgentModelOptionChoices(next),
...(next.permissionMode ? { permissionMode: next.permissionMode } : {})
})
const unlistedModel =
catalog && selected?.model && !findCatalogModel(catalog, selected.model)
? selected.model
: undefined
const options = sourceControlActionOptions(agent, selected?.model ?? '')
const model = catalog && selected?.model ? findCatalogModel(catalog, selected.model) : undefined
const unlistedModel = catalog && selected?.model && !model ? selected.model : undefined
const options = sourceControlActionOptions(agent, selected?.model ?? '').filter(
// CLI carrier mappings remain available to Args badges without advertising a mode control.
({ option }) => Boolean(model) || option.category !== 'mode'
)
const defaultLabel = translate('settings.sourceControl.actionModel.effortDefault', 'Default')
return (
<div className={layout === 'contents' ? 'contents' : 'grid min-w-0 grid-cols-1 gap-2'}>
@@ -83,7 +84,7 @@ export function AgentChoiceControls({
disabled={disabled}
label={
selected?.model
? (findCatalogModel(catalog, selected.model)?.label ?? selected.model)
? (model?.label ?? selected.model)
: translate('settings.sourceControl.actionModel.agentDefault', 'Agent default')
}
/>
@@ -434,6 +434,76 @@ it('keeps the old Codex workspace-write Ask literal and explains that terminal t
).toBeDefined()
})
it.each([
{ model: undefined, controls: 2, fast: false },
{ model: 'unlisted-host-model', controls: 2, fast: false },
{ model: 'opus', controls: 3, fast: true }
])('offers only model-advertised action mode controls for $model', ({ model, controls, fast }) => {
render(
<TooltipProvider>
<SourceControlActionArguments
agent="claude"
value="--verbose"
preferences={{ agentId: 'claude', model }}
onArgsChange={vi.fn()}
onPreferencesChange={vi.fn()}
/>
</TooltipProvider>
)
expect(screen.getAllByRole('combobox')).toHaveLength(controls)
expect(screen.queryByText('Fast mode') !== null).toBe(fast)
expect(screen.getByRole('button', { name: 'Claude permissions' })).toBeDefined()
expect(screen.getByRole('textbox', { name: 'Arguments' })).toHaveProperty('value', '--verbose')
})
it.each(['host-private', 'sonnet'])(
'keeps a saved %s Fast badge removable without offering a Fast select',
async (model) => {
write.mockClear()
const saved = {
value: normalizeSourceControlAiSettings({
...initial,
actions: {
fixChecks: {
agentId: 'claude',
agentArgs: `--settings '{"theme":"light"}' --add-dir docs`,
launchPreferences: {
agentId: 'claude',
model,
options: { fastMode: 'true' }
}
}
}
})
}
render(<GlobalHarness saved={saved} />)
expect(
screen.getByRole('combobox', { name: model === 'sonnet' ? 'Sonnet' : model })
).toBeDefined()
expect(screen.queryByText('Fast mode')).toBeNull()
expect(screen.getAllByRole('combobox')).toHaveLength(2)
fireEvent.click(screen.getByRole('button', { name: /Remove --settings/ }))
expect(screen.queryByRole('button', { name: /Remove --settings/ })).toBeNull()
fireEvent.click(screen.getByText('Save'))
await waitFor(() => expect(write).toHaveBeenCalledTimes(1))
const recipe = saved.value.actions?.fixChecks
expect(sourceControlActionSelectedPreferences(recipe ?? {}, 'claude')).toMatchObject({
model
})
expect(
sourceControlActionSelectedPreferences(recipe ?? {}, 'claude')?.options?.fastMode
).toBeUndefined()
const args = sourceControlActionAgentArgs(recipe ?? {}, 'claude', null)
expect(shell.tokenizeStartupCommand(args ?? '', 'posix')).toMatchObject({
ok: true,
tokens: ['--settings', '{"theme":"light"}', '--add-dir', 'docs']
})
expect(args).toContain('"theme":"light"')
expect(args).toContain('--add-dir docs')
expect(args).not.toContain('"fastMode":true')
}
)
it('edits and saves a sparse repository model without authoring the inherited permission', async () => {
write.mockClear()
const source = {
@@ -75,7 +75,11 @@ import {
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { ReviewNotesSendMenuContent } from '@/components/editor/ReviewNotesSendMenuContent'
import { QuickLaunchAgentMenuItems, ProjectedAgentMenuItems } from './QuickLaunchButton'
import {
QuickLaunchAgentMenuItems,
ProjectedAgentMenuItems,
useTabAgentMenuLaunch
} from './QuickLaunchButton'
import { useTabBarCreateMenuController } from './use-tab-bar-create-menu-controller'
import * as menuRows from './use-new-tab-agent-menu-rows'
import {
@@ -655,3 +659,45 @@ describe('QuickLaunchAgentMenuItems launches', () => {
expect(launchMock).not.toHaveBeenCalled()
})
})
it('forwards configured choices through the existing dispatcher and launch-only Arguments only to terminals', () => {
launchMock.mockClear()
launchMock.mockReturnValue({ surface: { kind: 'local-terminal', tabId: 'new-tab' } })
const hook = renderHook(() =>
useTabAgentMenuLaunch({
worktreeId: WORKTREE_ID,
groupId: 'group-1',
onFocusTerminal: vi.fn()
})
)
const choices = { model: 'astra', options: { effort: 'low' }, permissionMode: 'ask' as const }
hook.result.current(
{ agent: 'codex', kind: 'cli', executionHostId: 'runtime:server' },
{
agent: 'codex',
surface: 'terminal',
choices,
arguments: '--add-dir docs'
}
)
expect(launchMock.mock.calls[0][0]).toMatchObject({
requestedSurface: 'terminal',
selectedExecutionHostId: 'runtime:server',
agentArgs: expect.stringContaining('--add-dir docs'),
sessionOptions: { model: 'astra', effort: 'low', permissionMode: 'ask' }
})
hook.result.current(
{ agent: 'codex', kind: 'chat', executionHostId: 'runtime:server' },
{
agent: 'codex',
surface: 'chat',
choices,
arguments: '--add-dir docs'
}
)
expect(launchMock.mock.calls[1][0]).toMatchObject({
requestedSurface: 'chat',
sessionOptions: { model: 'astra', effort: 'low', permissionMode: 'ask' }
})
expect(launchMock.mock.calls[1][0]).not.toHaveProperty('agentArgs')
})
@@ -1,3 +1,9 @@
import { AgentLaunchPickerMenu } from '@/components/agent/AgentLaunchPickerMenu'
import {
agentPickerLaunchInputs,
type AgentPickerEntry,
type AgentPickerDraft
} from '../../../../shared/agent-launch-picker'
import React, { useCallback, useRef } from 'react'
import { toast } from 'sonner'
import { launchAgentInNewTab } from '@/lib/launch-agent-in-new-tab'
@@ -70,7 +76,7 @@ export function useTabAgentMenuLaunch({
onFocusChat?: (tabId: string) => void
}) {
const runLaunch = useCallback(
(row: NewTabAgentMenuRow) => {
(row: NewTabAgentMenuRow, draft?: AgentPickerDraft, pickerEntry?: AgentPickerEntry) => {
if (disabled || row.kind === 'chat-unavailable') {
return
}
@@ -87,6 +93,7 @@ export function useTabAgentMenuLaunch({
}
: {}),
agent,
...(draft ? agentPickerLaunchInputs(draft, pickerEntry) : {}),
worktreeId,
groupId,
...(prompt !== undefined ? { prompt } : {}),
@@ -165,11 +172,17 @@ function QuickLaunchAgentMenuItemsInner(props: QuickLaunchAgentMenuItemsProps):
export function ProjectedAgentMenuItems({
disabled = false,
model,
runLaunch
runLaunch,
pickerEntries,
onSelectPickerAgent,
hidePickerSearch
}: {
disabled?: boolean
model: NewTabAgentMenuModel
runLaunch: (row: NewTabAgentMenuRow) => void
runLaunch: (row: NewTabAgentMenuRow, draft?: AgentPickerDraft) => void
pickerEntries?: AgentPickerEntry[]
onSelectPickerAgent?: (agent: AgentPickerEntry['agent']) => void
hidePickerSearch?: boolean
}): React.JSX.Element {
const desktopDefaultAgent = useAppStore((s) => s.settings?.defaultTuiAgent)
const openSettingsPage = useAppStore((s) => s.openSettingsPage)
@@ -235,60 +248,70 @@ export function ProjectedAgentMenuItems({
)}
</DropdownMenuItem>
) : null}
{rows.map((row, index) => {
const { agent } = row
const label = getCatalogEntry(agent)?.label ?? agent
// Chat rows come first; the rows that open a terminal start their own group.
const startsCliGroup = row.kind === 'cli' && index > 0 && rows[index - 1]?.kind !== 'cli'
if (row.kind === 'chat-unavailable') {
return (
<DropdownMenuItem key={rowId(row)} variant="launch" disabled>
<AgentIcon agent={agent} size={14} />
<span className="flex-1">{label}</span>
<span className="font-normal text-muted-foreground">
{chatUnavailableLabel(row.reason)}
</span>
</DropdownMenuItem>
)
}
const showsDefaultAgentShortcut =
newAgentShortcut !== null && defaultAgent !== 'blank' && agent === defaultAgent
const rowContent = (
<>
<AgentIcon agent={agent} size={14} />
<span className="flex-1">{rowLabel(row)}</span>
{showsDefaultAgentShortcut ? (
<DropdownMenuShortcut>{newAgentShortcut}</DropdownMenuShortcut>
) : null}
</>
)
const other = otherSurfaceRow(row, detectedIds)
return (
<React.Fragment key={rowId(row)}>
{startsCliGroup ? <DropdownMenuSeparator /> : null}
{other ? (
<SurfaceChoiceRow
row={row}
other={other}
label={label}
disabled={disabled}
runLaunch={runLaunch}
>
{rowContent}
</SurfaceChoiceRow>
) : (
<DropdownMenuItem
variant="launch"
disabled={disabled}
onSelect={() => runLaunch(row)}
title={rowTitle(row, label)}
>
{rowContent}
{pickerEntries ? (
<AgentLaunchPickerMenu
model={model}
entries={pickerEntries}
runLaunch={runLaunch}
onSelectAgent={onSelectPickerAgent}
hideSearch={hidePickerSearch}
/>
) : (
rows.map((row, index) => {
const { agent } = row
const label = getCatalogEntry(agent)?.label ?? agent
// Chat rows come first; the rows that open a terminal start their own group.
const startsCliGroup = row.kind === 'cli' && index > 0 && rows[index - 1]?.kind !== 'cli'
if (row.kind === 'chat-unavailable') {
return (
<DropdownMenuItem key={rowId(row)} variant="launch" disabled>
<AgentIcon agent={agent} size={14} />
<span className="flex-1">{label}</span>
<span className="font-normal text-muted-foreground">
{chatUnavailableLabel(row.reason)}
</span>
</DropdownMenuItem>
)}
</React.Fragment>
)
})}
)
}
const showsDefaultAgentShortcut =
newAgentShortcut !== null && defaultAgent !== 'blank' && agent === defaultAgent
const rowContent = (
<>
<AgentIcon agent={agent} size={14} />
<span className="flex-1">{rowLabel(row)}</span>
{showsDefaultAgentShortcut ? (
<DropdownMenuShortcut>{newAgentShortcut}</DropdownMenuShortcut>
) : null}
</>
)
const other = otherSurfaceRow(row, detectedIds)
return (
<React.Fragment key={rowId(row)}>
{startsCliGroup ? <DropdownMenuSeparator /> : null}
{other ? (
<SurfaceChoiceRow
row={row}
other={other}
label={label}
disabled={disabled}
runLaunch={runLaunch}
>
{rowContent}
</SurfaceChoiceRow>
) : (
<DropdownMenuItem
variant="launch"
disabled={disabled}
onSelect={() => runLaunch(row)}
title={rowTitle(row, label)}
>
{rowContent}
</DropdownMenuItem>
)}
</React.Fragment>
)
})
)}
<AgentSettingsMenuItem onSelect={openAgentSettings} />
</>
)
@@ -546,3 +546,105 @@ describe('TabBarCreateEntry search behavior', () => {
expect(tabSearchMock.calls.at(-1)).toEqual({ enabled: false, query: '' })
})
})
it('launches searched defaults on Enter and reaches the alternate only through Configure', () => {
const launched = vi.fn()
const entry = {
agent: 'codex' as const,
label: 'Codex',
rows: [{ agent: 'codex' as const, kind: 'chat' as const }],
surfaces: ['chat' as const, 'terminal' as const],
defaultSurface: 'chat' as const,
terminal: {},
structured: {},
catalog: {
origin: 'probe' as const,
fetchedAt: 1,
models: [
{ id: 'astra', label: 'Astra', isDefault: true, efforts: [{ value: 'low', label: 'Low' }] }
]
}
}
renderEntry({ agentPickerEntries: [entry], onLaunchAgentOption: launched })
setQuery('astra low')
submit()
expect(launched).toHaveBeenCalledOnce()
expect(launched.mock.calls[0][0].setup).toMatchObject({
surface: 'chat',
choices: { model: 'astra', options: { effort: 'low' } }
})
launched.mockClear()
renderEntry({ agentPickerEntries: [entry], onLaunchAgentOption: launched })
setQuery('astra low')
expect(container.querySelector('button[aria-label="Launch terminal"]')).toBeNull()
const configure = container.querySelector<HTMLButtonElement>(
'button[aria-label="Configure Codex"]'
)!
expect(configure.dataset.variant).toBe('ghost')
expect(configure.dataset.size).toBe('icon-xs')
expect(
configure.querySelector('svg.lucide-chevron-right')?.classList.contains('text-muted-foreground')
).toBe(true)
expect(configure.classList.contains('border')).toBe(false)
expect(configure.previousElementSibling?.getAttribute('data-slot')).toBe('separator')
expect(configure.previousElementSibling?.getAttribute('data-orientation')).toBe('vertical')
expect(
configure.previousElementSibling?.classList.contains('data-[orientation=vertical]:h-4')
).toBe(true)
act(() => configure.click())
expect(launched).not.toHaveBeenCalled()
act(() =>
[...document.querySelectorAll<HTMLButtonElement>('button')]
.find((button) => button.textContent === 'Launch terminal')
?.click()
)
expect(launched).toHaveBeenCalledOnce()
expect(launched.mock.calls[0][0].setup.surface).toBe('terminal')
})
it.each(['g', 'low', 'high'])(
'keeps the file as the selected Enter action for %s with setup search mounted',
(query) => {
fileListMock.current = { files: [`${query}.ts`], loading: false, loadError: null }
const launched = vi.fn(),
opened = vi.fn().mockResolvedValue(undefined)
renderEntry({
onOpenEntry: opened,
onLaunchAgentOption: launched,
agentPickerEntries: [
{
agent: 'codex',
label: 'Codex',
rows: [{ agent: 'codex', kind: 'chat' }],
surfaces: ['chat', 'terminal'],
defaultSurface: 'chat',
terminal: {},
structured: {},
catalog: {
origin: 'probe',
fetchedAt: 1,
models: [
{
id: 'gpt-6-astra',
label: 'GPT 6 Astra',
isDefault: true,
efforts: [
{ value: 'low', label: 'Low' },
{ value: 'high', label: 'High' }
]
}
]
}
}
]
})
setQuery(query)
expect(container.querySelector('[aria-selected="true"]')?.textContent).toContain(
query === 'g' ? 'Search Google' : `${query}.ts`
)
expect(rowTexts().some((text) => text.includes(`${query}.ts`))).toBe(true)
submit()
expect(launched).not.toHaveBeenCalled()
expect(opened).toHaveBeenCalledOnce()
}
)
@@ -6,7 +6,11 @@ import {
getTabEntryOptions,
isTabEntryAbsolutePathLike
} from './tab-create-entry-action'
import { findMatchingTabAgentLaunchOptions } from './tab-agent-launch-options'
import {
configuredAgentPickerLaunchOption,
findMatchingTabAgentLaunchOptions,
matchingAgentPickerLaunchOptions
} from './tab-agent-launch-options'
import { findMatchingTabCreateMenuOptions } from './tab-create-menu-options'
import { getActiveOptionId, type ActiveOption } from './tab-create-entry-active-option'
import {
@@ -42,6 +46,7 @@ export default function TabBarCreateEntry(props: TabBarCreateEntryProps): React.
function TabBarCreateEntrySession({
agentOptions = EMPTY_AGENT_OPTIONS,
agentPickerEntries,
groupId,
menuOpen,
menuOptions = EMPTY_MENU_OPTIONS,
@@ -148,8 +153,11 @@ function TabBarCreateEntrySession({
() =>
terminalQueryMode
? EMPTY_AGENT_OPTIONS
: findMatchingTabAgentLaunchOptions(query, agentOptions),
[agentOptions, query, terminalQueryMode]
: [
...matchingAgentPickerLaunchOptions(agentPickerEntries ?? [], query),
...findMatchingTabAgentLaunchOptions(query, agentOptions)
],
[agentOptions, agentPickerEntries, query, terminalQueryMode]
)
const disabled = !onOpenEntry
@@ -377,6 +385,15 @@ function TabBarCreateEntrySession({
id={resultOptionDomId(index)}
option={option}
selected={index === activeSelectedIndex}
onUseAgent={
option.kind === 'agent' && option.option.pickerEntry
? (draft) =>
submitOption({
kind: 'agent',
option: configuredAgentPickerLaunchOption(option.option, draft)
})
: undefined
}
disabled={
disabled ||
pending ||
@@ -18,6 +18,8 @@ import { SEARCH_ENGINE_LABELS } from '../../../../shared/browser-url'
import { formatBrowserHistoryUrl } from '@/lib/browser-history-match'
import type { ActiveOption } from './tab-create-entry-active-option'
import { BrowserFavicon } from '@/components/browser-favicon'
import { AgentLaunchSetupButton } from '../agent/AgentLaunchSetupButton'
import type { AgentPickerDraft } from '../../../../shared/agent-launch-picker'
export const RESULT_LISTBOX_ID = 'tab-create-entry-results'
@@ -47,6 +49,7 @@ export function EntryActionRow({
labelOverride,
loading = false,
onClick,
onUseAgent,
option,
selected
}: {
@@ -54,6 +57,7 @@ export function EntryActionRow({
id: string
labelOverride?: string
loading?: boolean
onUseAgent?: (draft: AgentPickerDraft) => void
onClick: () => void
option: ActiveOption
selected: boolean
@@ -61,43 +65,54 @@ export function EntryActionRow({
const presentation = getActionPresentation(option, labelOverride)
const row = (
<button
type="button"
id={id}
role="option"
aria-selected={selected}
disabled={disabled}
className={cn(
'flex h-6 w-full items-center gap-1.5 rounded-[7px] px-1 text-left text-[11px] leading-5 outline-none disabled:cursor-not-allowed disabled:opacity-50',
selected
? 'bg-black/8 text-accent-foreground dark:bg-white/14'
: // Why: CSS :hover still matches a disabled button, so a pending row would
// light up under the pointer despite being unactivatable.
'text-muted-foreground hover:bg-black/8 hover:text-accent-foreground disabled:hover:bg-transparent disabled:hover:text-muted-foreground dark:hover:bg-white/14 dark:disabled:hover:bg-transparent'
)}
onClick={onClick}
>
{loading ? (
<Loader2 className="size-3.5 shrink-0 animate-spin" aria-hidden="true" />
) : (
presentation.icon
)}
<span className={cn('min-w-0 truncate font-medium', presentation.showDetail && 'shrink-0')}>
{presentation.label}
</span>
{presentation.showDetail ? (
<>
<span className="shrink-0 text-muted-foreground/70" aria-hidden="true">
·
</span>
{presentation.prioritizeFilename ? (
<FilenameFirstPath path={presentation.detail} />
) : (
<span className="min-w-0 flex-1 truncate">{presentation.detail}</span>
)}
</>
<div className="flex items-center gap-1">
<button
type="button"
id={id}
role="option"
aria-selected={selected}
disabled={disabled}
className={cn(
// Why min-w-0: a long agent setup must truncate so the row's › stays inside the menu.
'flex h-6 w-full min-w-0 items-center gap-1.5 rounded-[7px] px-1 text-left text-[11px] leading-5 outline-none disabled:cursor-not-allowed disabled:opacity-50',
selected
? 'bg-black/8 text-accent-foreground dark:bg-white/14'
: // Why: CSS :hover still matches a disabled button, so a pending row would
// light up under the pointer despite being unactivatable.
'text-muted-foreground hover:bg-black/8 hover:text-accent-foreground disabled:hover:bg-transparent disabled:hover:text-muted-foreground dark:hover:bg-white/14 dark:disabled:hover:bg-transparent'
)}
onClick={onClick}
>
{loading ? (
<Loader2 className="size-3.5 shrink-0 animate-spin" aria-hidden="true" />
) : (
presentation.icon
)}
<span className={cn('min-w-0 truncate font-medium', presentation.showDetail && 'shrink-0')}>
{presentation.label}
</span>
{presentation.showDetail ? (
<>
<span className="shrink-0 text-muted-foreground/70" aria-hidden="true">
·
</span>
{presentation.prioritizeFilename ? (
<FilenameFirstPath path={presentation.detail} />
) : (
<span className="min-w-0 flex-1 truncate">{presentation.detail}</span>
)}
</>
) : null}
</button>
{onUseAgent && option.kind === 'agent' && option.option.pickerEntry ? (
<AgentLaunchSetupButton
entry={option.option.pickerEntry}
initial={option.option.setup}
disabled={disabled}
onUse={onUseAgent}
/>
) : null}
</button>
</div>
)
// Only the filename-first rows hide information. Every other row already shows
@@ -0,0 +1,30 @@
import type { buildNewTabAgentMenuRows } from '@/lib/new-tab-agent-menu-rows'
import type { UseDetectedAgentsResult } from '@/hooks/useDetectedAgents'
import type { GlobalSettings } from '../../../../shared/global-settings-types'
import type { ProspectiveWorkspace } from '@/lib/agent-launch-route-input'
import type { AgentPickerDefaultSettings } from '../../../../shared/agent-launch-picker'
export type NewTabAgentMenuModel =
| { state: 'loading'; placeholderCount: number }
| {
state: 'settled'
rows: ReturnType<typeof buildNewTabAgentMenuRows>
detection: UseDetectedAgentsResult
/** The default these rows were ordered by, so the new-agent badge marks the same agent. */
defaultTuiAgent?: GlobalSettings['defaultTuiAgent']
pickerContext?: {
workspace: ProspectiveWorkspace
executionHostId: string
pairingRevision?: number
runtimeId?: string | null
settings: AgentPickerDefaultSettings | null
key: string
}
}
/** A launch whose workspace may not exist yet, or that starts in a directory of its own. */
export type AgentMenuRowsTarget = {
workspace: ProspectiveWorkspace
/** Only a terminal can start here, so chat rows say so instead of dropping the directory. */
launchCwd?: string | null
}
@@ -1,10 +1,12 @@
import { describe, expect, it } from 'vitest'
import {
buildTabAgentLaunchOptions,
buildAgentPickerLaunchOptions,
buildTabAgentLaunchOptionsFromRows,
findMatchingTabAgentLaunchOptions,
orderTabLaunchAgents
} from './tab-agent-launch-options'
import type { AgentPickerEntry } from '../../../../shared/agent-launch-picker'
describe('tab agent launch options', () => {
it('orders detected agents by the configured default first', () => {
@@ -120,3 +122,32 @@ it('projects the same CLI label, unavailable reason and stable chat ID as static
])
).toEqual([['cli:codex', 'cli']])
})
it.each(['chat', 'terminal'] as const)(
'offers only the %s default row while preserving custom command aliases and host identity',
(surface) => {
const entry: AgentPickerEntry = {
agent: 'codex',
label: 'Codex',
rows: [
{ agent: 'codex', kind: 'chat', executionHostId: 'runtime:paired' },
{ agent: 'codex', kind: 'cli', executionHostId: 'runtime:paired' }
],
surfaces: ['chat', 'terminal'],
defaultSurface: surface,
terminal: {},
structured: {},
catalog: { origin: 'unknown' }
}
const options = buildAgentPickerLaunchOptions([entry], { codex: 'cx' })
expect(findMatchingTabAgentLaunchOptions('cx', options)).toEqual(options)
expect(findMatchingTabAgentLaunchOptions('codex', options)).toEqual(options)
expect(options).toHaveLength(1)
expect(options[0]).toMatchObject({
label: 'Codex',
row: { kind: surface === 'chat' ? 'chat' : 'cli', executionHostId: 'runtime:paired' },
pickerEntry: entry
})
expect(options[0].setup).toBeUndefined()
}
)
@@ -1,6 +1,11 @@
import {
searchAgentPickerSetups,
type AgentPickerDraft,
type AgentPickerEntry
} from '../../../../shared/agent-launch-picker'
import { rowLabel, rowId, chatUnavailableLabel } from './tab-agent-row-presentation'
import { getAgentCatalog } from '@/lib/agent-catalog'
import { filterEnabledTuiAgents } from '../../../../shared/tui-agent-selection'
import { orderAgentLaunchPickerAgents } from '../../../../shared/agent-launch-picker-rows'
import { normalizeMatchQuery, tokenizeMatchValue } from './query-token-match'
import type { TuiAgent } from '../../../../shared/tui-agent'
import type { NewTabAgentMenuRow } from '@/lib/new-tab-agent-menu-rows'
@@ -12,6 +17,8 @@ export type TabAgentLaunchOption = {
disabled?: boolean
id?: string
row?: NewTabAgentMenuRow
setup?: AgentPickerDraft
pickerEntry?: AgentPickerEntry
reason?: string
}
@@ -32,14 +39,7 @@ export function orderTabLaunchAgents(
detected: readonly TuiAgent[],
disabled?: Iterable<unknown> | null
): TuiAgent[] {
const enabledDetected = filterEnabledTuiAgents(detected, disabled)
const inCatalogOrder = getAgentCatalog()
.filter((entry) => enabledDetected.includes(entry.id))
.map((entry) => entry.id)
if (!defaultAgent || defaultAgent === 'blank' || !inCatalogOrder.includes(defaultAgent)) {
return inCatalogOrder
}
return [defaultAgent, ...inCatalogOrder.filter((id) => id !== defaultAgent)]
return orderAgentLaunchPickerAgents(defaultAgent, detected, disabled)
}
export function buildTabAgentLaunchOptions(
@@ -91,6 +91,26 @@ export function buildTabAgentLaunchOptionsFromRows(
})
}
export function buildAgentPickerLaunchOptions(
entries: readonly AgentPickerEntry[],
commandOverrides: Partial<Record<TuiAgent, string>> = {}
): TabAgentLaunchOption[] {
return entries.flatMap((entry) => {
const row = entry.rows.find((row) =>
entry.defaultSurface === 'chat'
? row.kind === 'chat'
: row.kind === 'cli' || row.kind === 'terminal'
)
return row
? buildTabAgentLaunchOptionsFromRows([row], commandOverrides).map((option) => ({
...option,
label: entry.label,
pickerEntry: entry
}))
: []
})
}
// Require exact aliases or token prefixes; substring hits would outrank files and hijack the omnibox.
function scoreAgentLaunchOption(
normalizedQuery: string,
@@ -151,3 +171,40 @@ export function findMatchingTabAgentLaunchOptions(
.map((entry) => entry.option)
)
}
export function matchingAgentPickerLaunchOptions(
entries: readonly AgentPickerEntry[],
query: string
): TabAgentLaunchOption[] {
return searchAgentPickerSetups(entries, query).map((setup) => {
const surface = setup.entry.defaultSurface
return {
agent: setup.entry.agent,
label: setup.label,
aliases: [],
id: `setup:${setup.entry.agent}:${setup.model.id}:${setup.choices.options?.effort ?? ''}:${surface}`,
row: {
agent: setup.entry.agent,
kind: surface === 'chat' ? 'chat' : 'cli',
executionHostId: setup.entry.rows[0]?.executionHostId
},
setup: { agent: setup.entry.agent, surface, choices: setup.choices },
pickerEntry: setup.entry
}
})
}
export function configuredAgentPickerLaunchOption(
option: TabAgentLaunchOption,
draft: AgentPickerDraft
): TabAgentLaunchOption {
return {
...option,
row: {
agent: draft.agent,
kind: draft.surface === 'chat' ? 'chat' : 'cli',
executionHostId: option.row?.executionHostId
},
setup: draft
}
}
@@ -0,0 +1,27 @@
import type { NewTabAgentMenuModel } from './use-new-tab-agent-menu-rows'
import type { AgentPickerEntry } from '../../../../shared/agent-launch-picker'
import type { TabAgentLaunchOption } from './tab-agent-launch-options'
import type { useTabAgentMenuLaunch } from './QuickLaunchButton'
import type { TabCreateMenuOption } from './tab-create-menu-options'
import type { WindowsShellMenuEntry } from './tab-bar-windows-shell-options'
export type TabBarCreateMenuController = {
agentMenu: NewTabAgentMenuModel
agentLaunchOptions: TabAgentLaunchOption[]
agentPickerEntries: AgentPickerEntry[]
selectPickerAgent: (agent: AgentPickerEntry['agent']) => void
launchAgentRow: ReturnType<typeof useTabAgentMenuLaunch>
newTabMenuOpen: boolean
setNewTabMenuOpen: (open: boolean) => void
setCreateMenuQuery: (query: string) => void
createMenuOptions: TabCreateMenuOption[]
windowsShellEntries: WindowsShellMenuEntry[] | undefined
handleSelectCreateMenuOption: (option: TabCreateMenuOption) => void
launchAgentFromNewTabEntry: (option: TabAgentLaunchOption) => void
runPendingNewTabMenuFocusAfterClose: () => void
clearPendingNewTabMenuFocusOnUnmount: (node: HTMLDivElement | null) => void
queueNewActiveTerminalFocusAfterNewTabMenuClose: () => void
queueTerminalTabFocusAfterNewTabMenuClose: (tabId: string) => void
queueFocusAfterNewTabMenuClose: (focus: () => void) => void
showStaticCreateMenuItems: boolean
}
@@ -76,6 +76,8 @@ export function renderTabBarSurface({
const {
agentMenu,
agentLaunchOptions,
agentPickerEntries,
selectPickerAgent,
launchAgentRow,
newTabMenuOpen,
setNewTabMenuOpen,
@@ -238,6 +240,7 @@ export function renderTabBarSurface({
menuOpen={newTabMenuOpen}
menuOptions={createMenuOptions}
agentOptions={agentLaunchOptions}
agentPickerEntries={agentPickerEntries}
onLaunchAgentOption={launchAgentFromNewTabEntry}
onOpenDefaultTerminal={() => {
queueNewActiveTerminalFocusAfterNewTabMenuClose()
@@ -278,7 +281,13 @@ export function renderTabBarSurface({
{showStaticCreateMenuItems && showAgentLaunchItems ? (
<>
<DropdownMenuSeparator />
<ProjectedAgentMenuItems model={agentMenu} runLaunch={launchAgentRow} />
<ProjectedAgentMenuItems
model={agentMenu}
pickerEntries={agentPickerEntries}
onSelectPickerAgent={selectPickerAgent}
hidePickerSearch={!terminalOnly && Boolean(onOpenEntry)}
runLaunch={launchAgentRow}
/>
</>
) : null}
</DropdownMenuContent>
@@ -1,8 +1,10 @@
import type { AgentPickerEntry } from '../../../../shared/agent-launch-picker'
import type { TabCreateEntryArgs } from './tab-create-entry-action'
import type { TabAgentLaunchOption } from './tab-agent-launch-options'
import type { TabCreateMenuOption } from './tab-create-menu-options'
export type TabBarCreateEntryProps = {
agentPickerEntries?: readonly AgentPickerEntry[]
agentOptions?: readonly TabAgentLaunchOption[]
groupId: string
menuOpen: boolean
@@ -28,6 +28,7 @@ import {
readAgentLaunchPreferencesForWorktree,
readAgentLaunchPreferencesForWorkspace
} from '../settings/use-host-agent-launch-preferences'
import { useAgentLaunchPickerInventory } from '../agent/use-agent-launch-picker-inventory'
import { useNewTabAgentMenuRows } from './use-new-tab-agent-menu-rows'
const rpc = vi.hoisted(() => ({ call: vi.fn(), supports: vi.fn() }))
@@ -443,6 +444,124 @@ it.each(['pairing', 'runtime'] as const)(
}
)
it('keeps real observation/inventory consumers asleep for unrelated settings and another host, and updates a used default', async () => {
let renders = 0
const hook = renderHook(() => {
const menu = useNewTabAgentMenuRows('wt', true, { kind: 'runtime', environmentId: 'alpha' })
const entries = useAgentLaunchPickerInventory('wt', menu, true)
renders++
return { menu, entries }
})
await act(async () => {})
const settled = renders
await act(async () => {
useAppStore.setState((state) => ({ settings: { ...state.settings!, terminalFontSize: 19 } }))
})
expect(renders).toBe(settled)
await act(async () => {
useAppStore.setState((state) => ({
runtimeStatusByEnvironmentId: new Map(state.runtimeStatusByEnvironmentId).set(
'beta',
status('another-host-update')
)
}))
})
expect(renders).toBe(settled)
await act(async () => {
useAppStore.setState((state) => ({
settings: {
...state.settings!,
nativeChatPermissionMode:
state.settings?.nativeChatPermissionMode === 'ask' ? 'full-access' : 'ask'
}
}))
})
expect(renders).toBeGreaterThan(settled)
expect(hook.result.current.menu.state).toBe('settled')
useAppStore.setState({
folderWorkspaces: [makeFolderWorkspace({ id: 'local', executionHostId: 'local' })]
})
const local = renderHook(() => {
const menu = useNewTabAgentMenuRows('folder:local', true, { kind: 'local' })
return useAgentLaunchPickerInventory('folder:local', menu, true)
})
await act(async () => {})
await act(async () => {
useAppStore.setState((state) => ({
settings: { ...state.settings!, agentPermissionDefault: 'ask' }
}))
})
expect(
local.result.current.find((entry) => entry.agent === 'claude')?.terminal.permissionMode
).toBe('ask')
})
it.each([30, 300])(
'skips all fallback route/worktree work for a supplied context host across %i worktrees',
async (count) => {
const route = await import('@/lib/agent-launch-route-input')
const builder = vi.spyOn(route, 'buildAgentLaunchRouteInput')
let visited = 0
const worktrees = Array.from({ length: count }, (_, i) => {
const worktree = makeWorktree({
id: `scale-${i}`,
repoId: TEST_REPO.id,
hostId: 'runtime:alpha'
})
Object.defineProperty(worktree, 'id', {
get: () => {
visited++
return `scale-${i}`
},
enumerable: true
})
return worktree
})
useAppStore.setState({ worktreesByRepo: { [TEST_REPO.id]: worktrees } })
visited = 0
const id = `scale-${count - 1}`
const model = {
state: 'settled' as const,
rows: [{ kind: 'terminal' as const, agent: 'claude' as const }],
detection: {
detectedIds: ['claude' as const],
isLoading: false,
isRefreshing: false,
detectionFailed: false,
needsServerUpdate: false,
refresh: async () => ['claude' as const]
},
pickerContext: {
workspace: { kind: 'git-worktree' as const, worktreeId: id },
executionHostId: 'runtime:alpha',
settings: null,
key: `route-${count}`
}
}
const hook = renderHook(
({ supplied, query }) => {
void query
return useAgentLaunchPickerInventory(
id,
supplied ? model : { ...model, pickerContext: undefined },
false
)
},
{ initialProps: { supplied: true, query: '' } }
)
for (const query of ['c', 'cl', 'cla', 'claude']) {
await act(async () => hook.rerender({ supplied: true, query }))
}
expect(builder).not.toHaveBeenCalled()
expect(visited).toBe(0)
await act(async () => hook.rerender({ supplied: false, query: 'fallback' }))
expect(builder).toHaveBeenCalledOnce()
expect(builder.mock.results[0].value.executionHostId).toBe('runtime:alpha')
expect(visited).toBeGreaterThanOrEqual(count)
builder.mockRestore()
}
)
it.each([false, true])(
'shares prospective, git and folder owner snapshots, including old hosts (%s)',
async (supported) => {
@@ -352,7 +352,9 @@ it('shares real answer preparation at N and 10N across candidates, owners and se
hook.rerender({ target, phase })
const measured = work(),
{ model, selectionFor } = hook.result.current
expect(measured.routes).toBe(measured.selectors + 1 + Number(Boolean(launchCwd)))
expect(measured.routes).toBeLessThanOrEqual(
measured.selectors + 1 + Number(Boolean(launchCwd))
)
if (launchCwd) {
expect(measured.records).toBeGreaterThanOrEqual(count)
} else {
@@ -1,3 +1,5 @@
import type { AgentMenuRowsTarget, NewTabAgentMenuModel } from './new-tab-agent-menu-model'
export type { AgentMenuRowsTarget, NewTabAgentMenuModel } from './new-tab-agent-menu-model'
import { useSyncExternalStore } from 'react'
import { useShallow } from 'zustand/react/shallow'
import {
@@ -13,8 +15,7 @@ import { useAppStore } from '@/store'
import { useAgentPickerRefusedWake } from './use-agent-picker-refused-wake'
import {
buildAgentLaunchRouteInput,
workspaceKindForWorktreeId,
type ProspectiveWorkspace
workspaceKindForWorktreeId
} from '@/lib/agent-launch-route-input'
import { useHostStructuredAgentsVersion } from '@/lib/chat-provider-agents'
import { structuredAgentSessionTargetForHost } from '@/runtime/structured-agent-session-owner'
@@ -34,30 +35,15 @@ import {
readLocalRuntimeCapabilityObservation,
subscribeLocalRuntimeCapabilityObservation
} from '@/runtime/local-runtime-capabilities'
import {
useDetectedAgents,
type AgentDetectionTarget,
type UseDetectedAgentsResult
} from '@/hooks/useDetectedAgents'
import { useDetectedAgents, type AgentDetectionTarget } from '@/hooks/useDetectedAgents'
import { readDetectedAgentsForTarget } from '@/lib/agent-detection-target-inventory'
import { useChatProgramFacts } from '@/lib/host-chat-program-acquisition'
import { readChatProgram } from '@/lib/host-chat-program'
import { resolveAgentMenuSelection } from '@/lib/agent-menu-selection'
import { useHostAgentMenuSettings } from '@/components/settings/use-host-agent-launch-preferences'
import type { TuiAgent } from '../../../../shared/tui-agent'
import type { GlobalSettings } from '../../../../shared/global-settings-types'
import type { AgentLaunchSurface } from '@/lib/agent-session-launch-plan'
export type NewTabAgentMenuModel =
| { state: 'loading'; placeholderCount: number }
| {
state: 'settled'
rows: ReturnType<typeof buildNewTabAgentMenuRows>
detection: UseDetectedAgentsResult
/** The default these rows were ordered by, so the new-agent badge marks the same agent. */
defaultTuiAgent?: GlobalSettings['defaultTuiAgent']
}
function catalogUnlearned(
host: string,
runtimeId: string | null | undefined,
@@ -71,13 +57,6 @@ function catalogUnlearned(
)
}
/** A launch whose workspace may not exist yet, or that starts in a directory of its own. */
export type AgentMenuRowsTarget = {
workspace: ProspectiveWorkspace
/** Only a terminal can start here, so chat rows say so instead of dropping the directory. */
launchCwd?: string | null
}
/** `detectionTarget` is the workspace's agent-inventory host, resolved by the caller. */
export function useNewTabAgentMenuRows(
target: string | AgentMenuRowsTarget,
@@ -123,7 +102,21 @@ export function useAgentMenuObservation(
readLocalRuntimeCapabilityObservation,
readLocalRuntimeCapabilityObservation
)
const desktopSettings = useAppStore((state) => state.settings)
useAppStore(
useShallow((state) => [
state.settings?.defaultTuiAgent,
state.settings?.disabledTuiAgents,
state.settings?.experimentalNativeChat,
state.settings?.nativeChatProviders,
state.settings?.agentDefaultArgs,
state.settings?.agentPermissionDefault,
state.settings?.agentPermissionOverrides,
state.settings?.nativeChatSessionOptions,
state.settings?.nativeChatPermissionMode
])
)
// The observation owns these settings; picker consumers reuse its snapshot.
const desktopSettings = useAppStore.getState().settings
const workspace =
typeof target === 'string'
? { kind: workspaceKindForWorktreeId(target), worktreeId: target }
@@ -266,7 +259,20 @@ export function useAgentMenuObservation(
state: 'loading',
placeholderCount: Math.max(chatAgents.length, detection.detectedIds?.length ?? 0, 2)
}
: { state: 'settled', rows, detection, defaultTuiAgent: settings?.defaultTuiAgent }
: {
state: 'settled',
rows,
detection,
defaultTuiAgent: settings?.defaultTuiAgent,
pickerContext: {
workspace,
executionHostId: facts.executionHostId,
pairingRevision: facts.pairingRevision,
runtimeId: facts.runtimeId,
settings,
key: JSON.stringify([workspace, launchCwd, facts])
}
}
const { defaultTuiAgent, disabledTuiAgents } = settings ?? {}
return {
model,
@@ -1,43 +1,26 @@
import type { TabBarCreateMenuController } from './tab-bar-create-menu-contract'
export type { TabBarCreateMenuController } from './tab-bar-create-menu-contract'
import { useAgentLaunchPickerInventory } from '../agent/use-agent-launch-picker-inventory'
import type { AgentPickerEntry } from '../../../../shared/agent-launch-picker'
import { useEffect, useMemo, useRef, useState } from 'react'
import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface'
import type { WindowsTerminalCapabilities } from '@/lib/windows-terminal-capabilities'
import { useAppStore } from '../../store'
import { useTabAgentMenuLaunch } from './QuickLaunchButton'
import { requestNativeChatComposerFocus } from '@/components/native-chat/use-native-chat-composer-reveal-focus'
import { useNewTabAgentMenuRows, type NewTabAgentMenuModel } from './use-new-tab-agent-menu-rows'
import { useNewTabAgentMenuRows } from './use-new-tab-agent-menu-rows'
import type { AgentDetectionTarget } from '@/hooks/useDetectedAgents'
import type { TabAgentLaunchOption } from './tab-agent-launch-options'
import { buildTabAgentLaunchOptionsFromRows } from './tab-agent-launch-options'
import { buildAgentPickerLaunchOptions } from './tab-agent-launch-options'
import { buildTabCreateMenuOptions, type TabCreateMenuOption } from './tab-create-menu-options'
import { resolveWindowsShellLaunchTarget } from './windows-shell-launch'
import {
buildWindowsShellMenuEntries,
type WindowsShellMenuEntry
} from './tab-bar-windows-shell-options'
import { buildWindowsShellMenuEntries } from './tab-bar-windows-shell-options'
import type {
getProjectRuntimeShellMenuMode,
resolveWindowsPowerShellImplementationSetting
} from './use-tab-bar-runtime-model'
const NEW_TAB_MENU_TERMINAL_FOCUS_RETRY_MS = 50
const NEW_TAB_MENU_TERMINAL_FOCUS_TIMEOUT_MS = 5000
export type TabBarCreateMenuController = {
agentMenu: NewTabAgentMenuModel
agentLaunchOptions: TabAgentLaunchOption[]
launchAgentRow: ReturnType<typeof useTabAgentMenuLaunch>
newTabMenuOpen: boolean
setNewTabMenuOpen: (open: boolean) => void
setCreateMenuQuery: (query: string) => void
createMenuOptions: TabCreateMenuOption[]
windowsShellEntries: WindowsShellMenuEntry[] | undefined
handleSelectCreateMenuOption: (option: TabCreateMenuOption) => void
launchAgentFromNewTabEntry: (option: TabAgentLaunchOption) => void
runPendingNewTabMenuFocusAfterClose: () => void
clearPendingNewTabMenuFocusOnUnmount: (node: HTMLDivElement | null) => void
queueNewActiveTerminalFocusAfterNewTabMenuClose: () => void
queueTerminalTabFocusAfterNewTabMenuClose: (tabId: string) => void
queueFocusAfterNewTabMenuClose: (focus: () => void) => void
showStaticCreateMenuItems: boolean
}
export function useTabBarCreateMenuController({
worktreeId,
resolvedGroupId,
@@ -92,9 +75,15 @@ export function useTabBarCreateMenuController({
agentDetectionTarget,
{ wakeOnOpen: true }
)
const [selectedPickerAgent, selectPickerAgent] = useState<AgentPickerEntry['agent']>()
const agentPickerEntries = useAgentLaunchPickerInventory(
worktreeId,
agentMenu,
newTabMenuOpen && showAgentLaunchItems,
selectedPickerAgent
)
const commandOverrides = useAppStore((state) => state.settings?.agentCmdOverrides)
const settledAgentRows = agentMenu.state === 'settled' ? agentMenu.rows : []
const agentLaunchOptions = buildTabAgentLaunchOptionsFromRows(settledAgentRows, commandOverrides)
const agentLaunchOptions = buildAgentPickerLaunchOptions(agentPickerEntries, commandOverrides)
const pendingNewTabMenuFocusRef = useRef<{ focus: () => void; synchronous?: true } | null>(null)
const pendingNewTabMenuFocusAnimationRef = useRef<number | null>(null)
const pendingNewTabMenuFocusRetryRef = useRef<number | null>(null)
@@ -240,7 +229,11 @@ export function useTabBarCreateMenuController({
})
const launchAgentFromNewTabEntry = (option: TabAgentLaunchOption): void => {
if (option.row) {
launchAgentRow(option.row)
if (option.setup) {
launchAgentRow(option.row, option.setup, option.pickerEntry)
} else {
launchAgentRow(option.row)
}
}
}
const runPendingNewTabMenuFocusAfterClose = (): void => {
@@ -285,6 +278,8 @@ export function useTabBarCreateMenuController({
return {
agentMenu,
agentLaunchOptions,
agentPickerEntries,
selectPickerAgent,
launchAgentRow,
newTabMenuOpen,
setNewTabMenuOpen,
+10 -1
View File
@@ -20,9 +20,18 @@ const commandVariants = cva('flex h-full w-full flex-col overflow-hidden rounded
density: {
default: '',
// Why: matches DropdownMenuItem type so a searchable picker reads like its sibling menus.
menu: '[&_[cmdk-empty]]:text-[12px] [&_[cmdk-input-wrapper]_svg]:size-3.5 [&_[cmdk-input]]:h-8 [&_[cmdk-input]]:text-[12px] [&_[cmdk-item]]:rounded-md [&_[cmdk-item]]:py-[4px] [&_[cmdk-item]]:text-[12px] [&_[cmdk-item]]:leading-[17px] [&_[cmdk-item]]:font-[450]'
menu: '[&_[cmdk-item]]:py-[4px] [&_[cmdk-item]]:text-[12px]',
model:
'[&_[cmdk-group]]:p-1.5 [&_[cmdk-group-items]]:flex [&_[cmdk-group-items]]:flex-col [&_[cmdk-group-items]]:gap-px [&_[cmdk-item]]:min-h-7 [&_[cmdk-item]]:py-1.5 [&_[cmdk-item]]:text-[13px]'
}
},
compoundVariants: [
{
density: ['menu', 'model'],
className:
'[&_[cmdk-empty]]:text-[12px] [&_[cmdk-input-wrapper]_svg]:size-3.5 [&_[cmdk-input]]:h-8 [&_[cmdk-input]]:text-[12px] [&_[cmdk-item]]:rounded-md [&_[cmdk-item]]:leading-[17px] [&_[cmdk-item]]:font-[450]'
}
],
defaultVariants: { surface: 'default', density: 'default' }
})
+16
View File
@@ -22779,6 +22779,22 @@
"unmarkFile": "Unmark {{path}} as viewed"
}
},
"agentPicker": {
"search": "Search agents and setups…",
"configure": "Configure {{agent}}",
"arguments": "Arguments",
"argumentsHint": "Applies only to CLI launches.",
"launchChat": "Launch chat",
"launchTerminal": "Launch terminal",
"setByArguments": "Set by your Arguments",
"searchModels": "Search models",
"noModels": "No models match your search.",
"legacyModels": "Legacy models",
"toggleArguments": "Toggle Arguments",
"argumentsTerminalHint": "Only applies to terminal (CLI) launches",
"searchAgentModels": "Search {{agent}} models…",
"legacyModelCount": "{{total}} models"
},
"automations": {
"runFailures": {
"noDispatchHost": "No Orca window was available to launch the automation.",
+8
View File
@@ -22744,6 +22744,14 @@
"unmarkFile": "Desmarcar {{path}} como visto"
}
},
"agentPicker": {
"search": "Buscar agentes y configuraciones…",
"configure": "Configurar {{agent}}",
"arguments": "Argumentos",
"argumentsHint": "Solo se aplica a los inicios de CLI.",
"launchChat": "Iniciar chat",
"launchTerminal": "Iniciar terminal"
},
"wslMove": {
"entry": {
"hostRow": "Mover proyectos de esta distro…",
+8
View File
@@ -22744,6 +22744,14 @@
"unmarkFile": "Ne plus marquer {{path}} comme consulté"
}
},
"agentPicker": {
"search": "Rechercher des agents et des configurations…",
"configure": "Configurer {{agent}}",
"arguments": "Arguments",
"argumentsHint": "S’applique uniquement aux lancements CLI.",
"launchChat": "Lancer le chat",
"launchTerminal": "Lancer le terminal"
},
"wslMove": {
"entry": {
"hostRow": "Déplacer des projets depuis cette distro…",
+8
View File
@@ -22056,6 +22056,14 @@
"unmarkFile": "{{path}} の閲覧済みのマークを外す"
}
},
"agentPicker": {
"search": "Agent と設定を検索…",
"configure": "{{agent}} を設定",
"arguments": "引数",
"argumentsHint": "CLI での起動にのみ適用されます。",
"launchChat": "チャットを起動",
"launchTerminal": "ターミナルを起動"
},
"wslMove": {
"entry": {
"hostRow": "このディストリビューションからプロジェクトを移動…",
+8
View File
@@ -22056,6 +22056,14 @@
"unmarkFile": "{{path}}을(를) 본 것으로 표시 해제"
}
},
"agentPicker": {
"search": "에이전트 및 설정 검색…",
"configure": "{{agent}} 설정",
"arguments": "인수",
"argumentsHint": "CLI 실행에만 적용됩니다.",
"launchChat": "채팅 시작",
"launchTerminal": "터미널 시작"
},
"wslMove": {
"entry": {
"hostRow": "이 배포판에서 프로젝트 이동…",
+8
View File
@@ -22545,6 +22545,14 @@
"unmarkFile": "Desmarcar {{path}} como visualizado"
}
},
"agentPicker": {
"search": "Buscar agentes e configurações…",
"configure": "Configurar {{agent}}",
"arguments": "Argumentos",
"argumentsHint": "Aplica-se apenas às inicializações pela CLI.",
"launchChat": "Iniciar chat",
"launchTerminal": "Iniciar terminal"
},
"wslMove": {
"entry": {
"hostRow": "Mover projetos desta distro…",
+8
View File
@@ -22057,6 +22057,14 @@
"unmarkFile": "取消 {{path}} 的已查看标记"
}
},
"agentPicker": {
"search": "搜索智能体和配置…",
"configure": "配置 {{agent}}",
"arguments": "参数",
"argumentsHint": "仅适用于 CLI 启动。",
"launchChat": "启动聊天",
"launchTerminal": "启动终端"
},
"wslMove": {
"entry": {
"hostRow": "从此发行版移动项目…",
@@ -0,0 +1,22 @@
import { vi } from 'vitest'
import type { AgentSessionModelOption } from '../../../shared/agent-session-wire'
export const { fetchCodexModelCatalogListing } = await vi.importActual<{
fetchCodexModelCatalogListing(input: {
connection: {
request: (
method: string,
params?: Record<string, unknown>,
options?: { timeoutMs?: number | null; onReply?: () => void }
) => Promise<unknown>
}
timeoutMs?: number
deadlineMs?: number
cwd?: string
}): Promise<{
models: AgentSessionModelOption[]
complete?: boolean
configured?: { model?: string; effort?: string }
listedServiceTiers?: readonly string[]
}>
}>('../../../main/codex/codex-structured-model-catalog')
@@ -0,0 +1,220 @@
import { beforeEach, vi } from 'vitest'
import { setLocalRuntimeCapabilitiesForTests } from '@/runtime/local-runtime-capabilities'
import type { AppState } from '@/store/types'
import type { pasteDraftWhenAgentReady } from './agent-paste-draft'
import type { waitForAgentReady } from './agent-ready-wait'
import type { track } from './telemetry'
import type { toast } from 'sonner'
import type {
createWebRuntimeSessionTerminal,
createWebRuntimeAgentSessionTerminalWithLaunchDraft
} from '@/runtime/web-runtime-terminal-creation'
import type { isWebRuntimeSessionActive } from '@/runtime/web-runtime-session-environment'
import type { Project } from '../../../shared/project-types'
export const mockCreateTab = vi.fn<(...args: Parameters<AppState['createTab']>) => { id: string }>()
export function queuedStartups() {
return mockCreateTab.mock.calls.flatMap((call) => call[3]?.pendingStartup ?? [])
}
export const mockSetActiveTabType = vi.fn<AppState['setActiveTabType']>()
export const mockSetTabViewMode = vi.fn<AppState['setTabViewMode']>()
export const mockSetTabBarOrder = vi.fn<AppState['setTabBarOrder']>()
export const mockSetAgentStatus = vi.fn<AppState['setAgentStatus']>()
export const mockPasteDraftWhenAgentReady = vi.fn<typeof pasteDraftWhenAgentReady>()
export const mockSeedNativeChatLaunchPrompt = vi.fn<AppState['seedNativeChatLaunchPrompt']>()
export const mockSeedNativeChatLaunchDraft = vi.fn<AppState['seedNativeChatLaunchDraft']>()
export const mockMarkNativeChatLaunchPromptFailed =
vi.fn<AppState['markNativeChatLaunchPromptFailed']>()
export const mockTrack = vi.fn<typeof track>()
export const mockToastMessage = vi.fn<typeof toast.message>()
export const mockWaitForAgentReady = vi.fn<typeof waitForAgentReady>()
export const LEAF_ID = '11111111-1111-4111-8111-111111111111'
const initialProjects: Pick<Project, 'id' | 'localWindowsRuntimePreference'>[] = [
{ id: 'repo-1', localWindowsRuntimePreference: { kind: 'inherit-global' } }
]
export const store = {
activeRepoId: 'repo-1',
activeWorktreeId: 'wt-1',
settings: {
agentCmdOverrides: {},
agentDefaultArgs: {} as Record<string, string>,
agentDefaultEnv: {} as Record<string, Record<string, string>>,
activeRuntimeEnvironmentId: null as string | null
} as {
agentCmdOverrides: Record<string, string>
agentDefaultArgs: Record<string, string>
agentDefaultEnv: Record<string, Record<string, string>>
activeRuntimeEnvironmentId: string | null
terminalWindowsShell?: string
experimentalNativeChat?: boolean
nativeChatSessionOptions?: Record<
string,
{ model?: string; valuesByModel?: Record<string, Record<string, string | boolean>> }
>
},
projects: initialProjects,
repos: [{ id: 'repo-1', connectionId: null as string | null, path: '/repo' }],
sshConnectionStates: new Map([
['ssh-a', { status: 'connected', remotePlatform: 'linux' }],
['ssh-target-1', { status: 'connected', remotePlatform: 'linux' }]
]),
transientClearedAgentStatusConnectionIds: {} as Record<string, true>,
sshStateByEnvironment: new Map(),
runtimeStatusByEnvironmentId: new Map([['web-runtime', { status: { hostPlatform: 'linux' } }]]),
worktreesByRepo: {
'repo-1': [
{
id: 'wt-1',
repoId: 'repo-1',
projectId: 'repo-1',
path: '/repo/worktree',
displayName: 'main'
}
]
},
allWorktrees: vi.fn<
() => { id: string; repoId: string; projectId: string; path: string; displayName: string }[]
>(() => store.worktreesByRepo['repo-1']),
tabsByWorktree: {
'wt-1': [{ id: 'tab-1' }]
},
openFiles: [] as { id: string; worktreeId: string }[],
browserTabsByWorktree: {} as Record<string, { id: string }[]>,
tabBarOrderByWorktree: {} as Record<string, string[]>,
terminalLayoutsByTabId: {} as Record<
string,
{ activeLeafId: string | null; ptyIdsByLeafId?: Record<string, string> }
>,
ptyIdsByTabId: {} as Record<string, string[]>,
createTab: mockCreateTab,
closeTab: vi.fn<AppState['closeTab']>(),
setActiveTabType: mockSetActiveTabType,
setTabViewMode: mockSetTabViewMode,
setTabBarOrder: mockSetTabBarOrder,
setAgentStatus: mockSetAgentStatus,
seedNativeChatLaunchPrompt: mockSeedNativeChatLaunchPrompt,
seedNativeChatLaunchDraft: mockSeedNativeChatLaunchDraft,
markNativeChatLaunchPromptFailed: mockMarkNativeChatLaunchPromptFailed
}
vi.mock('@/store', () => ({
useAppStore: {
getState: () => store
}
}))
export const mockToastError = vi.fn<typeof toast.error>()
vi.mock('sonner', () => ({
toast: { message: mockToastMessage, error: mockToastError }
}))
vi.mock('@/components/tab-bar/reconcile-order', () => ({
reconcileTabOrder: vi.fn(
(_stored, termIds: string[], editorIds: string[], browserIds: string[]) => [
...termIds,
...editorIds,
...browserIds
]
)
}))
vi.mock('@/lib/agent-paste-draft', () => ({
pasteDraftWhenAgentReady: mockPasteDraftWhenAgentReady
}))
// Cover window-planned launches/paste here; AI-button host launches share delivery but have separate ownership tests.
vi.mock('@/lib/launch-agent-new-tab-host-route', () => ({
newTabPromptLaunchesThroughHost: () => false,
launchNewTabPromptThroughHost: vi.fn()
}))
vi.mock('@/lib/agent-ready-wait', () => ({
waitForAgentReady: mockWaitForAgentReady
}))
vi.mock('@/lib/telemetry', () => ({
track: mockTrack,
tuiAgentToAgentKind: (agent: string) => agent
}))
export const mockCreateWebRuntimeSessionTerminal = vi.fn<typeof createWebRuntimeSessionTerminal>()
export const mockCreateWebRuntimeAgentSessionTerminalWithLaunchDraft =
vi.fn<typeof createWebRuntimeAgentSessionTerminalWithLaunchDraft>()
export const mockIsWebRuntimeSessionActive = vi.fn<typeof isWebRuntimeSessionActive>(() => false)
vi.mock('@/runtime/web-runtime-terminal-create-operation', () => ({
createWebRuntimeSessionTerminalResult: async (
args: Parameters<typeof createWebRuntimeSessionTerminal>[0]
) => ({
outcome: await mockCreateWebRuntimeSessionTerminal(args)
})
}))
vi.mock('@/runtime/web-runtime-terminal-creation', () => ({
createWebRuntimeSessionTerminal: mockCreateWebRuntimeSessionTerminal,
createWebRuntimeAgentSessionTerminalWithLaunchDraft:
mockCreateWebRuntimeAgentSessionTerminalWithLaunchDraft
}))
vi.mock('@/runtime/web-runtime-session-environment', () => ({
isWebRuntimeSessionActive: mockIsWebRuntimeSessionActive
}))
vi.mock('../../../shared/terminal-surface-id', () => ({
isWebTerminalSurfaceTabId: vi.fn(() => false)
}))
export const COMMAND_CODE_CLICK = {
requestId: 'command-code-click',
agent: 'command-code',
worktreeId: 'wt-1'
} as const
beforeEach(() => {
vi.clearAllMocks()
setLocalRuntimeCapabilitiesForTests([])
mockIsWebRuntimeSessionActive.mockReturnValue(false)
mockCreateWebRuntimeSessionTerminal.mockResolvedValue({ status: 'created' })
mockCreateWebRuntimeAgentSessionTerminalWithLaunchDraft.mockResolvedValue({ status: 'created' })
store.activeRepoId = 'repo-1'
store.activeWorktreeId = 'wt-1'
store.settings = {
agentCmdOverrides: {},
agentDefaultArgs: {},
agentDefaultEnv: {},
activeRuntimeEnvironmentId: null
}
store.projects = [
{
id: 'repo-1',
localWindowsRuntimePreference: { kind: 'inherit-global' }
}
]
store.repos = [{ id: 'repo-1', connectionId: null, path: '/repo' }]
store.sshConnectionStates = new Map([
['ssh-a', { status: 'connected', remotePlatform: 'linux' }],
['ssh-target-1', { status: 'connected', remotePlatform: 'linux' }]
])
store.transientClearedAgentStatusConnectionIds = {}
store.worktreesByRepo = {
'repo-1': [
{
id: 'wt-1',
repoId: 'repo-1',
projectId: 'repo-1',
path: '/repo/worktree',
displayName: 'main'
}
]
}
store.tabsByWorktree = { 'wt-1': [{ id: 'tab-1' }] }
store.openFiles = []
store.browserTabsByWorktree = {}
store.tabBarOrderByWorktree = {}
store.terminalLayoutsByTabId = {}
store.ptyIdsByTabId = {}
mockCreateTab.mockReturnValue({ id: 'tab-1' })
mockPasteDraftWhenAgentReady.mockResolvedValue(true)
mockWaitForAgentReady.mockResolvedValue({ ready: true, reason: 'foreground-match' })
})
@@ -1,211 +1,27 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { setLocalRuntimeCapabilitiesForTests } from '@/runtime/local-runtime-capabilities'
import { describe, expect, it, vi } from 'vitest'
import {
mockCreateTab,
queuedStartups,
mockSetActiveTabType,
mockSetTabViewMode,
mockSetAgentStatus,
mockPasteDraftWhenAgentReady,
mockSeedNativeChatLaunchPrompt,
mockSeedNativeChatLaunchDraft,
mockTrack,
mockToastMessage,
LEAF_ID,
store,
mockToastError,
mockCreateWebRuntimeSessionTerminal,
mockCreateWebRuntimeAgentSessionTerminalWithLaunchDraft,
mockIsWebRuntimeSessionActive,
COMMAND_CODE_CLICK
} from './launch-agent-in-new-tab-test-fixture'
import { toAppSshPtyId } from '../../../shared/ssh-pty-id'
import type { Project } from '../../../shared/project-types'
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants'
const mockCreateTab = vi.fn()
function queuedStartups() {
return mockCreateTab.mock.calls.flatMap((call) => call[3]?.pendingStartup ?? [])
}
const mockSetActiveTabType = vi.fn()
const mockSetTabViewMode = vi.fn()
const mockSetTabBarOrder = vi.fn()
const mockSetAgentStatus = vi.fn()
const mockPasteDraftWhenAgentReady = vi.fn()
const mockSeedNativeChatLaunchPrompt = vi.fn()
const mockSeedNativeChatLaunchDraft = vi.fn()
const mockMarkNativeChatLaunchPromptFailed = vi.fn()
const mockTrack = vi.fn()
const mockToastMessage = vi.fn()
const mockWaitForAgentReady = vi.fn()
const LEAF_ID = '11111111-1111-4111-8111-111111111111'
const initialProjects: Pick<Project, 'id' | 'localWindowsRuntimePreference'>[] = [
{ id: 'repo-1', localWindowsRuntimePreference: { kind: 'inherit-global' } }
]
const store = {
activeRepoId: 'repo-1',
activeWorktreeId: 'wt-1',
settings: {
agentCmdOverrides: {},
agentDefaultArgs: {} as Record<string, string>,
agentDefaultEnv: {} as Record<string, Record<string, string>>,
activeRuntimeEnvironmentId: null as string | null
} as {
agentCmdOverrides: Record<string, string>
agentDefaultArgs: Record<string, string>
agentDefaultEnv: Record<string, Record<string, string>>
activeRuntimeEnvironmentId: string | null
terminalWindowsShell?: string
experimentalNativeChat?: boolean
nativeChatSessionOptions?: Record<
string,
{ model?: string; valuesByModel?: Record<string, Record<string, string | boolean>> }
>
},
projects: initialProjects,
repos: [{ id: 'repo-1', connectionId: null as string | null, path: '/repo' }],
sshConnectionStates: new Map([
['ssh-a', { status: 'connected', remotePlatform: 'linux' }],
['ssh-target-1', { status: 'connected', remotePlatform: 'linux' }]
]),
transientClearedAgentStatusConnectionIds: {} as Record<string, true>,
sshStateByEnvironment: new Map(),
runtimeStatusByEnvironmentId: new Map([['web-runtime', { status: { hostPlatform: 'linux' } }]]),
worktreesByRepo: {
'repo-1': [
{
id: 'wt-1',
repoId: 'repo-1',
projectId: 'repo-1',
path: '/repo/worktree',
displayName: 'main'
}
]
},
allWorktrees: vi.fn(() => store.worktreesByRepo['repo-1']),
tabsByWorktree: {
'wt-1': [{ id: 'tab-1' }]
},
openFiles: [] as { id: string; worktreeId: string }[],
browserTabsByWorktree: {} as Record<string, { id: string }[]>,
tabBarOrderByWorktree: {} as Record<string, string[]>,
terminalLayoutsByTabId: {} as Record<
string,
{ activeLeafId: string | null; ptyIdsByLeafId?: Record<string, string> }
>,
ptyIdsByTabId: {} as Record<string, string[]>,
createTab: mockCreateTab,
closeTab: vi.fn(),
setActiveTabType: mockSetActiveTabType,
setTabViewMode: mockSetTabViewMode,
setTabBarOrder: mockSetTabBarOrder,
setAgentStatus: mockSetAgentStatus,
seedNativeChatLaunchPrompt: mockSeedNativeChatLaunchPrompt,
seedNativeChatLaunchDraft: mockSeedNativeChatLaunchDraft,
markNativeChatLaunchPromptFailed: mockMarkNativeChatLaunchPromptFailed
}
vi.mock('@/store', () => ({
useAppStore: {
getState: () => store
}
}))
const mockToastError = vi.fn()
vi.mock('sonner', () => ({
toast: { message: mockToastMessage, error: mockToastError }
}))
vi.mock('@/components/tab-bar/reconcile-order', () => ({
reconcileTabOrder: vi.fn(
(_stored, termIds: string[], editorIds: string[], browserIds: string[]) => [
...termIds,
...editorIds,
...browserIds
]
)
}))
vi.mock('@/lib/agent-paste-draft', () => ({
pasteDraftWhenAgentReady: mockPasteDraftWhenAgentReady
}))
// Cover window-planned launches/paste here; AI-button host launches share delivery but have separate ownership tests.
vi.mock('@/lib/launch-agent-new-tab-host-route', () => ({
newTabPromptLaunchesThroughHost: () => false,
launchNewTabPromptThroughHost: vi.fn()
}))
vi.mock('@/lib/agent-ready-wait', () => ({
waitForAgentReady: mockWaitForAgentReady
}))
vi.mock('@/lib/telemetry', () => ({
track: mockTrack,
tuiAgentToAgentKind: (agent: string) => agent
}))
const mockCreateWebRuntimeSessionTerminal = vi.fn()
const mockCreateWebRuntimeAgentSessionTerminalWithLaunchDraft = vi.fn()
const mockIsWebRuntimeSessionActive = vi.fn(() => false)
vi.mock('@/runtime/web-runtime-terminal-create-operation', () => ({
createWebRuntimeSessionTerminalResult: async (args: unknown) => ({
outcome: await mockCreateWebRuntimeSessionTerminal(args)
})
}))
vi.mock('@/runtime/web-runtime-terminal-creation', () => ({
createWebRuntimeSessionTerminal: mockCreateWebRuntimeSessionTerminal,
createWebRuntimeAgentSessionTerminalWithLaunchDraft:
mockCreateWebRuntimeAgentSessionTerminalWithLaunchDraft
}))
vi.mock('@/runtime/web-runtime-session-environment', () => ({
isWebRuntimeSessionActive: mockIsWebRuntimeSessionActive
}))
vi.mock('../../../shared/terminal-surface-id', () => ({
isWebTerminalSurfaceTabId: vi.fn(() => false)
}))
const COMMAND_CODE_CLICK = {
requestId: 'command-code-click',
agent: 'command-code',
worktreeId: 'wt-1'
} as const
describe('launchAgentInNewTab', () => {
beforeEach(() => {
vi.clearAllMocks()
setLocalRuntimeCapabilitiesForTests([])
mockIsWebRuntimeSessionActive.mockReturnValue(false)
mockCreateWebRuntimeSessionTerminal.mockResolvedValue({ status: 'created' })
mockCreateWebRuntimeAgentSessionTerminalWithLaunchDraft.mockResolvedValue({ status: 'created' })
store.activeRepoId = 'repo-1'
store.activeWorktreeId = 'wt-1'
store.settings = {
agentCmdOverrides: {},
agentDefaultArgs: {},
agentDefaultEnv: {},
activeRuntimeEnvironmentId: null
}
store.projects = [
{
id: 'repo-1',
localWindowsRuntimePreference: { kind: 'inherit-global' }
}
]
store.repos = [{ id: 'repo-1', connectionId: null, path: '/repo' }]
store.sshConnectionStates = new Map([
['ssh-a', { status: 'connected', remotePlatform: 'linux' }],
['ssh-target-1', { status: 'connected', remotePlatform: 'linux' }]
])
store.transientClearedAgentStatusConnectionIds = {}
store.worktreesByRepo = {
'repo-1': [
{
id: 'wt-1',
repoId: 'repo-1',
projectId: 'repo-1',
path: '/repo/worktree',
displayName: 'main'
}
]
}
store.tabsByWorktree = { 'wt-1': [{ id: 'tab-1' }] }
store.openFiles = []
store.browserTabsByWorktree = {}
store.tabBarOrderByWorktree = {}
store.terminalLayoutsByTabId = {}
store.ptyIdsByTabId = {}
mockCreateTab.mockReturnValue({ id: 'tab-1' })
mockPasteDraftWhenAgentReady.mockResolvedValue(true)
mockWaitForAgentReady.mockResolvedValue({ ready: true, reason: 'foreground-match' })
})
it('stamps the launched agent on the new tab for immediate provider icon bootstrap', async () => {
const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab')
const cancelFocus = vi.fn()
@@ -0,0 +1,579 @@
// @vitest-environment happy-dom
import type * as SettingsTransport from '../components/settings/agent-launch-settings-transport'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup, fireEvent, render } from '@testing-library/react'
import { createElement } from 'react'
import { AgentLaunchSetupPanel } from '../components/agent/AgentLaunchSetupPanel'
import { TooltipProvider } from '../components/ui/tooltip'
import { useAgentLaunchPickerInventory } from '../components/agent/use-agent-launch-picker-inventory'
import type { NewTabAgentMenuModel } from '../components/tab-bar/new-tab-agent-menu-model'
import type { AgentPickerDraft, AgentPickerEntry } from '../../../shared/agent-launch-picker'
import {
agentPickerDraft,
agentPickerLaunchInputs,
agentPickerEntriesFromRows
} from '../../../shared/agent-launch-picker'
import { editAgentPickerPermission } from '../../../shared/agent-launch-picker-permission'
import {
editAgentPickerPanelArguments,
editAgentPickerPanelText,
editAgentPickerSpeed,
agentPickerPanelArguments
} from '../../../shared/agent-launch-picker-arguments'
import {
mockCreateTab,
queuedStartups,
store,
mockToastError,
mockCreateWebRuntimeSessionTerminal,
mockIsWebRuntimeSessionActive
} from './launch-agent-in-new-tab-test-fixture'
import { buildNewTabAgentMenuRows } from '../../../shared/agent-launch-picker-rows'
import type { ExecutionHostId } from '../../../shared/execution-host'
import { fetchCodexModelCatalogListing } from './codex-model-catalog-reader-test-fixture'
vi.mock('@/hooks/useShortcutLabel', () => ({ useOptionalShortcutLabel: () => null }))
afterEach(() => {
cleanup()
vi.restoreAllMocks()
})
const inventory = vi.hoisted(() => ({ settings: vi.fn(), legacy: vi.fn(), session: vi.fn() }))
vi.mock('../components/settings/agent-launch-settings-transport', async (importOriginal) => ({
...(await importOriginal<typeof SettingsTransport>()),
readHostAgentLaunchSettings: inventory.settings
}))
vi.mock('@/runtime/runtime-rpc-client', () => ({ callRuntimeRpc: inventory.legacy }))
vi.mock('@/runtime/structured-agent-session-client', () => ({
callStructuredAgentSession: inventory.session
}))
describe('picker window launch contract', () => {
it.each(['claude', 'codex'] as const)(
'preserves %s permission and model intent through the actual picker-to-terminal compiler',
async (agent) => {
const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab')
const {
agentPickerChoiceSeeds,
agentPickerDraft,
editAgentPickerArguments,
agentPickerLaunchInputs
} = await import('../../../shared/agent-launch-picker')
const settings = {
agentPermissionDefault: 'ask' as const,
agentDefaultArgs: { [agent]: '--add-dir docs' },
nativeChatSessionOptions: { [agent]: { model: 'remembered-chat-model' } }
}
Object.assign(store.settings, settings)
const entry = {
agent,
label: agent,
rows: [{ agent, kind: 'terminal' as const }],
surfaces: ['terminal' as const, 'chat' as const],
defaultSurface: 'terminal' as const,
catalog: { origin: 'unknown' as const },
...agentPickerChoiceSeeds(agent, settings)
}
const launch = (input = {}) => {
mockCreateTab.mockClear()
launchAgentInNewTab({
requestId: 'picker-contract',
agent,
worktreeId: 'wt-1',
requestedSurface: 'terminal',
...input
})
return queuedStartups()[0]?.command
}
const ordinary = launch()
expect(launch(agentPickerLaunchInputs(agentPickerDraft(entry), entry))).toBe(ordinary)
for (const text of ['--add-dir src', '', '--unknown opaque --add-dir src']) {
const command = launch(
agentPickerLaunchInputs(editAgentPickerArguments(agentPickerDraft(entry), text), entry)
)
expect(command).toContain(
agent === 'claude'
? "'--permission-mode' 'default'"
: "'-a' 'on-request' '-s' 'read-only'"
)
expect(command).not.toContain('remembered-chat-model')
expect(command).not.toContain('docs')
}
const raw =
agent === 'claude'
? '--permission-mode plan --add-dir src'
: '-a never -s danger-full-access --add-dir src'
const command = launch(
agentPickerLaunchInputs(editAgentPickerArguments(agentPickerDraft(entry), raw), entry)
)
expect(command).toContain(agent === 'claude' ? "'--permission-mode' 'plan'" : "'-a' 'never'")
expect(command).not.toContain(
agent === 'claude' ? "'--permission-mode' 'default'" : "'-a' 'on-request'"
)
}
)
it.each(['local', 'ssh', 'paired', 'older-host'] as const)(
'keeps ordinary and configured-default launch input/failure contracts on %s',
async (target) => {
const launcher = await import('./launch-agent-in-new-tab')
const dispatch = vi.spyOn(launcher, 'launchAgentInNewTab')
const { launchAgentInNewTab } = launcher
const { AgentLaunchPickerMenu } = await import('../components/agent/AgentLaunchPickerMenu')
const { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } =
await import('../components/ui/dropdown-menu')
const { useTabAgentMenuLaunch } = await import('../components/tab-bar/QuickLaunchButton')
const { EntryActionRow } = await import('../components/tab-bar/TabBarCreateEntryRow')
const { matchingAgentPickerLaunchOptions, configuredAgentPickerLaunchOption } =
await import('../components/tab-bar/tab-agent-launch-options')
const { agentPickerChoiceSeeds, agentPickerLaunchInputs } =
await import('../../../shared/agent-launch-picker')
Object.assign(store.settings, { agentDefaultArgs: { claude: '--add-dir docs' } })
if (target === 'ssh') {
store.repos[0].connectionId = 'ssh-target-1'
}
if (target === 'paired' || target === 'older-host') {
mockIsWebRuntimeSessionActive.mockReturnValue(true)
store.settings.activeRuntimeEnvironmentId = 'web-runtime'
Object.assign(store.repos[0], { executionHostId: 'runtime:web-runtime' })
if (target === 'older-host') {
mockCreateWebRuntimeSessionTerminal.mockResolvedValue({
status: 'failed',
message: 'Upgrade the remote Orca host before starting or resuming agent sessions.'
})
}
}
const host: ExecutionHostId =
target === 'ssh' ? 'ssh:ssh-target-1' : target === 'local' ? 'local' : 'runtime:web-runtime'
const rows = buildNewTabAgentMenuRows({
settings: { experimentalNativeChat: true, nativeChatProviders: { claude: false } },
detectedOnHost: ['claude'],
chatAgents: ['claude'],
hostChatAnswer: () => (target === 'ssh' ? 'ssh' : 'yes'),
chatProgram: () => 'available'
}).map((row) => ({ ...row, executionHostId: host }))
const entry = agentPickerEntriesFromRows(
rows,
['claude'],
(selected) => agentPickerChoiceSeeds(selected, store.settings),
() => 'Claude'
)[0]
entry.catalog = {
origin: 'probe',
fetchedAt: 1,
models: [
{
id: 'opus',
label: 'Opus',
isDefault: true,
efforts: [{ value: 'high', label: 'High' }]
}
]
}
expect(entry.defaultSurface).toBe('terminal')
expect(entry.surfaces).toEqual(target === 'ssh' ? ['terminal'] : ['chat', 'terminal'])
const option = matchingAgentPickerLaunchOptions([entry], 'opus high')[0]
function Menu({ omnibox }: { omnibox: boolean }) {
const runLaunch = useTabAgentMenuLaunch({
worktreeId: 'wt-1',
groupId: 'group-1',
onFocusTerminal: () => {}
})
return createElement(
TooltipProvider,
null,
omnibox
? createElement(EntryActionRow, {
id: 'setup-result',
selected: true,
option: { kind: 'agent', option },
onClick: () => runLaunch(option.row!, option.setup, entry),
onUseAgent: (draft) => {
const configured = configuredAgentPickerLaunchOption(option, draft)
runLaunch(configured.row!, configured.setup, entry)
}
})
: createElement(
DropdownMenu,
{ defaultOpen: true, modal: false },
createElement(DropdownMenuTrigger, null, 'New tab'),
createElement(
DropdownMenuContent,
null,
createElement(AgentLaunchPickerMenu, {
entries: [entry],
runLaunch,
model: {
state: 'settled',
rows,
detection: {
detectedIds: ['claude'],
isLoading: false,
isRefreshing: false,
detectionFailed: false,
needsServerUpdate: false,
refresh: async (): Promise<AgentPickerEntry['agent'][]> => ['claude']
}
}
})
)
)
)
}
const perform = async (inputs = {}, menu?: 'ordinary' | 'searched' | 'omnibox') => {
mockCreateTab.mockClear()
mockCreateWebRuntimeSessionTerminal.mockClear()
mockToastError.mockClear()
dispatch.mockClear()
if (menu) {
render(createElement(Menu, { omnibox: menu === 'omnibox' }))
if (menu === 'searched') {
fireEvent.change(document.querySelector('input')!, { target: { value: 'opus high' } })
}
fireEvent.click(document.querySelector('button[aria-label="Configure Claude"]')!)
expect(dispatch).not.toHaveBeenCalled()
fireEvent.click(
[...document.querySelectorAll('button')].find(
(button) => button.textContent === 'Launch terminal'
)!
)
expect(dispatch).toHaveBeenCalledOnce()
expect(dispatch.mock.calls[0][0]).toMatchObject({
requestedSurface: 'terminal',
selectedExecutionHostId: host
})
} else {
launchAgentInNewTab({
requestId: 'equivalence',
agent: 'claude',
worktreeId: 'wt-1',
groupId: 'group-1',
requestedSurface: 'terminal',
selectedExecutionHostId: host,
...inputs
})
}
const call = dispatch.mock.results.at(-1)
const result = call?.type === 'return' ? call.value : undefined
await result?.terminalCompletion
cleanup()
return {
command: queuedStartups()[0]?.command,
remote: mockCreateWebRuntimeSessionTerminal.mock.calls[0]?.[0],
errors: mockToastError.mock.calls,
surface: result?.surface
}
}
const ordinary = await perform()
const configured = await perform({}, 'ordinary')
expect(configured).toEqual(ordinary)
expect(ordinary.surface?.kind).toBe(
target === 'paired' || target === 'older-host' ? 'host-published' : 'local-terminal'
)
if (target === 'older-host') {
expect(configured.errors).toHaveLength(1)
}
const expectedSearch = await perform(
agentPickerLaunchInputs({ ...option.setup!, surface: 'terminal' }, entry)
)
expect(await perform({}, 'searched')).toEqual(expectedSearch)
expect(await perform({}, 'omnibox')).toEqual(expectedSearch)
expect(expectedSearch.command ?? expectedSearch.remote?.command ?? '').toContain('opus')
dispatch.mockRestore()
}
)
})
it.each(['claude', 'codex'] as const)(
'passes %s speed through the real picker-to-terminal compiler',
async (agent) => {
const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab')
const { agentPickerDraft, agentPickerLaunchInputs } =
await import('../../../shared/agent-launch-picker')
const { editAgentPickerSpeed } = await import('../../../shared/agent-launch-picker-arguments')
const options: Record<string, string> =
agent === 'claude' ? { fastMode: 'false' } : { serviceTier: 'default' }
const entry = {
agent,
label: agent,
rows: [{ agent, kind: 'terminal' as const }],
surfaces: ['chat' as const, 'terminal' as const],
defaultSurface: 'chat' as const,
terminal: { permissionMode: 'ask' as const },
structured: {
model: agent === 'claude' ? 'opus' : 'account-model',
permissionMode: 'ask' as const,
options
},
arguments:
agent === 'claude'
? '--settings ./team.json --settings \'{"language":"french"}\''
: '--add-dir docs',
catalog: {
origin: 'probe' as const,
fetchedAt: 1,
models: [
{
id: agent === 'claude' ? 'opus' : 'account-model',
label: 'Model',
isDefault: true,
efforts: [],
supportsFastMode: true,
...(agent === 'codex'
? { serviceTiers: [{ value: 'account_fast_v9', label: 'Fast' }] }
: {})
}
]
}
}
Object.assign(store.settings, { agentPermissionDefault: 'ask' })
const draft = editAgentPickerSpeed(
entry,
agentPickerDraft(entry),
agent === 'claude' ? 'fastMode' : 'serviceTier',
agent === 'claude' ? 'true' : 'account_fast_v9',
entry.structured.model
)
const launch = {
...draft,
surface: 'terminal' as const
}
mockCreateTab.mockClear()
launchAgentInNewTab({
requestId: 'picker-fast-contract',
agent,
worktreeId: 'wt-1',
requestedSurface: 'terminal',
...agentPickerLaunchInputs(launch, entry)
})
const command = queuedStartups()[0]?.command ?? ''
expect(command).toContain(
agent === 'claude' ? '"fastMode":true' : 'service_tier=account_fast_v9'
)
if (agent === 'claude') {
expect(command).toContain('"language":"french"')
expect(command).toContain('./team.json')
}
expect(command).not.toContain('account-model')
}
)
it.each(['claude', 'codex'] as const)(
'keeps failed %s defaults unknown through acquisition, controls, projection and actual dispatch, then wakes to a permission-preserving edit',
async (agent) => {
const { projectAgentLaunchSettings } = await import('../../../shared/agent-launch-settings')
const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab')
const settings = {
agentPermissionDefault: 'ask' as const,
agentDefaultArgs: { [agent]: '--add-dir docs' }
}
Object.assign(store.settings, settings)
inventory.settings.mockRejectedValue(new Error('offline'))
inventory.legacy.mockRejectedValue(new Error('offline'))
inventory.session.mockImplementation(async (_target, method) =>
method === 'agentSession.modelCatalog'
? { origin: 'unknown' }
: { seedOptions: { permissionMode: 'ask' } }
)
const menu: NewTabAgentMenuModel = {
state: 'settled',
rows: [{ agent, kind: 'chat', executionHostId: 'runtime:authority' }],
detection: {
detectedIds: [agent],
isLoading: false,
isRefreshing: false,
detectionFailed: false,
needsServerUpdate: false,
refresh: async () => [agent]
},
pickerContext: {
workspace: { kind: 'git-worktree', worktreeId: 'wt-1' },
executionHostId: 'runtime:authority',
pairingRevision: 1,
runtimeId: agent,
settings: null,
key: `contract-${agent}`
}
}
let current: AgentPickerEntry
const launches: AgentPickerDraft[] = []
function Panel({ open }: { open: boolean }) {
const entries = useAgentLaunchPickerInventory('wt-1', menu, open)
current = entries[0]
return open
? createElement(
TooltipProvider,
null,
createElement(AgentLaunchSetupPanel, {
key: current.ownerKey,
entry: current,
onUse: (draft) => launches.push(draft)
})
)
: null
}
const view = render(createElement(Panel, { open: true }))
try {
await act(async () => {})
const entry = current!
expect(entry.defaultsPending).toBe(true)
fireEvent.click(view.getByLabelText('Toggle Arguments'))
expect(view.getByLabelText('Arguments')).toHaveProperty('disabled', true)
expect(view.container.querySelector('button[aria-label$=" permissions"]')).toHaveProperty(
'disabled',
true
)
const draft = agentPickerDraft(entry)
for (const next of [
draft,
{ ...draft, arguments: '--add-dir src' },
editAgentPickerPanelArguments(entry, draft, '--add-dir src'),
editAgentPickerPanelText(entry, draft, '--add-dir src'),
editAgentPickerPermission(entry, draft, 'full-access'),
editAgentPickerSpeed(
entry,
draft,
agent === 'claude' ? 'fastMode' : 'serviceTier',
agent === 'claude' ? 'true' : 'priority'
)
]) {
for (const surface of ['chat', 'terminal'] as const) {
expect(agentPickerLaunchInputs({ ...next, surface }, entry)).not.toHaveProperty(
'agentArgs'
)
}
}
for (const button of ['Launch chat', 'Launch terminal']) {
fireEvent.click(view.getByText(button))
}
fireEvent.keyDown(view.getByLabelText('Arguments'), { key: 'Enter' })
expect(launches).toHaveLength(3)
for (const next of launches) {
expect(agentPickerLaunchInputs(next, entry)).toEqual({})
}
mockCreateTab.mockClear()
launchAgentInNewTab({
requestId: 'unknown-defaults',
agent,
worktreeId: 'wt-1',
requestedSurface: 'terminal',
...agentPickerLaunchInputs({ ...launches[1], surface: 'terminal' }, entry)
})
expect(queuedStartups()[0]?.command).toContain('docs')
expect(queuedStartups()[0]?.command).toContain(
agent === 'claude' ? "'--permission-mode' 'default'" : "'-a' 'on-request' '-s' 'read-only'"
)
view.rerender(createElement(Panel, { open: false }))
const read = Promise.withResolvers<ReturnType<typeof projectAgentLaunchSettings>>()
inventory.settings.mockReturnValue(read.promise)
view.rerender(createElement(Panel, { open: true }))
expect(current!.defaultsPending).toBe(true)
await act(async () => read.resolve(projectAgentLaunchSettings(settings)))
fireEvent.click(view.getByLabelText('Toggle Arguments'))
const input = view.getByLabelText('Arguments')
expect(input).toHaveProperty('disabled', false)
for (const text of ['--add-dir src', '']) {
fireEvent.change(input, { target: { value: text } })
fireEvent.click(view.getByText('Launch terminal'))
const launch = agentPickerLaunchInputs(launches.at(-1)!, current!)
expect(launch.agentArgs).toContain(
agent === 'claude' ? '--permission-mode default' : '-a on-request -s read-only'
)
mockCreateTab.mockClear()
launchAgentInNewTab({
requestId: 'known-edit',
agent,
worktreeId: 'wt-1',
requestedSurface: 'terminal',
...launch
})
const command = queuedStartups()[0]?.command
expect(command).toContain(
agent === 'claude'
? "'--permission-mode' 'default'"
: "'-a' 'on-request' '-s' 'read-only'"
)
expect(command).not.toContain('docs')
}
const knownDraft = agentPickerDraft(current!)
const badge = agentPickerPanelArguments(current!, knownDraft).chips.find(
(c) => c.key === 'permissionMode'
)!
const removed = badge.remove()
expect(
agentPickerLaunchInputs({ ...removed, surface: 'terminal' }, current!).agentArgs
).not.toContain(agent === 'claude' ? '--permission-mode' : '-a on-request')
} finally {
cleanup()
}
}
)
it('passes the recorded Codex catalog tier and approval/sandbox policy through the common reader, picker and real compiler', async () => {
const { default: capture } =
await import('../../../shared/fixtures/agent-picker-cli-recordings.json')
const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab')
const listing = await fetchCodexModelCatalogListing({
connection: { request: vi.fn().mockResolvedValue(capture.modelList) }
})
const model = listing.models[0]
const tier = model.serviceTiers![0].value
expect(capture.policy.sandbox.type).toBe('workspaceWrite')
const raw = `-a ${capture.policy.approvalPolicy} -s workspace-write -c approvals_reviewer=${capture.policy.approvalsReviewer}`
const entry: AgentPickerEntry = {
agent: 'codex',
label: 'Codex',
rows: [{ agent: 'codex', kind: 'chat' }],
surfaces: ['chat', 'terminal'],
defaultSurface: 'terminal',
terminal: {},
structured: {},
arguments: `${raw} --add-dir docs`,
catalog: { origin: 'probe', fetchedAt: 1, models: listing.models }
}
const selected = editAgentPickerSpeed(
entry,
agentPickerDraft(entry),
'serviceTier',
tier,
model.id
)
const view = agentPickerPanelArguments(entry, selected, model.id)
expect(view.chips.map((chip) => chip.key)).toContain('permissionMode')
const edited = editAgentPickerPanelText(entry, selected, '--add-dir src', model.id)
for (const next of [selected, edited]) {
mockCreateTab.mockClear()
const inputs = agentPickerLaunchInputs({ ...next, surface: 'terminal' }, entry)
launchAgentInNewTab({
requestId: 'recorded-policy',
agent: 'codex',
worktreeId: 'wt-1',
requestedSurface: 'terminal',
...inputs
})
expect(queuedStartups()[0]?.command).toContain(`'service_tier=${tier}'`)
expect(queuedStartups()[0]?.command).toContain("'-a' 'on-request' '-s' 'workspace-write'")
expect(
agentPickerLaunchInputs({ ...next, surface: 'chat' }, entry).sessionOptions?.serviceTier
).toBe(tier)
}
const standard = editAgentPickerPanelText(
entry,
selected,
'-c service_tier=default --add-dir src',
model.id
)
expect(
agentPickerLaunchInputs({ ...standard, surface: 'chat' }, entry).sessionOptions?.serviceTier
).toBe('default')
expect(agentPickerLaunchInputs({ ...standard, surface: 'terminal' }, entry).agentArgs).toContain(
'service_tier=default'
)
expect(view.chips.some((chip) => chip.key === 'serviceTier')).toBe(true)
const removed = view.chips.find((chip) => chip.key === 'serviceTier')!.remove()
expect(
agentPickerPanelArguments(entry, removed, model.id).chips.some(
(chip) => chip.key === 'serviceTier'
)
).toBe(false)
})
@@ -1,296 +1,329 @@
// @vitest-environment happy-dom
import type * as LaunchErrors from '@/lib/structured-agent-session-launch-errors'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
mockCreateTab,
mockCreateUnifiedTab,
mockWaitForAgentReady,
mockPasteDraftWhenAgentReady,
mockCreateStructuredCodexSessionLaunchIntent,
mockRetryStructuredAgentSessionLaunchIntent,
mockLaunchStructuredCodexSession,
mockRefreshLocalStructuredSessionTabs,
mockToastError,
mockCallStructuredAgentSession,
mockCreateSupport,
structuredLaunchIntent,
store,
structuredLaunchErrorMocks,
setHostCapabilities
} from './launch-agent-structured-chat-test-fixture'
import type * as PickerMenu from '@/components/agent/AgentLaunchPickerMenu'
import type * as Dropdown from '@/components/ui/dropdown-menu'
import type * as Tooltip from '@/components/ui/tooltip'
import type * as QuickLaunch from '@/components/tab-bar/QuickLaunchButton'
import { createElement } from 'react'
import { cleanup, fireEvent, render } from '@testing-library/react'
import { buildNewTabAgentMenuRows } from '../../../shared/agent-launch-picker-rows'
import type * as StructuredLaunch from './launch-structured-agent-session'
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
const mockCreateTab = vi.fn()
const mockCreateUnifiedTab = vi.fn<
(
worktreeId: string,
contentType: string,
init: {
id: string
entityId: string
targetGroupId?: string
agentSessionAgent?: string
label?: string
}
) => {
id: string
entityId: string
contentType: string
worktreeId: string
groupId: string
agentSessionAgent?: string
label?: string
}
>()
const mockSetTabViewMode = vi.fn()
const mockWaitForAgentReady = vi.fn()
const mockPasteDraftWhenAgentReady = vi.fn()
const mockMarkNativeChatLaunchPromptFailed = vi.fn()
const mockCreateStructuredCodexSessionLaunchIntent = vi.fn()
const mockAbandonStructuredAgentSessionLaunchIntent = vi.fn()
const mockRetryStructuredAgentSessionLaunchIntent =
vi.fn<
(intent: ReturnType<typeof structuredLaunchIntent>) => ReturnType<typeof structuredLaunchIntent>
>()
const mockLaunchStructuredCodexSession = vi.fn()
const mockRefreshLocalStructuredSessionTabs = vi.fn()
const mockToastError = vi.fn()
const mockCallStructuredAgentSession = vi.fn()
const mockCreateSupport = vi.fn()
const STRUCTURED_HOST_CAPABILITIES = ['agent-session.structured.v1']
let hostCapabilities: readonly string[] | null = STRUCTURED_HOST_CAPABILITIES
type UnifiedTabFixture = {
id: string
contentType: string
entityId: string
worktreeId: string
groupId: string
agentSessionAgent?: string
label?: string
}
const emptyUnifiedTabsByWorktree: Record<string, UnifiedTabFixture[]> = {}
function structuredLaunchIntent(worktreeId: string, sessionId = 'codex-session-1') {
return {
sessionId,
worktreeId,
executionHostId: 'local' as const,
target: { kind: 'local' as const },
params: {
envelope: {
sessionId,
clientOperationId: `operation-${sessionId}`,
expectedRuntimeFence: null,
payloadFingerprint: 'f'.repeat(64)
},
worktree: `id:${worktreeId}`,
agent: 'codex' as const
}
}
}
const store = {
clearNativeChatLaunchDraft: vi.fn(),
readLocalAgentInventory: () => ({ ids: null, state: 'pending' }),
activeRepoId: 'repo-1',
activeWorktreeId: 'wt-1',
settings: {
agentCmdOverrides: {},
agentDefaultArgs: {},
agentDefaultEnv: {},
activeRuntimeEnvironmentId: null,
experimentalNativeChat: true,
nativeChatSessionOptions: undefined as
| Record<
string,
{ model?: string; valuesByModel?: Record<string, Record<string, string | boolean>> }
>
| undefined
},
projects: [{ id: 'repo-1', localWindowsRuntimePreference: { kind: 'inherit-global' as const } }],
repos: [{ id: 'repo-1', connectionId: null as string | null, path: '/repo' }],
sshConnectionStates: new Map([
['ssh-a', { status: 'connected', remotePlatform: 'linux' }],
['runtime-ssh-a', { status: 'connected', remotePlatform: 'linux' }]
]),
sshStateByEnvironment: new Map(),
runtimeStatusByEnvironmentId: new Map([['web-runtime', { status: { hostPlatform: 'linux' } }]]),
transientClearedAgentStatusConnectionIds: {},
worktreesByRepo: {
'repo-1': [{ id: 'wt-1', repoId: 'repo-1', projectId: 'repo-1', path: '/repo/worktree' }]
},
detectedWorktreesByRepo: {},
allWorktrees: vi.fn(() => store.worktreesByRepo['repo-1']),
tabsByWorktree: { 'wt-1': [{ id: 'tab-1' }] },
unifiedTabsByWorktree: emptyUnifiedTabsByWorktree,
groupsByWorktree: {},
layoutByWorktree: {},
activeGroupIdByWorktree: {},
openFiles: [] as { id: string; worktreeId: string }[],
browserTabsByWorktree: {} as Record<string, { id: string }[]>,
tabBarOrderByWorktree: {} as Record<string, string[]>,
terminalLayoutsByTabId: {},
ptyIdsByTabId: {},
createTab: mockCreateTab,
createUnifiedTab: mockCreateUnifiedTab,
closeTab: vi.fn(),
queueTabStartupCommand: vi.fn(),
setActiveTabType: vi.fn(),
focusGroup: vi.fn(),
activateTab: vi.fn(),
setTabViewMode: mockSetTabViewMode,
setTabBarOrder: vi.fn(),
setAgentStatus: vi.fn(),
seedNativeChatLaunchPrompt: vi.fn(),
seedNativeChatLaunchDraft: vi.fn(),
markNativeChatLaunchPromptFailed: mockMarkNativeChatLaunchPromptFailed
}
vi.mock('@/store', () => ({ useAppStore: { getState: () => store } }))
vi.mock('sonner', () => ({ toast: { message: vi.fn(), error: mockToastError } }))
vi.mock('@/components/tab-bar/reconcile-order', () => ({ reconcileTabOrder: vi.fn(() => []) }))
vi.mock('@/lib/agent-paste-draft', () => ({
pasteDraftWhenAgentReady: mockPasteDraftWhenAgentReady
}))
vi.mock('@/lib/agent-ready-wait', () => ({ waitForAgentReady: mockWaitForAgentReady }))
vi.mock('@/lib/telemetry', () => ({
track: vi.fn(),
tuiAgentToAgentKind: (agent: string) => agent
}))
vi.mock('@/runtime/web-runtime-terminal-creation', () => ({
createWebRuntimeSessionTerminal: vi.fn(),
createWebRuntimeAgentSessionTerminalWithLaunchDraft: vi.fn()
}))
vi.mock('@/runtime/web-runtime-session-environment', () => ({
isWebRuntimeSessionActive: vi.fn(() => false)
}))
vi.mock('../../../shared/terminal-surface-id', () => ({
isWebTerminalSurfaceTabId: vi.fn(() => false)
}))
const launchErrorMocks = vi.hoisted(() => {
class StructuredAgentSessionCreateRefusalError extends Error {}
class StructuredAgentSessionHostDeclinedError extends StructuredAgentSessionCreateRefusalError {}
class StructuredAgentSessionHostUnreachableError extends StructuredAgentSessionCreateRefusalError {}
class StructuredAgentSessionOwnerUnresolvedError extends Error {}
return {
StructuredAgentSessionCreateRefusalError,
StructuredAgentSessionHostDeclinedError,
StructuredAgentSessionHostUnreachableError,
StructuredAgentSessionOwnerUnresolvedError
}
})
vi.mock('@/lib/structured-agent-session-launch-errors', async () => ({
...(await vi.importActual<typeof LaunchErrors>('@/lib/structured-agent-session-launch-errors')),
...launchErrorMocks
}))
vi.mock('@/lib/structured-agent-session-launch-create-call', () => ({
launchStructuredAgentSession: mockLaunchStructuredCodexSession
}))
vi.mock('@/lib/launch-structured-agent-session', () => {
return {
createStructuredAgentSessionLaunchIntent: mockCreateStructuredCodexSessionLaunchIntent,
abandonStructuredAgentSessionLaunchIntent: mockAbandonStructuredAgentSessionLaunchIntent,
retryStructuredAgentSessionLaunchIntent: mockRetryStructuredAgentSessionLaunchIntent,
launchStructuredAgentSession: mockLaunchStructuredCodexSession,
StructuredAgentSessionCreateRefusalError:
launchErrorMocks.StructuredAgentSessionCreateRefusalError,
StructuredAgentSessionHostDeclinedError:
launchErrorMocks.StructuredAgentSessionHostDeclinedError,
StructuredAgentSessionHostUnreachableError:
launchErrorMocks.StructuredAgentSessionHostUnreachableError,
StructuredAgentSessionOwnerUnresolvedError:
launchErrorMocks.StructuredAgentSessionOwnerUnresolvedError
}
})
vi.mock('@/runtime/local-structured-session-tabs-sync/inventory-refresh', () => ({
refreshLocalStructuredSessionTabs: mockRefreshLocalStructuredSessionTabs
}))
vi.mock('@/runtime/local-structured-session-owner', () => ({
LOCAL_STRUCTURED_SESSION_OWNER: 'local-structured-session'
}))
vi.mock('@/runtime/local-runtime-capabilities', () => ({
readLocalRuntimeCapabilitiesOrUnknown: () => hostCapabilities,
ensureLocalRuntimeCapabilities: async () => hostCapabilities,
awaitLocalRuntimeCapabilities: () => ({ known: Promise.resolve([]), stop: () => {} })
}))
vi.mock('@/lib/worktree-runtime-owner', () => ({
getExecutionHostIdForWorktree: () =>
store.repos[0]?.connectionId ? `ssh:${store.repos[0].connectionId}` : 'local',
runtimeEnvironmentIdOrLocalUnlessTwin: () => null
}))
vi.mock('@/runtime/structured-agent-session-client', () => ({
callStructuredAgentSession: (target: unknown, method: string, params: unknown) =>
method === 'agentSession.createSupport'
? mockCreateSupport()
: mockCallStructuredAgentSession(target, method, params)
}))
const launchErrorMocks = structuredLaunchErrorMocks()
// No initial chat mode proves availability rejection preceded terminal-tab adoption.
describe('structured chat adoption guard on the launch path', () => {
beforeEach(() => {
vi.resetAllMocks()
store.unifiedTabsByWorktree = {
'wt-1': [
{
id: 'structured-agent-session-codex-session-1',
contentType: 'agent-session',
entityId: 'codex-session-1',
worktreeId: 'wt-1',
groupId: 'group-1'
}
]
}
store.repos = [{ id: 'repo-1', connectionId: null, path: '/repo' }]
store.projects = [{ id: 'repo-1', localWindowsRuntimePreference: { kind: 'inherit-global' } }]
mockCreateTab.mockReturnValue({ id: 'tab-1' })
mockCreateUnifiedTab.mockImplementation((worktreeId, contentType, init) => {
const tab = {
id: init.id,
entityId: init.entityId,
contentType,
worktreeId,
groupId: init.targetGroupId ?? 'group-1',
...(init.agentSessionAgent ? { agentSessionAgent: init.agentSessionAgent } : {}),
...(init.label ? { label: init.label } : {})
}
store.unifiedTabsByWorktree[worktreeId] = [
...(store.unifiedTabsByWorktree[worktreeId] ?? []),
tab
]
return tab
let restoreCatalogStore: (() => void) | undefined
beforeEach(async () => {
const { registerStructuredSelectionCatalogStore } =
await import('./structured-agent-session-selection-controller')
restoreCatalogStore = registerStructuredSelectionCatalogStore({
read: () => ({ ...store, runtimeEnvironmentCatalogHydrated: true }),
subscribe: () => () => {}
})
mockWaitForAgentReady.mockResolvedValue({ ready: true, reason: 'foreground-match' })
mockPasteDraftWhenAgentReady.mockResolvedValue(true)
mockCreateStructuredCodexSessionLaunchIntent.mockImplementation((worktreeId: string) =>
structuredLaunchIntent(worktreeId)
)
mockRetryStructuredAgentSessionLaunchIntent.mockImplementation((intent) => ({
...intent,
params: {
...intent.params,
envelope: {
...intent.params.envelope,
clientOperationId: `${intent.params.envelope.clientOperationId}-retry`
}
}
}))
mockLaunchStructuredCodexSession.mockResolvedValue({
sessionId: 'codex-session-1',
fence: 1
})
mockCallStructuredAgentSession.mockResolvedValue({
ok: true,
page: { fence: 1 },
value: { submission: { dispatchState: 'accepted' } }
})
mockRefreshLocalStructuredSessionTabs.mockResolvedValue([
{
worktree: 'wt-1',
tabs: [{ type: 'agent-session', sessionId: 'codex-session-1' }]
}
])
mockCreateSupport.mockResolvedValue({ supported: true })
hostCapabilities = STRUCTURED_HOST_CAPABILITIES
store.settings.experimentalNativeChat = true
store.settings.nativeChatSessionOptions = undefined
})
afterEach(async () => {
cleanup()
vi.restoreAllMocks()
const { cancelStructuredAgentLaunch, retireStructuredAgentSessionLaunchCancellationTombstone } =
await import('./structured-agent-session-launch')
for (const sessionId of ['codex-session-1', 'codex-session-2']) {
cancelStructuredAgentLaunch('wt-1', sessionId)
retireStructuredAgentSessionLaunchCancellationTombstone('wt-1', sessionId)
}
restoreCatalogStore?.()
})
let pickerMenu: typeof PickerMenu
let dropdown: typeof Dropdown
let tooltip: typeof Tooltip
let quickLaunch: typeof QuickLaunch
let actual: typeof StructuredLaunch
beforeAll(async () => {
actual = await vi.importActual<typeof StructuredLaunch>('./launch-structured-agent-session')
;[pickerMenu, dropdown, tooltip, quickLaunch] = await Promise.all([
import('@/components/agent/AgentLaunchPickerMenu'),
import('@/components/ui/dropdown-menu'),
import('@/components/ui/tooltip'),
import('@/components/tab-bar/QuickLaunchButton')
])
})
it.each(
(['local', 'runtime:web-runtime'] as const).flatMap((host) =>
(
[
['claude', 'success'],
['claude', 'refusal'],
['claude', 'failure'],
['codex', 'success'],
['codex', 'refusal'],
['codex', 'failure']
] as const
).map(([agent, outcome]) => ({ host, agent, outcome }))
)
)(
'keeps ordinary and picker $agent Chat inputs and $outcome endpoint outcomes equivalent on $host',
async ({ host, agent, outcome }) => {
store.settings.activeRuntimeEnvironmentId = host === 'local' ? null : 'web-runtime'
const { captureNativeChatComposerFocusIntent } =
await import('@/components/native-chat/use-native-chat-composer-reveal-focus')
const { AgentLaunchPickerMenu } = pickerMenu
const { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } = dropdown
const { TooltipProvider } = tooltip
const { useTabAgentMenuLaunch } = quickLaunch
const launcher = await import('./launch-agent-in-new-tab')
const dispatch = vi.spyOn(launcher, 'launchAgentInNewTab')
const { launchAgentInNewTab } = launcher
const { resetStructuredAgentLaunchRegistryForTests } =
await import('./agent-session/launch/structured-agent-session-launch-registry')
const { resetStructuredAgentLaunchPersistenceForTests } =
await import('./agent-session/launch/structured-agent-session-launch-persistence')
const { agentPickerChoiceSeeds, agentPickerEntriesFromRows } =
await import('../../../shared/agent-launch-picker')
store.settings.nativeChatSessionOptions = {
[agent]: {
model: 'saved-unknown-model',
valuesByModel: { 'saved-unknown-model': { effort: 'high' } }
}
}
store.settings.nativeChatProviders = { [agent]: false }
const rows = buildNewTabAgentMenuRows({
settings: store.settings,
detectedOnHost: [agent],
chatAgents: [agent],
hostChatAnswer: () => 'yes',
chatProgram: () => 'available'
}).map((row) => ({ ...row, executionHostId: host }))
expect(rows).toEqual([{ agent, kind: 'cli', chat: true, executionHostId: host }])
const entry = agentPickerEntriesFromRows(
rows,
[agent],
(selected) => agentPickerChoiceSeeds(selected, store.settings),
(selected) => selected
)[0]
expect(entry.defaultSurface).toBe('terminal')
expect(entry.surfaces).toEqual(['chat', 'terminal'])
Object.assign(entry, {
defaultsPending: true,
structuredDefaultsPending: true,
arguments: undefined,
terminal: {},
structured: {}
})
// The searched setup matches the saved choices so endpoint parity includes search intent.
entry.catalog = {
origin: 'probe',
fetchedAt: 1,
models: [
{
id: 'saved-unknown-model',
label: 'Saved model',
isDefault: true,
efforts: [{ value: 'high', label: 'High' }]
}
]
}
function Menu() {
const runLaunch = useTabAgentMenuLaunch({
worktreeId: 'wt-1',
groupId: 'group-1',
onFocusTerminal: () => {}
})
return createElement(
TooltipProvider,
null,
createElement(
DropdownMenu,
{ defaultOpen: true, modal: false },
createElement(DropdownMenuTrigger, null, 'New tab'),
createElement(
DropdownMenuContent,
null,
createElement(AgentLaunchPickerMenu, {
entries: [entry],
runLaunch,
model: {
state: 'settled',
rows,
detection: {
detectedIds: [agent],
isLoading: false,
isRefreshing: false,
detectionFailed: false,
needsServerUpdate: false,
refresh: async () => [agent]
}
}
})
)
)
)
}
mockCreateStructuredCodexSessionLaunchIntent.mockImplementation(
(worktreeId, selectedAgent, host, resume, seed) => {
const intent = actual.createStructuredAgentSessionLaunchIntent(
worktreeId,
selectedAgent,
host,
resume,
seed,
false
)
return {
...intent,
sessionId: 'codex-session-1',
params: {
...intent.params,
envelope: structuredLaunchIntent(worktreeId).params.envelope
}
}
}
)
const error =
outcome === 'refusal'
? new launchErrorMocks.StructuredAgentSessionCreateRefusalError('provider unavailable')
: new Error('connection failed')
const perform = async (picker: false | 'ordinary' | 'searched') => {
resetStructuredAgentLaunchRegistryForTests()
resetStructuredAgentLaunchPersistenceForTests()
mockLaunchStructuredCodexSession.mockClear()
mockCreateTab.mockClear()
mockToastError.mockClear()
if (outcome === 'failure') {
store.unifiedTabsByWorktree = {}
mockRefreshLocalStructuredSessionTabs.mockResolvedValue([])
}
const preparationErrors: unknown[] = []
mockLaunchStructuredCodexSession.mockImplementation(async (intent) => {
await Promise.resolve()
intent.createMessageSupport = true
try {
expect(intent.prepareCreate).toBeTypeOf('function')
intent.prepareCreate!(false)
expect(intent.createPrepared).toBe(true)
} catch (error) {
preparationErrors.push(error instanceof Error ? error.stack : error)
throw error
}
if (outcome !== 'success') {
throw error
}
return { sessionId: intent.sessionId, fence: 1 }
})
dispatch.mockClear()
if (picker) {
const view = render(createElement(Menu))
if (picker === 'searched') {
fireEvent.change(view.getByPlaceholderText('Search agents and setups…'), {
target: { value: 'saved-unknown-model high' }
})
}
fireEvent.click(
document.querySelector<HTMLButtonElement>(`button[aria-label="Configure ${agent}"]`)!
)
expect(mockLaunchStructuredCodexSession).not.toHaveBeenCalled()
expect(document.querySelector('textarea')).toHaveProperty('disabled', true)
expect(document.body.textContent).toContain('Launch terminal')
expect(
[...document.querySelectorAll('button')].find(
(button) => button.textContent === 'Launch terminal'
)?.className
).toContain('bg-primary')
fireEvent.click(
[...document.querySelectorAll('button')].find(
(button) => button.textContent === 'Launch chat'
)!
)
} else {
launchAgentInNewTab({
requestId: `equivalent-${agent}-${outcome}`,
agent,
worktreeId: 'wt-1',
groupId: 'group-1',
requestedSurface: 'chat',
selectedExecutionHostId: host,
focusIntent: captureNativeChatComposerFocusIntent()
})
}
const call = dispatch.mock.results.at(-1)
const result = call?.type === 'return' ? call.value : undefined
const settlement = await result?.structuredSettlement
cleanup()
expect(preparationErrors).toEqual([])
const intent = mockLaunchStructuredCodexSession.mock.calls[0]?.[0]
return {
surface: result?.surface,
settlement,
target: intent?.target,
params: intent?.params,
seed: intent?.seedOptions,
explicit: intent?.explicitLaunchOptions,
terminalCalls: mockCreateTab.mock.calls,
errors: mockToastError.mock.calls
}
}
const ordinary = await perform(false)
const picker = await perform('ordinary')
expect(picker).toEqual(ordinary)
const searched = await perform('searched')
expect(searched).toMatchObject({
surface: ordinary.surface,
settlement: ordinary.settlement,
target: ordinary.target,
errors: ordinary.errors,
terminalCalls: []
})
expect(searched.params).toMatchObject({
agent,
worktree: 'id:wt-1',
options: { model: 'saved-unknown-model', effort: 'high' }
})
expect(searched.explicit).toMatchObject({ model: 'saved-unknown-model', effort: 'high' })
dispatch.mockRestore()
expect(picker.target).toEqual(
host === 'local' ? { kind: 'local' } : { kind: 'environment', environmentId: 'web-runtime' }
)
expect(picker.params).toMatchObject({
agent,
worktree: 'id:wt-1'
})
expect(picker.params).not.toHaveProperty('options')
expect(picker.explicit).toBeUndefined()
if (host === 'local') {
expect(picker.seed).toMatchObject({ model: 'saved-unknown-model', effort: 'high' })
} else {
expect(picker.seed).toBeUndefined()
}
expect(picker.terminalCalls).toEqual([])
expect(picker.settlement?.kind).toBe(
outcome === 'success'
? 'structured'
: outcome === 'refusal'
? 'failed'
: 'visibility-unknown'
)
}
)
it('takes the structured path when the chat-default view is selected', async () => {
const { launchAgentInNewTab, shouldQueueTerminalFocusAfterMenuClose } =
await import('./launch-agent-in-new-tab')
@@ -423,7 +456,7 @@ describe('structured chat adoption guard on the launch path', () => {
it.each([[], null])(
'preserves terminal-backed launches with capability answer %s',
async (capabilities) => {
hostCapabilities = capabilities
setHostCapabilities(capabilities)
const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab')
const launches = [
@@ -0,0 +1,311 @@
import { beforeEach, vi } from 'vitest'
import type { AppState } from '@/store/types'
import type { pasteDraftWhenAgentReady } from './agent-paste-draft'
import type { waitForAgentReady } from './agent-ready-wait'
import type { createStructuredAgentSessionLaunchIntent as createIntent } from './launch-structured-agent-session'
import type { launchStructuredAgentSession } from './structured-agent-session-launch-create-call'
import type { refreshLocalStructuredSessionTabs as refreshTabs } from '@/runtime/local-structured-session-tabs-sync/inventory-refresh'
import type { toast } from 'sonner'
import type * as LaunchErrors from '@/lib/structured-agent-session-launch-errors'
export const mockCreateTab = vi.fn<(...args: Parameters<AppState['createTab']>) => { id: string }>()
export const mockCreateUnifiedTab = vi.fn<
(
worktreeId: string,
contentType: string,
init: Pick<UnifiedTabFixture, 'id' | 'entityId' | 'agentSessionAgent' | 'label'> & {
targetGroupId?: string
}
) => UnifiedTabFixture
>()
const mockSetTabViewMode = vi.fn<AppState['setTabViewMode']>()
export const mockWaitForAgentReady = vi.fn<typeof waitForAgentReady>()
export const mockPasteDraftWhenAgentReady = vi.fn<typeof pasteDraftWhenAgentReady>()
const mockMarkNativeChatLaunchPromptFailed = vi.fn<AppState['markNativeChatLaunchPromptFailed']>()
export const mockCreateStructuredCodexSessionLaunchIntent =
vi.fn<
(...args: Parameters<typeof createIntent>) => Omit<ReturnType<typeof createIntent>, 'agent'>
>()
const mockAbandonStructuredAgentSessionLaunchIntent = vi.fn()
export const mockRetryStructuredAgentSessionLaunchIntent =
vi.fn<
(intent: ReturnType<typeof structuredLaunchIntent>) => ReturnType<typeof structuredLaunchIntent>
>()
export const mockLaunchStructuredCodexSession = vi.fn<typeof launchStructuredAgentSession>()
type TabSnapshotFixture = { worktree: string; tabs: { type: 'agent-session'; sessionId: string }[] }
export const mockRefreshLocalStructuredSessionTabs =
vi.fn<(...args: Parameters<typeof refreshTabs>) => Promise<TabSnapshotFixture[]>>()
export const mockToastError = vi.fn<typeof toast.error>()
export const mockCallStructuredAgentSession =
vi.fn<(target: unknown, method: string, params: unknown) => Promise<unknown>>()
export const mockCreateSupport = vi.fn<() => Promise<{ supported: boolean; reason?: string }>>()
const STRUCTURED_HOST_CAPABILITIES = ['agent-session.structured.v1']
let hostCapabilities: readonly string[] | null = STRUCTURED_HOST_CAPABILITIES
type UnifiedTabFixture = {
id: string
contentType: string
entityId: string
worktreeId: string
groupId: string
agentSessionAgent?: string
label?: string
}
const emptyUnifiedTabsByWorktree: Record<string, UnifiedTabFixture[]> = {}
type StructuredLaunchFixtureSettings = {
agentCmdOverrides: Record<string, string>
agentDefaultArgs: Record<string, string>
agentDefaultEnv: Record<string, Record<string, string>>
activeRuntimeEnvironmentId: string | null
experimentalNativeChat: boolean
nativeChatProviders: Record<string, boolean>
nativeChatSessionOptions?: Record<
string,
{ model?: string; valuesByModel?: Record<string, Record<string, string | boolean>> }
>
}
const settings: StructuredLaunchFixtureSettings = {
agentCmdOverrides: {},
agentDefaultArgs: {},
agentDefaultEnv: {},
activeRuntimeEnvironmentId: null,
experimentalNativeChat: true,
nativeChatProviders: {},
nativeChatSessionOptions: undefined
}
export function structuredLaunchIntent(worktreeId: string, sessionId = 'codex-session-1') {
return {
sessionId,
worktreeId,
executionHostId: 'local' as const,
target: { kind: 'local' as const },
params: {
envelope: {
sessionId,
clientOperationId: `operation-${sessionId}`,
expectedRuntimeFence: null,
payloadFingerprint: 'f'.repeat(64)
},
worktree: `id:${worktreeId}`,
agent: 'codex' as const
}
}
}
export const store = {
clearNativeChatLaunchDraft: vi.fn<AppState['clearNativeChatLaunchDraft']>(),
readLocalAgentInventory: () => ({ ids: null, state: 'pending' }),
activeRepoId: 'repo-1',
activeWorktreeId: 'wt-1',
settings,
projects: [{ id: 'repo-1', localWindowsRuntimePreference: { kind: 'inherit-global' as const } }],
repos: [{ id: 'repo-1', connectionId: null as string | null, path: '/repo' }],
sshConnectionStates: new Map([
['ssh-a', { status: 'connected', remotePlatform: 'linux' }],
['runtime-ssh-a', { status: 'connected', remotePlatform: 'linux' }]
]),
sshStateByEnvironment: new Map(),
runtimeStatusByEnvironmentId: new Map([
[
'web-runtime',
{ status: { hostPlatform: 'linux', capabilities: STRUCTURED_HOST_CAPABILITIES } }
]
]),
transientClearedAgentStatusConnectionIds: {},
worktreesByRepo: {
'repo-1': [{ id: 'wt-1', repoId: 'repo-1', projectId: 'repo-1', path: '/repo/worktree' }]
},
detectedWorktreesByRepo: {},
allWorktrees: vi.fn<() => { id: string; repoId: string; projectId: string; path: string }[]>(
() => store.worktreesByRepo['repo-1']
),
tabsByWorktree: { 'wt-1': [{ id: 'tab-1' }] },
unifiedTabsByWorktree: emptyUnifiedTabsByWorktree,
groupsByWorktree: {},
layoutByWorktree: {},
activeGroupIdByWorktree: {},
openFiles: [] as { id: string; worktreeId: string }[],
browserTabsByWorktree: {} as Record<string, { id: string }[]>,
tabBarOrderByWorktree: {} as Record<string, string[]>,
terminalLayoutsByTabId: {},
ptyIdsByTabId: {},
createTab: mockCreateTab,
createUnifiedTab: mockCreateUnifiedTab,
closeTab: vi.fn<AppState['closeTab']>(),
queueTabStartupCommand: vi.fn<AppState['queueTabStartupCommand']>(),
setActiveTabType: vi.fn<AppState['setActiveTabType']>(),
focusGroup: vi.fn<AppState['focusGroup']>(),
activateTab: vi.fn<AppState['activateTab']>(),
setTabViewMode: mockSetTabViewMode,
setTabBarOrder: vi.fn<AppState['setTabBarOrder']>(),
setAgentStatus: vi.fn<AppState['setAgentStatus']>(),
seedNativeChatLaunchPrompt: vi.fn<AppState['seedNativeChatLaunchPrompt']>(),
seedNativeChatLaunchDraft: vi.fn<AppState['seedNativeChatLaunchDraft']>(),
markNativeChatLaunchPromptFailed: mockMarkNativeChatLaunchPromptFailed
}
vi.mock('@/hooks/useShortcutLabel', () => ({ useOptionalShortcutLabel: () => null }))
vi.mock('@/store', () => ({ useAppStore: { getState: () => store } }))
vi.mock('sonner', () => ({ toast: { message: vi.fn(), error: mockToastError } }))
vi.mock('@/components/tab-bar/reconcile-order', () => ({ reconcileTabOrder: vi.fn(() => []) }))
vi.mock('@/lib/agent-paste-draft', () => ({
pasteDraftWhenAgentReady: mockPasteDraftWhenAgentReady
}))
vi.mock('@/lib/agent-ready-wait', () => ({ waitForAgentReady: mockWaitForAgentReady }))
vi.mock('@/lib/telemetry', () => ({
track: vi.fn(),
tuiAgentToAgentKind: (agent: string) => agent
}))
vi.mock('@/runtime/web-runtime-terminal-creation', () => ({
createWebRuntimeSessionTerminal: vi.fn(),
createWebRuntimeAgentSessionTerminalWithLaunchDraft: vi.fn()
}))
vi.mock('@/runtime/web-runtime-session-environment', () => ({
isWebRuntimeSessionActive: vi.fn(() => false)
}))
vi.mock('../../../shared/terminal-surface-id', () => ({
isWebTerminalSurfaceTabId: vi.fn(() => false)
}))
const launchErrorMocks = vi.hoisted(() => {
class StructuredAgentSessionCreateRefusalError extends Error {}
class StructuredAgentSessionHostDeclinedError extends StructuredAgentSessionCreateRefusalError {}
class StructuredAgentSessionHostUnreachableError extends StructuredAgentSessionCreateRefusalError {}
class StructuredAgentSessionOwnerUnresolvedError extends Error {}
return {
StructuredAgentSessionCreateRefusalError,
StructuredAgentSessionHostDeclinedError,
StructuredAgentSessionHostUnreachableError,
StructuredAgentSessionOwnerUnresolvedError
}
})
vi.mock('@/lib/structured-agent-session-launch-errors', async () => ({
...(await vi.importActual<typeof LaunchErrors>('@/lib/structured-agent-session-launch-errors')),
...launchErrorMocks
}))
vi.mock('@/lib/structured-agent-session-launch-create-call', () => ({
launchStructuredAgentSession: mockLaunchStructuredCodexSession
}))
vi.mock('@/lib/launch-structured-agent-session', () => {
return {
createStructuredAgentSessionLaunchIntent: mockCreateStructuredCodexSessionLaunchIntent,
abandonStructuredAgentSessionLaunchIntent: mockAbandonStructuredAgentSessionLaunchIntent,
retryStructuredAgentSessionLaunchIntent: mockRetryStructuredAgentSessionLaunchIntent,
launchStructuredAgentSession: mockLaunchStructuredCodexSession,
StructuredAgentSessionCreateRefusalError:
launchErrorMocks.StructuredAgentSessionCreateRefusalError,
StructuredAgentSessionHostDeclinedError:
launchErrorMocks.StructuredAgentSessionHostDeclinedError,
StructuredAgentSessionHostUnreachableError:
launchErrorMocks.StructuredAgentSessionHostUnreachableError,
StructuredAgentSessionOwnerUnresolvedError:
launchErrorMocks.StructuredAgentSessionOwnerUnresolvedError
}
})
vi.mock('@/runtime/local-structured-session-tabs-sync/inventory-refresh', () => ({
refreshLocalStructuredSessionTabs: mockRefreshLocalStructuredSessionTabs
}))
vi.mock('@/runtime/local-structured-session-owner', () => ({
LOCAL_STRUCTURED_SESSION_OWNER: 'local-structured-session'
}))
vi.mock('@/runtime/local-runtime-capabilities', () => ({
readLocalRuntimeCapabilitiesOrUnknown: () => hostCapabilities,
ensureLocalRuntimeCapabilities: async () => hostCapabilities,
awaitLocalRuntimeCapabilities: () => ({ known: Promise.resolve([]), stop: () => {} })
}))
vi.mock('@/lib/worktree-runtime-owner', () => ({
getExecutionHostIdForWorktree: () =>
store.repos[0]?.connectionId
? `ssh:${store.repos[0].connectionId}`
: store.settings.activeRuntimeEnvironmentId
? `runtime:${store.settings.activeRuntimeEnvironmentId}`
: 'local',
runtimeEnvironmentIdOrLocalUnlessTwin: () => null
}))
vi.mock('@/runtime/structured-agent-session-client', () => ({
callStructuredAgentSession: (target: unknown, method: string, params: unknown) =>
method === 'agentSession.createSupport'
? mockCreateSupport()
: mockCallStructuredAgentSession(target, method, params)
}))
beforeEach(() => {
vi.resetAllMocks()
store.unifiedTabsByWorktree = {
'wt-1': [
{
id: 'structured-agent-session-codex-session-1',
contentType: 'agent-session',
entityId: 'codex-session-1',
worktreeId: 'wt-1',
groupId: 'group-1'
}
]
}
store.repos = [{ id: 'repo-1', connectionId: null, path: '/repo' }]
store.projects = [{ id: 'repo-1', localWindowsRuntimePreference: { kind: 'inherit-global' } }]
mockCreateTab.mockReturnValue({ id: 'tab-1' })
mockCreateUnifiedTab.mockImplementation((worktreeId, contentType, init) => {
const tab = {
id: init.id,
entityId: init.entityId,
contentType,
worktreeId,
groupId: init.targetGroupId ?? 'group-1',
...(init.agentSessionAgent ? { agentSessionAgent: init.agentSessionAgent } : {}),
...(init.label ? { label: init.label } : {})
}
store.unifiedTabsByWorktree[worktreeId] = [
...(store.unifiedTabsByWorktree[worktreeId] ?? []),
tab
]
return tab
})
mockWaitForAgentReady.mockResolvedValue({ ready: true, reason: 'foreground-match' })
mockPasteDraftWhenAgentReady.mockResolvedValue(true)
mockCreateStructuredCodexSessionLaunchIntent.mockImplementation((worktreeId: string) =>
structuredLaunchIntent(worktreeId)
)
mockRetryStructuredAgentSessionLaunchIntent.mockImplementation((intent) => ({
...intent,
params: {
...intent.params,
envelope: {
...intent.params.envelope,
clientOperationId: `${intent.params.envelope.clientOperationId}-retry`
}
}
}))
mockLaunchStructuredCodexSession.mockResolvedValue({
sessionId: 'codex-session-1',
fence: 1
})
mockCallStructuredAgentSession.mockResolvedValue({
ok: true,
page: { fence: 1 },
value: { submission: { dispatchState: 'accepted' } }
})
mockRefreshLocalStructuredSessionTabs.mockResolvedValue([
{
worktree: 'wt-1',
tabs: [{ type: 'agent-session', sessionId: 'codex-session-1' }]
}
])
mockCreateSupport.mockResolvedValue({ supported: true })
hostCapabilities = STRUCTURED_HOST_CAPABILITIES
store.settings.activeRuntimeEnvironmentId = null
store.settings.nativeChatProviders = {}
store.settings.experimentalNativeChat = true
store.settings.nativeChatSessionOptions = undefined
})
export function setHostCapabilities(value: readonly string[] | null) {
hostCapabilities = value
}
export function structuredLaunchErrorMocks() {
return launchErrorMocks
}
+8 -113
View File
@@ -3,27 +3,14 @@ import {
type AgentLaunchRoutingInput
} from '@/lib/agent-launch-routing'
import { STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY as CATALOG } from '../../../shared/protocol-version'
import type { GlobalSettings } from '../../../shared/global-settings-types'
import { isNativeChatProviderDisplayedOn } from '../../../shared/native-chat/native-chat-providers'
import {
isNativeChatEnabled,
type NativeChatDefaultSettings
} from '../../../shared/structured-native-chat-launch-route'
import type { ExecutionHostId } from '../../../shared/execution-host'
import type { TuiAgent } from '../../../shared/tui-agent'
import { filterEnabledTuiAgents } from '../../../shared/tui-agent-selection'
import { orderTabLaunchAgents as orderAgents } from '@/components/tab-bar/tab-agent-launch-options'
import { chatProgramOffered, type ChatProgramObservation } from '@/lib/host-chat-program'
/** What the workspace's machine says about opening an agent as a chat, installation aside. */
export type HostChatAnswer =
| 'yes'
| 'not-yet-known'
| 'ssh'
| 'wsl'
| 'subfolder'
| 'not-on-host'
| 'no-chats'
import type { HostChatAnswer } from '../../../shared/agent-launch-picker-rows'
export { buildNewTabAgentMenuRows } from '../../../shared/agent-launch-picker-rows'
export type {
HostChatAnswer,
ChatUnavailableReason,
NewTabAgentMenuRow,
NewTabAgentMenuSettings
} from '../../../shared/agent-launch-picker-rows'
export function hostChatAnswer(input: AgentLaunchRoutingInput): HostChatAnswer {
const support = structuredAgentLaunchSupport(input)
@@ -50,95 +37,3 @@ export function hostChatAnswer(input: AgentLaunchRoutingInput): HostChatAnswer {
return 'yes'
}
}
export type ChatUnavailableReason =
| 'check-failed'
| 'not-installed'
| 'ssh'
| 'wsl'
| 'subfolder'
| 'not-on-host'
export type NewTabAgentMenuRow = (
| { kind: 'chat'; agent: TuiAgent }
| { kind: 'chat-unavailable'; agent: TuiAgent; reason: ChatUnavailableReason }
/** `chat`: set to open as a CLI, though it can open as a chat here too. */
| { kind: 'cli'; agent: TuiAgent; chat?: true }
| { kind: 'terminal'; agent: TuiAgent }
) & { executionHostId?: ExecutionHostId }
export type NewTabAgentMenuSettings = NativeChatDefaultSettings &
Partial<Pick<GlobalSettings, 'defaultTuiAgent' | 'disabledTuiAgents'>>
export function buildNewTabAgentMenuRows(args: {
settings: NewTabAgentMenuSettings | null | undefined
detectedOnHost: readonly TuiAgent[] | null
chatAgents: readonly TuiAgent[]
hostChatAnswer: (agent: TuiAgent) => HostChatAnswer
/** Whether the chat's own program runs there, which for some agents is not their CLI. */
chatProgram: (agent: TuiAgent) => ChatProgramObservation
}): NewTabAgentMenuRow[] {
const { settings } = args
const defaultAgent = settings?.defaultTuiAgent
const installed = filterEnabledTuiAgents(args.detectedOnHost ?? [], settings?.disabledTuiAgents)
if (!isNativeChatEnabled(settings) || args.hostChatAnswer('claude') === 'no-chats') {
return orderAgents(defaultAgent, installed).map((agent) => ({ kind: 'terminal', agent }))
}
const runnable: TuiAgent[] = []
const unavailable = new Map<TuiAgent, ChatUnavailableReason>()
const chatAgents = filterEnabledTuiAgents(args.chatAgents, settings?.disabledTuiAgents)
// An unchosen provider is on wherever its chat program is, whether or not its CLI is.
const canOpen = (agent: TuiAgent): boolean =>
chatProgramOffered(args.chatProgram(agent)) ||
(args.chatProgram(agent) === 'unknown' && installed.includes(agent))
const providedHere = chatAgents.filter(canOpen)
const chatToo = new Set<TuiAgent>()
for (const agent of chatAgents) {
const displayed = isNativeChatProviderDisplayedOn(settings, agent, providedHere)
if (!displayed && (!installed.includes(agent) || !canOpen(agent))) {
continue
}
const answer = args.hostChatAnswer(agent)
if (!displayed) {
if (answer === 'yes') {
chatToo.add(agent)
}
continue
}
const program = args.chatProgram(agent)
if (answer === 'yes' && canOpen(agent)) {
runnable.push(agent)
} else {
unavailable.set(
agent,
answer === 'yes'
? program === 'missing'
? 'not-installed'
: 'check-failed'
: args.detectedOnHost === null
? 'check-failed'
: answer === 'not-yet-known'
? 'check-failed'
: answer === 'no-chats'
? 'not-on-host'
: answer
)
}
}
return [
...orderAgents(defaultAgent, runnable).map((agent) => ({ kind: 'chat' as const, agent })),
...orderAgents(defaultAgent, [...unavailable.keys()]).map((agent) => ({
kind: 'chat-unavailable' as const,
agent,
reason: unavailable.get(agent) ?? 'not-on-host'
})),
...orderAgents(
defaultAgent,
installed.filter((agent) => !runnable.includes(agent))
).map((agent) => ({
kind: 'cli' as const,
agent,
...(chatToo.has(agent) ? { chat: true as const } : {})
}))
]
}
+86 -7
View File
@@ -6,7 +6,8 @@ import {
PERMISSION_LAUNCH_GRAMMARS
} from './agent-permission-args'
import { agentPermissionArgs } from './tui-agent-permission-choice'
import { tokenizeStartupCommand } from './tui-agent-startup-shell'
import { readAgentChoiceSettings, agentChoiceSettingsOccurrences } from './agent-choice-settings'
import { quoteStartupArg, tokenizeStartupCommand } from './tui-agent-startup-shell'
import type { TuiAgent } from './tui-agent'
import { agentChoiceConfigValue } from './agent-choice-cli-apply'
@@ -17,6 +18,7 @@ type ChoiceOccurrence = {
end: number
lift: boolean
owned: boolean
replacement?: string
}
/** All candidates and cuts refer to the same original text in every execution grammar. */
@@ -36,6 +38,46 @@ export function analyzeAgentChoiceArgs(agent: TuiAgent, text: string) {
const first = originals[0]
const occurrences: ChoiceOccurrence[] = []
const seen = new Set<string>()
const opaqueKeys = new Set<string>()
const effectiveSettings = new Map<
(typeof forms)[number]['form'],
ReturnType<typeof agentChoiceSettingsOccurrences>[number] | undefined
>()
for (const { key, form } of forms) {
if (form.kind !== 'settings') {
continue
}
const effective = first
? agentChoiceSettingsOccurrences(agent, form.names, first.parsed.tokens).at(-1)
: undefined
effectiveSettings.set(form, effective)
if (
!first ||
(effective &&
(!readAgentChoiceSettings(effective.value) ||
!first.analysis.optionIndices.has(effective.index) ||
first.analysis.roles[effective.index] === 'uncertain' ||
!originals.every((original) => {
if (!original) {
return false
}
const start = first.parsed.spans[effective.index].start
const at = original.offsets.get(start) ?? -1
return (
at >= 0 &&
Array.from({ length: effective.consumed }, (_, offset) => offset).every(
(offset) =>
original.parsed.spans[at + offset]?.end ===
first.parsed.spans[effective.index + offset].end &&
original.parsed.tokens[at + offset] ===
first.parsed.tokens[effective.index + offset]
)
)
})))
) {
opaqueKeys.add(key)
}
}
if (first && originals.every((original) => original !== null)) {
for (let index = 0; index < first.parsed.tokens.length; index++) {
const token = first.parsed.tokens[index]
@@ -50,6 +92,7 @@ export function analyzeAgentChoiceArgs(agent: TuiAgent, text: string) {
let count = 1
let value = ''
let lift = false
let replacement: string | undefined
if (form.kind === 'recipe') {
count = form.words.length
if (!form.words.every((word, offset) => first.parsed.tokens[index + offset] === word)) {
@@ -82,6 +125,20 @@ export function analyzeAgentChoiceArgs(agent: TuiAgent, text: string) {
(!inline || name.startsWith('--')) &&
!!value &&
!value.startsWith('-')
if (form.kind === 'settings') {
const effective = effectiveSettings.get(form)
if (effective?.index !== index || opaqueKeys.has(key)) {
continue
}
const settings = readAgentChoiceSettings(value)
const choice = settings?.[form.key]
if (!settings || (typeof choice !== 'boolean' && typeof choice !== 'string')) {
continue
}
const { [form.key]: _removed, ...remaining } = settings
replacement = `${name} ${quoteStartupArg(JSON.stringify(remaining), 'posix')}`
value = String(choice)
}
if (form.kind === 'config') {
const configValue = agentChoiceConfigValue(value, form.key)
if (configValue === undefined) {
@@ -117,7 +174,15 @@ export function analyzeAgentChoiceArgs(agent: TuiAgent, text: string) {
})
if (!seen.has(`${key}:${start}`)) {
seen.add(`${key}:${start}`)
occurrences.push({ key, value, start, end, lift: lift && agreed, owned: agreed })
occurrences.push({
key,
value,
start,
end,
lift: lift && agreed,
owned: agreed,
...(replacement !== undefined ? { replacement } : {})
})
}
}
}
@@ -130,10 +195,13 @@ export function analyzeAgentChoiceArgs(agent: TuiAgent, text: string) {
if (ranges.some((range, index) => index > 0 && range.start < ranges[index - 1].end)) {
return null
}
const replacements = new Map(
ranges.filter((range) => range.replacement).map((range) => [range.start, range.replacement])
)
const chunks: string[] = []
let cursor = 0
for (const { start, end } of ranges) {
chunks.push(text.slice(cursor, start))
for (const { start, end, replacement } of ranges) {
chunks.push(text.slice(cursor, start), replacement ?? '')
cursor = end
}
chunks.push(text.slice(cursor))
@@ -151,11 +219,22 @@ export function analyzeAgentChoiceArgs(agent: TuiAgent, text: string) {
return !!range && span.start >= range.start && span.end <= range.end
})
const kept = original.parsed.tokens.filter((_, index) => !removed[index])
const expected = original.parsed.tokens.flatMap((token, index) => {
if (!removed[index]) {
return [token]
}
const text = replacements.get(original.parsed.spans[index].start)
if (!text) {
return []
}
const replacement = tokenizeStartupCommand(text, PERMISSION_LAUNCH_GRAMMARS[grammarIndex])
return replacement.ok ? replacement.tokens : []
})
const parsed = tokenizeStartupCommand(args, PERMISSION_LAUNCH_GRAMMARS[grammarIndex])
if (
!parsed.ok ||
kept.length !== parsed.tokens.length ||
!kept.every((token, index) => token === parsed.tokens[index])
expected.length !== parsed.tokens.length ||
!expected.every((token, index) => token === parsed.tokens[index])
) {
return false
}
@@ -171,5 +250,5 @@ export function analyzeAgentChoiceArgs(agent: TuiAgent, text: string) {
})
return proven ? args : null
}
return { occurrences, cut }
return { occurrences, cut, opaqueKeys }
}
+17 -12
View File
@@ -1,6 +1,7 @@
import type { AgentType } from './agent-status-types'
import type { AgentChoiceCliForm } from './agent-launch-choices'
import type { CatalogOptionApply } from './agent-session-option-catalog-types'
import { mergeAgentChoiceSettings, withoutInlineAgentChoiceSettings } from './agent-choice-settings'
import { removeAgentArgOption } from './agent-session-option-agent-args'
type ChoiceCliArgumentForm = Exclude<AgentChoiceCliForm, { kind: 'recipe' }>
@@ -21,21 +22,25 @@ export function agentChoiceCliApply(
const encoder = forms.find((form) => form.names.includes(emitted)) ?? forms[0]
return {
cli: forms,
launchArgs: (value) =>
encoder.kind === 'config'
? [emitted, `${encoder.key}=${String(value)}`]
: [emitted, String(value)],
launchArgs: (value, trailing = []) =>
encoder.kind === 'settings'
? mergeAgentChoiceSettings(agent, encoder.names, trailing, encoder.key, value)
: encoder.kind === 'config'
? [emitted, `${encoder.key}=${String(value)}`]
: [emitted, String(value)],
removeAgentArgs: (tokens) =>
forms.reduce(
(kept, form) =>
removeAgentArgOption(
agent,
kept,
form.names,
form.kind === 'config'
? (value) => agentChoiceConfigValue(value ?? '', form.key) !== undefined
: undefined
),
form.kind === 'settings'
? withoutInlineAgentChoiceSettings(agent, form.names, kept)
: removeAgentArgOption(
agent,
kept,
form.names,
form.kind === 'config'
? (value) => agentChoiceConfigValue(value ?? '', form.key) !== undefined
: undefined
),
[...tokens]
)
}
+11 -7
View File
@@ -105,14 +105,18 @@ export function reconcileAgentChoiceArgsDetailed(input: AgentChoiceArgsInput) {
? (patch.model ?? undefined)
: (all.find((other) => other.key === 'model' && other.lift && counts.get('model') === 1)
?.value ?? choices.model)
const option = (
findCatalogModel(catalog ?? { models: [], modelApply: {} }, model ?? '')?.options ??
catalog?.unknownModelOptions
)?.find((option) => option.id === entry.key)
const option =
(
findCatalogModel(catalog ?? { models: [], modelApply: {} }, model ?? '')?.options ??
catalog?.unknownModelOptions
)?.find((option) => option.id === entry.key) ??
catalog?.unknownModelOptions?.find((option) => option.id === entry.key)
if (
option?.kind.type !== 'select' ||
!option.apply.launchArgs ||
!option.kind.choices.some((choice) => choice.value === entry.value)
!option?.apply.launchArgs ||
(option.kind.type === 'boolean'
? entry.value !== 'true' && entry.value !== 'false'
: option.kind.choices.length > 0 &&
!option.kind.choices.some((choice) => choice.value === entry.value))
) {
return false
}
+95
View File
@@ -0,0 +1,95 @@
import { findOptionOccurrence } from './command-option-occurrence'
import { agentRequiredValueOptions, removeAgentArgOption } from './agent-session-option-agent-args'
import type { AgentType } from './agent-status-types'
export function readAgentChoiceSettings(
value: string | undefined
): Record<string, unknown> | undefined {
if (!value?.trim().startsWith('{')) {
return undefined
}
try {
const parsed: unknown = JSON.parse(value)
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
? Object.fromEntries(Object.entries(parsed))
: undefined
} catch {
return undefined
}
}
/** Shell settings flags select one value: earlier carriers remain shadowed. */
export function agentChoiceSettingsOccurrences(
agent: AgentType,
names: readonly string[],
tokens: readonly string[]
) {
const occurrences: { index: number; consumed: number; value?: string }[] = []
let found = findOptionOccurrence(tokens, names, true, agentRequiredValueOptions(agent))
while (found) {
occurrences.push(found)
found = findOptionOccurrence(
tokens,
names,
true,
agentRequiredValueOptions(agent),
found.index + found.consumed
)
}
return occurrences
}
export function mergeAgentChoiceSettings(
agent: AgentType,
names: readonly string[],
tokens: readonly string[],
key: string,
value: string | boolean
): string[] {
return agentChoiceSettingsOccurrences(agent, names, tokens).length
? []
: [names[0], JSON.stringify({ [key]: value })]
}
export function rewriteAgentChoiceSettings(
agent: AgentType,
names: readonly string[],
tokens: readonly string[],
key: string,
value: string | boolean
): string[] {
const effective = agentChoiceSettingsOccurrences(agent, names, tokens).at(-1)
const settings = readAgentChoiceSettings(effective?.value)
if (!effective || !settings) {
return [...tokens]
}
const edited = JSON.stringify({ ...settings, [key]: value })
const result = [...tokens]
if (effective.consumed === 2) {
result[effective.index + 1] = edited
} else {
result[effective.index] = `${tokens[effective.index].split('=')[0]}=${edited}`
}
return result
}
export function agentChoiceSettingsArguments(
agent: AgentType,
names: readonly string[],
tokens: readonly string[]
): Record<string, unknown>[] {
const effective = agentChoiceSettingsOccurrences(agent, names, tokens).at(-1)
const settings = readAgentChoiceSettings(effective?.value)
return settings ? [settings] : []
}
export function withoutInlineAgentChoiceSettings(
agent: AgentType,
names: readonly string[],
tokens: readonly string[]
): string[] {
const effective = agentChoiceSettingsOccurrences(agent, names, tokens).at(-1)
return effective && readAgentChoiceSettings(effective.value)
? removeAgentArgOption(agent, tokens, names)
: [...tokens]
}
+127
View File
@@ -356,6 +356,94 @@ describe('dedicated agent choices', () => {
})
})
it('adds one Claude settings carrier only for deliberate Fast, including explicit off', () => {
for (const value of ['true', 'false']) {
const launch = projectAgentLaunchProfile('claude', undefined, {
target: 'terminal',
userArgs: '--add-dir docs',
choices: { options: { fastMode: value } }
})
expect(launch.args).toContain(`"fastMode":${value}`)
expect(launch.args.match(/--settings/g)).toHaveLength(1)
}
expect(
projectAgentLaunchProfile('claude', undefined, {
target: 'terminal',
userArgs: '--add-dir docs',
choices: { model: 'opus' }
}).args
).not.toContain('--settings')
})
it('edits only the last inline Claude settings object and never unshadows earlier settings on removal', () => {
const earlier = `--settings '{"language":"french"}'`
const effective = '{"__proto__":{"unchanged":1},"env":{"A":"B"},"fastMode":false}'
const text = `${earlier} --settings '${effective}' --add-dir docs`
const launch = projectAgentLaunchProfile('claude', undefined, {
target: 'terminal',
userArgs: text,
choices: { options: { fastMode: 'true' } }
})
const parsed = tokenizeStartupCommand(launch.args, 'posix')
expect(parsed.ok).toBe(true)
if (!parsed.ok) {
throw new Error('invalid compiled arguments')
}
const objects = parsed.tokens
.filter((_, index) => parsed.tokens[index - 1] === '--settings')
.map((value) => JSON.parse(value))
expect(objects).toEqual([{ language: 'french' }, JSON.parse(effective.replace('false', 'true'))])
const analysis = choiceArgs.analyzeAgentChoiceArgs(
'claude',
`${earlier} --settings '{"fastMode":true}'`
)
const removed = analysis.cut(analysis.occurrences.filter((item) => item.key === 'fastMode'))
expect(removed).toBe(`${earlier} --settings '{}'`)
const shadowed = choiceArgs.analyzeAgentChoiceArgs(
'claude',
`--settings '{"fastMode":true}' --settings '{}'`
)
expect(shadowed.occurrences.filter((item) => item.key === 'fastMode')).toEqual([])
})
it('leaves effective file and unparseable Claude settings literal and never reports Fast applied', () => {
for (const text of [
`--settings '{"fastMode":true}' --settings './file with spaces.json'`,
`--settings '{"language":"french"}' --settings '{broken'`
]) {
const launch = projectAgentLaunchProfile('claude', undefined, {
target: 'terminal',
userArgs: text,
choices: { options: { fastMode: 'true' } }
})
expect(launch.args).toBe(text)
const analysis = choiceArgs.analyzeAgentChoiceArgs('claude', text)
expect(analysis.opaqueKeys.has('fastMode')).toBe(true)
expect(analysis.occurrences.filter((item) => item.key === 'fastMode')).toEqual([])
}
})
it('round-trips arbitrary host-owned Codex tier ids through the common config carrier without a release list', () => {
const userArgs = '-c service_tier=account_fast_v9 --add-dir docs'
const read = reconcileAgentChoiceArgs({
agent: 'codex',
userArgs,
choices: {},
mode: 'arguments-edit',
keys: new Set(['serviceTier'])
})
expect(read).toEqual({
userArgs: '--add-dir docs',
choices: { options: { serviceTier: 'account_fast_v9' } }
})
const launch = projectAgentLaunchProfile('codex', undefined, {
target: 'terminal',
userArgs: read.userArgs,
choices: read.choices
})
expect(launch.args).toBe('-c service_tier=account_fast_v9 --add-dir docs')
})
it.each([
['claude', '--add-dir /tmp/😀 --model "opus" --effort high -- tail'],
['claude', '--add-dir /tmp --model=opus --effort=high -- tail'],
@@ -622,3 +710,42 @@ it('bounds real raw-edit transport across invalid, divergent, exhausted and two-
spy.mockRestore()
}
})
it('keeps settings equals, operand and terminator ownership at the shared analyzer boundary', () => {
const text = `--settings='{"fastMode":true,"shadowed":1}' --settings='{"fastMode":false,"env":{"fastMode":true}}' --add-dir docs`
const analysis = choiceArgs.analyzeAgentChoiceArgs('claude', text)
const fast = analysis.occurrences.filter((item) => item.key === 'fastMode')
expect(fast).toMatchObject([{ value: 'false', owned: false, lift: false }])
expect(
reconcileAgentChoiceArgs({
agent: 'claude',
userArgs: text,
choices: {},
mode: 'arguments-edit',
keys: new Set(['fastMode'])
})
).toEqual({ userArgs: text, choices: {} })
const cut = analysis.cut(fast)!
for (const shell of ['posix', 'powershell', 'cmd'] as const) {
const parsed = tokenizeStartupCommand(cut, shell)
expect(parsed).toMatchObject({
ok: true,
tokens: [
'--settings={"fastMode":true,"shadowed":1}',
'--settings',
'{"env":{"fastMode":true}}',
'--add-dir',
'docs'
]
})
}
for (const raw of [
`-- --settings '{"fastMode":true}'`,
`--model --settings '{"fastMode":true}'`,
`--settings '{broken`
]) {
const result = choiceArgs.analyzeAgentChoiceArgs('claude', raw)
expect(result.occurrences.filter((item) => item.key === 'fastMode')).toEqual([])
expect(result.cut([])).toBe(raw)
}
})
+1
View File
@@ -15,6 +15,7 @@ export type AgentLaunchChoices = Partial<Record<TuiAgent, AgentModelOptionChoice
export type AgentChoiceCliForm =
| { kind: 'value'; names: readonly string[]; lift: 'separate-and-long-equals' | 'never' }
| { kind: 'config'; names: readonly string[]; key: string; lift: 'codex-bare-string' | 'never' }
| { kind: 'settings'; names: readonly string[]; key: string; lift: 'merged-json' }
| { kind: 'recipe'; words: readonly string[]; value: AgentPermissionChoice }
export function normalizeAgentModelOptionChoices(
+191
View File
@@ -0,0 +1,191 @@
import { agentArgsSetPermission } from './agent-permission-args'
import { analyzeAgentChoiceArgs } from './agent-choice-args'
import { agentPermissionChoices } from './tui-agent-permission-choice'
import {
agentPickerSpeedValue,
agentPickerSpeedFlag,
agentPickerSpeedIsOpaque,
editAgentPickerSpeed,
cutAgentPickerComposedSpeed
} from './agent-launch-picker-speed'
export { agentPickerSpeedValue, editAgentPickerSpeed } from './agent-launch-picker-speed'
import { agentPickerEntryOptions } from './agent-launch-picker-catalog'
import {
agentPickerArgumentPermission,
agentPickerPermissionValue,
editAgentPickerPermission,
editAgentPickerPermissionArguments
} from './agent-launch-picker-permission'
import {
agentPickerArgumentsText,
type AgentPickerDraft,
type AgentPickerEntry
} from './agent-launch-picker'
const speedKeys = new Set(['fastMode', 'serviceTier'])
const textOf = (entry: AgentPickerEntry, draft: AgentPickerDraft) =>
agentPickerArgumentsText(entry, draft)
/** Only permission and speed carriers are shared by the panel; raw model/effort remain terminal-owned. */
export function editAgentPickerPanelArguments(
entry: AgentPickerEntry,
draft: AgentPickerDraft,
text: string
): AgentPickerDraft {
if (textOf(entry, draft) === undefined) {
return draft
}
const next = editAgentPickerPermissionArguments(entry, draft, text)
const analysis = analyzeAgentChoiceArgs(
entry.agent,
agentPickerArgumentPermission(entry, next)?.remainingArguments ?? text
)
const previous = analyzeAgentChoiceArgs(
entry.agent,
agentPickerArgumentPermission(entry, draft)?.remainingArguments ?? textOf(entry, draft) ?? ''
)
for (const key of speedKeys) {
const raw = analysis.occurrences.filter((item) => item.key === key)
const value =
raw.length && analysis.cut(raw) !== null
? raw.at(-1)?.value
: !raw.length && previous.occurrences.some((item) => item.key === key)
? key === 'fastMode'
? 'false'
: 'default'
: undefined
if (analysis.opaqueKeys.has(key)) {
const options = { ...next.choices.options }
delete options[key]
next.choices = { ...next.choices, options }
} else if (value !== undefined) {
next.choices = { ...next.choices, options: { ...next.choices.options, [key]: value } }
}
}
return next
}
export function agentPickerPanelArguments(
entry: AgentPickerEntry,
draft: AgentPickerDraft,
model?: string
) {
let text = textOf(entry, draft) ?? ''
const value = agentPickerPermissionValue(entry, draft)
const rawPermission = agentPickerArgumentPermission(entry, draft)
const permission =
rawPermission && (draft.arguments !== undefined || draft.surface === 'terminal')
? rawPermission.choice
: agentPermissionChoices(entry.agent).find((choice) => choice === value)
const resolved =
permission && textOf(entry, draft) !== undefined
? editAgentPickerPermission(entry, draft, permission)
: draft
text = textOf(entry, resolved) ?? ''
const carrier = agentPickerArgumentPermission(entry, { ...resolved, arguments: text || null })
const chips: { key: string; flag: string; remove: () => AgentPickerDraft }[] = []
if (carrier && !carrier.opaque) {
text = carrier.remainingArguments
chips.push({
key: 'permissionMode',
flag: carrier.flag,
remove: () =>
editAgentPickerPermission(
entry,
{ ...resolved, arguments: textOf(entry, resolved) || null },
'own'
)
})
}
for (const key of speedKeys) {
if (agentPickerSpeedIsOpaque(entry, draft, key)) {
continue
}
const analysis = analyzeAgentChoiceArgs(entry.agent, text)
const raw = analysis.occurrences.filter((item) => item.key === key)
let remaining = analysis.cut(raw)
const selected = agentPickerSpeedValue(entry, draft, key)
const option = agentPickerEntryOptions(entry, model, 'terminal').find(
(option) => option.id === key
)
if (option?.apply.composedIntoModel && selected !== undefined) {
remaining = cutAgentPickerComposedSpeed(entry, draft, text, key, selected, model)
}
const flag =
raw.length && remaining !== null
? raw.some((item) => item.replacement !== undefined)
? agentPickerSpeedFlag(entry, draft, key, raw.at(-1)!.value, model)
: raw.map((item) => text.slice(item.start, item.end)).join(' ')
: textOf(entry, draft) !== undefined &&
selected !== undefined &&
selected !== 'false' &&
selected !== 'default'
? agentPickerSpeedFlag(entry, draft, key, selected, model)
: ''
if (!flag || remaining === null) {
continue
}
text = remaining
chips.push({
key,
flag,
remove: () => {
const next = editAgentPickerSpeed(
entry,
{ ...draft, arguments: textOf(entry, resolved) || null },
key,
key === 'fastMode' ? 'false' : 'default',
model
)
const permission = agentPickerArgumentPermission(entry, next)
const prefix = permission && !permission.opaque ? permission.flag : ''
const source = prefix ? permission!.remainingArguments : (textOf(entry, next) ?? '')
const analysis = analyzeAgentChoiceArgs(entry.agent, source)
const raw = analysis.occurrences.filter((item) => item.key === key)
const remaining = analysis.cut(raw)
return remaining === null
? next
: { ...next, arguments: [prefix, remaining].filter(Boolean).join(' ') || null }
}
})
}
return { text, chips, permission: value, selectedPermission: permission }
}
export function editAgentPickerPanelText(
entry: AgentPickerEntry,
draft: AgentPickerDraft,
text: string,
model?: string
): AgentPickerDraft {
if (textOf(entry, draft) === undefined) {
return draft
}
const view = agentPickerPanelArguments(entry, draft, model)
const analysis = analyzeAgentChoiceArgs(entry.agent, text)
const composedKeys = new Set(
agentPickerEntryOptions(entry, model, 'terminal')
.filter((option) => option.apply.composedIntoModel)
.map((option) => option.id)
)
const flags = view.chips.filter(
(chip) =>
!(entry.agent === 'claude' && chip.key === 'fastMode') &&
(chip.key === 'permissionMode'
? !agentArgsSetPermission(entry.agent, text)
: !analysis.occurrences.some(
(item) => item.key === chip.key || (item.key === 'model' && composedKeys.has(chip.key))
))
)
const next = editAgentPickerPanelArguments(
entry,
draft,
[...flags.map((chip) => chip.flag), text].filter(Boolean).join(' ')
)
const speed = view.chips.find((chip) => chip.key === 'fastMode')
const explicit = analysis.occurrences.some((item) => item.key === 'fastMode')
const value = agentPickerSpeedValue(entry, draft, 'fastMode')
return entry.agent === 'claude' && speed && !explicit && value !== undefined
? editAgentPickerSpeed(entry, next, 'fastMode', value, model)
: next
}
@@ -0,0 +1,25 @@
import type { AgentPickerEntry, AgentPickerDraft } from './agent-launch-picker'
export function agentPickerArgumentsKnown(
entry: AgentPickerEntry,
draft: AgentPickerDraft
): boolean {
if (draft.argumentsOwner !== undefined && draft.argumentsOwner !== entry.ownerKey) {
return false
}
return (
!entry.defaultsPending ||
(draft.arguments !== undefined &&
entry.ownerKey !== undefined &&
draft.argumentsOwner === entry.ownerKey)
)
}
export function agentPickerArgumentsText(
entry: AgentPickerEntry,
draft: AgentPickerDraft
): string | undefined {
return agentPickerArgumentsKnown(entry, draft)
? ((draft.arguments === undefined ? entry.arguments : draft.arguments) ?? '')
: undefined
}
+102
View File
@@ -0,0 +1,102 @@
import { discoveredModel } from './structured-agent-session-discovered-model'
import {
createClaudeCatalogOptions,
type CatalogOption,
getAgentSessionOptionLaunchCatalog
} from './agent-session-option-catalog'
import { sourceControlActionOptions } from './source-control-action-launch-preferences'
import {
agentPermissionChoices,
agentPermissionHasTerminalChoice
} from './tui-agent-permission-choice'
import type { TuiAgent } from './tui-agent'
import type { AgentPickerEntry, AgentPickerModel, AgentPickerSurface } from './agent-launch-picker'
export function agentPickerOptions(
agent: TuiAgent,
model: string | undefined,
surface: AgentPickerSurface
) {
return sourceControlActionOptions(agent, model ?? '')
.filter(({ route }) => route === null || route === (surface === 'chat' ? 'chats' : 'terminals'))
.map(({ option }) => option)
}
export function agentPickerEntryOptions(
entry: AgentPickerEntry,
model: string | undefined,
surface: AgentPickerSurface
): CatalogOption[] {
let options = agentPickerOptions(entry.agent, model, surface)
const live = agentPickerModels(entry).find(
(row) => row.id === model || (model === undefined && row.isDefault)
)
if (entry.catalog.origin === 'unknown' || !live) {
return options.filter(
(option) => option.kind.type !== 'select' || option.kind.choices.length > 0
)
}
if (entry.agent === 'claude') {
options = createClaudeCatalogOptions({
effortLevelIds: live.efforts.map((choice) => choice.value),
supportsFastMode:
live.supportsFastMode === true && entry.catalog.fastModeSupport?.supported !== false
})
}
if (surface === 'chat' || entry.agent === 'codex') {
const dynamic = discoveredModel(
live,
entry.catalog.fastModeSupport?.supported !== false
).options
if (entry.agent === 'codex') {
options = options.filter((option) => option.id !== 'serviceTier' && option.id !== 'fastMode')
}
for (const option of dynamic) {
if (!options.some((existing) => existing.id === option.id)) {
const mapping = getAgentSessionOptionLaunchCatalog(entry.agent)?.unknownModelOptions?.find(
(seed) => seed.id === option.id
)
options.push(mapping ? { ...option, apply: mapping.apply } : option)
}
}
}
return options.flatMap((option) => {
if (option.id !== 'effort') {
return [option]
}
if (!live.efforts.length) {
return live.effortsKnown === false ? [option] : []
}
return [
{
...option,
kind: {
type: 'select' as const,
choices: live.efforts,
defaultValue: live.defaultEffort ?? ''
}
}
]
})
}
export function agentPickerPermissions(agent: TuiAgent, surface: AgentPickerSurface) {
return agentPermissionChoices(agent).filter(
(choice) => surface === 'chat' || agentPermissionHasTerminalChoice(agent, choice)
)
}
export function agentPickerModels(entry: AgentPickerEntry): AgentPickerModel[] {
if (entry.catalog.origin !== 'unknown') {
return entry.catalog.models
}
return (getAgentSessionOptionLaunchCatalog(entry.agent)?.models ?? []).map((model) => ({
id: model.id,
label: model.label,
description: model.description,
isDefault: model.isDefault === true,
efforts: model.options.flatMap((option) =>
option.id === 'effort' && option.kind.type === 'select' ? option.kind.choices : []
)
}))
}
@@ -0,0 +1,34 @@
import { projectAgentLaunchProfile } from './agent-launch-profile'
import { structuredLaunchSeedOptions } from './native-chat/input/native-chat-session-option-defaults'
import { isAgentChatPermissionChoice } from './agent-chat-permission-mode'
import type { GlobalSettings } from './global-settings-types'
import type { TuiAgent } from './tui-agent'
import type { AgentPickerEntry } from './agent-launch-picker'
export type AgentPickerDefaultSettings = Partial<
Pick<
GlobalSettings,
| 'agentDefaultArgs'
| 'agentPermissionDefault'
| 'agentPermissionOverrides'
| 'nativeChatSessionOptions'
| 'nativeChatPermissionMode'
>
>
export function agentPickerChoiceSeeds(
agent: TuiAgent,
settings: AgentPickerDefaultSettings | null | undefined
): Pick<AgentPickerEntry, 'terminal' | 'structured' | 'arguments'> {
const profile = projectAgentLaunchProfile(agent, settings, { target: 'structured' })
const { model, permissionMode, ...options } = structuredLaunchSeedOptions(settings, agent) ?? {}
return {
terminal: profile.choices,
structured: {
...(model ? { model } : {}),
options,
...(isAgentChatPermissionChoice(permissionMode) ? { permissionMode } : {})
},
arguments: profile.args
}
}
@@ -0,0 +1,112 @@
import { expect, it } from 'vitest'
import {
agentPickerDraft,
agentPickerChoiceSeeds,
editAgentPickerArguments,
editAgentPickerDraft,
type AgentPickerEntry
} from './agent-launch-picker'
import { agentPickerArgumentPermission } from './agent-launch-picker-permission'
import { projectAgentLaunchProfile } from './agent-launch-profile'
import { structuredAgentConfiguredArgs } from '../main/native-chat/structured-agent-configured-args'
import { claudeStructuredLaunchArgs } from '../main/claude/claude-structured-launch-args'
import { codexStructuredLaunchArgs } from '../main/codex/codex-structured-launch-args'
const settings = Object.freeze({
agentPermissionDefault: 'ask' as const,
agentDefaultArgs: Object.freeze({ claude: '--dangerously-skip-permissions' })
})
const entry: AgentPickerEntry = {
agent: 'claude',
label: 'Claude',
rows: [{ kind: 'terminal', agent: 'claude' }],
surfaces: ['terminal', 'chat'],
defaultSurface: 'terminal',
catalog: { origin: 'unknown' },
...agentPickerChoiceSeeds('claude', settings)
}
it('retains inherited raw permission precedence despite a typed choice; removal affects only this launch', () => {
const draft = editAgentPickerDraft(agentPickerDraft(entry), { permissionMode: 'ask' })
expect(draft.arguments).toBeUndefined()
const permission = agentPickerArgumentPermission(entry, draft)
expect(permission).toEqual({
flag: '--dangerously-skip-permissions',
remainingArguments: '',
value: 'full-access',
choice: 'full-access',
opaque: false
})
expect(
projectAgentLaunchProfile('claude', settings, { target: 'terminal', choices: draft.choices })
.args
).toContain('--dangerously-skip-permissions')
const removed = editAgentPickerArguments(draft, permission?.remainingArguments ?? '')
expect(removed.arguments).toBeNull()
expect(agentPickerArgumentPermission(entry, removed)).toBeUndefined()
expect(
projectAgentLaunchProfile('claude', settings, {
target: 'terminal',
choices: removed.choices,
userArgs: removed.arguments ?? ''
}).args
).not.toContain('--dangerously-skip-permissions')
expect(settings.agentDefaultArgs.claude).toBe('--dangerously-skip-permissions')
expect(agentPickerArgumentPermission(entry, { ...removed, arguments: undefined })?.choice).toBe(
'full-access'
)
})
it('shows unliftable launch-owned permission bytes without guessing a native choice and preserves unrelated Arguments', () => {
const draft = editAgentPickerArguments(
agentPickerDraft(entry),
'--permission-mode plan --add-dir docs'
)
expect(draft.choices.permissionMode).toBeUndefined()
const permission = agentPickerArgumentPermission(entry, draft)
expect(permission).toEqual({
flag: '--permission-mode plan',
remainingArguments: '--add-dir docs',
value: 'plan',
choice: undefined,
opaque: false
})
const removed = editAgentPickerArguments(draft, permission?.remainingArguments ?? '')
expect(removed.arguments).toBe('--add-dir docs')
expect(agentPickerArgumentPermission(entry, removed)).toBeUndefined()
})
it('keeps shell-ambiguous Arguments together rather than inventing a removable permission boundary', () => {
const draft = {
...agentPickerDraft(entry),
arguments: '--unknown value --permission-mode plan --add-dir docs'
}
expect(agentPickerArgumentPermission(entry, draft)).toEqual({
flag: '--permission-mode',
value: 'plan',
choice: undefined,
remainingArguments: draft.arguments,
opaque: true
})
})
it('retains raw CLI permission bytes while native configured-Arguments filtering stays unchanged', () => {
const chat = { ...agentPickerDraft(entry, 'chat'), arguments: '--permission-mode plan' }
expect(agentPickerArgumentPermission(entry, chat)).toMatchObject({
value: 'plan',
choice: undefined,
opaque: false
})
expect(
claudeStructuredLaunchArgs(structuredAgentConfiguredArgs('claude', settings)).extraArgs
).toEqual({})
expect(
codexStructuredLaunchArgs(
structuredAgentConfiguredArgs('codex', {
agentDefaultArgs: {
codex: '-a never -s danger-full-access -c approval_policy=never --search'
}
})
)
).toEqual(['--search'])
})
@@ -0,0 +1,120 @@
import { analyzeAgentChoiceArgs } from './agent-choice-args'
import { agentArgsSetPermission, analyzeAgentChoiceWords } from './agent-permission-args'
import {
agentPermissionArgs,
agentPermissionChoices,
type AgentPermissionChoice
} from './tui-agent-permission-choice'
import { tokenizeStartupCommand } from './tui-agent-startup-shell'
import {
agentPickerArgumentsText,
type AgentPickerDraft,
type AgentPickerEntry
} from './agent-launch-picker'
/** Keep opaque Arguments literal; only separate spans the common analyzer can cut safely. */
export function agentPickerArgumentPermission(entry: AgentPickerEntry, draft: AgentPickerDraft) {
const text = agentPickerArgumentsText(entry, draft)
if (text === undefined) {
return undefined
}
const parsed = tokenizeStartupCommand(text, 'posix')
if (!parsed.ok) {
return { flag: text, value: text, remainingArguments: text, opaque: true }
}
if (!agentArgsSetPermission(entry.agent, text)) {
return undefined
}
const analysis = analyzeAgentChoiceArgs(entry.agent, text)
const { roles, carrierIndices } = analyzeAgentChoiceWords(entry.agent, parsed.tokens)
const recipe = analysis.occurrences.find((item) => item.key === 'permissionMode' && item.owned)
const choice = agentPermissionChoices(entry.agent).find((choice) => choice === recipe?.value)
const ranges = carrierIndices.map((index) => {
const name = roles[index]?.slice('option:'.length)
let end = index
while (roles[end + 1] === `value:${name}`) {
end++
}
return {
key: 'permissionMode',
value: '',
start: parsed.spans[index].start,
end: parsed.spans[end].end,
lift: false,
owned: false
}
})
const flag = ranges.map(({ start, end }) => text.slice(start, end)).join(' ')
const at = carrierIndices[0]
const value =
choice ??
(carrierIndices.length === 1
? parsed.tokens[at].includes('=')
? parsed.tokens[at].split('=').slice(1).join('=')
: parsed.tokens[at + 1]
: flag) ??
flag
const remainingArguments = carrierIndices.some((index) => roles[index] === 'uncertain')
? null
: analysis.cut(ranges)
return {
flag,
value,
choice,
remainingArguments: remainingArguments ?? text,
opaque: remainingArguments === null
}
}
export function agentPickerPermissionValue(entry: AgentPickerEntry, draft: AgentPickerDraft) {
const carrier = agentPickerArgumentPermission(entry, draft)
if (carrier && (draft.arguments !== undefined || draft.surface === 'terminal')) {
return carrier.value
}
return (
draft.choices.permissionMode ??
(draft.surface === 'chat' ? entry.structured : entry.terminal).permissionMode
)
}
export function editAgentPickerPermission(
entry: AgentPickerEntry,
draft: AgentPickerDraft,
permissionMode: AgentPermissionChoice
): AgentPickerDraft {
const text = agentPickerArgumentsText(entry, draft)
if (text === undefined || !entry.surfaces.includes('terminal')) {
return { ...draft, choices: { ...draft.choices, permissionMode } }
}
const carrier = agentPickerArgumentPermission(entry, draft)
return {
...draft,
choices: { ...draft.choices, permissionMode },
...(entry.ownerKey ? { argumentsOwner: entry.ownerKey } : {}),
// Opaque carriers retain the existing compiler's precedence; never fabricate a span.
arguments: carrier?.opaque
? text
: [agentPermissionArgs(entry.agent, permissionMode), carrier?.remainingArguments ?? text]
.filter(Boolean)
.join(' ') || null
}
}
export function editAgentPickerPermissionArguments(
entry: AgentPickerEntry,
draft: AgentPickerDraft,
text: string
): AgentPickerDraft {
if (agentPickerArgumentsText(entry, draft) === undefined) {
return draft
}
const next = {
...draft,
arguments: text || null,
...(entry.ownerKey ? { argumentsOwner: entry.ownerKey } : {})
}
const carrier = agentPickerArgumentPermission(entry, next)
const permissionMode = carrier?.choice ?? (!carrier ? 'own' : undefined)
const { permissionMode: _previous, ...choices } = draft.choices
return { ...next, choices: { ...choices, ...(permissionMode ? { permissionMode } : {}) } }
}
+131
View File
@@ -0,0 +1,131 @@
import type { GlobalSettings } from './global-settings-types'
import { isNativeChatProviderDisplayedOn } from './native-chat/native-chat-providers'
import {
isNativeChatEnabled,
type NativeChatDefaultSettings
} from './structured-native-chat-launch-route'
import type { ExecutionHostId } from './execution-host'
import type { TuiAgent } from './tui-agent'
import { filterEnabledTuiAgents, TUI_AGENT_AUTO_PICK_ORDER } from './tui-agent-selection'
import type { AgentSessionChatProgram } from './agent-session-agent-availability'
export type HostChatAnswer =
| 'yes'
| 'not-yet-known'
| 'ssh'
| 'wsl'
| 'subfolder'
| 'not-on-host'
| 'no-chats'
export type ChatProgramObservation = AgentSessionChatProgram | 'unlearned'
const chatProgramOffered = (program: ChatProgramObservation) =>
program === 'available' || program === 'installable'
export function orderAgentLaunchPickerAgents(
defaultAgent: TuiAgent | 'blank' | null | undefined,
detected: readonly TuiAgent[],
disabled?: Iterable<unknown> | null
): TuiAgent[] {
const enabled = filterEnabledTuiAgents(detected, disabled)
const ordered = TUI_AGENT_AUTO_PICK_ORDER.filter((agent) => enabled.includes(agent))
return defaultAgent && defaultAgent !== 'blank' && ordered.includes(defaultAgent)
? [defaultAgent, ...ordered.filter((agent) => agent !== defaultAgent)]
: ordered
}
export type ChatUnavailableReason =
| 'check-failed'
| 'not-installed'
| 'ssh'
| 'wsl'
| 'subfolder'
| 'not-on-host'
export type NewTabAgentMenuRow = (
| { kind: 'chat'; agent: TuiAgent }
| { kind: 'chat-unavailable'; agent: TuiAgent; reason: ChatUnavailableReason }
| { kind: 'cli'; agent: TuiAgent; chat?: true }
| { kind: 'terminal'; agent: TuiAgent }
) & { executionHostId?: ExecutionHostId }
export type NewTabAgentMenuSettings = NativeChatDefaultSettings &
Partial<Pick<GlobalSettings, 'defaultTuiAgent' | 'disabledTuiAgents'>>
export function buildNewTabAgentMenuRows(args: {
settings: NewTabAgentMenuSettings | null | undefined
detectedOnHost: readonly TuiAgent[] | null
chatAgents: readonly TuiAgent[]
hostChatAnswer: (agent: TuiAgent) => HostChatAnswer
/** Whether the chat's own program runs there, which for some agents is not their CLI. */
chatProgram: (agent: TuiAgent) => ChatProgramObservation
}): NewTabAgentMenuRow[] {
const { settings } = args
const defaultAgent = settings?.defaultTuiAgent
const installed = filterEnabledTuiAgents(args.detectedOnHost ?? [], settings?.disabledTuiAgents)
if (!isNativeChatEnabled(settings) || args.hostChatAnswer('claude') === 'no-chats') {
return orderAgentLaunchPickerAgents(defaultAgent, installed).map((agent) => ({
kind: 'terminal',
agent
}))
}
const runnable: TuiAgent[] = []
const unavailable = new Map<TuiAgent, ChatUnavailableReason>()
const chatAgents = filterEnabledTuiAgents(args.chatAgents, settings?.disabledTuiAgents)
// An unchosen provider is on wherever its chat program is, whether or not its CLI is.
const canOpen = (agent: TuiAgent): boolean =>
chatProgramOffered(args.chatProgram(agent)) ||
(args.chatProgram(agent) === 'unknown' && installed.includes(agent))
const providedHere = chatAgents.filter(canOpen)
const chatToo = new Set<TuiAgent>()
for (const agent of chatAgents) {
const displayed = isNativeChatProviderDisplayedOn(settings, agent, providedHere)
if (!displayed && (!installed.includes(agent) || !canOpen(agent))) {
continue
}
const answer = args.hostChatAnswer(agent)
if (!displayed) {
if (answer === 'yes') {
chatToo.add(agent)
}
continue
}
const program = args.chatProgram(agent)
if (answer === 'yes' && canOpen(agent)) {
runnable.push(agent)
} else {
unavailable.set(
agent,
answer === 'yes'
? program === 'missing'
? 'not-installed'
: 'check-failed'
: args.detectedOnHost === null
? 'check-failed'
: answer === 'not-yet-known'
? 'check-failed'
: answer === 'no-chats'
? 'not-on-host'
: answer
)
}
}
return [
...orderAgentLaunchPickerAgents(defaultAgent, runnable).map((agent) => ({
kind: 'chat' as const,
agent
})),
...orderAgentLaunchPickerAgents(defaultAgent, [...unavailable.keys()]).map((agent) => ({
kind: 'chat-unavailable' as const,
agent,
reason: unavailable.get(agent) ?? 'not-on-host'
})),
...orderAgentLaunchPickerAgents(
defaultAgent,
installed.filter((agent) => !runnable.includes(agent))
).map((agent) => ({
kind: 'cli' as const,
agent,
...(chatToo.has(agent) ? { chat: true as const } : {})
}))
]
}
+82
View File
@@ -0,0 +1,82 @@
import { agentPickerModels } from './agent-launch-picker-catalog'
import { resolveAgentChoiceLayers } from './agent-choice-layers'
import type { AgentPickerEntry, AgentPickerSetup } from './agent-launch-picker'
const tokens = (value: string): string[] =>
value
.toLowerCase()
.replace(/extra[\s-]*high/g, 'xhigh')
.split(/[\s_\-/·]+/)
.filter(Boolean)
const score = (word: string, values: readonly string[]): number =>
values.includes(word)
? 2
: word.length >= 2 && values.some((value) => value.startsWith(word))
? 1
: 0
export function searchAgentPickerSetups(
entries: readonly AgentPickerEntry[],
query: string,
limit = 8
): AgentPickerSetup[] {
const words = tokens(query)
if (!words.length) {
return []
}
const matches: { setup: AgentPickerSetup; score: number }[] = []
for (const entry of entries) {
if (!entry.surfaces.length || entry.catalog.origin === 'unknown') {
continue
}
const agentWords = tokens([entry.agent, entry.label, ...(entry.aliases ?? [])].join(' '))
if (words.every((word) => score(word, agentWords))) {
continue
}
for (const model of agentPickerModels(entry)) {
if (model.isLegacy || model.hidden) {
continue
}
const modelWords = tokens([model.id, model.label].join(' '))
if (!words.some((word) => !score(word, agentWords) && score(word, modelWords))) {
continue
}
const effortQuery = model.efforts.filter((effort) =>
words.some(
(word) =>
!score(word, agentWords) &&
!score(word, modelWords) &&
score(word, tokens(`${effort.value} ${effort.label}`))
)
)
const efforts = effortQuery.length ? effortQuery : [undefined]
for (const effort of efforts) {
const effortWords = effort ? tokens(`${effort.value} ${effort.label}`) : []
const values = words.map((word) =>
score(word, [...agentWords, ...modelWords, ...effortWords])
)
if (values.some((value) => value === 0)) {
continue
}
const choices = resolveAgentChoiceLayers({
agent: entry.agent,
explicit: { model: model.id, ...(effort ? { options: { effort: effort.value } } : {}) },
argumentOwner: 'defaults'
})
matches.push({
setup: {
entry,
model,
choices,
label: [entry.label, model.label, effort?.label].filter(Boolean).join(' · ')
},
score: values.reduce((a, b) => a + b, 0)
})
}
}
}
return matches
.sort((a, b) => b.score - a.score)
.slice(0, limit)
.map((match) => match.setup)
}
+175
View File
@@ -0,0 +1,175 @@
import { analyzeAgentChoiceArgs } from './agent-choice-args'
import { reconcileAgentChoiceArgs } from './agent-choice-reconciliation'
import { projectAgentLaunchProfile } from './agent-launch-profile'
import { getAgentSessionOptionLaunchCatalog } from './agent-session-option-catalog'
import { agentPermissionArgs } from './tui-agent-permission-choice'
import { agentPickerEntryOptions } from './agent-launch-picker-catalog'
import { agentPickerArgumentPermission } from './agent-launch-picker-permission'
import {
agentPickerArgumentsText,
type AgentPickerEntry,
type AgentPickerDraft
} from './agent-launch-picker'
const textOf = agentPickerArgumentsText
export function cutAgentPickerComposedSpeed(
entry: AgentPickerEntry,
draft: AgentPickerDraft,
source: string,
key: string,
value: string,
model?: string
) {
const terminal = {
...entry.terminal,
...draft.choices,
model:
draft.choices.model ??
entry.terminal.model ??
(draft.surface === 'terminal' ? model : undefined),
options: { ...entry.terminal.options, ...draft.choices.options }
}
return reconcileAgentChoiceArgs({
agent: entry.agent,
userArgs: source,
choices: terminal,
mode: 'choice-edit',
keys: new Set([key, 'model']),
patch: { options: { [key]: value } }
}).userArgs
}
function speedText(entry: AgentPickerEntry, draft: AgentPickerDraft): string {
const permission = agentPickerArgumentPermission(entry, draft)
return permission && !permission.opaque
? permission.remainingArguments
: (textOf(entry, draft) ?? '')
}
export function agentPickerSpeedIsOpaque(
entry: AgentPickerEntry,
draft: AgentPickerDraft,
key: string
) {
return analyzeAgentChoiceArgs(entry.agent, speedText(entry, draft)).opaqueKeys.has(key)
}
export function agentPickerSpeedValue(
entry: AgentPickerEntry,
draft: AgentPickerDraft,
key: string
) {
const analysis = analyzeAgentChoiceArgs(entry.agent, speedText(entry, draft))
if (analysis.opaqueKeys.has(key)) {
return undefined
}
const raw = analysis.occurrences.filter((item) => item.key === key)
if (
(draft.surface === 'terminal' || draft.arguments !== undefined) &&
raw.length &&
analysis.cut(raw) !== null
) {
return raw.at(-1)?.value
}
return (
draft.choices.options?.[key] ??
(draft.surface === 'chat' ? entry.structured : entry.terminal).options?.[key]
)
}
export function agentPickerSpeedFlag(
entry: AgentPickerEntry,
draft: AgentPickerDraft,
key: string,
value: string,
model?: string
) {
const option =
agentPickerEntryOptions(entry, model, 'terminal').find((option) => option.id === key) ??
getAgentSessionOptionLaunchCatalog(entry.agent)?.unknownModelOptions?.find(
(option) => option.id === key
)
if (!option?.apply.launchArgs && !option?.apply.composedIntoModel) {
return ''
}
return projectAgentLaunchProfile(entry.agent, undefined, {
target: 'terminal',
userArgs: '',
choices: {
permissionMode: 'own',
...(option.apply.composedIntoModel
? {
model:
draft.choices.model ??
entry.terminal.model ??
(draft.surface === 'terminal' ? model : undefined)
}
: {}),
options: option.apply.composedIntoModel
? {
...entry.terminal.options,
...draft.choices.options,
[key]: value
}
: { [key]: value }
}
}).args
}
export function editAgentPickerSpeed(
entry: AgentPickerEntry,
draft: AgentPickerDraft,
key: string,
value: string,
model?: string
): AgentPickerDraft {
if (textOf(entry, draft) === undefined || !entry.surfaces.includes('terminal')) {
return {
...draft,
choices: { ...draft.choices, options: { ...draft.choices.options, [key]: value } }
}
}
if (agentPickerSpeedIsOpaque(entry, draft, key)) {
return draft
}
const permission = agentPickerArgumentPermission(entry, draft)
const permissionPrefix = permission
? permission.opaque
? ''
: permission.flag
: agentPermissionArgs(entry.agent, entry.terminal.permissionMode ?? 'own')
const source =
permission && !permission.opaque ? permission.remainingArguments : (textOf(entry, draft) ?? '')
if (entry.agent === 'claude' && key === 'fastMode') {
const projected = projectAgentLaunchProfile(entry.agent, undefined, {
target: 'terminal',
userArgs: source,
choices: { permissionMode: 'own', options: { [key]: value } }
})
return {
...draft,
choices: { ...draft.choices, options: { ...draft.choices.options, [key]: value } },
arguments: [permissionPrefix, projected.args].filter(Boolean).join(' ') || null,
...(entry.ownerKey ? { argumentsOwner: entry.ownerKey } : {})
}
}
const analysis = analyzeAgentChoiceArgs(entry.agent, source)
const selected = analysis.occurrences.filter((item) => item.key === key)
let remaining = analysis.cut(selected) ?? source
const option = agentPickerEntryOptions(entry, model, 'terminal').find(
(option) => option.id === key
)
if (option?.apply.composedIntoModel) {
remaining = cutAgentPickerComposedSpeed(entry, draft, source, key, value, model)
}
const flag = agentPickerSpeedFlag(entry, draft, key, value, model)
return {
...draft,
choices: { ...draft.choices, options: { ...draft.choices.options, [key]: value } },
...(flag
? {
arguments: [permissionPrefix, flag, remaining].filter(Boolean).join(' '),
...(entry.ownerKey ? { argumentsOwner: entry.ownerKey } : {})
}
: {})
}
}
+541
View File
@@ -0,0 +1,541 @@
import { buildNewTabAgentMenuRows } from './agent-launch-picker-rows'
import { readFileSync } from 'node:fs'
import { z } from 'zod'
import { describe, expect, it } from 'vitest'
import {
agentPickerLaunchInputs,
agentPickerEntriesFromRows,
agentPickerChoiceSeeds,
agentPickerDraft,
agentPickerHydrationQueue,
agentPickerEntryOptions,
agentPickerSessionOptions,
agentPickerEffectiveChoices,
agentPickerButtonOrder,
searchAgentPickerSetups,
editAgentPickerDraft,
editAgentPickerArguments,
type AgentPickerEntry
} from './agent-launch-picker'
const entry: AgentPickerEntry = {
agent: 'codex',
label: 'Codex',
rows: [{ agent: 'codex', kind: 'chat' }],
surfaces: ['chat', 'terminal'],
defaultSurface: 'chat',
terminal: { permissionMode: 'ask' },
structured: { model: 'astra', options: { effort: 'high' }, permissionMode: 'ask' },
arguments: '--add-dir docs',
catalog: {
origin: 'probe',
fetchedAt: 1,
models: [
{
id: 'astra',
label: 'Astra',
isDefault: true,
efforts: [
{ value: 'low', label: 'Low' },
{ value: 'high', label: 'High' },
{ value: 'xhigh', label: 'Extra high' }
]
}
]
}
}
describe('shared agent launch picker', () => {
it('keeps ordinary launch unconfigured and the existing default on the right', () => {
expect(agentPickerDraft(entry)).toEqual({
agent: 'codex',
surface: 'chat',
choices: {},
arguments: undefined
})
expect(agentPickerEffectiveChoices(entry, agentPickerDraft(entry))).toEqual(entry.structured)
expect(agentPickerEffectiveChoices(entry, agentPickerDraft(entry, 'terminal'))).toEqual(
entry.terminal
)
expect(agentPickerButtonOrder(entry)).toEqual(['terminal', 'chat'])
expect(agentPickerButtonOrder({ ...entry, defaultSurface: 'terminal' })).toEqual([
'chat',
'terminal'
])
})
it('derives setup matches from every query word, folds extra high and leaves plain agent searches plain', () => {
expect(searchAgentPickerSetups([entry], 'codex')).toEqual([])
expect(searchAgentPickerSetups([entry], 'astra low')[0]?.choices).toEqual({
model: 'astra',
options: { effort: 'low' }
})
expect(
searchAgentPickerSetups([entry], 'codex astra extra high')[0]?.choices.options?.effort
).toBe('xhigh')
expect(searchAgentPickerSetups([entry], 'astra unsupported')).toEqual([])
expect(searchAgentPickerSetups([entry], 'astra')).toHaveLength(1)
})
it('keeps unknown saved models launchable while current search excludes a classified legacy model', () => {
const legacy = {
...entry,
catalog: {
origin: 'probe' as const,
fetchedAt: 1,
models: [
{
id: 'retired',
label: 'Retired',
isDefault: false,
efforts: [],
isLegacy: true as const
}
]
}
}
expect(searchAgentPickerSetups([legacy], 'retired')).toEqual([])
const draft = editAgentPickerDraft(agentPickerDraft(entry), { model: 'my-private-alias' })
expect(editAgentPickerDraft(draft, { permissionMode: 'own' }).choices).toEqual({
model: 'my-private-alias',
permissionMode: 'own'
})
})
it('reconciles deliberate Arguments edits once and preserves explicit empty versus inheritance', () => {
const draft = editAgentPickerArguments(
agentPickerDraft(entry, 'terminal'),
'--model astra --add-dir docs'
)
expect(draft.choices.model).toBeUndefined()
expect(agentPickerLaunchInputs(draft, entry).sessionOptions?.model).toBe('astra')
expect(draft.arguments).toBe('--model astra --add-dir docs')
expect(editAgentPickerArguments(draft, '').arguments).toBeNull()
expect(agentPickerDraft(entry).arguments).toBeUndefined()
})
it('uses live effort facts for an opaque model and emits an independent effort', () => {
expect(
agentPickerEntryOptions(entry, 'astra', 'terminal').find((option) => option.id === 'effort')
?.kind
).toMatchObject({
type: 'select',
choices: entry.catalog.origin !== 'unknown' ? entry.catalog.models[0].efforts : []
})
expect(agentPickerSessionOptions({ options: { effort: 'low' } })).toEqual({ effort: 'low' })
})
it('bounds suggestions without mutating the inventory or creating saved setups', () => {
const large = {
...entry,
catalog: {
origin: 'probe' as const,
fetchedAt: 1,
models: Array.from({ length: 80 }, (_, id) => ({
id: `astra-${id}`,
label: `Astra ${id}`,
isDefault: false,
efforts: []
}))
}
}
expect(searchAgentPickerSetups([large], 'astra')).toHaveLength(8)
expect(large.catalog.models).toHaveLength(80)
})
})
it('prioritizes the selected pending catalog and reads each offered agent only once', () => {
let selected: AgentPickerEntry['agent'] | undefined
const entries = [
entry,
{ ...entry, agent: 'claude' as const },
{ ...entry, agent: 'gemini' as const },
{ ...entry, agent: 'grok' as const, surfaces: [] }
]
const next = agentPickerHydrationQueue(entries, () => selected)
expect(next()).toBe(0)
selected = 'gemini'
expect(next()).toBe(2)
expect(next()).toBe(1)
expect(next()).toBeUndefined()
})
it('retains a model named by a captured Claude init and honors its disabled fast mode', () => {
const initSchema = z.object({
frame: z.object({
type: z.literal('system'),
subtype: z.literal('init'),
model: z.string(),
fast_mode_disabled_reason: z.literal('sdk_opt_in_required')
})
})
const capture = readFileSync(
new URL('../main/claude/__fixtures__/claude-adapter-capture-fold.jsonl', import.meta.url),
'utf8'
)
const init = capture
.trim()
.split('\n')
.map((line) => initSchema.safeParse(JSON.parse(line)))
.find((result) => result.success)
if (!init?.success) {
throw new Error('Captured init is missing')
}
const model = init.data.frame.model
const captured: AgentPickerEntry = {
...entry,
agent: 'claude',
catalog: {
origin: 'live-session',
fetchedAt: 1,
fastModeSupport: { supported: false, reason: init.data.frame.fast_mode_disabled_reason },
models: [{ id: model, label: model, isDefault: false, efforts: [], effortsKnown: false }]
}
}
expect(
agentPickerEntryOptions(captured, model, 'chat').some((option) => option.id === 'fastMode')
).toBe(false)
expect(editAgentPickerDraft(agentPickerDraft(captured), { model }).choices.model).toBe(model)
})
it.each(['chat', 'terminal'] as const)(
'keeps CLI edits separate from shared intent with %s as the default',
(surface) => {
const own = { ...entry, agent: 'claude' as const, defaultSurface: surface }
const shared = editAgentPickerDraft(agentPickerDraft(own), {
model: 'my-private-alias',
permissionMode: 'ask'
})
const edited = editAgentPickerArguments(
shared,
'--model haiku --dangerously-skip-permissions --add-dir docs'
)
expect(agentPickerLaunchInputs({ ...edited, surface: 'chat' }, own)).toEqual({
sessionOptions: { model: 'my-private-alias', permissionMode: 'ask' }
})
expect(agentPickerLaunchInputs({ ...edited, surface: 'terminal' }, own).agentArgs).toContain(
'--model haiku'
)
expect(agentPickerLaunchInputs({ ...edited, surface: 'terminal' }, own).agentArgs).toContain(
'--dangerously-skip-permissions'
)
const reset = { ...edited, arguments: undefined }
expect(agentPickerLaunchInputs({ ...reset, surface: 'chat' }, own).sessionOptions?.model).toBe(
'my-private-alias'
)
expect(agentPickerLaunchInputs({ ...reset, surface: 'terminal' }, own)).not.toHaveProperty(
'agentArgs'
)
}
)
it('uses actual Chat permission seeds without transferring remembered Chat model into terminal Arguments', () => {
expect(
agentPickerChoiceSeeds('codex', { agentPermissionDefault: 'accept-edits' }).structured
.permissionMode
).toBe('ask')
expect(
agentPickerChoiceSeeds('claude', { nativeChatPermissionMode: 'full-access' }).structured
.permissionMode
).toBe('full-access')
const edited = editAgentPickerArguments(agentPickerDraft(entry, 'terminal'), '')
expect(agentPickerLaunchInputs(edited, entry)).toEqual({
agentArgs: '-a on-request -s read-only -c approvals_reviewer=user'
})
})
it('shows inherited terminal Arguments choices without promoting them into launch overrides or Chat', () => {
const own = {
...entry,
agent: 'claude' as const,
arguments: '--model sonnet --effort low',
structured: { model: 'opus' },
terminal: { permissionMode: 'ask' as const }
}
const draft = agentPickerDraft(own, 'terminal')
expect(agentPickerEffectiveChoices(own, draft)).toMatchObject({
model: 'sonnet',
options: { effort: 'low' }
})
expect(agentPickerLaunchInputs(draft, own)).toEqual({})
expect(
agentPickerEffectiveChoices(own, editAgentPickerArguments(draft, '')).model
).toBeUndefined()
expect(
agentPickerEffectiveChoices(own, editAgentPickerArguments(draft, '--add-dir src')).model
).toBeUndefined()
expect(agentPickerEffectiveChoices(own, { ...draft, surface: 'chat' }).model).toBe('opus')
expect(
agentPickerEffectiveChoices(own, editAgentPickerDraft(draft, { model: 'opus' })).model
).toBe('opus')
})
it('only suggests host-advertised model words and preserves the mixed omnibox short-prefix boundary', () => {
expect(
searchAgentPickerSetups([{ ...entry, catalog: { origin: 'unknown' } }], 'gpt low')
).toEqual([])
for (const query of ['a', 'low', 'high', 'codex low']) {
expect(searchAgentPickerSetups([entry], query)).toEqual([])
}
expect(searchAgentPickerSetups([entry], 'as low')).toHaveLength(1)
})
it('bounds real setup search work at N and 10N for no, broad and effort matches', () => {
const measure = (count: number, query: string) => {
let visits = 0
const models = Array.from({ length: count }, (_, index) => ({
...(entry.catalog.origin !== 'unknown'
? entry.catalog.models[0]
: { isDefault: false, efforts: [] }),
get id() {
visits++
return `astra-${index}`
},
get label() {
visits++
return `Astra ${index}`
}
}))
const result = searchAgentPickerSetups(
[{ ...entry, catalog: { origin: 'probe', fetchedAt: 1, models } }],
query
)
return { visits, length: result.length }
}
for (const query of ['absent', 'astra', 'astra low', 'low']) {
const small = measure(30, query),
large = measure(300, query)
expect(large.visits).toBeLessThanOrEqual(10 * small.visits)
expect(large.visits).toBeGreaterThan(0)
expect(large.length).toBeLessThanOrEqual(8)
}
})
it('clears an Arguments-owned model/effort through deliberate controls without copying other CLI choices into Chat', () => {
const own = { ...entry, agent: 'claude' as const }
const original = editAgentPickerDraft(agentPickerDraft(own), {
model: 'sonnet',
permissionMode: 'ask'
})
const edited = editAgentPickerArguments(
original,
'--model opus --effort low --dangerously-skip-permissions --add-dir docs'
)
const cleared = editAgentPickerDraft(edited, { model: null, options: { effort: null } })
expect(cleared.arguments).toBe('--dangerously-skip-permissions --add-dir docs')
expect(cleared.choices).toEqual({ permissionMode: 'ask' })
expect(agentPickerLaunchInputs({ ...cleared, surface: 'chat' }, own)).toEqual({
sessionOptions: { permissionMode: 'ask' }
})
const next = editAgentPickerDraft(edited, { model: 'sonnet' })
expect(agentPickerLaunchInputs({ ...next, surface: 'chat' }, own).sessionOptions).toEqual({
model: 'sonnet',
permissionMode: 'ask'
})
expect(agentPickerLaunchInputs({ ...next, surface: 'terminal' }, own).agentArgs).toContain(
'--dangerously-skip-permissions'
)
})
it('uses the common Cursor composed model carrier for both Fast directions without duplicate model flags', async () => {
const { editAgentPickerSpeed, agentPickerPanelArguments, editAgentPickerPanelText } =
await import('./agent-launch-picker-arguments')
const own: AgentPickerEntry = {
...entry,
agent: 'cursor',
defaultSurface: 'terminal',
terminal: { model: 'gpt-5.3-codex', options: { effort: 'low', fastMode: 'false' } },
structured: {},
arguments: '--add-dir docs',
catalog: { origin: 'unknown' }
}
const fast = editAgentPickerSpeed(own, agentPickerDraft(own), 'fastMode', 'true', 'gpt-5.3-codex')
expect(agentPickerLaunchInputs(fast, own).agentArgs).toBe(
'--model gpt-5.3-codex-low-fast --add-dir docs'
)
const view = agentPickerPanelArguments(own, fast, 'gpt-5.3-codex')
expect(view.text).toBe('--add-dir docs')
expect(view.chips.filter((chip) => chip.key === 'fastMode')).toHaveLength(1)
const edited = editAgentPickerPanelText(own, fast, '--add-dir src', 'gpt-5.3-codex')
expect(agentPickerLaunchInputs(edited, own).agentArgs).toBe(
'--model gpt-5.3-codex-low-fast --add-dir src'
)
const normal = editAgentPickerSpeed(own, fast, 'fastMode', 'false', 'gpt-5.3-codex')
expect(agentPickerLaunchInputs(normal, own).agentArgs).toBe(
'--model gpt-5.3-codex-low --add-dir docs'
)
expect(agentPickerLaunchInputs({ ...normal, surface: 'chat' }, own)).toEqual({
sessionOptions: { fastMode: 'false' }
})
})
it('reports opaque permission literally when the actual compiler ignores a different selected Terminal policy', async () => {
const { editAgentPickerPermission, agentPickerPermissionValue } =
await import('./agent-launch-picker-permission')
const { agentPickerPanelArguments } = await import('./agent-launch-picker-arguments')
const raw = '--unknown value --permission-mode plan --add-dir docs'
const own: AgentPickerEntry = {
...entry,
agent: 'claude',
defaultSurface: 'terminal',
arguments: raw
}
const next = editAgentPickerPermission(own, agentPickerDraft(own), 'full-access')
expect(agentPickerPermissionValue(own, next)).toBe('plan')
expect(agentPickerPanelArguments(own, next).chips).toEqual([])
expect(agentPickerLaunchInputs(next, own).agentArgs).toBe(raw)
expect(agentPickerLaunchInputs({ ...next, surface: 'chat' }, own)).toEqual({
sessionOptions: { permissionMode: 'full-access' }
})
})
it('retains a raw permission spelling even when its literal value happens to name a typed choice', async () => {
const { agentPickerPanelArguments } = await import('./agent-launch-picker-arguments')
const own: AgentPickerEntry = {
...entry,
agent: 'claude',
defaultSurface: 'terminal',
arguments: '--permission-mode ask --add-dir docs'
}
const view = agentPickerPanelArguments(own, agentPickerDraft(own))
expect(view.permission).toBe('ask')
expect(view.selectedPermission).toBeUndefined()
expect(view.chips[0].flag).toBe('--permission-mode ask')
})
it('keeps offered host Chat independent of preference through the actual row builder and adapter', () => {
for (const scenario of [
{
enabled: true,
preferred: false,
answer: 'yes',
program: 'available',
detected: ['codex'],
surfaces: ['chat', 'terminal'],
defaultSurface: 'terminal'
},
{
enabled: true,
preferred: true,
answer: 'yes',
program: 'available',
detected: ['codex'],
surfaces: ['chat', 'terminal'],
defaultSurface: 'chat'
},
{
enabled: false,
preferred: false,
answer: 'yes',
program: 'available',
detected: ['codex'],
surfaces: ['terminal'],
defaultSurface: 'terminal'
},
{
enabled: true,
preferred: false,
answer: 'ssh',
program: 'available',
detected: ['codex'],
surfaces: ['terminal'],
defaultSurface: 'terminal'
},
{
enabled: true,
preferred: false,
answer: 'not-yet-known',
program: 'available',
detected: ['codex'],
surfaces: ['terminal'],
defaultSurface: 'terminal'
},
{
enabled: true,
preferred: false,
answer: 'yes',
program: 'installable',
detected: ['codex'],
surfaces: ['chat', 'terminal'],
defaultSurface: 'terminal'
},
{
enabled: true,
preferred: false,
answer: 'yes',
program: 'unknown',
detected: ['codex'],
surfaces: ['chat', 'terminal'],
defaultSurface: 'terminal'
},
{
enabled: true,
preferred: false,
answer: 'no-chats',
program: 'available',
detected: ['codex'],
surfaces: ['terminal'],
defaultSurface: 'terminal'
},
{
enabled: true,
preferred: false,
answer: 'yes',
program: 'missing',
detected: ['codex'],
surfaces: ['terminal'],
defaultSurface: 'terminal'
},
{
enabled: true,
preferred: true,
answer: 'yes',
program: 'available',
detected: [],
surfaces: ['chat'],
defaultSurface: 'chat'
}
] as const) {
const rows = buildNewTabAgentMenuRows({
settings: {
experimentalNativeChat: scenario.enabled,
nativeChatProviders: { codex: scenario.preferred }
},
detectedOnHost: scenario.detected,
chatAgents: ['codex'],
hostChatAnswer: () => scenario.answer,
chatProgram: () => scenario.program
}).map((row) => ({ ...row, executionHostId: 'runtime:paired' as const }))
const [result] = agentPickerEntriesFromRows(
rows,
scenario.detected,
() => ({ terminal: {}, structured: {} }),
(agent) => agent
)
expect(result.surfaces).toEqual(scenario.surfaces)
expect(result.defaultSurface).toBe(scenario.defaultSurface)
expect(result.rows.every((row) => row.executionHostId === 'runtime:paired')).toBe(true)
}
for (const answer of ['not-yet-known', 'ssh', 'wsl', 'subfolder', 'not-on-host'] as const) {
for (const detected of [[], ['codex']] as const) {
const rows = buildNewTabAgentMenuRows({
settings: { experimentalNativeChat: true },
detectedOnHost: detected,
chatAgents: ['codex'],
hostChatAnswer: () => answer,
chatProgram: () => 'available'
})
const [result] = agentPickerEntriesFromRows(
rows,
detected,
() => ({ terminal: {}, structured: {} }),
(agent) => agent
)
expect(result.rows.some((row) => row.kind === 'chat-unavailable')).toBe(true)
expect(result.surfaces).toEqual(detected.length ? ['terminal'] : [])
}
}
expect(
buildNewTabAgentMenuRows({
settings: { experimentalNativeChat: true, disabledTuiAgents: ['codex'] },
detectedOnHost: ['codex'],
chatAgents: ['codex'],
hostChatAnswer: () => 'yes',
chatProgram: () => 'available'
})
).toEqual([])
})
+256
View File
@@ -0,0 +1,256 @@
import { analyzeAgentChoiceArgs } from './agent-choice-args'
import {
agentPickerArgumentsKnown,
agentPickerArgumentsText
} from './agent-launch-picker-authority'
export {
agentPickerArgumentsKnown,
agentPickerArgumentsText
} from './agent-launch-picker-authority'
export {
agentPickerChoiceSeeds,
type AgentPickerDefaultSettings
} from './agent-launch-picker-defaults'
import { agentPickerOptions } from './agent-launch-picker-catalog'
export {
agentPickerOptions,
agentPickerModels,
agentPickerEntryOptions,
agentPickerPermissions
} from './agent-launch-picker-catalog'
import { projectAgentLaunchProfile } from './agent-launch-profile'
import type { AgentChoicePatch, AgentDedicatedChoices } from './agent-launch-choices'
import { reconcileAgentChoiceArgs } from './agent-choice-reconciliation'
import { resolveAgentChoiceLayers } from './agent-choice-layers'
import type { AgentSessionModelCatalogResult, AgentSessionModelOption } from './agent-session-wire'
import type { NewTabAgentMenuRow } from './agent-launch-picker-rows'
import type { TuiAgent } from './tui-agent'
export type AgentPickerSurface = 'chat' | 'terminal'
export type AgentPickerModel = AgentSessionModelOption & { isLegacy?: true; hidden?: true }
export type AgentPickerEntry = {
agent: TuiAgent
label: string
aliases?: string[]
rows: NewTabAgentMenuRow[]
surfaces: AgentPickerSurface[]
defaultSurface: AgentPickerSurface
terminal: AgentDedicatedChoices
structured: AgentDedicatedChoices
arguments?: string | null
catalog: AgentSessionModelCatalogResult
ownerKey?: string
defaultsPending?: boolean
structuredDefaultsPending?: boolean
}
export type AgentPickerDraft = {
agent: TuiAgent
surface: AgentPickerSurface
choices: AgentDedicatedChoices
argumentsOwner?: string
arguments?: string | null
}
export type AgentPickerSetup = {
entry: AgentPickerEntry
model: AgentPickerModel
choices: AgentDedicatedChoices
label: string
}
export function agentPickerDraft(
entry: AgentPickerEntry,
surface = entry.defaultSurface
): AgentPickerDraft {
return { agent: entry.agent, surface, choices: {}, arguments: undefined }
}
export function agentPickerEffectiveChoices(
entry: AgentPickerEntry,
draft: AgentPickerDraft
): AgentDedicatedChoices {
const defaults =
draft.surface === 'chat'
? entry.structured
: draft.arguments !== undefined
? entry.terminal
: reconcileAgentChoiceArgs({
agent: entry.agent,
userArgs: entry.arguments ?? '',
choices: entry.terminal,
mode: 'load',
keys: new Set([
'model',
'permissionMode',
...agentPickerOptions(entry.agent, entry.terminal.model, 'terminal').map(
(option) => option.id
)
])
}).choices
return resolveAgentChoiceLayers({
agent: entry.agent,
defaults,
explicit: agentPickerLaunchChoices(draft).choices,
userArgs:
draft.surface === 'chat'
? undefined
: draft.arguments === undefined
? entry.arguments
: draft.arguments,
argumentOwner: draft.arguments === undefined ? 'defaults' : 'launch'
})
}
export function editAgentPickerDraft(
draft: AgentPickerDraft,
patch: AgentChoicePatch
): AgentPickerDraft {
const keys = new Set([
...(patch.model !== undefined ? ['model'] : []),
...Object.keys(patch.options ?? {})
])
const result = reconcileAgentChoiceArgs({
agent: draft.agent,
userArgs: '',
choices: draft.choices,
mode: 'choice-edit',
keys,
patch
})
// Terminal text can clear its own flags without making its other lifted values shared choices.
const argumentsEdit =
draft.arguments === undefined
? undefined
: reconcileAgentChoiceArgs({
agent: draft.agent,
userArgs: draft.arguments ?? '',
choices: agentPickerLaunchChoices({ ...draft, surface: 'terminal' }).choices,
mode: 'choice-edit',
keys,
patch
})
return {
...draft,
choices: result.choices,
...(argumentsEdit ? { arguments: argumentsEdit.userArgs || null } : {})
}
}
export function editAgentPickerArguments(draft: AgentPickerDraft, text: string): AgentPickerDraft {
return { ...draft, arguments: text || null }
}
function agentPickerLaunchChoices(draft: AgentPickerDraft) {
if (draft.surface !== 'terminal' || draft.arguments === undefined) {
return { choices: draft.choices, userArgs: draft.arguments }
}
const result = reconcileAgentChoiceArgs({
agent: draft.agent,
userArgs: draft.arguments ?? '',
choices: draft.choices,
mode: 'arguments-edit',
keys: new Set([
'model',
'permissionMode',
...Object.keys(draft.choices.options ?? {}),
...agentPickerOptions(draft.agent, draft.choices.model, 'terminal').map((option) => option.id)
])
})
return result
}
export function agentPickerLaunchInputs(draft: AgentPickerDraft, entry?: AgentPickerEntry) {
const authorized = !entry || agentPickerArgumentsKnown(entry, draft)
const { choices: launchChoices, userArgs } = agentPickerLaunchChoices(
authorized ? draft : { ...draft, arguments: undefined }
)
const choices = {
...launchChoices,
...(launchChoices.options ? { options: { ...launchChoices.options } } : {})
}
const text = entry ? agentPickerArgumentsText(entry, draft) : draft.arguments
if (
text &&
analyzeAgentChoiceArgs(draft.agent, text).opaqueKeys.has('fastMode') &&
choices.options
) {
choices.options = Object.fromEntries(
Object.entries(choices.options).filter(([key]) => key !== 'fastMode')
)
}
// The existing dispatcher treats an Arguments override as the complete terminal profile.
const sessionOptions = agentPickerSessionOptions(choices)
return {
...(Object.keys(sessionOptions).length ? { sessionOptions } : {}),
...(draft.surface === 'terminal' && userArgs !== undefined
? {
agentArgs: projectAgentLaunchProfile(draft.agent, undefined, {
target: 'terminal',
userArgs: userArgs ?? '',
choices: {
...(entry?.terminal.permissionMode
? { permissionMode: entry.terminal.permissionMode }
: {}),
...choices
}
}).args
}
: {})
}
}
export function agentPickerButtonOrder(entry: AgentPickerEntry): AgentPickerSurface[] {
return [
...entry.surfaces.filter((surface) => surface !== entry.defaultSurface),
...entry.surfaces.filter((surface) => surface === entry.defaultSurface)
]
}
export { searchAgentPickerSetups } from './agent-launch-picker-search'
export function agentPickerSessionOptions(choices: AgentDedicatedChoices): Record<string, string> {
return {
...choices.options,
...(choices.model ? { model: choices.model } : {}),
...(choices.permissionMode ? { permissionMode: choices.permissionMode } : {})
}
}
export function agentPickerEntriesFromRows(
rows: readonly NewTabAgentMenuRow[],
detected: readonly TuiAgent[],
seed: (agent: TuiAgent) => Pick<AgentPickerEntry, 'terminal' | 'structured' | 'arguments'>,
label: (agent: TuiAgent) => string
): AgentPickerEntry[] {
return [...new Set(rows.map((row) => row.agent))].map((agent) => {
const own = rows.filter((row) => row.agent === agent)
const chatDefault = own.some((row) => row.kind === 'chat')
const chat = chatDefault || own.some((row) => row.kind === 'cli' && row.chat)
return {
agent,
label: label(agent),
rows: own,
surfaces: [
...(chat ? ['chat' as const] : []),
...(detected.includes(agent) ? ['terminal' as const] : [])
],
defaultSurface: chatDefault ? 'chat' : 'terminal',
...seed(agent),
catalog: { origin: 'unknown' }
}
})
}
export function agentPickerHydrationQueue(
entries: readonly AgentPickerEntry[],
selected: () => TuiAgent | undefined
): () => number | undefined {
const pending = new Set(entries.flatMap((entry, index) => (entry.surfaces.length ? [index] : [])))
return () => {
const preferred = entries.findIndex((entry) => entry.agent === selected())
const index = pending.has(preferred) ? preferred : pending.values().next().value
if (index !== undefined) {
pending.delete(index)
}
return index
}
}
+2 -1
View File
@@ -68,7 +68,8 @@ export function projectAgentLaunchProfile(
const kept = removeOverriddenAgentSessionArgs(agent, values, parsed.tokens)
const launch = resolveAgentSessionOptionLaunch(agent, values, [], false, parsed.tokens)
const raw =
kept.length === parsed.tokens.length
kept.length === parsed.tokens.length &&
kept.every((token, index) => token === parsed.tokens[index])
? userArgs
: kept.map((token) => quoteStartupArg(token, shell)).join(' ')
if (input.target === 'structured') {
+37 -1
View File
@@ -24,6 +24,40 @@ const CLAUDE_PERMISSION_OPTIONS = [
'--disallowedTools',
'--disallowed-tools'
]
// Zero-operand switches from Claude's CLI help; optional-value options remain uncertain.
const CLAUDE_SWITCHES = [
'--allow-dangerously-skip-permissions',
'--ax-screen-reader',
'--bg',
'--background',
'--bare',
'--brief',
'--chrome',
'-c',
'--continue',
'--dangerously-skip-permissions',
'--desktop',
'--disable-slash-commands',
'--exclude-dynamic-system-prompt-sections',
'--fork-session',
'--forward-subagent-text',
'-h',
'--help',
'--ide',
'--include-hook-events',
'--include-partial-messages',
'--no-chrome',
'--no-session-persistence',
'-p',
'--print',
'--replay-user-messages',
'--restricted',
'--safe-mode',
'--strict-mcp-config',
'--verbose',
'-v',
'--version'
]
const EXTRA_CARRIERS: Partial<Record<TuiAgent, readonly string[]>> = {
claude: CLAUDE_PERMISSION_OPTIONS,
'claude-agent-teams': CLAUDE_PERMISSION_OPTIONS,
@@ -96,7 +130,9 @@ export function permissionOptionInventory(agent: TuiAgent): PermissionOptionInve
})
const carriers = new Set(EXTRA_CARRIERS[agent])
const values = new Set(agent === 'codex' ? CODEX_VALUES : [])
const switches = new Set<string>()
const switches = new Set<string>(
agent === 'claude' || agent === 'claude-agent-teams' ? CLAUDE_SWITCHES : []
)
for (const { token, next } of recipes) {
if (!token.startsWith('-')) {
continue
@@ -87,7 +87,14 @@ const CLAUDE_FAST_MODE: CatalogOption = {
label: 'Fast mode',
category: 'mode',
kind: { type: 'boolean', defaultValue: false },
apply: { midSession: { kind: 'toggle-command', command: '/fast' } }
apply: {
...agentChoiceCliApply(
'claude',
[{ kind: 'settings', names: ['--settings'], key: 'fastMode', lift: 'merged-json' }],
'--settings'
),
midSession: { kind: 'toggle-command', command: '/fast' }
}
}
export const CLAUDE_SESSION_OPTION_CATALOG: AgentSessionOptionCatalog = {
@@ -140,7 +147,7 @@ export const CLAUDE_SESSION_OPTION_CATALOG: AgentSessionOptionCatalog = {
detectAgentInteraction: 'claude-model-switch-confirmation'
}
},
unknownModelOptions: [claudeEffort(true)],
unknownModelOptions: [claudeEffort(true), CLAUDE_FAST_MODE],
listModels: {
command: `echo '${CLAUDE_MODEL_LIST_STDIN.trim()}' | claude ${CLAUDE_MODEL_LIST_ARGS.join(' ')}`,
parse: parseClaudeCatalogModels
@@ -188,14 +195,38 @@ function codexEffort(ceiling: 'xhigh' | 'max' | 'ultra'): CatalogOption {
}
}
const CODEX_SERVICE_TIER: CatalogOption = {
id: 'serviceTier',
label: 'Speed',
category: 'mode',
kind: { type: 'select', choices: [], defaultValue: 'default' },
apply: agentChoiceCliApply(
'codex',
[{ kind: 'config', names: ['-c', '--config'], key: 'service_tier', lift: 'codex-bare-string' }],
'-c'
)
}
export const CODEX_SESSION_OPTION_CATALOG: AgentSessionOptionCatalog = {
supportsWorkerLaunchPreferences: true,
hostListingNamesConfiguredModel: true,
// Auth scopes Codex access; keep a short seed and admit unknown persisted IDs.
models: [
{ id: 'gpt-5.6-sol', label: 'GPT-5.6 Sol', options: [codexEffort('ultra')] },
{ id: 'gpt-5.6-terra', label: 'GPT-5.6 Terra', options: [codexEffort('ultra')] },
{ id: 'gpt-5.6-luna', label: 'GPT-5.6 Luna', options: [codexEffort('max')] },
{
id: 'gpt-5.6-sol',
label: 'GPT-5.6 Sol',
options: [codexEffort('ultra')]
},
{
id: 'gpt-5.6-terra',
label: 'GPT-5.6 Terra',
options: [codexEffort('ultra')]
},
{
id: 'gpt-5.6-luna',
label: 'GPT-5.6 Luna',
options: [codexEffort('max')]
},
{ id: 'gpt-5.5', label: 'GPT-5.5', options: [codexEffort('xhigh')] },
{
id: 'gpt-5.2-codex',
@@ -220,5 +251,5 @@ export const CODEX_SESSION_OPTION_CATALOG: AgentSessionOptionCatalog = {
midSession: { kind: 'agent-picker', command: '/model', delivery: 'type' }
},
// New models may support max/ultra; unsupported efforts fall back to the model default instead of failing.
unknownModelOptions: [codexEffort('ultra')]
unknownModelOptions: [codexEffort('ultra'), CODEX_SERVICE_TIER]
}
@@ -22,7 +22,7 @@ export type CatalogMidSessionApply =
export type CatalogOptionApply = {
cli?: readonly AgentChoiceCliForm[]
launchArgs?: (value: SessionOptionValue) => string[]
launchArgs?: (value: SessionOptionValue, trailingAgentArgs?: readonly string[]) => string[]
/** Removes conflicting free-form args, or detects when to omit the pick from launch args and the record. */
removeAgentArgs?: (tokens: readonly string[]) => string[]
composedIntoModel?: true
+59 -10
View File
@@ -1,3 +1,8 @@
import {
agentChoiceSettingsOccurrences,
rewriteAgentChoiceSettings,
readAgentChoiceSettings
} from './agent-choice-settings'
import type { AgentType } from './agent-status-types'
import {
findCatalogModel,
@@ -27,7 +32,17 @@ export type ResolvedSessionOptionLaunch = {
appliedValues: Record<string, SessionOptionValue>
}
function isOverriddenByAgentArgs(apply: CatalogOptionApply, tokens: readonly string[]): boolean {
function isOverriddenByAgentArgs(
agent: AgentType,
apply: CatalogOptionApply,
tokens: readonly string[]
): boolean {
const settings = apply.cli?.filter((form) => form.kind === 'settings')
if (settings?.length) {
return settings.some(
(form) => agentChoiceSettingsOccurrences(agent, form.names, tokens).length > 0
)
}
const kept = apply.removeAgentArgs?.(tokens)
return kept !== undefined && kept.length < tokens.length
}
@@ -47,8 +62,15 @@ export function removeOverriddenAgentSessionArgs(
result = catalog.modelApply.removeAgentArgs?.(result) ?? result
}
for (const option of launchOptionsFor(catalog, modelId).options) {
if (values[option.id] !== undefined && option.apply.removeAgentArgs) {
result = option.apply.removeAgentArgs(result)
const value = values[option.id]
if (value !== undefined && option.apply.removeAgentArgs) {
const settings = option.apply.cli?.find((form) => form.kind === 'settings')
const decoded = decodeCatalogOptionValue(option.kind, value)
result = settings
? decoded === undefined
? result
: rewriteAgentChoiceSettings(agent, settings.names, result, settings.key, decoded)
: option.apply.removeAgentArgs(result)
}
}
return result
@@ -60,7 +82,19 @@ function launchOptionsFor(
modelId: string | null
): { model: CatalogModel | null; options: CatalogOption[] } {
const model = (modelId ? findCatalogModel(catalog, modelId) : null) ?? null
return { model, options: model?.options ?? catalog.unknownModelOptions ?? [] }
const options = model?.options ?? catalog.unknownModelOptions ?? []
return {
model,
options: [
...options,
...(catalog.unknownModelOptions ?? []).filter(
(option) =>
option.kind.type === 'select' &&
option.kind.choices.length === 0 &&
!options.some((existing) => existing.id === option.id)
)
]
}
}
export function resolveAgentSessionOptionLaunch(
@@ -74,7 +108,8 @@ export function resolveAgentSessionOptionLaunch(
agent,
values,
trailingAgentArgs,
includeCatalogDefaults
includeCatalogDefaults,
permissionTrailingArgs
)
const permission = readLaunchPermissionChoice(values)
if (!permission || !isTuiAgent(agent) || !agentPermissionHasTerminalChoice(agent, permission)) {
@@ -98,7 +133,8 @@ function resolveModelOptionLaunch(
agent: AgentType,
values: Record<string, SessionOptionValue> | null | undefined,
trailingAgentArgs: readonly string[],
includeCatalogDefaults: boolean
includeCatalogDefaults: boolean,
originalArgs: readonly string[]
): ResolvedSessionOptionLaunch {
const catalog = getAgentSessionOptionLaunchCatalog(agent)
const modelId = typeof values?.model === 'string' ? values.model : null
@@ -111,7 +147,7 @@ function resolveModelOptionLaunch(
const args: string[] = []
const modelValues = launchModelValues(model, modelOptions, values, includeCatalogDefaults)
const modelOverridden =
modelId !== null && isOverriddenByAgentArgs(catalog.modelApply, trailingAgentArgs)
modelId !== null && isOverriddenByAgentArgs(agent, catalog.modelApply, trailingAgentArgs)
const appliesModel = Boolean(modelId && catalog.modelApply.launchArgs && !modelOverridden)
// Why: a repeated model flag can prevent the CLI from starting.
@@ -137,11 +173,18 @@ function resolveModelOptionLaunch(
if (
!option.apply.launchArgs ||
modelOverridden ||
isOverriddenByAgentArgs(option.apply, trailingAgentArgs)
isOverriddenByAgentArgs(agent, option.apply, trailingAgentArgs)
) {
continue
}
args.push(...option.apply.launchArgs(value))
const settings = option.apply.cli?.find((form) => form.kind === 'settings')
const effective =
settings && agentChoiceSettingsOccurrences(agent, settings.names, originalArgs).at(-1)
if (effective && !readAgentChoiceSettings(effective.value)) {
continue
}
const compiled = option.apply.launchArgs(value, originalArgs)
args.push(...compiled)
appliedValues[option.id] = value
}
return { args, appliedValues }
@@ -164,13 +207,19 @@ function launchModelValues(
if (
!model &&
option.kind.type === 'select' &&
option.kind.choices.length > 0 &&
!option.kind.choices.some((choice) => choice.value === explicitValue)
) {
return []
}
return [[option.id, explicitValue]]
}
return model && includeCatalogDefaults ? [[option.id, option.kind.defaultValue]] : []
const launchDefault =
!option.apply.cli?.some((form) => form.kind === 'settings') &&
!(option.kind.type === 'select' && option.kind.choices.length === 0)
return model && includeCatalogDefaults && launchDefault
? [[option.id, option.kind.defaultValue]]
: []
})
)
}
@@ -0,0 +1,68 @@
{
"capture": "codex-cli 0.149.0, 2026-08-21; model/list first row and thread/start policy fields; workspace paths omitted",
"modelList": {
"data": [
{
"id": "gpt-5.6-sol",
"model": "gpt-5.6-sol",
"upgrade": null,
"upgradeInfo": null,
"availabilityNux": {
"message": "Our most capable model yet. GPT-5.6 Sol can tackle complex code changes, dig into research, produce polished documents, and take on your most ambitious work. Sol is highly capable at lower reasoning efforts\u2014try starting lower, then turn it up for harder jobs."
},
"displayName": "GPT-5.6-Sol",
"description": "Latest frontier agentic coding model.",
"modelSpecialty": null,
"hidden": false,
"supportedReasoningEfforts": [
{
"reasoningEffort": "low",
"description": "Fast responses with lighter reasoning"
},
{
"reasoningEffort": "medium",
"description": "Balances speed and reasoning depth for everyday tasks"
},
{
"reasoningEffort": "high",
"description": "Greater reasoning depth for complex problems"
},
{
"reasoningEffort": "xhigh",
"description": "Extra high reasoning depth for complex problems"
},
{
"reasoningEffort": "max",
"description": "Maximum reasoning depth for the hardest problems"
},
{
"reasoningEffort": "ultra",
"description": "Maximum reasoning with automatic task delegation"
}
],
"defaultReasoningEffort": "low",
"inputModalities": ["text", "image"],
"supportsPersonality": false,
"multiAgentVersion": "v2",
"additionalSpeedTiers": ["fast"],
"serviceTiers": [
{
"id": "priority",
"name": "Fast",
"description": "1.5x speed, increased usage"
}
],
"defaultServiceTier": null,
"isDefault": true
}
],
"nextCursor": null
},
"policy": {
"approvalPolicy": "on-request",
"approvalsReviewer": "user",
"sandbox": {
"type": "workspaceWrite"
}
}
}
@@ -71,14 +71,15 @@ export function sourceControlActionOptions(
modelId: string
): SourceControlActionOption[] {
const catalog = getAgentSessionOptionCatalog(agent)
const options =
findCatalogModel(catalog ?? { models: [], modelApply: {} }, modelId)?.options ??
catalog?.unknownModelOptions ??
[]
const model = findCatalogModel(catalog ?? { models: [], modelApply: {} }, modelId)
const options = model?.options ?? catalog?.unknownModelOptions ?? []
return options.flatMap((option): SourceControlActionOption[] => {
const chats = optionReachesChats(agent, option)
const terminals = Boolean(option.apply.launchArgs || option.apply.composedIntoModel)
if (!chats && !terminals) {
if (
(!chats && !terminals) ||
(option.kind.type === 'select' && option.kind.choices.length === 0)
) {
return []
}
return [{ option, route: !terminals ? 'chats' : !chats ? 'terminals' : null }]
@@ -1,3 +1,4 @@
import { tokenizeStartupCommand } from './tui-agent-startup-shell'
import { describe, expect, it } from 'vitest'
import { resolveAgentLaunchCommand } from './tui-agent-launch-command'
@@ -119,3 +120,73 @@ describe('a saved permission mode on terminals', () => {
expect(result.appliedSessionOptions).toEqual({})
})
})
it.each([false, true])(
'keeps Claude effective settings and Restart literal with override=%s',
(override) => {
const raw = `--settings '{"language":"french"}' --settings '{"env":{"SAFE":"kept"}}' --add-dir docs`
const result = launch({
agentArgs: raw,
sessionOptions: { fastMode: true },
sessionOptionsOverrideAgentArgs: override
})
const parsed = tokenizeStartupCommand(result.command, 'posix')
expect(parsed.ok).toBe(true)
if (!parsed.ok) {
throw new Error('invalid compiler result')
}
const settings = parsed.tokens
.filter((_, index) => parsed.tokens[index - 1] === '--settings')
.map((value) => JSON.parse(value))
expect(settings).toEqual([
{ language: 'french' },
{ env: { SAFE: 'kept' }, ...(override ? { fastMode: true } : {}) }
])
expect(result.appliedSessionOptions.fastMode).toBe(override ? true : undefined)
const restart = tokenizeStartupCommand(result.resumeLaunch.agentCommand, 'posix')
expect(restart.ok).toBe(true)
if (!restart.ok) {
throw new Error('invalid Restart')
}
const resumed = restart.tokens
.filter((_, index) => restart.tokens[index - 1] === '--settings')
.map((value) => JSON.parse(value))
expect(resumed).toEqual(settings)
}
)
it('never claims Fast applied over a literal settings file, including custom commands', () => {
for (const override of [false, true]) {
const result = launch({
cmdOverrides: { claude: 'my-claude' },
agentArgs: '--settings ./team.json',
sessionOptions: { fastMode: true },
sessionOptionsOverrideAgentArgs: override
})
expect(result.command).toBe("my-claude '--settings' './team.json'")
expect(result.appliedSessionOptions.fastMode).toBeUndefined()
expect(result.resumeLaunch.agentCommand).toBe(result.command)
}
})
it('retains same-token-count Claude settings rewrites in projection and Restart', () => {
const agentArgs = `--settings '{"language":"french","fastMode":true}'`
const result = launch({
agentArgs,
sessionOptions: { fastMode: false },
sessionOptionsOverrideAgentArgs: true
})
expect(result.command).toContain('"language":"french","fastMode":false')
expect(result.resumeLaunch.agentArgs).toContain('"language":"french","fastMode":false')
expect(result.appliedSessionOptions.fastMode).toBe(false)
const conflict = resolveAgentLaunchCommand({
agent: 'claude',
platform: 'linux',
shell: 'posix',
cmdOverrides: { claude: 'my-claude --settings ./fixed.json' },
agentArgs,
sessionOptions: { fastMode: false },
sessionOptionsOverrideAgentArgs: true
})
expect(conflict).toMatchObject({ ok: false, error: expect.stringContaining('conflicts') })
})
+3 -1
View File
@@ -172,7 +172,9 @@ export function resolveAgentLaunchCommand(args: {
command: withArgs(render(launchTokens), launchHead),
resumeLaunch: {
agentCommand: withArgs(resumeArgs),
...(keptTokens.length !== rawTokens.length || resolvedOptions.permissionArgs
...(keptTokens.length !== rawTokens.length ||
keptTokens.some((token, index) => token !== rawTokens[index]) ||
resolvedOptions.permissionArgs
? { agentArgs: resumeArgs }
: {})
},