diff --git a/src/renderer/src/components/agent/AgentLaunchPickerMenu.test.tsx b/src/renderer/src/components/agent/AgentLaunchPickerMenu.test.tsx new file mode 100644 index 00000000000..df3598275a5 --- /dev/null +++ b/src/renderer/src/components/agent/AgentLaunchPickerMenu.test.tsx @@ -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( + + + New tab + + 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} + /> + + + + ) + ) +} +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( + + + + ) + ) + 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('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() + class ResizeObserverFixture implements ResizeObserver { + targets = new Set() + 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('button[aria-label="Configure Codex"]')!.click() + ) + const content = document.querySelector('[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('[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('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('[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('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('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('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('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('[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( + '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( + '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('button[aria-label="Configure Claude"]')! + ) + const panel = document.querySelector('[role="dialog"]')! + const model = panel.querySelector('[cmdk-item]')! + await user.hover(model) + await user.click(model) + expect(document.querySelector('[role="dialog"]')).toBe(panel) + await user.click( + panel.querySelector('button[aria-label="Claude permissions"]')! + ) + await user.click( + [...document.querySelectorAll('[role="menuitemradio"]')].find((item) => + item.textContent?.startsWith('Full access') + )! + ) + expect(document.querySelector('[role="dialog"]')).toBe(panel) + await user.click(panel.querySelector('button[aria-label="Toggle Arguments"]')!) + const chip = panel.querySelector('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('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('[role="menuitem"]')! + expect(row.querySelectorAll('button')).toHaveLength(1) + expect(document.querySelector('button[aria-label="Launch terminal"]')).toBeNull() + act(() => row.querySelector('button')?.click()) + expect(launch).not.toHaveBeenCalled() + const alternate = [...document.querySelectorAll('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('[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('[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('[role="menuitem"]')! + await user.click(row.querySelector('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( + '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( + 'button[aria-label="Configure Codex"]' + )! + act(() => configure.click()) + const search = document.querySelector('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( + + + + ) + ) + show() + const configure = () => + document.querySelector('button[aria-label="Configure Codex"]')! + fireEvent.click(configure()) + fireEvent.change(document.querySelector('textarea')!, { target: { value: '--add-dir src' } }) + expect(document.querySelector('textarea')?.value).toBe('--add-dir src') + fireEvent.keyDown(document.querySelector('textarea')!, { key: 'Escape' }) + expect(document.querySelector('textarea')).toBeNull() + fireEvent.click(configure()) + expect(document.querySelector('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('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( + + + + ) + ) + showButton() + const user = userEvent.setup() + const configure = () => + document.querySelector('button[aria-label="Configure Claude"]')! + await user.click(configure()) + await user.click( + [...document.querySelectorAll('[cmdk-item]')].find( + (item) => item.textContent === 'sonnet' + )! + ) + await user.click(getByRole(document.body, 'radio', { name: 'High' })) + await user.click( + document.querySelector('button[aria-label="Claude permissions"]')! + ) + await user.click( + [...document.querySelectorAll('[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('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('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('textarea')?.value).toBe('--add-dir docs') + expect(getByRole(document.body, 'switch').getAttribute('aria-checked')).toBe('false') +}) diff --git a/src/renderer/src/components/agent/AgentLaunchPickerMenu.tsx b/src/renderer/src/components/agent/AgentLaunchPickerMenu.tsx new file mode 100644 index 00000000000..6c701cd5f81 --- /dev/null +++ b/src/renderer/src/components/agent/AgentLaunchPickerMenu.tsx @@ -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) { + // 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(null) + const root = useRef(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 ( +
+ {!hideSearch ? ( +
+ 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( + '[role="menuitem"]:not([data-disabled])' + ) + if (event.key === 'Enter') { + first?.click() + } else { + first?.focus() + } + } + }} + /> +
+ ) : null} + {setups.map((setup) => ( + + ))} + {visible.map((entry) => ( + + ))} + {!visible.length && !setups.length ? ( + + {translate( + 'auto.components.agent.AgentCombobox.579c768bde', + 'No agents match your search.' + )} + + ) : null} +
+ ) +} + +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(null) + const configured = useRef(undefined) + const unavailable = entry.rows.find((row) => row.kind === 'chat-unavailable') + return ( + { + onSetupOpenChange(next ? setupId : null) + if (next) { + onSelectAgent?.(entry.agent) + } + }} + onUse={(draft) => { + configured.current = draft + item.current?.click() + }} + renderAnchor={(trigger) => ( + { + 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('button')?.focus() + } + }} + onPointerMove={keepSetupFocus} + onPointerLeave={keepSetupFocus} + > + + {label} + {shortcut ? {shortcut} : null} + {unavailable?.kind === 'chat-unavailable' && !entry.surfaces.includes('chat') ? ( + + {chatUnavailableLabel(unavailable.reason)} + + ) : null} + {trigger} + + )} + /> + ) +} diff --git a/src/renderer/src/components/agent/AgentLaunchSetupButton.tsx b/src/renderer/src/components/agent/AgentLaunchSetupButton.tsx new file mode 100644 index 00000000000..5307df3e863 --- /dev/null +++ b/src/renderer/src/components/agent/AgentLaunchSetupButton.tsx @@ -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() + +function setupKeyDown(event: KeyboardEvent) { + 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('[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(null) + const anchor = useRef(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 = ( +
+ + + + +
+ ) + return ( + + {renderAnchor ? ( + + {renderAnchor(trigger)} + + ) : ( + trigger + )} + 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() + } + }} + > +
+ sessionDrafts.set(draftKey, draft)} + onUse={(draft) => { + onUse(draft) + changeOpen(false) + }} + /> +
+
+
+ ) +} diff --git a/src/renderer/src/components/agent/AgentLaunchSetupPanel.authority.test.tsx b/src/renderer/src/components/agent/AgentLaunchSetupPanel.authority.test.tsx new file mode 100644 index 00000000000..5e2c7d9a33c --- /dev/null +++ b/src/renderer/src/components/agent/AgentLaunchSetupPanel.authority.test.tsx @@ -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( + + + + ) + ) + const trigger = host.querySelector('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('[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( + + + + ) + ) + const radios = [ + ...host.querySelectorAll('[data-option-id="effort"] [role="radio"]') + ] + expect(radios).toHaveLength(2) + expect(radios.every((radio) => radio.disabled)).toBe(state === 'pending') + expect( + host.querySelector('[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( + + + + ) + ) + const fast = host.querySelector('[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( + + + + ) + ) + const permission = host.querySelector('button[aria-label$=" permissions"]')! + fireEvent.keyDown(permission, { key: 'ArrowDown' }) + fireEvent.click( + [...document.querySelectorAll('[role="menuitemradio"]')].find((item) => + item.textContent?.startsWith('Ask for approval') + )! + ) + expect(host.querySelector('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.' }) +}) diff --git a/src/renderer/src/components/agent/AgentLaunchSetupPanel.perf.test.tsx b/src/renderer/src/components/agent/AgentLaunchSetupPanel.perf.test.tsx new file mode 100644 index 00000000000..50a1e96bd33 --- /dev/null +++ b/src/renderer/src/components/agent/AgentLaunchSetupPanel.perf.test.tsx @@ -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>() + 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( + + + + ) + ) + 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('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() + 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( + + + + ) + ) + const input = host.querySelector('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( + + + + ) + ) + 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() + } +}) diff --git a/src/renderer/src/components/agent/AgentLaunchSetupPanel.test.tsx b/src/renderer/src/components/agent/AgentLaunchSetupPanel.test.tsx new file mode 100644 index 00000000000..c7cf2fae558 --- /dev/null +++ b/src/renderer/src/components/agent/AgentLaunchSetupPanel.test.tsx @@ -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( + + + + ) + ) + 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( + + + + ) + ) + const permission = () => + host.querySelector('button[aria-label$=" permissions"]') + expect(permission()?.disabled).toBe(false) + expect(permission()?.textContent).toContain('plan') + expect(host.querySelector('textarea[aria-label="Arguments"]')?.value).toBe( + '--add-dir docs' + ) + act(() => + host + .querySelector('button[aria-label="Remove --permission-mode plan"]') + ?.click() + ) + expect(permission()?.disabled).toBe(false) + expect(host.querySelector('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( + + + + ) + ) + expect( + host.querySelector('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( + + + + ) + ) + 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( + + + + ) + ) + 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('input[aria-label="Search models"]')?.disabled).toBe( + true + ) + expect( + host.querySelector('button[aria-label$=" permissions"]')?.disabled + ).toBe(true) + expect(host.querySelector('[cmdk-item][data-current="true"]')).toBeNull() + expect( + host.querySelector('textarea[aria-label="Arguments"]')?.disabled + ).toBe(true) + render({ + ...pending, + defaultsPending: false, + structuredDefaultsPending: true, + arguments: '--host-only' + }) + expect(host.querySelector('input[aria-label="Search models"]')?.disabled).toBe( + true + ) + expect( + host.querySelector('textarea[aria-label="Arguments"]')?.disabled + ).toBe(false) + expect(host.querySelector('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('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('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( + + + + ) + ) + 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( + + + + ) + ) + const model = (name: string) => + act(() => + [...host.querySelectorAll('[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('[role="menuitemradio"]')].find((row) => + row.textContent?.startsWith(value) + )! + ) + } + fireEvent.click( + [...host.querySelectorAll('[role="radio"]')].find( + (row) => row.textContent === 'High' + )! + ) + fireEvent.click( + [...host.querySelectorAll('[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( + + + + ) + ) + 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('[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( + + + + ) + ) + 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( + + + + ) + ) + const disclosure = host.querySelector( + '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('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( + + + + ) + ) + expect(host.querySelector('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( + + + + ) + ) + const permission = () => + host.querySelector('button[aria-label="Claude permissions"]')! + expect(permission().textContent).toBe('plan') + fireEvent.keyDown(permission(), { key: 'ArrowDown' }) + const items = [...document.querySelectorAll('[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('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( + + + + ) + ) + if (surface === 'chat') { + fireEvent.click(host.querySelector('button[aria-label="Toggle Arguments"]')!) + } + const fast = host.querySelector('[role="switch"]')! + expect(fast.getAttribute('aria-checked')).toBe('false') + fireEvent.click(fast) + expect(fast.getAttribute('aria-checked')).toBe('true') + const chip = host.querySelector("button[aria-label*='Remove --settings']")! + const input = host.querySelector('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( + + + + ) + ) + if (surface === 'chat') { + fireEvent.click(host.querySelector('button[aria-label="Toggle Arguments"]')!) + } + const chip = host.querySelector('button[aria-label*="Remove --settings"]') + expect(chip).not.toBeNull() + const input = host.querySelector('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( + + + + ) + ) + const radios = () => [...host.querySelectorAll('[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( + + + + ) + ) + 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('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') + } + } +) diff --git a/src/renderer/src/components/agent/AgentLaunchSetupPanel.tsx b/src/renderer/src/components/agent/AgentLaunchSetupPanel.tsx new file mode 100644 index 00000000000..2633e82ce87 --- /dev/null +++ b/src/renderer/src/components/agent/AgentLaunchSetupPanel.tsx @@ -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[]) => ( + + 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 ( +
{ + event.stopPropagation() + if ( + !disabled && + event.key === 'Enter' && + (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) + ) { + event.preventDefault() + use(entry.defaultSurface) + } + }} + > + { + 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() + } + }} + > + + + + {translate('agentPicker.noModels', 'No models match your search.')} + + + {legacy && !modelQuery ? ( + setLegacy(false)}> + + {translate('agentPicker.legacyModels', 'Legacy models')} + + ) : null} + {models + .filter( + (model) => + !model.hidden && + (model.id === currentModel || modelQuery || Boolean(model.isLegacy) === legacy) + ) + .map((model) => ( + setDraft(editAgentPickerDraft(draft, { model: model.id }))} + > + {model.label} + + {currentModel === model.id ? ( + + ) : null} + + + ))} + {!legacy && !modelQuery && legacyCount > 0 ? ( + setLegacy(true)}> + + {translate('agentPicker.legacyModels', 'Legacy models')} + + {translate('agentPicker.legacyModelCount', '{{total}} models', { + total: legacyCount + })} + + + + + ) : null} + {currentModel && !models.some((model) => model.id === currentModel) ? ( + {}} + data-current="true" + > + {currentModel} + + + ) : null} + + + +
+ {renderOptions( + optionRows.filter((row) => row.id !== 'fastMode' && row.id !== 'serviceTier') + )} +
+ { + if (choice !== 'default') { + setDraft(editAgentPickerPermission(entry, draft, choice)) + } + } + }} + /> +
+ {renderOptions( + optionRows.filter((row) => row.id === 'fastMode' || row.id === 'serviceTier') + )} + {entry.surfaces.includes('terminal') ? ( +
+ + + + + +
+ {}} + 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 + } + }))} + /> +
+
+
+
+ ) : null} +
+
+ {agentPickerButtonOrder(entry).map((surface) => ( + + ))} +
+
+ ) +} diff --git a/src/renderer/src/components/agent/agent-launch-picker-acquisition.ts b/src/renderer/src/components/agent/agent-launch-picker-acquisition.ts new file mode 100644 index 00000000000..bd6e08e8e13 --- /dev/null +++ b/src/renderer/src/components/agent/agent-launch-picker-acquisition.ts @@ -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() +type Observer = { + publish: (entries: AgentPickerEntry[]) => void + selected: () => TuiAgent | undefined +} +type Reads = { + settings?: () => Promise + catalog: (entry: AgentPickerEntry) => Promise + support: (entry: AgentPickerEntry) => Promise +} +type SupportRead = { + state: 'queued' | 'pending' | 'settled' + value?: AgentPickerEntry['structured'] +} + +class AgentPickerAcquisition { + readonly observers = new Set() + entries: AgentPickerEntry[] + settingsPending = false + private outstanding = { catalog: 0, support: 0 } + private queued: Record<'catalog' | 'support', Set> + private supports = new Map() + 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) { + 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() })) +} diff --git a/src/renderer/src/components/agent/agent-launch-picker-freshness-test-fixture.tsx b/src/renderer/src/components/agent/agent-launch-picker-freshness-test-fixture.tsx new file mode 100644 index 00000000000..76dccdd64e4 --- /dev/null +++ b/src/renderer/src/components/agent/agent-launch-picker-freshness-test-fixture.tsx @@ -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 + session: ReturnType + 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() + 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 | undefined + const search = () => + view.container.querySelector('input[aria-label="Search models"]')! + const choice = () => view.container.querySelector('[cmdk-item][data-current="true"]')?.textContent + const effortControls = () => [ + ...view.container.querySelectorAll( + '[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([]) + } +} diff --git a/src/renderer/src/components/agent/agent-launch-setup-panel-test-fixture.ts b/src/renderer/src/components/agent/agent-launch-setup-panel-test-fixture.ts new file mode 100644 index 00000000000..c10574f89c9 --- /dev/null +++ b/src/renderer/src/components/agent/agent-launch-setup-panel-test-fixture.ts @@ -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() +}) diff --git a/src/renderer/src/components/agent/use-agent-launch-picker-inventory.test.tsx b/src/renderer/src/components/agent/use-agent-launch-picker-inventory.test.tsx new file mode 100644 index 00000000000..6733abb017c --- /dev/null +++ b/src/renderer/src/components/agent/use-agent-launch-picker-inventory.test.tsx @@ -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 { + 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>() + 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() + reads.push({ method, agent: params.agent, resolve: read.resolve }) + return read.promise + }) + const suspended = Promise.withResolvers() + 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({ 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() + 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() + 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 }>() + 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 } = { + value: { ...menu, detection: { ...menu.detection, detectedIds: [] } } + } + const hook = renderHook( + ({ value }: { value: Extract }) => + 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>() + 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() + 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() + 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>() + 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>() + 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>() + const oldOptional = Promise.withResolvers() + 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() + 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() + 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( + '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()) +}) diff --git a/src/renderer/src/components/agent/use-agent-launch-picker-inventory.ts b/src/renderer/src/components/agent/use-agent-launch-picker-inventory.ts new file mode 100644 index 00000000000..05d95094351 --- /dev/null +++ b/src/renderer/src/components/agent/use-agent-launch-picker-inventory.ts @@ -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 => + 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( + target, + 'agentSession.modelCatalog', + { worktree: `id:${worktreeId}`, agent: entry.agent, waitForListing: true } + ).catch(() => undefined), + support: async (entry) => { + const support = await callStructuredAgentSession<{ + seedOptions?: Record + }>(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) +} diff --git a/src/renderer/src/components/native-chat/NativeChatSessionOptionRows.tsx b/src/renderer/src/components/native-chat/NativeChatSessionOptionRows.tsx index 02f771405b3..b1243b210ee 100644 --- a/src/renderer/src/components/native-chat/NativeChatSessionOptionRows.tsx +++ b/src/renderer/src/components/native-chat/NativeChatSessionOptionRows.tsx @@ -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 ( -
+
{label} - - - permission.onSelect('default')} - > - - {followAll} · - - - - - - + + {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 buttonRef?: Ref choiceKey: string + source?: 'arguments' }): React.JSX.Element { return ( @@ -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({ - {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 } + )} ) @@ -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)} diff --git a/src/renderer/src/components/settings/AgentPermissionMenuOptions.tsx b/src/renderer/src/components/settings/AgentPermissionMenuOptions.tsx new file mode 100644 index 00000000000..74496d25032 --- /dev/null +++ b/src/renderer/src/components/settings/AgentPermissionMenuOptions.tsx @@ -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 ( + + {showInherited ? ( + <> + permission.onSelect('default')} + > + + {followAll} · + + + + + + ) : null} + {rawValue ? ( + + + + ) : null} + + + ) +} diff --git a/src/renderer/src/components/settings/SourceControlActionArguments.tsx b/src/renderer/src/components/settings/SourceControlActionArguments.tsx index 456f5d83477..c91a1f12192 100644 --- a/src/renderer/src/components/settings/SourceControlActionArguments.tsx +++ b/src/renderer/src/components/settings/SourceControlActionArguments.tsx @@ -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( diff --git a/src/renderer/src/components/settings/SourceControlActionModelPicker.tsx b/src/renderer/src/components/settings/SourceControlActionModelPicker.tsx index 316c1d635f3..45e8ab22b97 100644 --- a/src/renderer/src/components/settings/SourceControlActionModelPicker.tsx +++ b/src/renderer/src/components/settings/SourceControlActionModelPicker.tsx @@ -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 (
@@ -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') } /> diff --git a/src/renderer/src/components/settings/source-control-action-permission-draft.test.tsx b/src/renderer/src/components/settings/source-control-action-permission-draft.test.tsx index 7436ff0a614..bbfb4bbaf67 100644 --- a/src/renderer/src/components/settings/source-control-action-permission-draft.test.tsx +++ b/src/renderer/src/components/settings/source-control-action-permission-draft.test.tsx @@ -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( + + + + ) + 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() + 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 = { diff --git a/src/renderer/src/components/tab-bar/QuickLaunchButton.launch-status.test.tsx b/src/renderer/src/components/tab-bar/QuickLaunchButton.launch-status.test.tsx index 67a21fcfdad..d3f5f498b79 100644 --- a/src/renderer/src/components/tab-bar/QuickLaunchButton.launch-status.test.tsx +++ b/src/renderer/src/components/tab-bar/QuickLaunchButton.launch-status.test.tsx @@ -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') +}) diff --git a/src/renderer/src/components/tab-bar/QuickLaunchButton.tsx b/src/renderer/src/components/tab-bar/QuickLaunchButton.tsx index 9b36523c376..7fa6458efdd 100644 --- a/src/renderer/src/components/tab-bar/QuickLaunchButton.tsx +++ b/src/renderer/src/components/tab-bar/QuickLaunchButton.tsx @@ -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({ )} ) : 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 ( - - - {label} - - {chatUnavailableLabel(row.reason)} - - - ) - } - const showsDefaultAgentShortcut = - newAgentShortcut !== null && defaultAgent !== 'blank' && agent === defaultAgent - const rowContent = ( - <> - - {rowLabel(row)} - {showsDefaultAgentShortcut ? ( - {newAgentShortcut} - ) : null} - - ) - const other = otherSurfaceRow(row, detectedIds) - return ( - - {startsCliGroup ? : null} - {other ? ( - - {rowContent} - - ) : ( - runLaunch(row)} - title={rowTitle(row, label)} - > - {rowContent} + {pickerEntries ? ( + + ) : ( + 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 ( + + + {label} + + {chatUnavailableLabel(row.reason)} + - )} - - ) - })} + ) + } + const showsDefaultAgentShortcut = + newAgentShortcut !== null && defaultAgent !== 'blank' && agent === defaultAgent + const rowContent = ( + <> + + {rowLabel(row)} + {showsDefaultAgentShortcut ? ( + {newAgentShortcut} + ) : null} + + ) + const other = otherSurfaceRow(row, detectedIds) + return ( + + {startsCliGroup ? : null} + {other ? ( + + {rowContent} + + ) : ( + runLaunch(row)} + title={rowTitle(row, label)} + > + {rowContent} + + )} + + ) + }) + )} ) diff --git a/src/renderer/src/components/tab-bar/TabBarCreateEntry.search.test.tsx b/src/renderer/src/components/tab-bar/TabBarCreateEntry.search.test.tsx index 9ac3b9cb837..5db51f5edd1 100644 --- a/src/renderer/src/components/tab-bar/TabBarCreateEntry.search.test.tsx +++ b/src/renderer/src/components/tab-bar/TabBarCreateEntry.search.test.tsx @@ -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( + '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('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() + } +) diff --git a/src/renderer/src/components/tab-bar/TabBarCreateEntry.tsx b/src/renderer/src/components/tab-bar/TabBarCreateEntry.tsx index e2d4b42899a..39ccef2112e 100644 --- a/src/renderer/src/components/tab-bar/TabBarCreateEntry.tsx +++ b/src/renderer/src/components/tab-bar/TabBarCreateEntry.tsx @@ -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 || diff --git a/src/renderer/src/components/tab-bar/TabBarCreateEntryRow.tsx b/src/renderer/src/components/tab-bar/TabBarCreateEntryRow.tsx index 9ff5ad5fa8b..25c18b01ad1 100644 --- a/src/renderer/src/components/tab-bar/TabBarCreateEntryRow.tsx +++ b/src/renderer/src/components/tab-bar/TabBarCreateEntryRow.tsx @@ -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 = ( - + {onUseAgent && option.kind === 'agent' && option.option.pickerEntry ? ( + ) : null} - +
) // Only the filename-first rows hide information. Every other row already shows diff --git a/src/renderer/src/components/tab-bar/new-tab-agent-menu-model.ts b/src/renderer/src/components/tab-bar/new-tab-agent-menu-model.ts new file mode 100644 index 00000000000..bd3acd19f44 --- /dev/null +++ b/src/renderer/src/components/tab-bar/new-tab-agent-menu-model.ts @@ -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 + 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 +} diff --git a/src/renderer/src/components/tab-bar/tab-agent-launch-options.test.ts b/src/renderer/src/components/tab-bar/tab-agent-launch-options.test.ts index 7f19200f14d..5fb1ffd3c32 100644 --- a/src/renderer/src/components/tab-bar/tab-agent-launch-options.test.ts +++ b/src/renderer/src/components/tab-bar/tab-agent-launch-options.test.ts @@ -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() + } +) diff --git a/src/renderer/src/components/tab-bar/tab-agent-launch-options.ts b/src/renderer/src/components/tab-bar/tab-agent-launch-options.ts index ea6289b8ed5..557d24bb527 100644 --- a/src/renderer/src/components/tab-bar/tab-agent-launch-options.ts +++ b/src/renderer/src/components/tab-bar/tab-agent-launch-options.ts @@ -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 | 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> = {} +): 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 + } +} diff --git a/src/renderer/src/components/tab-bar/tab-bar-create-menu-contract.ts b/src/renderer/src/components/tab-bar/tab-bar-create-menu-contract.ts new file mode 100644 index 00000000000..5dc52db91c4 --- /dev/null +++ b/src/renderer/src/components/tab-bar/tab-bar-create-menu-contract.ts @@ -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 + 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 +} diff --git a/src/renderer/src/components/tab-bar/tab-bar-surface.tsx b/src/renderer/src/components/tab-bar/tab-bar-surface.tsx index e11fb801cd0..dfb6d734ced 100644 --- a/src/renderer/src/components/tab-bar/tab-bar-surface.tsx +++ b/src/renderer/src/components/tab-bar/tab-bar-surface.tsx @@ -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 ? ( <> - + ) : null}
diff --git a/src/renderer/src/components/tab-bar/tab-create-entry-props.ts b/src/renderer/src/components/tab-bar/tab-create-entry-props.ts index f0862170e0c..9dc6583dedc 100644 --- a/src/renderer/src/components/tab-bar/tab-create-entry-props.ts +++ b/src/renderer/src/components/tab-bar/tab-create-entry-props.ts @@ -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 diff --git a/src/renderer/src/components/tab-bar/use-new-tab-agent-menu-host-preferences.test.tsx b/src/renderer/src/components/tab-bar/use-new-tab-agent-menu-host-preferences.test.tsx index 1683814288f..26617cb1f8a 100644 --- a/src/renderer/src/components/tab-bar/use-new-tab-agent-menu-host-preferences.test.tsx +++ b/src/renderer/src/components/tab-bar/use-new-tab-agent-menu-host-preferences.test.tsx @@ -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) => { diff --git a/src/renderer/src/components/tab-bar/use-new-tab-agent-menu-rows.test.tsx b/src/renderer/src/components/tab-bar/use-new-tab-agent-menu-rows.test.tsx index 408e9719078..59a179dd2d8 100644 --- a/src/renderer/src/components/tab-bar/use-new-tab-agent-menu-rows.test.tsx +++ b/src/renderer/src/components/tab-bar/use-new-tab-agent-menu-rows.test.tsx @@ -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 { diff --git a/src/renderer/src/components/tab-bar/use-new-tab-agent-menu-rows.ts b/src/renderer/src/components/tab-bar/use-new-tab-agent-menu-rows.ts index f1d3b9ff3f5..018a5f07298 100644 --- a/src/renderer/src/components/tab-bar/use-new-tab-agent-menu-rows.ts +++ b/src/renderer/src/components/tab-bar/use-new-tab-agent-menu-rows.ts @@ -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 - 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, diff --git a/src/renderer/src/components/tab-bar/use-tab-bar-create-menu-controller.ts b/src/renderer/src/components/tab-bar/use-tab-bar-create-menu-controller.ts index 0537af62cbc..d3c3fd9ccd6 100644 --- a/src/renderer/src/components/tab-bar/use-tab-bar-create-menu-controller.ts +++ b/src/renderer/src/components/tab-bar/use-tab-bar-create-menu-controller.ts @@ -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 - 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() + 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(null) const pendingNewTabMenuFocusRetryRef = useRef(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, diff --git a/src/renderer/src/components/ui/command.tsx b/src/renderer/src/components/ui/command.tsx index 1fdfa34f916..b12bf91ab4f 100644 --- a/src/renderer/src/components/ui/command.tsx +++ b/src/renderer/src/components/ui/command.tsx @@ -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' } }) diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index e46ac0176a9..8c2491d95fc 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -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.", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 88598cba781..cc165e8b7f9 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -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…", diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index 0337d4b6940..044d41346dc 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -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…", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 2c33b597fb3..a0bc875d6f7 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -22056,6 +22056,14 @@ "unmarkFile": "{{path}} の閲覧済みのマークを外す" } }, + "agentPicker": { + "search": "Agent と設定を検索…", + "configure": "{{agent}} を設定", + "arguments": "引数", + "argumentsHint": "CLI での起動にのみ適用されます。", + "launchChat": "チャットを起動", + "launchTerminal": "ターミナルを起動" + }, "wslMove": { "entry": { "hostRow": "このディストリビューションからプロジェクトを移動…", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 250fe48ce5d..b4967b1d246 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -22056,6 +22056,14 @@ "unmarkFile": "{{path}}을(를) 본 것으로 표시 해제" } }, + "agentPicker": { + "search": "에이전트 및 설정 검색…", + "configure": "{{agent}} 설정", + "arguments": "인수", + "argumentsHint": "CLI 실행에만 적용됩니다.", + "launchChat": "채팅 시작", + "launchTerminal": "터미널 시작" + }, "wslMove": { "entry": { "hostRow": "이 배포판에서 프로젝트 이동…", diff --git a/src/renderer/src/i18n/locales/pt.json b/src/renderer/src/i18n/locales/pt.json index ad55bdc6d97..b6e93a8031d 100644 --- a/src/renderer/src/i18n/locales/pt.json +++ b/src/renderer/src/i18n/locales/pt.json @@ -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…", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index b62342dc93f..f6e387828fd 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -22057,6 +22057,14 @@ "unmarkFile": "取消 {{path}} 的已查看标记" } }, + "agentPicker": { + "search": "搜索智能体和配置…", + "configure": "配置 {{agent}}", + "arguments": "参数", + "argumentsHint": "仅适用于 CLI 启动。", + "launchChat": "启动聊天", + "launchTerminal": "启动终端" + }, "wslMove": { "entry": { "hostRow": "从此发行版移动项目…", diff --git a/src/renderer/src/lib/codex-model-catalog-reader-test-fixture.ts b/src/renderer/src/lib/codex-model-catalog-reader-test-fixture.ts new file mode 100644 index 00000000000..7de7eb2c6b1 --- /dev/null +++ b/src/renderer/src/lib/codex-model-catalog-reader-test-fixture.ts @@ -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, + options?: { timeoutMs?: number | null; onReply?: () => void } + ) => Promise + } + 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') diff --git a/src/renderer/src/lib/launch-agent-in-new-tab-test-fixture.ts b/src/renderer/src/lib/launch-agent-in-new-tab-test-fixture.ts new file mode 100644 index 00000000000..29b7651193f --- /dev/null +++ b/src/renderer/src/lib/launch-agent-in-new-tab-test-fixture.ts @@ -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) => { id: string }>() +export function queuedStartups() { + return mockCreateTab.mock.calls.flatMap((call) => call[3]?.pendingStartup ?? []) +} +export const mockSetActiveTabType = vi.fn() +export const mockSetTabViewMode = vi.fn() +export const mockSetTabBarOrder = vi.fn() +export const mockSetAgentStatus = vi.fn() +export const mockPasteDraftWhenAgentReady = vi.fn() +export const mockSeedNativeChatLaunchPrompt = vi.fn() +export const mockSeedNativeChatLaunchDraft = vi.fn() +export const mockMarkNativeChatLaunchPromptFailed = + vi.fn() +export const mockTrack = vi.fn() +export const mockToastMessage = vi.fn() +export const mockWaitForAgentReady = vi.fn() + +export const LEAF_ID = '11111111-1111-4111-8111-111111111111' + +const initialProjects: Pick[] = [ + { id: 'repo-1', localWindowsRuntimePreference: { kind: 'inherit-global' } } +] + +export const store = { + activeRepoId: 'repo-1', + activeWorktreeId: 'wt-1', + settings: { + agentCmdOverrides: {}, + agentDefaultArgs: {} as Record, + agentDefaultEnv: {} as Record>, + activeRuntimeEnvironmentId: null as string | null + } as { + agentCmdOverrides: Record + agentDefaultArgs: Record + agentDefaultEnv: Record> + activeRuntimeEnvironmentId: string | null + terminalWindowsShell?: string + experimentalNativeChat?: boolean + nativeChatSessionOptions?: Record< + string, + { model?: string; valuesByModel?: Record> } + > + }, + 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, + 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, + tabBarOrderByWorktree: {} as Record, + terminalLayoutsByTabId: {} as Record< + string, + { activeLeafId: string | null; ptyIdsByLeafId?: Record } + >, + ptyIdsByTabId: {} as Record, + 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 + } +})) + +export 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 +})) + +export const mockCreateWebRuntimeSessionTerminal = vi.fn() +export const mockCreateWebRuntimeAgentSessionTerminalWithLaunchDraft = + vi.fn() +export const mockIsWebRuntimeSessionActive = vi.fn(() => false) + +vi.mock('@/runtime/web-runtime-terminal-create-operation', () => ({ + createWebRuntimeSessionTerminalResult: async ( + args: Parameters[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' }) +}) diff --git a/src/renderer/src/lib/launch-agent-in-new-tab.test.ts b/src/renderer/src/lib/launch-agent-in-new-tab.test.ts index 95e0c4db4a9..f337b74a475 100644 --- a/src/renderer/src/lib/launch-agent-in-new-tab.test.ts +++ b/src/renderer/src/lib/launch-agent-in-new-tab.test.ts @@ -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[] = [ - { id: 'repo-1', localWindowsRuntimePreference: { kind: 'inherit-global' } } -] - -const store = { - activeRepoId: 'repo-1', - activeWorktreeId: 'wt-1', - settings: { - agentCmdOverrides: {}, - agentDefaultArgs: {} as Record, - agentDefaultEnv: {} as Record>, - activeRuntimeEnvironmentId: null as string | null - } as { - agentCmdOverrides: Record - agentDefaultArgs: Record - agentDefaultEnv: Record> - activeRuntimeEnvironmentId: string | null - terminalWindowsShell?: string - experimentalNativeChat?: boolean - nativeChatSessionOptions?: Record< - string, - { model?: string; valuesByModel?: Record> } - > - }, - 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, - 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, - tabBarOrderByWorktree: {} as Record, - terminalLayoutsByTabId: {} as Record< - string, - { activeLeafId: string | null; ptyIdsByLeafId?: Record } - >, - ptyIdsByTabId: {} as Record, - 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() diff --git a/src/renderer/src/lib/launch-agent-picker-contract.test.ts b/src/renderer/src/lib/launch-agent-picker-contract.test.ts new file mode 100644 index 00000000000..9f483ba6fb5 --- /dev/null +++ b/src/renderer/src/lib/launch-agent-picker-contract.test.ts @@ -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()), + 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 => ['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 = + 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>() + 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) +}) diff --git a/src/renderer/src/lib/launch-agent-structured-chat-guard.test.ts b/src/renderer/src/lib/launch-agent-structured-chat-guard.test.ts index 7e424dc33b0..5466b89131e 100644 --- a/src/renderer/src/lib/launch-agent-structured-chat-guard.test.ts +++ b/src/renderer/src/lib/launch-agent-structured-chat-guard.test.ts @@ -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) => ReturnType - >() -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 = {} - -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> } - > - | 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, - tabBarOrderByWorktree: {} as Record, - 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('@/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('./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(`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 = [ diff --git a/src/renderer/src/lib/launch-agent-structured-chat-test-fixture.ts b/src/renderer/src/lib/launch-agent-structured-chat-test-fixture.ts new file mode 100644 index 00000000000..9beec2551a3 --- /dev/null +++ b/src/renderer/src/lib/launch-agent-structured-chat-test-fixture.ts @@ -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) => { id: string }>() +export const mockCreateUnifiedTab = vi.fn< + ( + worktreeId: string, + contentType: string, + init: Pick & { + targetGroupId?: string + } + ) => UnifiedTabFixture +>() +const mockSetTabViewMode = vi.fn() +export const mockWaitForAgentReady = vi.fn() +export const mockPasteDraftWhenAgentReady = vi.fn() +const mockMarkNativeChatLaunchPromptFailed = vi.fn() +export const mockCreateStructuredCodexSessionLaunchIntent = + vi.fn< + (...args: Parameters) => Omit, 'agent'> + >() +const mockAbandonStructuredAgentSessionLaunchIntent = vi.fn() +export const mockRetryStructuredAgentSessionLaunchIntent = + vi.fn< + (intent: ReturnType) => ReturnType + >() +export const mockLaunchStructuredCodexSession = vi.fn() +type TabSnapshotFixture = { worktree: string; tabs: { type: 'agent-session'; sessionId: string }[] } +export const mockRefreshLocalStructuredSessionTabs = + vi.fn<(...args: Parameters) => Promise>() +export const mockToastError = vi.fn() +export const mockCallStructuredAgentSession = + vi.fn<(target: unknown, method: string, params: unknown) => Promise>() +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 = {} +type StructuredLaunchFixtureSettings = { + agentCmdOverrides: Record + agentDefaultArgs: Record + agentDefaultEnv: Record> + activeRuntimeEnvironmentId: string | null + experimentalNativeChat: boolean + nativeChatProviders: Record + nativeChatSessionOptions?: Record< + string, + { model?: string; valuesByModel?: Record> } + > +} +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(), + 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, + tabBarOrderByWorktree: {} as Record, + 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('@/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('@/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 +} diff --git a/src/renderer/src/lib/new-tab-agent-menu-rows.ts b/src/renderer/src/lib/new-tab-agent-menu-rows.ts index 8b4e6a11cde..4fb1ddfe0b4 100644 --- a/src/renderer/src/lib/new-tab-agent-menu-rows.ts +++ b/src/renderer/src/lib/new-tab-agent-menu-rows.ts @@ -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> - -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() - 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() - 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 } : {}) - })) - ] -} diff --git a/src/shared/agent-choice-args.ts b/src/shared/agent-choice-args.ts index 41f9bf44bc9..f53dc676c24 100644 --- a/src/shared/agent-choice-args.ts +++ b/src/shared/agent-choice-args.ts @@ -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() + const opaqueKeys = new Set() + const effectiveSettings = new Map< + (typeof forms)[number]['form'], + ReturnType[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 } } diff --git a/src/shared/agent-choice-cli-apply.ts b/src/shared/agent-choice-cli-apply.ts index de85daab807..a94ea1c2f5b 100644 --- a/src/shared/agent-choice-cli-apply.ts +++ b/src/shared/agent-choice-cli-apply.ts @@ -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 @@ -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] ) } diff --git a/src/shared/agent-choice-reconciliation.ts b/src/shared/agent-choice-reconciliation.ts index 76297d55be0..c4771066471 100644 --- a/src/shared/agent-choice-reconciliation.ts +++ b/src/shared/agent-choice-reconciliation.ts @@ -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 } diff --git a/src/shared/agent-choice-settings.ts b/src/shared/agent-choice-settings.ts new file mode 100644 index 00000000000..51f999f8356 --- /dev/null +++ b/src/shared/agent-choice-settings.ts @@ -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 | 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[] { + 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] +} diff --git a/src/shared/agent-launch-choices.test.ts b/src/shared/agent-launch-choices.test.ts index 84714cbf870..d23285c02b9 100644 --- a/src/shared/agent-launch-choices.test.ts +++ b/src/shared/agent-launch-choices.test.ts @@ -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) + } +}) diff --git a/src/shared/agent-launch-choices.ts b/src/shared/agent-launch-choices.ts index b0e7eb69fe5..b20b9db0b57 100644 --- a/src/shared/agent-launch-choices.ts +++ b/src/shared/agent-launch-choices.ts @@ -15,6 +15,7 @@ export type AgentLaunchChoices = Partial + 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 +} diff --git a/src/shared/agent-launch-picker-authority.ts b/src/shared/agent-launch-picker-authority.ts new file mode 100644 index 00000000000..94bbde4cdda --- /dev/null +++ b/src/shared/agent-launch-picker-authority.ts @@ -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 +} diff --git a/src/shared/agent-launch-picker-catalog.ts b/src/shared/agent-launch-picker-catalog.ts new file mode 100644 index 00000000000..1ee8e8bba8b --- /dev/null +++ b/src/shared/agent-launch-picker-catalog.ts @@ -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 : [] + ) + })) +} diff --git a/src/shared/agent-launch-picker-defaults.ts b/src/shared/agent-launch-picker-defaults.ts new file mode 100644 index 00000000000..52a5dcc9bbb --- /dev/null +++ b/src/shared/agent-launch-picker-defaults.ts @@ -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 { + 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 + } +} diff --git a/src/shared/agent-launch-picker-legacy-permission.test.ts b/src/shared/agent-launch-picker-legacy-permission.test.ts new file mode 100644 index 00000000000..4afab85202c --- /dev/null +++ b/src/shared/agent-launch-picker-legacy-permission.test.ts @@ -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']) +}) diff --git a/src/shared/agent-launch-picker-permission.ts b/src/shared/agent-launch-picker-permission.ts new file mode 100644 index 00000000000..57beb96129d --- /dev/null +++ b/src/shared/agent-launch-picker-permission.ts @@ -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 } : {}) } } +} diff --git a/src/shared/agent-launch-picker-rows.ts b/src/shared/agent-launch-picker-rows.ts new file mode 100644 index 00000000000..8936165c363 --- /dev/null +++ b/src/shared/agent-launch-picker-rows.ts @@ -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 | 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> + +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() + 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() + 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 } : {}) + })) + ] +} diff --git a/src/shared/agent-launch-picker-search.ts b/src/shared/agent-launch-picker-search.ts new file mode 100644 index 00000000000..77ec30c0708 --- /dev/null +++ b/src/shared/agent-launch-picker-search.ts @@ -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) +} diff --git a/src/shared/agent-launch-picker-speed.ts b/src/shared/agent-launch-picker-speed.ts new file mode 100644 index 00000000000..66efc29ab6d --- /dev/null +++ b/src/shared/agent-launch-picker-speed.ts @@ -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 } : {}) + } + : {}) + } +} diff --git a/src/shared/agent-launch-picker.test.ts b/src/shared/agent-launch-picker.test.ts new file mode 100644 index 00000000000..0c7e6e7b484 --- /dev/null +++ b/src/shared/agent-launch-picker.test.ts @@ -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([]) +}) diff --git a/src/shared/agent-launch-picker.ts b/src/shared/agent-launch-picker.ts new file mode 100644 index 00000000000..ef6dd250e39 --- /dev/null +++ b/src/shared/agent-launch-picker.ts @@ -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 { + 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, + 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 + } +} diff --git a/src/shared/agent-launch-profile.ts b/src/shared/agent-launch-profile.ts index fc56db38c51..6f8111d362f 100644 --- a/src/shared/agent-launch-profile.ts +++ b/src/shared/agent-launch-profile.ts @@ -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') { diff --git a/src/shared/agent-permission-args.ts b/src/shared/agent-permission-args.ts index f618c765411..18711f3ac8b 100644 --- a/src/shared/agent-permission-args.ts +++ b/src/shared/agent-permission-args.ts @@ -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> = { 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() + const switches = new Set( + agent === 'claude' || agent === 'claude-agent-teams' ? CLAUDE_SWITCHES : [] + ) for (const { token, next } of recipes) { if (!token.startsWith('-')) { continue diff --git a/src/shared/agent-session-option-catalog-claude-codex.ts b/src/shared/agent-session-option-catalog-claude-codex.ts index 797e96f117b..2b64123e20e 100644 --- a/src/shared/agent-session-option-catalog-claude-codex.ts +++ b/src/shared/agent-session-option-catalog-claude-codex.ts @@ -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] } diff --git a/src/shared/agent-session-option-catalog-types.ts b/src/shared/agent-session-option-catalog-types.ts index b6634d6dd4d..fb32a8583c6 100644 --- a/src/shared/agent-session-option-catalog-types.ts +++ b/src/shared/agent-session-option-catalog-types.ts @@ -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 diff --git a/src/shared/agent-session-option-launch.ts b/src/shared/agent-session-option-launch.ts index 8d42c4b81f4..cb0f493342f 100644 --- a/src/shared/agent-session-option-launch.ts +++ b/src/shared/agent-session-option-launch.ts @@ -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 } -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 | 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]] + : [] }) ) } diff --git a/src/shared/fixtures/agent-picker-cli-recordings.json b/src/shared/fixtures/agent-picker-cli-recordings.json new file mode 100644 index 00000000000..157c1fd4f40 --- /dev/null +++ b/src/shared/fixtures/agent-picker-cli-recordings.json @@ -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" + } + } +} diff --git a/src/shared/source-control-action-launch-preferences.ts b/src/shared/source-control-action-launch-preferences.ts index 9321a84764b..63584e48919 100644 --- a/src/shared/source-control-action-launch-preferences.ts +++ b/src/shared/source-control-action-launch-preferences.ts @@ -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 }] diff --git a/src/shared/tui-agent-launch-command-pick-flags.test.ts b/src/shared/tui-agent-launch-command-pick-flags.test.ts index e157be3ef34..bf7879a9fcf 100644 --- a/src/shared/tui-agent-launch-command-pick-flags.test.ts +++ b/src/shared/tui-agent-launch-command-pick-flags.test.ts @@ -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') }) +}) diff --git a/src/shared/tui-agent-launch-command.ts b/src/shared/tui-agent-launch-command.ts index f9d1b5be56e..2439c97de28 100644 --- a/src/shared/tui-agent-launch-command.ts +++ b/src/shared/tui-agent-launch-command.ts @@ -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 } : {}) },