From 969ce3130793ab755d2e9ade5cc3028424474395 Mon Sep 17 00:00:00 2001 From: "[._.]/ Adam Eivy" Date: Sun, 6 Sep 2026 05:49:50 +0000 Subject: [PATCH 1/2] feat: unify CLI and TUI provider cards with shared enablement --- .../components/ProviderModelSelector.test.jsx | 15 ++++++ .../src/components/providers/ProviderCard.jsx | 47 +++++++++++++++---- client/src/pages/AIProviders.jsx | 11 ++++- client/src/pages/AIProviders.test.jsx | 22 +++++++++ .../migrations/355-unify-provider-modes.js | 20 ++++++++ .../355-unify-provider-modes.test.js | 34 ++++++++++++++ .../lib/aiToolkit/internal/providerModes.js | 41 ++++++++++++++++ server/lib/aiToolkit/providers.js | 30 ++++++++---- server/lib/aiToolkit/providers.test.js | 23 +++++++++ server/routes/providers.js | 4 ++ server/routes/providers.prerequisites.test.js | 12 +++++ 11 files changed, 239 insertions(+), 20 deletions(-) create mode 100644 scripts/migrations/355-unify-provider-modes.js create mode 100644 scripts/migrations/355-unify-provider-modes.test.js create mode 100644 server/lib/aiToolkit/internal/providerModes.js diff --git a/client/src/components/ProviderModelSelector.test.jsx b/client/src/components/ProviderModelSelector.test.jsx index b7fa1e2e18..6ff4c0e1be 100644 --- a/client/src/components/ProviderModelSelector.test.jsx +++ b/client/src/components/ProviderModelSelector.test.jsx @@ -36,6 +36,21 @@ describe('ProviderModelSelector', () => { expect(options).toEqual(['Provider One', 'Provider Two', 'm1', 'm2']); }); + it('keeps both execution modes independently selectable when the settings page groups their card', () => { + const onProviderChange = vi.fn(); + const executionModes = [{ id: 'example-cli', type: 'cli' }, { id: 'example-tui', type: 'tui' }]; + renderSelector({ providers: [ + { id: 'example-cli', name: 'Example CLI', type: 'cli', enabled: true, executionModes }, + { id: 'example-tui', name: 'Example TUI', type: 'tui', enabled: true, executionModes }, + ], selectedProviderId: 'example-cli', onProviderChange }); + const select = screen.getByRole('combobox', { name: 'Provider' }); + expect([...select.options].map(option => [option.value, option.textContent])).toEqual([ + ['example-cli', 'Example CLI'], ['example-tui', 'Example TUI'], + ]); + fireEvent.change(select, { target: { value: 'example-tui' } }); + expect(onProviderChange).toHaveBeenCalledWith('example-tui'); + }); + it('renders every current Codex fallback choice, including Codex Spark', () => { const codexModels = SHIPPED_PROVIDERS.providers.codex.models; expect(codexModels).toContain('gpt-5.3-codex-spark'); diff --git a/client/src/components/providers/ProviderCard.jsx b/client/src/components/providers/ProviderCard.jsx index 3484ddd9f2..b77cc3e321 100644 --- a/client/src/components/providers/ProviderCard.jsx +++ b/client/src/components/providers/ProviderCard.jsx @@ -90,6 +90,8 @@ export default function ProviderCard({ status, isDefault, providersById, + activeProviderId, + statuses = {}, runnerAllowedCommands, testResult, refreshing, @@ -118,6 +120,9 @@ export default function ProviderCard({ onCodexCopyCode, onCodexEnable, }) { + const modes = (provider.executionModes || []).map(mode => providersById?.[mode.id]).filter(Boolean); + const unified = modes.length > 1; + const shellProvider = unified ? modes.find(isTuiProvider) : provider; const style = CARD_STATE_STYLES[cardState.state]; // Non-blocking: it never touches `cardState`, only what the card SAYS about // where this provider's runs actually go. @@ -153,13 +158,13 @@ export default function ProviderCard({ to split, and it is narrower than the viewport by the sidebar. */}
-

{provider.name}

+

{unified ? provider.name.replace(/\b(CLI|TUI)\b\s*/i, '').trim() : provider.name}

- {provider.type.toUpperCase()} + {unified ? 'CLI / TUI' : provider.type.toUpperCase()} {isDefault && ( - DEFAULT + DEFAULT{unified ? ` · ${provider.type.toUpperCase()}` : ''} )} {fleetProvider && ( @@ -249,11 +254,11 @@ export default function ProviderCard({ are secret, so they can't ride a URL anyway. `tuiCommandLine` is the display half of the same resolution: it shows what will run, and an older server that omits it simply renders no button. */} - {isLaunchableTuiProvider(provider) && ( + {isLaunchableTuiProvider(shellProvider) && ( Launch in Shell @@ -291,7 +296,7 @@ export default function ProviderCard({ {provider.enabled ? 'Disable' : 'Enable'} - {!isDefault && provider.enabled && ( + {!unified && !isDefault && provider.enabled && ( )} - + )} + + + ))} + {!unified && + } +

))}
)} diff --git a/client/src/pages/AIProviders.test.jsx b/client/src/pages/AIProviders.test.jsx index 11025f9866..77b171512e 100644 --- a/client/src/pages/AIProviders.test.jsx +++ b/client/src/pages/AIProviders.test.jsx @@ -144,6 +144,19 @@ describe('AIProviders page load error handling', () => { await waitFor(() => expect(api.setActiveProvider).toHaveBeenLastCalledWith('example')); }); + it('gates each unified Codex default on that mode’s own transport consent', async () => { + const executionModes = [{ id: 'codex', type: 'cli' }, { id: 'codex-tui', type: 'tui' }]; + api.getCodexAccount.mockResolvedValue({ readiness: { status: 'ready' } }); + api.getCodexModels.mockResolvedValue({ models: null }); + api.getProviders.mockResolvedValue({ activeProvider: null, providers: [ + { id: 'codex', name: 'Codex CLI', type: 'cli', command: 'codex', enabled: true, textTransportEnabled: true, executionModes }, + { id: 'codex-tui', name: 'Codex TUI', type: 'tui', command: 'codex', enabled: true, textTransportEnabled: false, executionModes }, + ] }); + renderPage(); + await waitFor(() => expect(screen.getByRole('button', { name: 'Set CLI default' })).toBeEnabled()); + expect(screen.getByRole('button', { name: 'Set TUI default' })).toBeDisabled(); + }); + it('offers an install button on the card of a provider whose CLI is missing', async () => { api.getProviders.mockResolvedValue({ providers: [{ id: 'opencode-ollama', name: 'OpenCode Ollama', type: 'cli', command: 'opencode', args: ['run'], enabled: true }], diff --git a/server/lib/aiToolkit/internal/providerModes.js b/server/lib/aiToolkit/internal/providerModes.js index c6737408bf..5ec32f1586 100644 --- a/server/lib/aiToolkit/internal/providerModes.js +++ b/server/lib/aiToolkit/internal/providerModes.js @@ -19,9 +19,16 @@ export function providerModeGroups(providers) { return [...groups, ...providers.filter(provider => !paired.has(provider.id)).map(provider => [provider])]; } -export function sharedModeUpdates(updates) { +export function sharedModeUpdates(updates, sibling) { // Arguments, timeouts, routing consent and model pins remain mode-specific. - return Object.fromEntries(['enabled', 'models', 'modelContextWindows'].filter(key => Object.hasOwn(updates, key)).map(key => [key, updates[key]])); + const shared = Object.fromEntries(['enabled', 'models', 'modelContextWindows'].filter(key => Object.hasOwn(updates, key)).map(key => [key, updates[key]])); + // A caller deliberately repicking a default with a new catalog (the editor + // or harness discovery) must repair a removed sibling default too. Ordinary + // catalog probes omit defaultModel and retain their existing pin semantics. + if (Array.isArray(updates.models) && Object.hasOwn(updates, 'defaultModel') && sibling?.defaultModel && !updates.models.includes(sibling.defaultModel)) { + shared.defaultModel = updates.models[0] ?? null; + } + return shared; } export function unifyProviderModes(data) { diff --git a/server/lib/aiToolkit/providers.js b/server/lib/aiToolkit/providers.js index d5852c18a3..ebf3136215 100644 --- a/server/lib/aiToolkit/providers.js +++ b/server/lib/aiToolkit/providers.js @@ -740,7 +740,7 @@ export function createProviderService(config = {}) { const group = providerModeGroups(Object.values(data.providers)).find(modes => modes.some(mode => mode.id === id)); data.providers[id] = provider; for (const sibling of group || []) { - if (sibling.id !== id) Object.assign(sibling, sharedModeUpdates(updates)); + if (sibling.id !== id) Object.assign(sibling, sharedModeUpdates(updates, sibling)); } await saveProviders(data); return provider; diff --git a/server/lib/aiToolkit/providers.test.js b/server/lib/aiToolkit/providers.test.js index bc9b1f4f08..d100f8d3b9 100644 --- a/server/lib/aiToolkit/providers.test.js +++ b/server/lib/aiToolkit/providers.test.js @@ -32,7 +32,7 @@ describe('Provider Service', () => { expect((await providerService.getProviderById('example')).enabled).toBe(true); expect((await providerService.getActiveProvider()).id).toBe('example-tui'); await providerService.updateProvider('example-tui', { enabled: false, models: ['c'], args: ['--interactive'], defaultModel: 'c' }); - expect(await providerService.getProviderById('example')).toMatchObject({ enabled: false, models: ['c'], args: ['--print'], defaultModel: 'a' }); + expect(await providerService.getProviderById('example')).toMatchObject({ enabled: false, models: ['c'], args: ['--print'], defaultModel: 'c' }); expect(await providerService.getProviderById('remote')).toMatchObject({ enabled: false, models: ['remote'] }); const catalog = vi.spyOn(providerService, 'fetchProviderModelCatalog').mockResolvedValue({ models: ['fresh'], contextWindows: { fresh: 8192 } }); await providerService.refreshProviderModelsBatch(['example-tui']);