diff --git a/mobile/app/h/[hostId]/tasks.tsx b/mobile/app/h/[hostId]/tasks.tsx index c35800c29..7785435a4 100644 --- a/mobile/app/h/[hostId]/tasks.tsx +++ b/mobile/app/h/[hostId]/tasks.tsx @@ -56,6 +56,8 @@ import { import { buildGitHubCheckSummary } from '../../../src/tasks/github-check-summary' import { buildTaskWorkspaceCreateParams } from '../../../src/tasks/workspace-create-params' import { + filterWorkspaceAgents, + isWorkspaceAgentEnabled, pickWorkspaceAgent, workspaceAgentLabel, type WorkspaceAgentChoice @@ -421,6 +423,7 @@ type TaskResumeState = { } type RuntimeTaskSettings = { defaultTuiAgent?: TuiAgent | 'blank' | null + disabledTuiAgents?: TuiAgent[] agentCmdOverrides?: Record defaultTaskSource?: TaskProvider defaultTaskViewPreset?: GitHubPreset | 'all' @@ -4514,15 +4517,18 @@ export default function MobileTasksScreen() { workspaceSparseDraftParsed !== null const workspaceAgentOptions = useMemo[]>(() => { + const enabledAgents = filterWorkspaceAgents( + MOBILE_TUI_AGENT_AUTO_PICK_ORDER, + runtimeTaskSettings.disabledTuiAgents + ) const availableAgents = workspaceDetectedAgentIds === null - ? new Set(MOBILE_TUI_AGENT_AUTO_PICK_ORDER) - : new Set( - MOBILE_TUI_AGENT_AUTO_PICK_ORDER.filter((agent) => workspaceDetectedAgentIds.has(agent)) - ) + ? new Set(enabledAgents) + : new Set(enabledAgents.filter((agent) => workspaceDetectedAgentIds.has(agent))) if ( workspaceAgent && workspaceAgent !== 'blank' && + isWorkspaceAgentEnabled(workspaceAgent, runtimeTaskSettings.disabledTuiAgents) && (workspaceDetectedAgentIds === null || workspaceDetectedAgentIds.has(workspaceAgent)) ) { availableAgents.add(workspaceAgent) @@ -4542,7 +4548,7 @@ export default function MobileTasksScreen() { renderIcon: () => } ] - }, [workspaceAgent, workspaceDetectedAgentIds]) + }, [runtimeTaskSettings.disabledTuiAgents, workspaceAgent, workspaceDetectedAgentIds]) const openWorkspaceCreate = useCallback((item: ActionableTaskItem, repoIdOverride?: string) => { const suggestedName = taskWorkspaceSuggestedName(item) @@ -5008,7 +5014,8 @@ export default function MobileTasksScreen() { workspaceDetectedAgentIds === null || !workspaceAgent || workspaceAgent === 'blank' || - workspaceDetectedAgentIds.has(workspaceAgent) + (workspaceDetectedAgentIds.has(workspaceAgent) && + isWorkspaceAgentEnabled(workspaceAgent, runtimeTaskSettings.disabledTuiAgents)) ) { return } @@ -5022,7 +5029,8 @@ export default function MobileTasksScreen() { tasksSupported, workspaceAgent, workspaceCreateDraft, - workspaceDetectedAgentIds + workspaceDetectedAgentIds, + runtimeTaskSettings.disabledTuiAgents ]) const resolvedWorkspaceAgent = useMemo( @@ -5101,6 +5109,33 @@ export default function MobileTasksScreen() { ) } await ensureWorkspaceSshReady(targetRepo) + let latestRuntimeTaskSettings = runtimeTaskSettings + try { + const settingsResponse = await client.sendRequest('settings.get') + if (isSuccess(settingsResponse)) { + latestRuntimeTaskSettings = (( + settingsResponse.result as { settings?: RuntimeTaskSettings } + ).settings ?? {}) as RuntimeTaskSettings + setRuntimeTaskSettings(latestRuntimeTaskSettings) + } + } catch { + // Best-effort refresh; the runtime still validates agent availability before spawning. + } + const selectedAgent = + agentOverride && + (agentOverride === 'blank' || + isWorkspaceAgentEnabled(agentOverride, latestRuntimeTaskSettings.disabledTuiAgents)) + ? agentOverride + : pickWorkspaceAgent(latestRuntimeTaskSettings, workspaceDetectedAgentIds) + if ( + agentOverride && + agentOverride !== 'blank' && + !isWorkspaceAgentEnabled(agentOverride, latestRuntimeTaskSettings.disabledTuiAgents) + ) { + setWorkspaceAgent(selectedAgent) + setWorkspaceAgentOverridden(false) + throw new Error('Selected agent is disabled. Choose an enabled agent before creating.') + } const setupResolution = await resolveCreateSetupDecision(targetRepo, setupOverride) const comment = noteOverride?.trim() if (setupResolution.kind === 'prompt') { @@ -5154,7 +5189,6 @@ export default function MobileTasksScreen() { }) return } - const selectedAgent = agentOverride let params: Record if (item.provider === 'github') { const source = item.source @@ -5291,9 +5325,11 @@ export default function MobileTasksScreen() { hostId, resolveCreateSetupDecision, router, + runtimeTaskSettings, taskStateHydrated, tasksSupported, - trustedOrcaHooks + trustedOrcaHooks, + workspaceDetectedAgentIds ] ) diff --git a/mobile/src/components/NewWorktreeModal.tsx b/mobile/src/components/NewWorktreeModal.tsx index e52062b2b..365de5316 100644 --- a/mobile/src/components/NewWorktreeModal.tsx +++ b/mobile/src/components/NewWorktreeModal.tsx @@ -26,8 +26,13 @@ import { wasSetupHookPreviouslyApproved, type SetupHookTrust } from '../tasks/setup-hook-trust' -import { isMobileTuiAgent, MOBILE_TUI_AGENT_LAUNCH_COMMANDS } from '../tasks/mobile-tui-agents' -import type { PersistedTrustedOrcaHooks } from '../../../src/shared/types' +import { + filterEnabledMobileTuiAgents, + isMobileTuiAgent, + isMobileTuiAgentEnabled, + MOBILE_TUI_AGENT_LAUNCH_COMMANDS +} from '../tasks/mobile-tui-agents' +import type { PersistedTrustedOrcaHooks, TuiAgent } from '../../../src/shared/types' import type { SshConnectionState } from '../../../src/shared/ssh-types' type Repo = { @@ -41,7 +46,8 @@ type Repo = { type SetupDecision = 'inherit' | 'run' | 'skip' type SetupRunPolicy = 'ask' | 'run-by-default' | 'skip-by-default' type RuntimeSettings = { - defaultTuiAgent?: string | 'blank' | null + defaultTuiAgent?: TuiAgent | 'blank' | null + disabledTuiAgents?: TuiAgent[] agentCmdOverrides?: Record } @@ -66,7 +72,7 @@ type SetupTrustPrompt = { } type AgentOption = { - id: string + id: TuiAgent | '__blank__' label: string faviconDomain?: string } @@ -89,10 +95,23 @@ function pickPreferredAgent( if (preferred?.id === '__blank__') { return preferred } - if (preferred && (detectedAgentIds === null || detectedAgentIds.has(preferred.id))) + if ( + preferred && + isMobileTuiAgent(preferred.id) && + isMobileTuiAgentEnabled(preferred.id, settings?.disabledTuiAgents) && + (detectedAgentIds === null || detectedAgentIds.has(preferred.id)) + ) { return preferred + } + const enabledAgents = filterEnabledMobileTuiAgents( + MOBILE_AGENT_CATALOG.map((agent) => agent.id), + settings?.disabledTuiAgents + ) const detectedOption = AGENT_OPTIONS.find( - (agent) => detectedAgentIds === null || detectedAgentIds.has(agent.id) + (agent) => + agent.id !== '__blank__' && + enabledAgents.includes(agent.id) && + (detectedAgentIds === null || detectedAgentIds.has(agent.id)) ) return detectedOption ?? BLANK_TERMINAL } @@ -371,7 +390,12 @@ export function NewWorktreeModal({ useEffect(() => { if (!visible || detectedAgentIds === null || selectedAgent.id === '__blank__') return - if (detectedAgentIds.has(selectedAgent.id)) return + if ( + detectedAgentIds.has(selectedAgent.id) && + isMobileTuiAgentEnabled(selectedAgent.id, runtimeSettings?.disabledTuiAgents) + ) { + return + } setSelectedAgent(pickPreferredAgent(runtimeSettings, detectedAgentIds)) setAgentOverridden(false) }, [detectedAgentIds, runtimeSettings, selectedAgent.id, visible]) @@ -487,9 +511,30 @@ export function NewWorktreeModal({ setError(`Connect ${selectedRepo.displayName} before creating a workspace.`) return } + let latestRuntimeSettings = runtimeSettings + try { + const settingsResponse = await client.sendRequest('settings.get') + if (settingsResponse.ok) { + const result = (settingsResponse as RpcSuccess).result as { settings: RuntimeSettings } + latestRuntimeSettings = result.settings + setRuntimeSettings(result.settings) + } + } catch { + // Best-effort refresh; the runtime validates the same setting before spawning. + } + if ( + selectedAgent.id !== '__blank__' && + !isMobileTuiAgentEnabled(selectedAgent.id, latestRuntimeSettings?.disabledTuiAgents) + ) { + setSelectedAgent(pickPreferredAgent(latestRuntimeSettings, detectedAgentIds)) + setAgentOverridden(false) + setError('Selected agent is disabled. Choose an enabled agent before creating.') + return + } + const command = selectedAgent.id !== '__blank__' - ? (runtimeSettings?.agentCmdOverrides?.[selectedAgent.id] ?? + ? (latestRuntimeSettings?.agentCmdOverrides?.[selectedAgent.id] ?? (isMobileTuiAgent(selectedAgent.id) ? MOBILE_TUI_AGENT_LAUNCH_COMMANDS[selectedAgent.id] : undefined)) @@ -598,8 +643,17 @@ export function NewWorktreeModal({ (!needsSetupChoice || setupDecisionChoice != null) const visibleAgentOptions = detectedAgentIds === null - ? AGENT_OPTIONS - : AGENT_OPTIONS.filter((agent) => detectedAgentIds.has(agent.id)) + ? AGENT_OPTIONS.filter( + (agent) => + agent.id !== '__blank__' && + isMobileTuiAgentEnabled(agent.id, runtimeSettings?.disabledTuiAgents) + ) + : AGENT_OPTIONS.filter( + (agent) => + agent.id !== '__blank__' && + detectedAgentIds.has(agent.id) && + isMobileTuiAgentEnabled(agent.id, runtimeSettings?.disabledTuiAgents) + ) const pickerAgentOptions = [...visibleAgentOptions, BLANK_TERMINAL] return ( diff --git a/mobile/src/tasks/mobile-tui-agents.ts b/mobile/src/tasks/mobile-tui-agents.ts index 079720004..d16342bec 100644 --- a/mobile/src/tasks/mobile-tui-agents.ts +++ b/mobile/src/tasks/mobile-tui-agents.ts @@ -131,19 +131,46 @@ export function isMobileTuiAgent(value: unknown): value is TuiAgent { return MOBILE_TUI_AGENT_AUTO_PICK_ORDER.includes(value as TuiAgent) } +export function normalizeDisabledMobileTuiAgents(value: unknown): TuiAgent[] { + if (!Array.isArray(value)) { + return [] + } + const seen = new Set() + for (const item of value) { + if (isMobileTuiAgent(item)) { + seen.add(item) + } + } + return [...seen] +} + +export function isMobileTuiAgentEnabled(agent: TuiAgent, disabled?: unknown): boolean { + return !normalizeDisabledMobileTuiAgents(disabled).includes(agent) +} + +export function filterEnabledMobileTuiAgents( + agents: Iterable, + disabled?: unknown +): T[] { + const disabledSet = new Set(normalizeDisabledMobileTuiAgents(disabled)) + return [...agents].filter((agent) => !disabledSet.has(agent)) +} + export function pickMobileTuiAgent( preferred: TuiAgent | 'blank' | null | undefined, - detected: Iterable + detected: Iterable, + disabled?: unknown ): TuiAgent | null { if (preferred === 'blank') { return null } + const disabledSet = new Set(normalizeDisabledMobileTuiAgents(disabled)) const detectedSet = detected instanceof Set ? detected : new Set(detected) - if (preferred && detectedSet.has(preferred)) { + if (preferred && detectedSet.has(preferred) && !disabledSet.has(preferred)) { return preferred } for (const agent of MOBILE_TUI_AGENT_AUTO_PICK_ORDER) { - if (detectedSet.has(agent)) { + if (detectedSet.has(agent) && !disabledSet.has(agent)) { return agent } } diff --git a/mobile/src/tasks/workspace-agent-selection.test.ts b/mobile/src/tasks/workspace-agent-selection.test.ts index 50558986d..816988faa 100644 --- a/mobile/src/tasks/workspace-agent-selection.test.ts +++ b/mobile/src/tasks/workspace-agent-selection.test.ts @@ -17,6 +17,21 @@ describe('workspace agent selection', () => { expect(pickWorkspaceAgent({ defaultTuiAgent: 'codex' }, new Set(['claude']))).toBe('claude') }) + it('skips disabled preferred and fallback agents', () => { + expect( + pickWorkspaceAgent( + { defaultTuiAgent: 'codex', disabledTuiAgents: ['codex'] }, + new Set(['claude', 'codex']) + ) + ).toBe('claude') + expect( + pickWorkspaceAgent( + { defaultTuiAgent: null, disabledTuiAgents: ['claude', 'codex', 'not-real'] }, + new Set(['claude', 'codex']) + ) + ).toBe('blank') + }) + it('honors blank terminal as an explicit no-agent preference', () => { expect(pickWorkspaceAgent({ defaultTuiAgent: 'blank' }, new Set(['claude', 'codex']))).toBe( 'blank' @@ -30,6 +45,9 @@ describe('workspace agent selection', () => { it('uses the preferred/default display value while detection is still pending', () => { expect(pickWorkspaceAgent({ defaultTuiAgent: 'codex' }, null)).toBe('codex') expect(pickWorkspaceAgent({ defaultTuiAgent: null }, null)).toBe('claude') + expect( + pickWorkspaceAgent({ defaultTuiAgent: 'codex', disabledTuiAgents: ['codex'] }, null) + ).toBe('claude') }) it('normalizes legacy blank sentinel and labels known choices', () => { diff --git a/mobile/src/tasks/workspace-agent-selection.ts b/mobile/src/tasks/workspace-agent-selection.ts index 9be68b9b7..6d2bd3846 100644 --- a/mobile/src/tasks/workspace-agent-selection.ts +++ b/mobile/src/tasks/workspace-agent-selection.ts @@ -1,6 +1,8 @@ import type { TuiAgent } from '../../../src/shared/types' import { + filterEnabledMobileTuiAgents, isMobileTuiAgent, + isMobileTuiAgentEnabled, MOBILE_TUI_AGENT_AUTO_PICK_ORDER, MOBILE_TUI_AGENT_LABELS, pickMobileTuiAgent @@ -8,6 +10,11 @@ import { export type WorkspaceAgentChoice = TuiAgent | 'blank' +type WorkspaceAgentSettings = { + defaultTuiAgent?: TuiAgent | 'blank' | null + disabledTuiAgents?: unknown +} + export function workspaceAgentLabel(agent: WorkspaceAgentChoice): string { return agent === 'blank' ? 'Blank Terminal' : MOBILE_TUI_AGENT_LABELS[agent] } @@ -20,18 +27,31 @@ export function normalizeWorkspaceAgent(value: unknown): WorkspaceAgentChoice | } export function pickWorkspaceAgent( - settings: { defaultTuiAgent?: TuiAgent | 'blank' | null }, + settings: WorkspaceAgentSettings, detectedAgentIds: Set | null ): WorkspaceAgentChoice { const preferred = normalizeWorkspaceAgent(settings.defaultTuiAgent) if (preferred === 'blank') { return preferred } - if (detectedAgentIds === null) { - return preferred ?? MOBILE_TUI_AGENT_AUTO_PICK_ORDER[0] ?? 'blank' - } - const detectedAgents = MOBILE_TUI_AGENT_AUTO_PICK_ORDER.filter((agent) => - detectedAgentIds.has(agent) + const disabled = settings.disabledTuiAgents + const enabledAutoPickOrder = filterEnabledMobileTuiAgents( + MOBILE_TUI_AGENT_AUTO_PICK_ORDER, + disabled ) - return pickMobileTuiAgent(preferred, detectedAgents) ?? 'blank' + if (detectedAgentIds === null) { + return preferred && isMobileTuiAgentEnabled(preferred, disabled) + ? preferred + : (enabledAutoPickOrder[0] ?? 'blank') + } + const detectedAgents = enabledAutoPickOrder.filter((agent) => detectedAgentIds.has(agent)) + return pickMobileTuiAgent(preferred, detectedAgents, disabled) ?? 'blank' +} + +export function filterWorkspaceAgents(agents: readonly TuiAgent[], disabled?: unknown): TuiAgent[] { + return filterEnabledMobileTuiAgents(agents, disabled) +} + +export function isWorkspaceAgentEnabled(agent: TuiAgent, disabled?: unknown): boolean { + return isMobileTuiAgentEnabled(agent, disabled) } diff --git a/src/main/codex-accounts/runtime-home-service.test.ts b/src/main/codex-accounts/runtime-home-service.test.ts index 6e00232ce..4f2ac9dd0 100644 --- a/src/main/codex-accounts/runtime-home-service.test.ts +++ b/src/main/codex-accounts/runtime-home-service.test.ts @@ -101,6 +101,7 @@ function createSettings(overrides: Partial = {}): GlobalSettings activeClaudeManagedAccountId: null, terminalScopeHistoryByWorktree: true, defaultTuiAgent: null, + disabledTuiAgents: [], skipDeleteWorktreeConfirm: false, skipDeleteAutomationConfirm: false, defaultTaskViewPreset: 'all', diff --git a/src/main/codex-accounts/service.test.ts b/src/main/codex-accounts/service.test.ts index e94241981..771c4422b 100644 --- a/src/main/codex-accounts/service.test.ts +++ b/src/main/codex-accounts/service.test.ts @@ -88,6 +88,7 @@ function createSettings(overrides: Partial = {}): GlobalSettings activeClaudeManagedAccountId: null, terminalScopeHistoryByWorktree: true, defaultTuiAgent: null, + disabledTuiAgents: [], skipDeleteWorktreeConfirm: false, skipDeleteAutomationConfirm: false, defaultTaskViewPreset: 'all', diff --git a/src/main/index.ts b/src/main/index.ts index 5b2b654bb..1a8b89080 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1170,11 +1170,7 @@ app.whenReady().then(async () => { // Why: these appearance settings are default-on for older profiles, so // a missing persisted value must toggle from visible -> hidden. const next = getNextDefaultOnAppearanceSettingValue(current[key]) - store.updateSettings({ [key]: next }) - // Why: settings:get returns the current snapshot; renderer tracks - // settings through window.api.settings.get(). Push the new value so - // the sidebar/titlebar re-render without waiting for a round-trip. - mainWindow?.webContents.send('settings:changed', { [key]: next }) + store.updateSettings({ [key]: next }, { notifyListeners: true }) rebuildAppMenu() }, getAppearanceState: () => { diff --git a/src/main/ipc/settings.test.ts b/src/main/ipc/settings.test.ts index 77f31b655..657283b4c 100644 --- a/src/main/ipc/settings.test.ts +++ b/src/main/ipc/settings.test.ts @@ -1,11 +1,13 @@ import { describe, expect, it, vi, beforeEach } from 'vitest' -const { handleMock, previewGhosttyImportMock } = vi.hoisted(() => ({ +const { browserWindowGetAllWindowsMock, handleMock, previewGhosttyImportMock } = vi.hoisted(() => ({ + browserWindowGetAllWindowsMock: vi.fn(), handleMock: vi.fn(), previewGhosttyImportMock: vi.fn() })) vi.mock('electron', () => ({ + BrowserWindow: { getAllWindows: browserWindowGetAllWindowsMock }, ipcMain: { handle: handleMock }, nativeTheme: { themeSource: 'system' } })) @@ -16,19 +18,29 @@ vi.mock('../ghostty/index', () => ({ import { registerSettingsHandlers } from './settings' +const settingsInvokeEvent = { sender: { id: 1 } } +type SettingsChangedListener = ( + updates: unknown, + settings: unknown, + originWebContentsId?: number +) => void + const store = { getSettings: vi.fn(), updateSettings: vi.fn(), getGitHubCache: vi.fn(), - setGitHubCache: vi.fn() + setGitHubCache: vi.fn(), + onSettingsChanged: vi.fn(() => () => {}) } describe('registerSettingsHandlers', () => { beforeEach(() => { handleMock.mockClear() previewGhosttyImportMock.mockClear() + browserWindowGetAllWindowsMock.mockReset() store.getSettings.mockReset() store.updateSettings.mockReset() + store.onSettingsChanged.mockClear() }) it('registers settings:previewGhosttyImport handler', () => { @@ -51,6 +63,48 @@ describe('registerSettingsHandlers', () => { expect(previewGhosttyImportMock).toHaveBeenCalledWith(store) }) + it('broadcasts store-level settings changes to open windows', () => { + const send = vi.fn() + browserWindowGetAllWindowsMock.mockReturnValue([ + { isDestroyed: () => false, webContents: { send } }, + { isDestroyed: () => true, webContents: { send: vi.fn() } } + ]) + registerSettingsHandlers(store as never) + + const onSettingsChanged = store.onSettingsChanged as unknown as { + mock: { calls: [SettingsChangedListener][] } + } + const listener = onSettingsChanged.mock.calls[0]?.[0] + if (!listener) { + throw new Error('settings change listener was not registered') + } + listener({ defaultTuiAgent: 'codex' }, { defaultTuiAgent: 'codex' }) + + expect(send).toHaveBeenCalledWith('settings:changed', { defaultTuiAgent: 'codex' }) + }) + + it('does not rebroadcast renderer settings writes to the origin window', () => { + const originSend = vi.fn() + const otherSend = vi.fn() + browserWindowGetAllWindowsMock.mockReturnValue([ + { isDestroyed: () => false, webContents: { id: 1, send: originSend } }, + { isDestroyed: () => false, webContents: { id: 2, send: otherSend } } + ]) + registerSettingsHandlers(store as never) + + const onSettingsChanged = store.onSettingsChanged as unknown as { + mock: { calls: [SettingsChangedListener][] } + } + const listener = onSettingsChanged.mock.calls[0]?.[0] + if (!listener) { + throw new Error('settings change listener was not registered') + } + listener({ defaultTuiAgent: 'codex' }, { defaultTuiAgent: 'codex' }, 1) + + expect(originSend).not.toHaveBeenCalled() + expect(otherSend).toHaveBeenCalledWith('settings:changed', { defaultTuiAgent: 'codex' }) + }) + it('updates the agent awake service when the keep-awake setting changes', () => { const agentAwakeService = { setEnabled: vi.fn() } store.getSettings.mockReturnValue({ keepComputerAwakeWhileAgentsRun: false }) @@ -62,7 +116,7 @@ describe('registerSettingsHandlers', () => { args: unknown ) => unknown - handler(null, { keepComputerAwakeWhileAgentsRun: true }) + handler(settingsInvokeEvent, { keepComputerAwakeWhileAgentsRun: true }) expect(agentAwakeService.setEnabled).toHaveBeenCalledWith(true) }) @@ -78,7 +132,7 @@ describe('registerSettingsHandlers', () => { args: unknown ) => unknown - handler(null, { defaultTuiAgent: 'codex' }) + handler(settingsInvokeEvent, { defaultTuiAgent: 'codex' }) expect(agentAwakeService.setEnabled).not.toHaveBeenCalled() }) @@ -93,8 +147,11 @@ describe('registerSettingsHandlers', () => { args: unknown ) => Promise - await handler(null, { floatingTerminalTrustedCwds: ['/tmp/notes'] }) + await handler(settingsInvokeEvent, { floatingTerminalTrustedCwds: ['/tmp/notes'] }) - expect(store.updateSettings).toHaveBeenCalledWith({}) + expect(store.updateSettings).toHaveBeenCalledWith( + {}, + { notifyListeners: true, originWebContentsId: 1 } + ) }) }) diff --git a/src/main/ipc/settings.ts b/src/main/ipc/settings.ts index 209a1e2c1..bbedaec54 100644 --- a/src/main/ipc/settings.ts +++ b/src/main/ipc/settings.ts @@ -1,4 +1,4 @@ -import { ipcMain, nativeTheme } from 'electron' +import { BrowserWindow, ipcMain, nativeTheme } from 'electron' import type { Store } from '../persistence' import type { GlobalSettings, PersistedState } from '../../shared/types' import { listSystemFontFamilies } from '../system-fonts' @@ -29,11 +29,21 @@ export function registerSettingsHandlers( store: Store, agentAwakeService?: AgentAwakeService ): void { + store.onSettingsChanged((updates, _settings, originWebContentsId) => { + for (const window of BrowserWindow.getAllWindows()) { + const isOrigin = + originWebContentsId !== undefined && window.webContents.id === originWebContentsId + if (!window.isDestroyed() && !isOrigin) { + window.webContents.send('settings:changed', updates) + } + } + }) + ipcMain.handle('settings:get', () => { return store.getSettings() }) - ipcMain.handle('settings:set', async (_event, args: Partial) => { + ipcMain.handle('settings:set', async (event, args: Partial) => { const sanitizedArgs = { ...args } // Why: Floating Workspace grants are trusted only when written by the // main-process directory picker, never by renderer-provided settings IPC. @@ -52,7 +62,10 @@ export function registerSettingsHandlers( // (e.g. blur after a no-op edit), and a `settings_changed` event for a // no-op flip would inflate the experimental-feature-adoption signal. const before = store.getSettings() - const result = store.updateSettings(sanitizedArgs) + const result = store.updateSettings(sanitizedArgs, { + notifyListeners: true, + originWebContentsId: event.sender.id + }) if ('keepComputerAwakeWhileAgentsRun' in sanitizedArgs) { agentAwakeService?.setEnabled(result.keepComputerAwakeWhileAgentsRun) } diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts index d5bf1b615..9dde81a48 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -1806,6 +1806,71 @@ describe('Store', () => { expect(updated.branchPrefix).toBe('git-username') }) + it('notifies settings listeners with changed keys only', async () => { + const store = await createStore() + const listener = vi.fn() + store.onSettingsChanged(listener) + + store.updateSettings( + { + theme: 'dark', + disabledTuiAgents: ['codex', 'not-real', 'codex'] as never + }, + { notifyListeners: true, originWebContentsId: 42 } + ) + + expect(listener).toHaveBeenCalledWith( + { + theme: 'dark', + disabledTuiAgents: ['codex'] + }, + expect.objectContaining({ + theme: 'dark', + disabledTuiAgents: ['codex'] + }), + 42 + ) + }) + + it('does not notify settings listeners for unchanged scalar updates', async () => { + const store = await createStore() + const listener = vi.fn() + store.onSettingsChanged(listener) + + store.updateSettings({ theme: store.getSettings().theme }, { notifyListeners: true }) + + expect(listener).not.toHaveBeenCalled() + }) + + it('does not notify settings listeners unless requested by the producer', async () => { + const store = await createStore() + const listener = vi.fn() + store.onSettingsChanged(listener) + + store.updateSettings({ theme: 'dark' }) + + expect(listener).not.toHaveBeenCalled() + }) + + it('normalizes disabled TUI agents on load and update', async () => { + writeFileSync( + join(testState.dir, 'orca-data.json'), + JSON.stringify({ + settings: { + disabledTuiAgents: ['codex', 'not-real', 'codex', 'claude'] + } + }) + ) + const store = await createStore() + + expect(store.getSettings().disabledTuiAgents).toEqual(['codex', 'claude']) + + const updated = store.updateSettings({ + disabledTuiAgents: ['gemini', 'not-real', 'gemini', 'opencode'] as never + }) + expect(updated.disabledTuiAgents).toEqual(['gemini', 'opencode']) + }) + it('updateSettings keeps the legacy commit-message AI projection in sync', async () => { const store = await createStore() const current = store.getSettings().sourceControlAi! diff --git a/src/main/persistence.ts b/src/main/persistence.ts index 947cbeab4..2a1cf94ec 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -115,6 +115,7 @@ import { projectSourceControlAiToLegacyCommitMessageAi, sourceControlAiSettingsFromLegacy } from '../shared/source-control-ai' +import { normalizeDisabledTuiAgents } from '../shared/tui-agent-selection' function encrypt(plaintext: string): string { if (!plaintext || !safeStorage.isEncryptionAvailable()) { @@ -1297,6 +1298,13 @@ export class Store { private writeGeneration = 0 private gitUsernameCache = new Map() private loadNeedsSave = false + private settingsChangeListeners = new Set< + ( + updates: Partial, + settings: GlobalSettings, + originWebContentsId?: number + ) => void + >() constructor() { const loaded = this.load() @@ -1602,6 +1610,7 @@ export class Store { terminalShortcutPolicy: normalizeTerminalShortcutPolicy( parsed.settings?.terminalShortcutPolicy ), + disabledTuiAgents: normalizeDisabledTuiAgents(parsed.settings?.disabledTuiAgents), openInApplications: normalizeOpenInApplications(parsed.settings?.openInApplications), notifications: normalizeNotificationSettings(parsed.settings?.notifications), sourceControlAi: migratedSourceControlAi, @@ -2659,8 +2668,36 @@ export class Store { return this.state.settings } - updateSettings(updates: Partial): GlobalSettings { + onSettingsChanged( + listener: ( + updates: Partial, + settings: GlobalSettings, + originWebContentsId?: number + ) => void + ): () => void { + this.settingsChangeListeners.add(listener) + return () => { + this.settingsChangeListeners.delete(listener) + } + } + + private notifySettingsChanged( + updates: Partial, + originWebContentsId?: number + ): void { + for (const listener of this.settingsChangeListeners) { + listener(updates, this.state.settings, originWebContentsId) + } + } + + updateSettings( + updates: Partial, + options: { notifyListeners?: boolean; originWebContentsId?: number } = {} + ): GlobalSettings { const sanitizedUpdates = { ...updates } + if ('disabledTuiAgents' in updates) { + sanitizedUpdates.disabledTuiAgents = normalizeDisabledTuiAgents(updates.disabledTuiAgents) + } if ('terminalQuickCommands' in updates) { sanitizedUpdates.terminalQuickCommands = normalizeTerminalQuickCommands( updates.terminalQuickCommands @@ -2721,6 +2758,7 @@ export class Store { sanitizedUpdates.commitMessageAi ) } + const previousSettings = this.state.settings this.state.settings = { ...this.state.settings, ...sanitizedUpdates, @@ -2731,6 +2769,15 @@ export class Store { ...(mergedTelemetry !== undefined ? { telemetry: mergedTelemetry } : {}) } this.scheduleSave() + const changedUpdates = {} as Partial & Record + for (const key of Object.keys(sanitizedUpdates) as (keyof GlobalSettings)[]) { + if (!Object.is(previousSettings[key], this.state.settings[key])) { + changedUpdates[String(key)] = this.state.settings[key] + } + } + if (options.notifyListeners === true && Object.keys(changedUpdates).length > 0) { + this.notifySettingsChanged(changedUpdates, options.originWebContentsId) + } return this.state.settings } diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 55b45ce2f..59766abf5 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -7162,6 +7162,109 @@ describe('OrcaRuntimeService', () => { }) }) + it('rejects explicit startup commands for disabled selected agents', async () => { + const runtimeStore = { + ...store, + getSettings: () => ({ + ...store.getSettings(), + disabledTuiAgents: ['codex' as const] + }) + } + const runtime = new OrcaRuntimeService(runtimeStore as never) + const spawn = vi.fn().mockResolvedValue({ id: 'pty-disabled-startup' }) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + + await expect( + runtime.createManagedWorktree({ + repoSelector: TEST_REPO_ID, + name: 'disabled-startup', + startup: { command: 'codex' }, + createdWithAgent: 'codex' + }) + ).rejects.toThrow('Selected agent is disabled. Choose an enabled agent before creating.') + + expect(spawn).not.toHaveBeenCalled() + expect(addWorktree).not.toHaveBeenCalled() + }) + + it('records the resolved fallback agent when the requested startup draft agent is disabled', async () => { + detectInstalledAgentsMock.mockResolvedValue(['claude']) + const metaById: Record = {} + const runtimeStore = { + ...store, + getSettings: () => ({ + ...store.getSettings(), + defaultTuiAgent: 'codex' as const, + disabledTuiAgents: ['codex' as const], + agentCmdOverrides: {} + }), + getAllWorktreeMeta: () => metaById, + getWorktreeMeta: (worktreeId: string) => metaById[worktreeId], + setWorktreeMeta: (worktreeId: string, meta: Partial) => { + metaById[worktreeId] = { ...(metaById[worktreeId] ?? makeWorktreeMeta()), ...meta } + return metaById[worktreeId] + } + } + const runtime = new OrcaRuntimeService(runtimeStore as never) + const spawn = vi.fn().mockResolvedValue({ id: 'pty-fallback-draft' }) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + runtime.setNotifier({ + worktreesChanged: vi.fn(), + reposChanged: vi.fn(), + activateWorktree: vi.fn(), + createTerminal: vi.fn(), + revealTerminalSession: vi.fn().mockResolvedValue({ tabId: 'tab-fallback-draft' }), + splitTerminal: vi.fn(), + renameTerminal: vi.fn(), + focusTerminal: vi.fn(), + closeTerminal: vi.fn(), + sleepWorktree: vi.fn(), + terminalFitOverrideChanged: vi.fn(), + terminalDriverChanged: vi.fn() + }) + runtime.attachWindow(1) + + computeWorktreePathMock.mockReturnValue('/tmp/workspaces/runtime-fallback-draft') + ensurePathWithinWorkspaceMock.mockReturnValue('/tmp/workspaces/runtime-fallback-draft') + vi.mocked(listWorktrees).mockResolvedValue([ + { + path: '/tmp/workspaces/runtime-fallback-draft', + head: 'def', + branch: 'runtime-fallback-draft', + isBare: false, + isMainWorktree: false + } + ]) + + const result = await runtime.createManagedWorktree({ + repoSelector: TEST_REPO_ID, + name: 'runtime-fallback-draft', + startupDraft: 'https://github.com/stablyai/orca/issues/456', + createdWithAgent: 'codex', + activate: true + }) + + expect(detectInstalledAgentsMock).toHaveBeenCalled() + expect(spawn).toHaveBeenCalledWith( + expect.objectContaining({ + cwd: '/tmp/workspaces/runtime-fallback-draft', + command: expect.stringContaining('claude'), + worktreeId: result.worktree.id + }) + ) + expect(metaById[result.worktree.id]).toMatchObject({ createdWithAgent: 'claude' }) + }) + it('honors split setup placement for local startup-draft worktrees', async () => { const metaById: Record = {} const runtimeStore = { diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index dba34876f..0ee4e78bc 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -66,7 +66,7 @@ import { FIRST_PANE_ID } from '../../shared/pane-key' import { isTerminalLeafId, makePaneKey, parsePaneKey } from '../../shared/stable-pane-id' import { isValidHostTerminalTabId } from '../../shared/terminal-tab-id' import { buildAgentDraftLaunchPlan, buildAgentStartupPlan } from '../../shared/tui-agent-startup' -import { pickTuiAgent } from '../../shared/tui-agent-selection' +import { isTuiAgentEnabled, pickTuiAgent } from '../../shared/tui-agent-selection' import { TUI_AGENT_CONFIG, isTuiAgent } from '../../shared/tui-agent-config' import { detectInstalledAgents, detectRemoteAgents } from '../ipc/preflight' import { @@ -455,6 +455,7 @@ type RuntimeStore = { branchPrefix: string branchPrefixCustom: string defaultTuiAgent?: GlobalSettings['defaultTuiAgent'] + disabledTuiAgents?: GlobalSettings['disabledTuiAgents'] agentCmdOverrides?: GlobalSettings['agentCmdOverrides'] agentStatusHooksEnabled?: GlobalSettings['agentStatusHooksEnabled'] defaultTaskSource?: GlobalSettings['defaultTaskSource'] @@ -471,7 +472,10 @@ type RuntimeStore = { // Why: narrow to `unknown` return so test mocks can return void without // a cast. The runtime never reads the return value — the persisted value // is read back via getSettings() on the next access. - updateSettings?: (updates: Partial) => unknown + updateSettings?: ( + updates: Partial, + options?: { notifyListeners?: boolean; originWebContentsId?: number } + ) => unknown } export type RuntimeAutomationCreateInput = Omit< @@ -1342,6 +1346,7 @@ export class OrcaRuntimeService { getClientSettings(): Pick< GlobalSettings, | 'defaultTuiAgent' + | 'disabledTuiAgents' | 'agentCmdOverrides' | 'agentStatusHooksEnabled' | 'defaultTaskSource' @@ -1357,6 +1362,7 @@ export class OrcaRuntimeService { const settings = this.store.getSettings() return { defaultTuiAgent: settings.defaultTuiAgent ?? null, + disabledTuiAgents: settings.disabledTuiAgents ?? [], agentCmdOverrides: settings.agentCmdOverrides ?? {}, agentStatusHooksEnabled: settings.agentStatusHooksEnabled !== false, defaultTaskSource: settings.defaultTaskSource ?? 'github', @@ -1372,6 +1378,8 @@ export class OrcaRuntimeService { updates: Pick< Partial, | 'agentStatusHooksEnabled' + | 'defaultTuiAgent' + | 'disabledTuiAgents' | 'defaultTaskSource' | 'defaultTaskViewPreset' | 'defaultRepoSelection' @@ -1381,6 +1389,7 @@ export class OrcaRuntimeService { ): Pick< GlobalSettings, | 'defaultTuiAgent' + | 'disabledTuiAgents' | 'agentCmdOverrides' | 'agentStatusHooksEnabled' | 'defaultTaskSource' @@ -1394,7 +1403,7 @@ export class OrcaRuntimeService { throw new Error('runtime_unavailable') } const before = this.store.getSettings().agentStatusHooksEnabled !== false - this.store.updateSettings(updates) + this.store.updateSettings(updates, { notifyListeners: true }) if ( typeof updates.agentStatusHooksEnabled === 'boolean' && before !== updates.agentStatusHooksEnabled @@ -3693,7 +3702,7 @@ export class OrcaRuntimeService { MOBILE_AUTO_RESTORE_FIT_MAX_MS ) } - this.store.updateSettings({ mobileAutoRestoreFitMs: normalized }) + this.store.updateSettings({ mobileAutoRestoreFitMs: normalized }, { notifyListeners: true }) if (normalized == null) { this.cancelAllPendingFitRestoreTimers() } @@ -7028,7 +7037,10 @@ export class OrcaRuntimeService { // workspace, so linked task drafts must not auto-pick a detected agent. return null } - let agent = isTuiAgent(preferredAgent) ? preferredAgent : null + let agent = + isTuiAgent(preferredAgent) && isTuiAgentEnabled(preferredAgent, settings.disabledTuiAgents) + ? preferredAgent + : null if (!agent) { let detected: string[] = [] try { @@ -7039,7 +7051,7 @@ export class OrcaRuntimeService { detected = [] } const typedDetected = detected.filter(isTuiAgent) - agent = pickTuiAgent(null, typedDetected) + agent = pickTuiAgent(null, typedDetected, settings.disabledTuiAgents) } if (!agent) { return null @@ -7370,15 +7382,32 @@ export class OrcaRuntimeService { } const repo = await this.resolveRepoSelector(args.repoSelector) + const createSettings = this.store.getSettings() + const requestedAgentEnabled = + args.createdWithAgent !== undefined + ? isTuiAgentEnabled(args.createdWithAgent, createSettings.disabledTuiAgents) + : false + if (args.startup && args.createdWithAgent && !requestedAgentEnabled) { + throw new Error('Selected agent is disabled. Choose an enabled agent before creating.') + } + if ( + args.startup && + args.startupDraftPaste && + !isTuiAgentEnabled(args.startupDraftPaste.agent, createSettings.disabledTuiAgents) + ) { + throw new Error('Selected agent is disabled. Choose an enabled agent before creating.') + } const draftStartup = args.startupDraft ? await this.buildStartupForDraft(repo, args.startupDraft, args.createdWithAgent) : null const effectiveStartup = args.startup ?? draftStartup?.startup - const effectiveCreatedWithAgent = args.createdWithAgent ?? draftStartup?.agent + const effectiveCreatedWithAgent = args.startup + ? args.createdWithAgent + : (draftStartup?.agent ?? (requestedAgentEnabled ? args.createdWithAgent : undefined)) const effectiveDraftPaste = args.startupDraftPaste ?? draftStartup?.draftPaste if (isFolderRepo(repo)) { const now = Date.now() - const settings = this.store.getSettings() + const settings = createSettings const instanceId = randomUUID() const worktreeId = getRuntimeFolderWorkspaceInstanceId(repo, instanceId) const meta = this.store.setWorktreeMeta(worktreeId, { @@ -7477,7 +7506,7 @@ export class OrcaRuntimeService { const lineageInput = args.lineage || args.comment ? { ...args.lineage, comment: args.comment } : undefined const lineageResolution = await this.resolveLineageForWorktreeCreate(lineageInput) - const settings = this.store.getSettings() + const settings = createSettings const requestedName = args.name const requestedDisplayName = args.displayName?.trim() || undefined const sanitizedName = sanitizeWorktreeName(args.name) diff --git a/src/main/runtime/rpc/methods/client-ui.test.ts b/src/main/runtime/rpc/methods/client-ui.test.ts index 48162cd93..d2d89488d 100644 --- a/src/main/runtime/rpc/methods/client-ui.test.ts +++ b/src/main/runtime/rpc/methods/client-ui.test.ts @@ -14,6 +14,7 @@ describe('client UI RPC methods', () => { it('returns the runtime host agent settings needed by mobile create flows', async () => { const settings = { defaultTuiAgent: 'codex', + disabledTuiAgents: ['claude'], agentCmdOverrides: { codex: 'codex --profile work' }, defaultTaskSource: 'gitlab', defaultTaskViewPreset: 'my-prs', @@ -42,6 +43,7 @@ describe('client UI RPC methods', () => { it('persists the runtime host task source setting for mobile Tasks', async () => { const settings = { defaultTuiAgent: null, + disabledTuiAgents: ['claude'], agentCmdOverrides: {}, defaultTaskSource: 'linear', defaultTaskViewPreset: 'issues', @@ -66,6 +68,7 @@ describe('client UI RPC methods', () => { const response = await dispatcher.dispatch( makeRequest('settings.update', { defaultTuiAgent: 'codex', + disabledTuiAgents: ['claude', 'not-real', 'claude'], defaultTaskSource: 'linear', defaultTaskViewPreset: 'my-prs', defaultRepoSelection: settings.defaultRepoSelection, @@ -76,6 +79,7 @@ describe('client UI RPC methods', () => { expect(runtime.updateClientSettings).toHaveBeenCalledWith({ defaultTuiAgent: 'codex', + disabledTuiAgents: ['claude'], defaultTaskSource: 'linear', defaultTaskViewPreset: 'my-prs', defaultRepoSelection: settings.defaultRepoSelection, diff --git a/src/main/runtime/rpc/methods/client-ui.ts b/src/main/runtime/rpc/methods/client-ui.ts index 2bfd86097..0cc3b22a7 100644 --- a/src/main/runtime/rpc/methods/client-ui.ts +++ b/src/main/runtime/rpc/methods/client-ui.ts @@ -5,6 +5,7 @@ import { } from '../../../../shared/feature-interactions' import { isFeatureTipId } from '../../../../shared/feature-tips' import { isTuiAgent } from '../../../../shared/tui-agent-config' +import { normalizeDisabledTuiAgents } from '../../../../shared/tui-agent-selection' import type { PersistedUIState } from '../../../../shared/types' import { defineMethod, type RpcMethod } from '../core' @@ -104,6 +105,10 @@ const SettingsUpdate = z value === null || value === 'blank' || isTuiAgent(value) ? value : undefined ) .optional(), + disabledTuiAgents: z + .unknown() + .transform((value) => normalizeDisabledTuiAgents(value)) + .optional(), defaultTaskSource: z.enum(['github', 'gitlab', 'linear']).optional(), defaultTaskViewPreset: z .enum(['issues', 'my-issues', 'prs', 'my-prs', 'review', 'all']) diff --git a/src/renderer/src/components/GitHubItemDialog.tsx b/src/renderer/src/components/GitHubItemDialog.tsx index fc43d92cd..0df1667fd 100644 --- a/src/renderer/src/components/GitHubItemDialog.tsx +++ b/src/renderer/src/components/GitHubItemDialog.tsx @@ -108,6 +108,7 @@ import { normalizeGitHubReviewerLogins } from '@/components/github-pr-reviewer-display' import { AGENT_CATALOG } from '@/lib/agent-catalog' +import { filterEnabledTuiAgents } from '../../../shared/tui-agent-selection' import { getConnectionId } from '@/lib/connection-context' import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface' import { launchAgentInNewTab } from '@/lib/launch-agent-in-new-tab' @@ -3247,12 +3248,14 @@ function findWorkspaceAttachedToPR( function pickDefaultAgent( defaultAgent: TuiAgent | 'blank' | null | undefined, - detectedAgents: TuiAgent[] + detectedAgents: TuiAgent[], + disabledAgents?: TuiAgent[] ): TuiAgent | null { - if (defaultAgent && defaultAgent !== 'blank' && detectedAgents.includes(defaultAgent)) { + const enabledAgents = filterEnabledTuiAgents(detectedAgents, disabledAgents) + if (defaultAgent && defaultAgent !== 'blank' && enabledAgents.includes(defaultAgent)) { return defaultAgent } - return AGENT_CATALOG.find((entry) => detectedAgents.includes(entry.id))?.id ?? null + return AGENT_CATALOG.find((entry) => enabledAgents.includes(entry.id))?.id ?? null } type CheckDetailsLoadState = { @@ -3445,9 +3448,13 @@ function ChecksTab({ typeof connectionId === 'string' ? await activeStore.ensureRemoteDetectedAgents(connectionId) : await activeStore.ensureDetectedAgents() - const agent = pickDefaultAgent(activeStore.settings?.defaultTuiAgent, detectedAgents) + const agent = pickDefaultAgent( + activeStore.settings?.defaultTuiAgent, + detectedAgents, + activeStore.settings?.disabledTuiAgents + ) if (!agent) { - toast.error('No AI agents detected. Configure a default agent in Settings.') + toast.error('No enabled AI agents. Configure agents in Settings.') return } diff --git a/src/renderer/src/components/NewWorkspaceComposerCard.tsx b/src/renderer/src/components/NewWorkspaceComposerCard.tsx index d0403ba91..2149c95b2 100644 --- a/src/renderer/src/components/NewWorkspaceComposerCard.tsx +++ b/src/renderer/src/components/NewWorkspaceComposerCard.tsx @@ -20,6 +20,7 @@ import { useAppStore } from '@/store' import { cn } from '@/lib/utils' import { WORKSPACE_FILE_PATH_MIME } from '@/lib/workspace-file-drag' import { getScreenSubmitModifierLabel } from '@/lib/screen-submit-shortcut' +import { filterEnabledTuiAgents } from '../../../shared/tui-agent-selection' import type { GitHubWorkItem, GitLabWorkItem, @@ -249,6 +250,7 @@ export default function NewWorkspaceComposerCard({ const { isFileDragOver, dragHandlers } = useComposerFileDragOver() const openModal = useAppStore((s) => s.openModal) const defaultTuiAgent = useAppStore((s) => s.settings?.defaultTuiAgent ?? null) + const disabledTuiAgents = useAppStore((s) => s.settings?.disabledTuiAgents ?? []) const updateSettings = useAppStore((s) => s.updateSettings) const submitShortcutModifierLabel = getScreenSubmitModifierLabel() const selectedRepoName = React.useMemo(() => { @@ -279,11 +281,18 @@ export default function NewWorkspaceComposerCard({ }) }, [nameInputRef]) - const visibleQuickAgents = React.useMemo( - () => - AGENT_CATALOG.filter((agent) => detectedAgentIds === null || detectedAgentIds.has(agent.id)), - [detectedAgentIds] - ) + const visibleQuickAgents = React.useMemo(() => { + const enabledIds = new Set( + filterEnabledTuiAgents( + AGENT_CATALOG.map((agent) => agent.id), + disabledTuiAgents + ) + ) + return AGENT_CATALOG.filter( + (agent) => + enabledIds.has(agent.id) && (detectedAgentIds === null || detectedAgentIds.has(agent.id)) + ) + }, [detectedAgentIds, disabledTuiAgents]) const handleAddRepo = React.useCallback((): void => { openModal('add-repo') diff --git a/src/renderer/src/components/NewWorkspaceComposerModal.tsx b/src/renderer/src/components/NewWorkspaceComposerModal.tsx index 02bf3bd0b..ef98d2c09 100644 --- a/src/renderer/src/components/NewWorkspaceComposerModal.tsx +++ b/src/renderer/src/components/NewWorkspaceComposerModal.tsx @@ -4,7 +4,8 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/u import NewWorkspaceComposerCard from '@/components/NewWorkspaceComposerCard' import AgentSettingsDialog from '@/components/agent/AgentSettingsDialog' import { useComposerState } from '@/hooks/useComposerState' -import { AGENT_CATALOG } from '@/lib/agent-catalog' +import { isTuiAgentEnabled } from '../../../shared/tui-agent-selection' +import { pickQuickWorkspaceAgent } from '@/lib/quick-workspace-agent-selection' import type { LinkedWorkItemSummary } from '@/lib/new-workspace' import { shouldAllowComposerEnterSubmitTarget } from '@/lib/new-workspace-enter-guard' import { isScreenSubmitShortcut } from '@/lib/screen-submit-shortcut' @@ -131,19 +132,29 @@ function QuickTabBody({ ) const preferredQuickAgent = useMemo(() => { const pref = settings?.defaultTuiAgent - if (pref === 'blank') { - // Why: 'blank' is the explicit "no agent" preference — the quick agent - // model already uses null to mean "blank terminal", so translate here. - return null - } - if (pref) { - return pref - } - const detected = cardProps.detectedAgentIds - return AGENT_CATALOG.find((agent) => detected === null || detected.has(agent.id))?.id ?? null - }, [cardProps.detectedAgentIds, settings?.defaultTuiAgent]) + // Why: detection can still be pending when quick-create submits; keep the + // prior catalog fallback while filtering disabled agents out of that choice. + return pickQuickWorkspaceAgent(pref, cardProps.detectedAgentIds, settings?.disabledTuiAgents) + }, [cardProps.detectedAgentIds, settings?.defaultTuiAgent, settings?.disabledTuiAgents]) const quickAgent = quickAgentOverride === undefined ? preferredQuickAgent : quickAgentOverride + useEffect(() => { + if ( + quickAgentOverride === undefined || + quickAgentOverride === null || + (isTuiAgentEnabled(quickAgentOverride, settings?.disabledTuiAgents) && + (cardProps.detectedAgentIds === null || cardProps.detectedAgentIds.has(quickAgentOverride))) + ) { + return + } + setQuickAgentOverride(preferredQuickAgent) + }, [ + cardProps.detectedAgentIds, + preferredQuickAgent, + quickAgentOverride, + settings?.disabledTuiAgents + ]) + const handleQuickAgentChange = useCallback((agent: TuiAgent | null) => { setQuickAgentOverride(agent) }, []) diff --git a/src/renderer/src/components/PullRequestPage.tsx b/src/renderer/src/components/PullRequestPage.tsx index 08f454c4f..06bcb5700 100644 --- a/src/renderer/src/components/PullRequestPage.tsx +++ b/src/renderer/src/components/PullRequestPage.tsx @@ -109,6 +109,7 @@ import { normalizeGitHubReviewerLogins } from '@/components/github-pr-reviewer-display' import { AGENT_CATALOG } from '@/lib/agent-catalog' +import { filterEnabledTuiAgents } from '../../../shared/tui-agent-selection' import { getConnectionId } from '@/lib/connection-context' import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface' import { @@ -3363,12 +3364,14 @@ function buildFixBrokenChecksPrompt(item: GitHubWorkItem, checks: PRCheckDetail[ function pickDefaultAgent( defaultAgent: TuiAgent | 'blank' | null | undefined, - detectedAgents: TuiAgent[] + detectedAgents: TuiAgent[], + disabledAgents?: TuiAgent[] ): TuiAgent | null { - if (defaultAgent && defaultAgent !== 'blank' && detectedAgents.includes(defaultAgent)) { + const enabledAgents = filterEnabledTuiAgents(detectedAgents, disabledAgents) + if (defaultAgent && defaultAgent !== 'blank' && enabledAgents.includes(defaultAgent)) { return defaultAgent } - return AGENT_CATALOG.find((entry) => detectedAgents.includes(entry.id))?.id ?? null + return AGENT_CATALOG.find((entry) => enabledAgents.includes(entry.id))?.id ?? null } type CheckDetailsLoadState = { @@ -3561,9 +3564,13 @@ function ChecksTab({ typeof connectionId === 'string' ? await activeStore.ensureRemoteDetectedAgents(connectionId) : await activeStore.ensureDetectedAgents() - const agent = pickDefaultAgent(activeStore.settings?.defaultTuiAgent, detectedAgents) + const agent = pickDefaultAgent( + activeStore.settings?.defaultTuiAgent, + detectedAgents, + activeStore.settings?.disabledTuiAgents + ) if (!agent) { - toast.error('No AI agents detected. Configure a default agent in Settings.') + toast.error('No enabled AI agents. Configure agents in Settings.') return } diff --git a/src/renderer/src/components/automations/AutomationEditorDialog.tsx b/src/renderer/src/components/automations/AutomationEditorDialog.tsx index 6599ef1f9..dad6b95a7 100644 --- a/src/renderer/src/components/automations/AutomationEditorDialog.tsx +++ b/src/renderer/src/components/automations/AutomationEditorDialog.tsx @@ -1,3 +1,5 @@ +/* eslint-disable max-lines -- Why: the automation editor keeps its form fields + colocated so create/edit draft preservation rules stay reviewable. */ import React from 'react' import { Info, Plus, Sparkles } from 'lucide-react' import { Button } from '@/components/ui/button' @@ -16,6 +18,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip import AgentCombobox from '@/components/agent/AgentCombobox' import RepoCombobox from '@/components/repo/RepoCombobox' import { AGENT_CATALOG } from '@/lib/agent-catalog' +import { filterEnabledTuiAgents } from '../../../../shared/tui-agent-selection' import type { AutomationSchedulePreset, AutomationWorkspaceMode @@ -113,6 +116,15 @@ export function AutomationEditorDialog({ }: AutomationEditorDialogProps): React.JSX.Element { const [templateOpen, setTemplateOpen] = React.useState(false) const isHermesCreate = !isEditing && createTarget === 'hermes' + const visibleAgents = React.useMemo(() => { + const enabledIds = new Set( + filterEnabledTuiAgents( + AGENT_CATALOG.map((agent) => agent.id), + settings?.disabledTuiAgents + ) + ) + return AGENT_CATALOG.filter((agent) => enabledIds.has(agent.id) || agent.id === draft.agentId) + }, [draft.agentId, settings?.disabledTuiAgents]) return ( @@ -312,7 +324,7 @@ export function AutomationEditorDialog({ {isHermesCreate ? null : ( agentId && onDraftChange((current) => ({ ...current, agentId })) diff --git a/src/renderer/src/components/automations/AutomationsPage.tsx b/src/renderer/src/components/automations/AutomationsPage.tsx index 4e1679345..ee49bed7a 100644 --- a/src/renderer/src/components/automations/AutomationsPage.tsx +++ b/src/renderer/src/components/automations/AutomationsPage.tsx @@ -15,6 +15,7 @@ import { X } from 'lucide-react' import { toast } from 'sonner' +import { filterEnabledTuiAgents, isTuiAgentEnabled } from '../../../../shared/tui-agent-selection' import type { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { @@ -251,10 +252,13 @@ export default function AutomationsPage(): React.JSX.Element { const setSelectedId = useAppStore((s) => s.setSelectedAutomationId) const repoMap = useRepoMap() const worktreeMap = useWorktreeMap() + const enabledAgents = filterEnabledTuiAgents(AGENTS, settings?.disabledTuiAgents) const defaultAgent = - settings?.defaultTuiAgent && settings.defaultTuiAgent !== 'blank' + settings?.defaultTuiAgent && + settings.defaultTuiAgent !== 'blank' && + isTuiAgentEnabled(settings.defaultTuiAgent, settings.disabledTuiAgents) ? settings.defaultTuiAgent - : AGENTS[0] + : (enabledAgents[0] ?? AGENTS[0]) const [automations, setAutomations] = useState([]) const [runs, setRuns] = useState([]) @@ -861,6 +865,14 @@ export default function AutomationsPage(): React.JSX.Element { toast.error('Enter a valid 5-field cron expression before saving.') return } + if ( + editingAutomationId === null && + !isHermesSave && + !isTuiAgentEnabled(draft.agentId, settings?.disabledTuiAgents) + ) { + toast.error('Choose an enabled agent before saving.') + return + } setIsSaving(true) try { const selectedWorkspaceExists = diff --git a/src/renderer/src/components/feature-wall/AiCommitPrSettingsCard.tsx b/src/renderer/src/components/feature-wall/AiCommitPrSettingsCard.tsx index 5660fa598..b2696ec71 100644 --- a/src/renderer/src/components/feature-wall/AiCommitPrSettingsCard.tsx +++ b/src/renderer/src/components/feature-wall/AiCommitPrSettingsCard.tsx @@ -108,7 +108,11 @@ export function AiCommitPrSettingsCard(): JSX.Element | null { } const config = readCommitMessageAiSettings(settings) - const resolvedAgentId = resolveCommitMessageAgentChoice(config.agentId, settings.defaultTuiAgent) + const resolvedAgentId = resolveCommitMessageAgentChoice( + config.agentId, + settings.defaultTuiAgent, + settings.disabledTuiAgents + ) const isCustom = isCustomAgentId(resolvedAgentId) const activeCapability = resolvedAgentId && !isCustomAgentId(resolvedAgentId) @@ -152,7 +156,11 @@ export function AiCommitPrSettingsCard(): JSX.Element | null { } // Why: this compact tour card must behave like Settings > Git: first // enable seeds the agent/model from the default agent when possible. - const seedAgentId = resolveCommitMessageAgentChoice(config.agentId, settings.defaultTuiAgent) + const seedAgentId = resolveCommitMessageAgentChoice( + config.agentId, + settings.defaultTuiAgent, + settings.disabledTuiAgents + ) if (!seedAgentId) { writeConfig({ enabled: true, agentId: null }) return diff --git a/src/renderer/src/components/feature-wall/use-feature-wall-completion.ts b/src/renderer/src/components/feature-wall/use-feature-wall-completion.ts index fa723939e..6e9066bf2 100644 --- a/src/renderer/src/components/feature-wall/use-feature-wall-completion.ts +++ b/src/renderer/src/components/feature-wall/use-feature-wall-completion.ts @@ -51,7 +51,11 @@ export function useFeatureWallCompletion( const commitMessageAi = settings?.commitMessageAi const resolvedCommitMessageAgent = settings && commitMessageAi?.enabled === true - ? resolveCommitMessageAgentChoice(commitMessageAi.agentId, settings.defaultTuiAgent) + ? resolveCommitMessageAgentChoice( + commitMessageAi.agentId, + settings.defaultTuiAgent, + settings.disabledTuiAgents + ) : null const aiCommitPrConfigured = commitMessageAi?.enabled === true && diff --git a/src/renderer/src/components/floating-terminal/FloatingTerminalWindowControls.tsx b/src/renderer/src/components/floating-terminal/FloatingTerminalWindowControls.tsx index 89c33154f..153eadb51 100644 --- a/src/renderer/src/components/floating-terminal/FloatingTerminalWindowControls.tsx +++ b/src/renderer/src/components/floating-terminal/FloatingTerminalWindowControls.tsx @@ -10,6 +10,7 @@ import { buildAgentStartupPlan } from '@/lib/tui-agent-startup' import { tuiAgentToAgentKind } from '@/lib/telemetry' import { useAppStore } from '@/store' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' +import { isTuiAgentEnabled } from '../../../../shared/tui-agent-selection' type FloatingTerminalWindowControlsProps = { maximized: boolean @@ -29,7 +30,13 @@ export function FloatingTerminalWindowControls({ const createTab = useAppStore((s) => s.createTab) const setActiveTabForWorktree = useAppStore((s) => s.setActiveTabForWorktree) - const defaultAgent = defaultTuiAgent && defaultTuiAgent !== 'blank' ? defaultTuiAgent : null + const disabledTuiAgents = useAppStore((s) => s.settings?.disabledTuiAgents ?? []) + const defaultAgent = + defaultTuiAgent && + defaultTuiAgent !== 'blank' && + isTuiAgentEnabled(defaultTuiAgent, disabledTuiAgents) + ? defaultTuiAgent + : null const defaultAgentLabel = useMemo( () => defaultAgent diff --git a/src/renderer/src/components/right-sidebar/ChecksPanel.tsx b/src/renderer/src/components/right-sidebar/ChecksPanel.tsx index 9773c6d21..b3b75f689 100644 --- a/src/renderer/src/components/right-sidebar/ChecksPanel.tsx +++ b/src/renderer/src/components/right-sidebar/ChecksPanel.tsx @@ -1099,9 +1099,13 @@ export default function ChecksPanel(): React.JSX.Element { typeof connectionId === 'string' ? await store.ensureRemoteDetectedAgents(connectionId) : await store.ensureDetectedAgents() - const agent = pickDefaultSourceControlAgent(store.settings?.defaultTuiAgent, detectedAgents) + const agent = pickDefaultSourceControlAgent( + store.settings?.defaultTuiAgent, + detectedAgents, + store.settings?.disabledTuiAgents + ) if (!agent) { - toast.error('No AI agents detected. Configure a default agent in Settings.') + toast.error('No enabled AI agents. Configure agents in Settings.') return } const prompt = buildResolvePullRequestConflictsPrompt({ @@ -1148,9 +1152,13 @@ export default function ChecksPanel(): React.JSX.Element { typeof connectionId === 'string' ? await store.ensureRemoteDetectedAgents(connectionId) : await store.ensureDetectedAgents() - const agent = pickDefaultSourceControlAgent(store.settings?.defaultTuiAgent, detectedAgents) + const agent = pickDefaultSourceControlAgent( + store.settings?.defaultTuiAgent, + detectedAgents, + store.settings?.disabledTuiAgents + ) if (!agent) { - toast.error('No AI agents detected. Configure a default agent in Settings.') + toast.error('No enabled AI agents. Configure agents in Settings.') return } const prompt = buildFixBrokenChecksPrompt({ diff --git a/src/renderer/src/components/right-sidebar/SourceControl.commit-drafts.test.ts b/src/renderer/src/components/right-sidebar/SourceControl.commit-drafts.test.ts index 368ac3e86..7e6de0863 100644 --- a/src/renderer/src/components/right-sidebar/SourceControl.commit-drafts.test.ts +++ b/src/renderer/src/components/right-sidebar/SourceControl.commit-drafts.test.ts @@ -153,6 +153,11 @@ describe('SourceControl conflict resolution state', () => { expect(pickDefaultSourceControlAgent('codex', ['claude', 'codex'])).toBe('codex') expect(pickDefaultSourceControlAgent('blank', ['codex'])).toBe('codex') expect(pickDefaultSourceControlAgent('claude', [])).toBeNull() + expect(pickDefaultSourceControlAgent('codex', ['claude', 'codex'], ['codex'])).toBe('claude') + expect( + pickDefaultSourceControlAgent('blank', ['claude', 'codex'], ['claude', 'codex']) + ).toBeNull() + expect(pickDefaultSourceControlAgent(null, ['claude'], ['claude'])).toBeNull() }) }) diff --git a/src/renderer/src/components/right-sidebar/SourceControl.tsx b/src/renderer/src/components/right-sidebar/SourceControl.tsx index 275034cee..aac5d94ca 100644 --- a/src/renderer/src/components/right-sidebar/SourceControl.tsx +++ b/src/renderer/src/components/right-sidebar/SourceControl.tsx @@ -109,6 +109,7 @@ import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface' import { DiffNotesSendMenu } from '@/components/editor/DiffNotesSendMenu' import { QuickLaunchAgentMenuItems } from '@/components/tab-bar/QuickLaunchButton' import { AGENT_CATALOG } from '@/lib/agent-catalog' +import { filterEnabledTuiAgents } from '../../../../shared/tui-agent-selection' import { launchAgentInNewTab } from '@/lib/launch-agent-in-new-tab' import { installWindowVisibilityInterval } from '@/lib/window-visibility-interval' import { @@ -550,12 +551,14 @@ export function shouldRenderCommitArea( export function pickDefaultSourceControlAgent( defaultAgent: TuiAgent | 'blank' | null | undefined, - detectedAgents: TuiAgent[] + detectedAgents: TuiAgent[], + disabledAgents?: TuiAgent[] ): TuiAgent | null { - if (defaultAgent && defaultAgent !== 'blank' && detectedAgents.includes(defaultAgent)) { + const enabledAgents = filterEnabledTuiAgents(detectedAgents, disabledAgents) + if (defaultAgent && defaultAgent !== 'blank' && enabledAgents.includes(defaultAgent)) { return defaultAgent } - return AGENT_CATALOG.find((entry) => detectedAgents.includes(entry.id))?.id ?? null + return AGENT_CATALOG.find((entry) => enabledAgents.includes(entry.id))?.id ?? null } function getConflictOperationPromptLabel(conflictOperation: GitConflictOperation): string { @@ -1170,8 +1173,13 @@ function SourceControlInner(): React.JSX.Element { return settings ? normalized : { ...normalized, enabled: false } }, [settings]) const effectiveCommitMessageAgentId = useMemo( - () => resolveCommitMessageAgentChoice(sourceControlAi.agentId, settings?.defaultTuiAgent), - [sourceControlAi.agentId, settings?.defaultTuiAgent] + () => + resolveCommitMessageAgentChoice( + sourceControlAi.agentId, + settings?.defaultTuiAgent, + settings?.disabledTuiAgents + ), + [sourceControlAi.agentId, settings?.defaultTuiAgent, settings?.disabledTuiAgents] ) const filterInputRef = useRef(null) const commitMessage = readCommitDraftForWorktree(commitDrafts, activeWorktreeId) @@ -1622,9 +1630,13 @@ function SourceControlInner(): React.JSX.Element { typeof connectionId === 'string' ? await store.ensureRemoteDetectedAgents(connectionId) : await store.ensureDetectedAgents() - const agent = pickDefaultSourceControlAgent(store.settings?.defaultTuiAgent, detectedAgents) + const agent = pickDefaultSourceControlAgent( + store.settings?.defaultTuiAgent, + detectedAgents, + store.settings?.disabledTuiAgents + ) if (!agent) { - toast.error('No AI agents detected. Configure a default agent in Settings.') + toast.error('No enabled AI agents. Configure agents in Settings.') return } @@ -1691,9 +1703,13 @@ function SourceControlInner(): React.JSX.Element { typeof connectionId === 'string' ? await store.ensureRemoteDetectedAgents(connectionId) : await store.ensureDetectedAgents() - const agent = pickDefaultSourceControlAgent(store.settings?.defaultTuiAgent, detectedAgents) + const agent = pickDefaultSourceControlAgent( + store.settings?.defaultTuiAgent, + detectedAgents, + store.settings?.disabledTuiAgents + ) if (!agent) { - toast.error('No AI agents detected. Configure a default agent in Settings.') + toast.error('No enabled AI agents. Configure agents in Settings.') return false } diff --git a/src/renderer/src/components/right-sidebar/useCreatePullRequestDialogFields.ts b/src/renderer/src/components/right-sidebar/useCreatePullRequestDialogFields.ts index 25d943b89..a508063d8 100644 --- a/src/renderer/src/components/right-sidebar/useCreatePullRequestDialogFields.ts +++ b/src/renderer/src/components/right-sidebar/useCreatePullRequestDialogFields.ts @@ -93,7 +93,8 @@ export function useCreatePullRequestDialogFields({ : { ...normalizedSourceControlAi, enabled: false } const effectiveCommitMessageAgentId = resolveCommitMessageAgentChoice( sourceControlAi.agentId, - settings?.defaultTuiAgent + settings?.defaultTuiAgent, + settings?.disabledTuiAgents ) const resolvedPrDefaults = { ...DEFAULT_SOURCE_CONTROL_AI_PR_CREATION_DEFAULTS, diff --git a/src/renderer/src/components/settings/AgentsPane.test.tsx b/src/renderer/src/components/settings/AgentsPane.test.tsx index 92f690c8a..8ac17f283 100644 --- a/src/renderer/src/components/settings/AgentsPane.test.tsx +++ b/src/renderer/src/components/settings/AgentsPane.test.tsx @@ -7,7 +7,13 @@ import { useAppStore } from '../../store' import { AGENT_STATUS_HOOKS_TITLE } from './agent-status-hooks-copy' import { getAgentAwakeDescription } from './agent-awake-copy' import { AgentAwakeSetting } from './AgentAwakeSetting' -import { AgentStatusHooksSetting, AgentsPane, AGENTS_PANE_SEARCH_ENTRIES } from './AgentsPane' +import { + AgentAvailabilityControl, + AgentStatusHooksSetting, + AgentsPane, + AGENTS_PANE_SEARCH_ENTRIES, + buildAgentEnabledSettingsUpdate +} from './AgentsPane' import { matchesSettingsSearch } from './settings-search' type ReactElementLike = { @@ -149,4 +155,75 @@ describe('AgentsPane', () => { expect(matchesSettingsSearch('waiting', AGENTS_PANE_SEARCH_ENTRIES)).toBe(true) expect(matchesSettingsSearch('codex', AGENTS_PANE_SEARCH_ENTRIES)).toBe(true) }) + + it('includes enable and hide search metadata for agent visibility', () => { + expect(matchesSettingsSearch('disable', AGENTS_PANE_SEARCH_ENTRIES)).toBe(true) + expect(matchesSettingsSearch('hide', AGENTS_PANE_SEARCH_ENTRIES)).toBe(true) + }) + + it('renders per-agent availability as labeled status choices with explicit row copy', () => { + const markup = renderPane({ + ...getDefaultSettings('/tmp'), + disabledTuiAgents: ['claude'] + }) + + expect(markup).toContain('aria-label="Claude availability"') + expect(markup).toContain('Enabled') + expect(markup).toContain('Disabled') + expect(markup).toContain('Hidden from launch and default choices.') + expect(markup).not.toContain('aria-label="Enable Claude"') + expect(markup).not.toContain('aria-label="Disable Claude"') + }) + + it('only toggles agent availability when the segmented value changes', () => { + const onToggleEnabled = vi.fn() + const control = AgentAvailabilityControl({ + label: 'Claude', + isEnabled: true, + onToggleEnabled + }) + const props = control.props as { + value: 'enabled' | 'disabled' + onChange: (value: 'enabled' | 'disabled') => void + ariaLabel: string + } + + expect(props.value).toBe('enabled') + expect(props.ariaLabel).toBe('Claude availability') + + props.onChange('enabled') + expect(onToggleEnabled).not.toHaveBeenCalled() + + props.onChange('disabled') + expect(onToggleEnabled).toHaveBeenCalledTimes(1) + }) + + it('clears the default agent when disabling that agent', () => { + expect( + buildAgentEnabledSettingsUpdate( + { + defaultTuiAgent: 'claude', + disabledTuiAgents: [] + }, + 'claude' + ) + ).toEqual({ + disabledTuiAgents: ['claude'], + defaultTuiAgent: null + }) + }) + + it('keeps the default setting untouched when re-enabling an agent', () => { + expect( + buildAgentEnabledSettingsUpdate( + { + defaultTuiAgent: null, + disabledTuiAgents: ['claude'] + }, + 'claude' + ) + ).toEqual({ + disabledTuiAgents: [] + }) + }) }) diff --git a/src/renderer/src/components/settings/AgentsPane.tsx b/src/renderer/src/components/settings/AgentsPane.tsx index 0c9cbf85c..feb988ec4 100644 --- a/src/renderer/src/components/settings/AgentsPane.tsx +++ b/src/renderer/src/components/settings/AgentsPane.tsx @@ -1,14 +1,27 @@ +/* eslint-disable max-lines -- Why: the Agents pane keeps catalog rows, default + selection, and per-agent controls together so settings reconciliation stays + visible in one file. */ import { useMemo, useState } from 'react' import { Check, ChevronDown, ExternalLink, RefreshCw, Terminal } from 'lucide-react' import type { GlobalSettings, TuiAgent } from '../../../../shared/types' import { AGENT_CATALOG, AgentIcon } from '@/lib/agent-catalog' import { useDetectedAgents } from '@/hooks/useDetectedAgents' +import { useAppStore } from '@/store' import { Button } from '../ui/button' import { Input } from '../ui/input' import { cn } from '@/lib/utils' import { AgentAwakeSetting } from './AgentAwakeSetting' import { AGENT_STATUS_HOOKS_DESCRIPTION, AGENT_STATUS_HOOKS_TITLE } from './agent-status-hooks-copy' -import { SettingsBadge, SettingsSubsectionHeader, SettingsSwitchRow } from './SettingsFormControls' +import { + SettingsBadge, + SettingsSegmentedControl, + SettingsSubsectionHeader, + SettingsSwitchRow +} from './SettingsFormControls' +import { + isTuiAgentEnabled, + normalizeDisabledTuiAgents +} from '../../../../shared/tui-agent-selection' export { AGENTS_PANE_SEARCH_ENTRIES } from './agents-search' @@ -23,9 +36,11 @@ type AgentRowProps = { homepageUrl: string defaultCmd: string isDetected: boolean + isEnabled: boolean isDefault: boolean cmdOverride: string | undefined onSetDefault: () => void + onToggleEnabled: () => void onSaveOverride: (value: string) => void } @@ -35,6 +50,55 @@ type AgentCommandOverrideInputProps = { onSaveOverride: (value: string) => void } +type AgentAvailability = 'enabled' | 'disabled' + +type AgentAvailabilityControlProps = { + label: string + isEnabled: boolean + onToggleEnabled: () => void +} + +export function buildAgentEnabledSettingsUpdate( + settings: Pick, + id: TuiAgent +): Pick & Partial> { + const latestDisabled = normalizeDisabledTuiAgents(settings.disabledTuiAgents) + const wasDisabled = latestDisabled.includes(id) + const nextDisabled = wasDisabled + ? latestDisabled.filter((agent) => agent !== id) + : [...latestDisabled, id] + + return { + disabledTuiAgents: nextDisabled, + ...(settings.defaultTuiAgent === id && !wasDisabled ? { defaultTuiAgent: null } : {}) + } +} + +export function AgentAvailabilityControl({ + label, + isEnabled, + onToggleEnabled +}: AgentAvailabilityControlProps): React.JSX.Element { + const value: AgentAvailability = isEnabled ? 'enabled' : 'disabled' + + return ( + + value={value} + onChange={(next) => { + if (next !== value) { + onToggleEnabled() + } + }} + ariaLabel={`${label} availability`} + size="sm" + options={[ + { value: 'enabled', label: 'Enabled' }, + { value: 'disabled', label: 'Disabled' } + ]} + /> + ) +} + function AgentCommandOverrideInput({ defaultCmd, cmdOverride, @@ -98,21 +162,30 @@ function AgentRow({ homepageUrl, defaultCmd, isDetected, + isEnabled, isDefault, cmdOverride, onSetDefault, + onToggleEnabled, onSaveOverride }: AgentRowProps): React.JSX.Element { const [cmdOpen, setCmdOpen] = useState(Boolean(cmdOverride)) + const availabilityDescription = isEnabled + ? isDetected + ? 'Shown in launch and default choices.' + : 'Install to use in launch and default choices.' + : isDetected + ? 'Hidden from launch and default choices.' + : 'Hidden from launch and default choices if installed.' return ( -
-
+
+
-
+
{label} {isDetected ? ( @@ -120,6 +193,7 @@ function AgentRow({ ) : ( Not installed )} + {!isEnabled && Disabled}
{cmdOverride ? ( @@ -131,10 +205,17 @@ function AgentRow({ defaultCmd )}
+
{availabilityDescription}
-
- {isDetected && ( +
+ + + {isDetected && isEnabled && (