mirror of
https://github.com/stablyai/orca.git
synced 2026-11-02 10:08:13 +00:00
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:
@@ -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,
|
||||
|
||||
@@ -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' }
|
||||
})
|
||||
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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…",
|
||||
|
||||
@@ -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…",
|
||||
|
||||
@@ -22056,6 +22056,14 @@
|
||||
"unmarkFile": "{{path}} の閲覧済みのマークを外す"
|
||||
}
|
||||
},
|
||||
"agentPicker": {
|
||||
"search": "Agent と設定を検索…",
|
||||
"configure": "{{agent}} を設定",
|
||||
"arguments": "引数",
|
||||
"argumentsHint": "CLI での起動にのみ適用されます。",
|
||||
"launchChat": "チャットを起動",
|
||||
"launchTerminal": "ターミナルを起動"
|
||||
},
|
||||
"wslMove": {
|
||||
"entry": {
|
||||
"hostRow": "このディストリビューションからプロジェクトを移動…",
|
||||
|
||||
@@ -22056,6 +22056,14 @@
|
||||
"unmarkFile": "{{path}}을(를) 본 것으로 표시 해제"
|
||||
}
|
||||
},
|
||||
"agentPicker": {
|
||||
"search": "에이전트 및 설정 검색…",
|
||||
"configure": "{{agent}} 설정",
|
||||
"arguments": "인수",
|
||||
"argumentsHint": "CLI 실행에만 적용됩니다.",
|
||||
"launchChat": "채팅 시작",
|
||||
"launchTerminal": "터미널 시작"
|
||||
},
|
||||
"wslMove": {
|
||||
"entry": {
|
||||
"hostRow": "이 배포판에서 프로젝트 이동…",
|
||||
|
||||
@@ -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…",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 } : {})
|
||||
}))
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 } : {}) } }
|
||||
}
|
||||
@@ -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 } : {})
|
||||
}))
|
||||
]
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 } : {})
|
||||
}
|
||||
: {})
|
||||
}
|
||||
}
|
||||
@@ -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([])
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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') {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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') })
|
||||
})
|
||||
|
||||
@@ -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 }
|
||||
: {})
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user