Add agent enablement controls (#2972)

* Add agent enablement controls

Implements the Enable/Disable Agents Dashboard behavior described in docs/enable-disable-agents-dashboard.md, including persisted agent enablement state, filtered launch surfaces, and settings UI affordances.

* Clarify agent availability controls

* Respect disabled agents across workspace and AI defaults

- Filter disabled TUI agents from mobile and quick workspace selection
- Avoid implicitly choosing disabled agents for commit/PR AI settings
- Broadcast settings changes to open windows for disabled-agent updates

* rm design doc

* fix: complete agent enablement propagation
This commit is contained in:
Jinjing 2026-05-28 17:29:49 -07:00 committed by GitHub
parent 10d5c66be3
commit bda658a364
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
49 changed files with 1061 additions and 135 deletions

View File

@ -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<string, string>
defaultTaskSource?: TaskProvider
defaultTaskViewPreset?: GitHubPreset | 'all'
@ -4514,15 +4517,18 @@ export default function MobileTasksScreen() {
workspaceSparseDraftParsed !== null
const workspaceAgentOptions = useMemo<PickerOption<WorkspaceAgentChoice>[]>(() => {
const enabledAgents = filterWorkspaceAgents(
MOBILE_TUI_AGENT_AUTO_PICK_ORDER,
runtimeTaskSettings.disabledTuiAgents
)
const availableAgents =
workspaceDetectedAgentIds === null
? new Set<TuiAgent>(MOBILE_TUI_AGENT_AUTO_PICK_ORDER)
: new Set<TuiAgent>(
MOBILE_TUI_AGENT_AUTO_PICK_ORDER.filter((agent) => workspaceDetectedAgentIds.has(agent))
)
? new Set<TuiAgent>(enabledAgents)
: new Set<TuiAgent>(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: () => <MobileAgentIcon agentId="__blank__" size={18} />
}
]
}, [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<string, unknown>
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
]
)

View File

@ -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<string, string>
}
@ -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 (

View File

@ -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<TuiAgent>()
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<T extends TuiAgent>(
agents: Iterable<T>,
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<TuiAgent>
detected: Iterable<TuiAgent>,
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
}
}

View File

@ -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', () => {

View File

@ -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<string> | 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)
}

View File

@ -101,6 +101,7 @@ function createSettings(overrides: Partial<GlobalSettings> = {}): GlobalSettings
activeClaudeManagedAccountId: null,
terminalScopeHistoryByWorktree: true,
defaultTuiAgent: null,
disabledTuiAgents: [],
skipDeleteWorktreeConfirm: false,
skipDeleteAutomationConfirm: false,
defaultTaskViewPreset: 'all',

View File

@ -88,6 +88,7 @@ function createSettings(overrides: Partial<GlobalSettings> = {}): GlobalSettings
activeClaudeManagedAccountId: null,
terminalScopeHistoryByWorktree: true,
defaultTuiAgent: null,
disabledTuiAgents: [],
skipDeleteWorktreeConfirm: false,
skipDeleteAutomationConfirm: false,
defaultTaskViewPreset: 'all',

View File

@ -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: () => {

View File

@ -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<unknown>
await handler(null, { floatingTerminalTrustedCwds: ['/tmp/notes'] })
await handler(settingsInvokeEvent, { floatingTerminalTrustedCwds: ['/tmp/notes'] })
expect(store.updateSettings).toHaveBeenCalledWith({})
expect(store.updateSettings).toHaveBeenCalledWith(
{},
{ notifyListeners: true, originWebContentsId: 1 }
)
})
})

View File

@ -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<GlobalSettings>) => {
ipcMain.handle('settings:set', async (event, args: Partial<GlobalSettings>) => {
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)
}

View File

@ -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!

View File

@ -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<string, string>()
private loadNeedsSave = false
private settingsChangeListeners = new Set<
(
updates: Partial<GlobalSettings>,
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>): GlobalSettings {
onSettingsChanged(
listener: (
updates: Partial<GlobalSettings>,
settings: GlobalSettings,
originWebContentsId?: number
) => void
): () => void {
this.settingsChangeListeners.add(listener)
return () => {
this.settingsChangeListeners.delete(listener)
}
}
private notifySettingsChanged(
updates: Partial<GlobalSettings>,
originWebContentsId?: number
): void {
for (const listener of this.settingsChangeListeners) {
listener(updates, this.state.settings, originWebContentsId)
}
}
updateSettings(
updates: Partial<GlobalSettings>,
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<GlobalSettings> & Record<string, unknown>
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
}

View File

@ -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<string, WorktreeMeta> = {}
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<WorktreeMeta>) => {
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<string, WorktreeMeta> = {}
const runtimeStore = {

View File

@ -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<GlobalSettings>) => unknown
updateSettings?: (
updates: Partial<GlobalSettings>,
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<GlobalSettings>,
| '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)

View File

@ -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,

View File

@ -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'])

View File

@ -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
}

View File

@ -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')

View File

@ -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<TuiAgent | null>(() => {
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)
}, [])

View File

@ -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
}

View File

@ -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 (
<Dialog open={open} onOpenChange={onOpenChange}>
@ -312,7 +324,7 @@ export function AutomationEditorDialog({
{isHermesCreate ? null : (
<Field label="Agent">
<AgentCombobox
agents={AGENT_CATALOG}
agents={visibleAgents}
value={draft.agentId}
onValueChange={(agentId) =>
agentId && onDraftChange((current) => ({ ...current, agentId }))

View File

@ -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<Automation[]>([])
const [runs, setRuns] = useState<AutomationRun[]>([])
@ -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 =

View File

@ -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

View File

@ -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 &&

View File

@ -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

View File

@ -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({

View File

@ -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()
})
})

View File

@ -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<HTMLInputElement>(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
}

View File

@ -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,

View File

@ -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: []
})
})
})

View File

@ -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<GlobalSettings, 'defaultTuiAgent' | 'disabledTuiAgents'>,
id: TuiAgent
): Pick<GlobalSettings, 'disabledTuiAgents'> & Partial<Pick<GlobalSettings, 'defaultTuiAgent'>> {
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 (
<SettingsSegmentedControl<AgentAvailability>
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 (
<div className={cn('py-3', !isDetected && 'opacity-60')}>
<div className="flex items-center gap-3">
<div className={cn('py-3', !isDetected && 'opacity-70')}>
<div className="flex flex-wrap items-start gap-3">
<div className="flex size-7 shrink-0 items-center justify-center rounded-md border border-border/50 bg-background/50">
<AgentIcon agent={agentId} size={16} />
</div>
<div className="min-w-0 flex-1">
<div className="min-w-0 flex-1 sm:min-w-[12rem]">
<div className="flex items-center gap-2">
<span className="text-sm font-medium leading-none">{label}</span>
{isDetected ? (
@ -120,6 +193,7 @@ function AgentRow({
) : (
<SettingsBadge tone="muted">Not installed</SettingsBadge>
)}
{!isEnabled && <SettingsBadge tone="muted">Disabled</SettingsBadge>}
</div>
<div className="mt-1 truncate font-mono text-[11px] text-muted-foreground">
{cmdOverride ? (
@ -131,10 +205,17 @@ function AgentRow({
defaultCmd
)}
</div>
<div className="mt-1 text-[11px] text-muted-foreground">{availabilityDescription}</div>
</div>
<div className="flex shrink-0 items-center gap-1">
{isDetected && (
<div className="ml-auto flex shrink-0 flex-wrap items-center justify-end gap-1.5">
<AgentAvailabilityControl
label={label}
isEnabled={isEnabled}
onToggleEnabled={onToggleEnabled}
/>
{isDetected && isEnabled && (
<Button
type="button"
variant={isDefault ? 'secondary' : 'ghost'}
@ -249,11 +330,17 @@ export function AgentsPane({ settings, updateSettings }: AgentsPaneProps): React
const defaultAgent = settings.defaultTuiAgent
const cmdOverrides = settings.agentCmdOverrides ?? {}
const disabledAgents = normalizeDisabledTuiAgents(settings.disabledTuiAgents)
const setDefault = (id: TuiAgent | 'blank' | null): void => {
updateSettings({ defaultTuiAgent: id })
}
const toggleEnabled = (id: TuiAgent): void => {
const latestSettings = useAppStore.getState().settings ?? settings
updateSettings(buildAgentEnabledSettingsUpdate(latestSettings, id))
}
const saveOverride = (id: TuiAgent, value: string): void => {
const next = { ...cmdOverrides }
if (value) {
@ -264,6 +351,10 @@ export function AgentsPane({ settings, updateSettings }: AgentsPaneProps): React
updateSettings({ agentCmdOverrides: next })
}
const enabledDetectedAgents = AGENT_CATALOG.filter(
(a) =>
(detectedIds === null || detectedIds.has(a.id)) && isTuiAgentEnabled(a.id, disabledAgents)
)
const detectedAgents = AGENT_CATALOG.filter((a) => detectedIds === null || detectedIds.has(a.id))
const undetectedAgents = AGENT_CATALOG.filter(
(a) => detectedIds !== null && !detectedIds.has(a.id)
@ -273,7 +364,9 @@ export function AgentsPane({ settings, updateSettings }: AgentsPaneProps): React
// so the Auto pill should only light up when the default is null OR when a
// selected agent id is no longer detected on PATH.
const isAutoDefault =
defaultAgent === null || (defaultAgent !== 'blank' && !detectedIds?.has(defaultAgent))
defaultAgent === null ||
(defaultAgent !== 'blank' &&
(!detectedIds?.has(defaultAgent) || !isTuiAgentEnabled(defaultAgent, disabledAgents)))
const isBlankDefault = defaultAgent === 'blank'
return (
@ -300,7 +393,7 @@ export function AgentsPane({ settings, updateSettings }: AgentsPaneProps): React
{isBlankDefault && <Check className="size-3.5" />}
</DefaultAgentPill>
{detectedAgents.map((agent) => {
{enabledDetectedAgents.map((agent) => {
const isActive = defaultAgent === agent.id
return (
<DefaultAgentPill
@ -355,9 +448,11 @@ export function AgentsPane({ settings, updateSettings }: AgentsPaneProps): React
homepageUrl={agent.homepageUrl}
defaultCmd={agent.cmd}
isDetected
isEnabled={isTuiAgentEnabled(agent.id, disabledAgents)}
isDefault={defaultAgent === agent.id}
cmdOverride={cmdOverrides[agent.id]}
onSetDefault={() => setDefault(agent.id)}
onToggleEnabled={() => toggleEnabled(agent.id)}
onSaveOverride={(v) => saveOverride(agent.id, v)}
/>
))}
@ -385,9 +480,11 @@ export function AgentsPane({ settings, updateSettings }: AgentsPaneProps): React
homepageUrl={agent.homepageUrl}
defaultCmd={agent.cmd}
isDetected={false}
isEnabled={isTuiAgentEnabled(agent.id, disabledAgents)}
isDefault={false}
cmdOverride={undefined}
onSetDefault={() => {}}
onToggleEnabled={() => toggleEnabled(agent.id)}
onSaveOverride={() => {}}
/>
))}

View File

@ -317,7 +317,11 @@ export function CommitMessageAiPane({
}),
[baseAgentCapabilities, discoveryHostKey, modelDiscoveryByAgent]
)
const resolvedAgentId = resolveCommitMessageAgentChoice(config.agentId, settings.defaultTuiAgent)
const resolvedAgentId = resolveCommitMessageAgentChoice(
config.agentId,
settings.defaultTuiAgent,
settings.disabledTuiAgents
)
const unsupportedSelectedAgent =
config.agentId &&
!isCustomAgentId(config.agentId) &&
@ -491,13 +495,21 @@ export function CommitMessageAiPane({
// user previously persisted 'custom', keep it and let them re-edit the
// command — no implicit reset to a preset.
const defaultTuiAgent = settings.defaultTuiAgent
const seedAgentId = resolveCommitMessageAgentChoice(config.agentId, defaultTuiAgent)
const seedAgentId = resolveCommitMessageAgentChoice(
config.agentId,
defaultTuiAgent,
settings.disabledTuiAgents
)
if (!seedAgentId) {
writeConfig({ enabled: true, agentId: null })
return
}
writeConfig((current) => {
const currentSeedAgentId = resolveCommitMessageAgentChoice(current.agentId, defaultTuiAgent)
const currentSeedAgentId = resolveCommitMessageAgentChoice(
current.agentId,
defaultTuiAgent,
settings.disabledTuiAgents
)
const agentId = currentSeedAgentId ?? seedAgentId
const currentCapability = isCustomAgentId(agentId)
? undefined

View File

@ -109,7 +109,11 @@ export function RepositorySourceControlAiSection({
)
const hostScope = getRuntimeGitScope(settings, repo.connectionId)
const hostKey = getCommitMessageModelDiscoveryHostKeyForScope(hostScope)
const agentId = resolveCommitMessageAgentChoice(source.agentId, settings?.defaultTuiAgent)
const agentId = resolveCommitMessageAgentChoice(
source.agentId,
settings?.defaultTuiAgent,
settings?.disabledTuiAgents
)
const baseCapability =
agentId && !isCustomAgentId(agentId) ? getCommitMessageAgentCapability(agentId) : null
const discoveredModels =

View File

@ -49,7 +49,11 @@ export const AGENTS_PANE_SEARCH_ENTRIES: SettingsSearchEntry[] = [
'command',
'override',
'install',
'detected'
'detected',
'enable',
'disable',
'hide',
'show'
]
},
{

View File

@ -8,6 +8,7 @@ import { useDetectedAgents } from '@/hooks/useDetectedAgents'
import { launchAgentInNewTab } from '@/lib/launch-agent-in-new-tab'
import type { TuiAgent } from '../../../../shared/types'
import type { LaunchSource } from '../../../../shared/telemetry-events'
import { filterEnabledTuiAgents } from '../../../../shared/tui-agent-selection'
export type QuickLaunchAgentMenuItemsProps = {
worktreeId: string
@ -111,6 +112,7 @@ function QuickLaunchAgentMenuItemsInner({
})
const { detectedIds } = useDetectedAgents(connectionId)
const defaultAgent = useAppStore((s) => s.settings?.defaultTuiAgent)
const disabledAgents = useAppStore((s) => s.settings?.disabledTuiAgents ?? [])
const openSettingsPage = useAppStore((s) => s.openSettingsPage)
const openSettingsTarget = useAppStore((s) => s.openSettingsTarget)
@ -161,7 +163,8 @@ function QuickLaunchAgentMenuItemsInner({
[worktreeId, groupId, onFocusTerminal, prompt, promptDelivery, launchSource, onPromptDelivered]
)
const agents = detectedIds ? orderAgents(defaultAgent, detectedIds) : []
const enabledDetectedIds = detectedIds ? filterEnabledTuiAgents(detectedIds, disabledAgents) : []
const agents = detectedIds ? orderAgents(defaultAgent, enabledDetectedIds) : []
return (
<>
@ -170,7 +173,7 @@ function QuickLaunchAgentMenuItemsInner({
disabled
className="gap-2 rounded-[7px] px-2 py-1.5 text-[12px] leading-5 text-muted-foreground"
>
No agents detected
{detectedIds && detectedIds.length > 0 ? 'No enabled agents' : 'No agents detected'}
</DropdownMenuItem>
) : null}
{agents.map((agent) => {

View File

@ -15,6 +15,7 @@ import {
import { activateAndRevealWorktree, type AgentStartedTelemetry } from '@/lib/worktree-activation'
import { buildAgentDraftLaunchPlan, buildAgentStartupPlan } from '@/lib/tui-agent-startup'
import { TUI_AGENT_CONFIG } from '../../../shared/tui-agent-config'
import { filterEnabledTuiAgents, isTuiAgentEnabled } from '../../../shared/tui-agent-selection'
import { tuiAgentToAgentKind } from '@/lib/telemetry'
import { isGitRepoKind } from '../../../shared/repo-kind'
import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client'
@ -392,14 +393,32 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
// reset inline (e.g. "was PR #8778") so the change is recoverable visually
// instead of slipping past the user. Cleared on any subsequent selection.
const [startFromResetHint, setStartFromResetHint] = useState<string | null>(null)
const disabledTuiAgentKey = (settings?.disabledTuiAgents ?? []).join('\u0000')
const disabledTuiAgents = useMemo<TuiAgent[]>(
() => settings?.disabledTuiAgents ?? [],
// Why: settings IPC round-trips clone arrays; agent availability only
// changes when the disabled-agent content changes.
// eslint-disable-next-line react-hooks/exhaustive-deps
[disabledTuiAgentKey]
)
// Why: the long-form composer's agent selection is a required TuiAgent (not
// null/blank), so 'blank' preferences from global settings must collapse to
// the Claude default here — the blank-terminal affordance only lives in the
// quick-create flow.
const enabledCatalogAgents = useMemo(
() =>
filterEnabledTuiAgents(
AGENT_CATALOG.map((agent) => agent.id),
disabledTuiAgents
),
[disabledTuiAgents]
)
const fallbackDefaultAgent: TuiAgent =
settings?.defaultTuiAgent && settings.defaultTuiAgent !== 'blank'
settings?.defaultTuiAgent &&
settings.defaultTuiAgent !== 'blank' &&
isTuiAgentEnabled(settings.defaultTuiAgent, disabledTuiAgents)
? settings.defaultTuiAgent
: 'claude'
: (enabledCatalogAgents[0] ?? 'claude')
const [tuiAgent, setTuiAgent] = useState<TuiAgent>(
persistDraft ? (newWorkspaceDraft?.agent ?? fallbackDefaultAgent) : fallbackDefaultAgent
)
@ -776,11 +795,15 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
if (cancelled) {
return
}
if (!newWorkspaceDraft?.agent && !settings?.defaultTuiAgent && ids.length > 0) {
const firstInCatalogOrder = AGENT_CATALOG.find((a) => ids.includes(a.id))
const enabledIds = filterEnabledTuiAgents(ids, disabledTuiAgents)
if (!newWorkspaceDraft?.agent && !settings?.defaultTuiAgent && enabledIds.length > 0) {
const firstInCatalogOrder = AGENT_CATALOG.find((a) => enabledIds.includes(a.id))
if (firstInCatalogOrder) {
setTuiAgent(firstInCatalogOrder.id)
}
} else if (!isTuiAgentEnabled(tuiAgent, disabledTuiAgents)) {
const firstEnabledDetected = AGENT_CATALOG.find((a) => enabledIds.includes(a.id))
setTuiAgent(firstEnabledDetected?.id ?? fallbackDefaultAgent)
}
})
return () => {
@ -790,7 +813,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
// detection targets the correct host. Draft/settings deps are intentionally
// excluded — detection is a best-effort PATH snapshot.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [connectionId, isRemote, selectedRepoSshStatus])
}, [connectionId, isRemote, selectedRepoSshStatus, disabledTuiAgents])
// Per-repo: load yaml hooks + issue command template.
useEffect(() => {
@ -1719,6 +1742,11 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
) {
return
}
if (!isTuiAgentEnabled(tuiAgent, disabledTuiAgents)) {
setTuiAgent(fallbackDefaultAgent)
toast.error('Selected agent is disabled. Choose an enabled agent before creating.')
return
}
setCreateError(null)
setCreating(true)
@ -1917,6 +1945,8 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
sparseError,
effectivePresetId,
telemetrySource,
fallbackDefaultAgent,
disabledTuiAgents,
tuiAgent,
shouldWaitForIssueAutomationCheck,
shouldWaitForSetupCheck,
@ -1924,7 +1954,11 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
])
const submitQuick = useCallback(
async (agent: TuiAgent | null): Promise<void> => {
async (requestedAgent: TuiAgent | null): Promise<void> => {
const agent =
requestedAgent && isTuiAgentEnabled(requestedAgent, disabledTuiAgents)
? requestedAgent
: null
const workspaceNameSeed = getWorkspaceSeedName({
explicitName: name,
prompt: '',
@ -2171,6 +2205,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
selectedRepoIsGit,
selectedRepoRequiresConnection,
settings?.agentCmdOverrides,
disabledTuiAgents,
setSidebarOpen,
setupDecision,
sparseEnabled,

View File

@ -252,7 +252,11 @@ export async function launchWorkItemDirect(args: LaunchWorkItemDirectArgs): Prom
const worktreePath = result.worktree.path
const detectedIds = new Set(await detectedAgentsPromise)
effectiveAgent = pickTuiAgent(settings?.defaultTuiAgent, detectedIds)
effectiveAgent = pickTuiAgent(
settings?.defaultTuiAgent,
detectedIds,
settings?.disabledTuiAgents
)
if (effectiveAgent) {
// Why: direct task launch creates and starts the workspace in separate
// steps so agent detection can overlap git worktree creation. Persist

View File

@ -1,5 +1,6 @@
import { buildAgentStartupPlan } from '@/lib/tui-agent-startup'
import { tuiAgentToAgentKind } from '@/lib/telemetry'
import { isTuiAgentEnabled } from '../../../shared/tui-agent-selection'
import type { AgentStartedTelemetry } from '@/lib/worktree-activation'
import type { GlobalSettings, OnboardingState } from '../../../shared/types'
@ -20,7 +21,12 @@ export function buildOnboardingFolderAgentStartup(
settings: GlobalSettings | null
): OnboardingFolderAgentStartup | undefined {
const agent = settings?.defaultTuiAgent
if (!settings || !agent || agent === 'blank') {
if (
!settings ||
!agent ||
agent === 'blank' ||
!isTuiAgentEnabled(agent, settings.disabledTuiAgents)
) {
return undefined
}

View File

@ -0,0 +1,19 @@
import { describe, expect, it } from 'vitest'
import { pickQuickWorkspaceAgent } from './quick-workspace-agent-selection'
describe('pickQuickWorkspaceAgent', () => {
it('uses the first enabled catalog agent while detection is pending', () => {
expect(pickQuickWorkspaceAgent(null, null, [])).toBe('claude')
expect(pickQuickWorkspaceAgent(null, null, ['claude'])).toBe('codex')
})
it('respects blank and disabled preferred agents', () => {
expect(pickQuickWorkspaceAgent('blank', null, [])).toBeNull()
expect(pickQuickWorkspaceAgent('codex', null, ['codex'])).toBe('claude')
})
it('uses detected enabled agents after detection resolves', () => {
expect(pickQuickWorkspaceAgent(null, ['codex'], ['claude'])).toBe('codex')
expect(pickQuickWorkspaceAgent('codex', ['claude', 'codex'], ['codex'])).toBe('claude')
})
})

View File

@ -0,0 +1,11 @@
import type { TuiAgent } from '../../../shared/types'
import { pickTuiAgent, TUI_AGENT_AUTO_PICK_ORDER } from '../../../shared/tui-agent-selection'
export function pickQuickWorkspaceAgent(
preferred: TuiAgent | 'blank' | null | undefined,
detectedAgentIds: Iterable<TuiAgent> | null,
disabledTuiAgents?: Iterable<unknown> | null
): TuiAgent | null {
const candidates = detectedAgentIds ?? TUI_AGENT_AUTO_PICK_ORDER
return pickTuiAgent(preferred, candidates, disabledTuiAgents)
}

View File

@ -12,6 +12,7 @@ import { normalizeTerminalQuickCommands } from '../../../../shared/terminal-quic
import { normalizeTaskProviderSettings } from '../../../../shared/task-providers'
import { normalizeOpenInApplications } from '../../../../shared/open-in-applications'
import { createSettingsSearchState, type SettingsSearchState } from './settings-search-state'
import { normalizeDisabledTuiAgents } from '../../../../shared/tui-agent-selection'
export type SettingsSlice = SettingsSearchState & {
settings: GlobalSettings | null
@ -270,6 +271,9 @@ export const createSettingsSlice: StateCreator<AppState, [], [], SettingsSlice>
}
)
}
if ('disabledTuiAgents' in updates) {
sanitizedUpdates.disabledTuiAgents = normalizeDisabledTuiAgents(updates.disabledTuiAgents)
}
const nextSettings = await window.api.settings.set(sanitizedUpdates)
set((s) => ({ settings: (nextSettings as GlobalSettings | undefined) ?? s.settings }))
} catch (err) {

View File

@ -26,6 +26,7 @@ import {
import { legacyBaseRefSearchResult } from '../../../shared/base-ref-search-result'
import { createE2EConfig } from '../../../shared/e2e-config'
import { relativePathInsideRoot } from '../../../shared/cross-platform-path'
import { normalizeDisabledTuiAgents } from '../../../shared/tui-agent-selection'
import type { RateLimitState } from '../../../shared/rate-limit-types'
import type { RuntimeStatus, RuntimeSyncWindowGraph } from '../../../shared/runtime-types'
import {
@ -2146,6 +2147,9 @@ function mergeSettings(base: GlobalSettings, updates: Partial<GlobalSettings>):
...(base.githubProjects ?? defaults.githubProjects),
...updates.githubProjects
} as GlobalSettings['githubProjects'],
disabledTuiAgents: normalizeDisabledTuiAgents(
updates.disabledTuiAgents ?? base.disabledTuiAgents
),
voice: {
...(base.voice ?? defaults.voice),
...updates.voice

View File

@ -13,7 +13,8 @@ import {
parseCodexModels,
parseCursorModels,
parseLineModels,
parsePiModels
parsePiModels,
resolveCommitMessageAgentChoice
} from './commit-message-agent-spec'
describe('COMMIT_MESSAGE_AGENT_SPECS', () => {
@ -61,6 +62,12 @@ describe('COMMIT_MESSAGE_AGENT_SPECS', () => {
expect(DEFAULT_COMMIT_MESSAGE_AGENT_ID).toBe('claude')
})
it('treats disabled default agents as unavailable for implicit Source Control AI choices', () => {
expect(resolveCommitMessageAgentChoice(null, 'codex', ['codex'])).toBe('claude')
expect(resolveCommitMessageAgentChoice(null, null, ['claude'])).toBeNull()
expect(resolveCommitMessageAgentChoice('codex', null, ['codex'])).toBe('codex')
})
it('gives every model with thinking levels a valid default', () => {
for (const spec of Object.values(COMMIT_MESSAGE_AGENT_SPECS)) {
if (!spec) {

View File

@ -1,4 +1,5 @@
import type { TuiAgent } from './types'
import { isTuiAgentEnabled } from './tui-agent-selection'
/* eslint-disable max-lines -- Why: this is the single registry for non-interactive commit-message agents, their model discovery parsers, and UI capabilities. */
@ -592,15 +593,22 @@ export function getCommitMessageAgentSpec(agentId: TuiAgent): CommitMessageAgent
export function resolveCommitMessageAgentChoice(
configuredAgentId: CommitMessageAgentChoice | null | undefined,
defaultTuiAgent: DefaultTuiAgentPreference
defaultTuiAgent: DefaultTuiAgentPreference,
disabledTuiAgents?: Iterable<unknown> | null
): CommitMessageAgentChoice | null {
if (configuredAgentId) {
return configuredAgentId
}
if (defaultTuiAgent && defaultTuiAgent !== 'blank') {
if (
defaultTuiAgent &&
defaultTuiAgent !== 'blank' &&
isTuiAgentEnabled(defaultTuiAgent, disabledTuiAgents)
) {
return getCommitMessageAgentSpec(defaultTuiAgent) ? defaultTuiAgent : null
}
return DEFAULT_COMMIT_MESSAGE_AGENT_ID
return isTuiAgentEnabled(DEFAULT_COMMIT_MESSAGE_AGENT_ID, disabledTuiAgents)
? DEFAULT_COMMIT_MESSAGE_AGENT_ID
: null
}
export function getCommitMessageModel(

View File

@ -241,6 +241,7 @@ export function getDefaultSettings(homedir: string): GlobalSettings {
activeClaudeManagedAccountId: null,
terminalScopeHistoryByWorktree: true,
defaultTuiAgent: null,
disabledTuiAgents: [],
skipDeleteWorktreeConfirm: false,
skipDeleteAutomationConfirm: false,
defaultTaskViewPreset: 'all',

View File

@ -55,7 +55,8 @@ type ResolveSourceControlAiInput = {
settings: Pick<
GlobalSettings,
'defaultTuiAgent' | 'agentCmdOverrides' | 'commitMessageAi' | 'sourceControlAi'
>
> &
Partial<Pick<GlobalSettings, 'disabledTuiAgents'>>
repo?: Pick<Repo, 'sourceControlAi'> | null
operation: SourceControlAiOperation
discoveryHostKey?: string
@ -767,7 +768,8 @@ export function resolveSourceControlAiForOperation(
// commitMessageAi should not make that choice sticky again.
const agentChoice = resolveCommitMessageAgentChoice(
source.agentId,
input.settings.defaultTuiAgent
input.settings.defaultTuiAgent,
input.settings.disabledTuiAgents
)
if (!agentChoice) {
return {

View File

@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { pickTuiAgent } from './tui-agent-selection'
import { normalizeDisabledTuiAgents, pickTuiAgent } from './tui-agent-selection'
describe('pickTuiAgent', () => {
it('uses an installed preferred agent', () => {
@ -15,4 +15,18 @@ describe('pickTuiAgent', () => {
it('respects the explicit blank terminal preference', () => {
expect(pickTuiAgent('blank', ['cursor', 'claude'])).toBeNull()
})
it('ignores disabled preferred and fallback agents', () => {
expect(pickTuiAgent('codex', ['claude', 'codex'], ['codex'])).toBe('claude')
expect(pickTuiAgent(null, ['claude', 'codex'], ['claude', 'codex'])).toBeNull()
})
})
describe('normalizeDisabledTuiAgents', () => {
it('dedupes supported agent ids and drops unsupported values', () => {
expect(normalizeDisabledTuiAgents(['codex', 'unknown', 'codex', null, 'claude'])).toEqual([
'codex',
'claude'
])
})
})

View File

@ -1,4 +1,5 @@
import type { TuiAgent } from './types'
import { isTuiAgent } from './tui-agent-config'
// Keep this order in sync with the desktop agent catalog. It defines the
// automatic fallback priority when the user has not chosen a default agent.
@ -36,19 +37,46 @@ export const TUI_AGENT_AUTO_PICK_ORDER = [
export function pickTuiAgent(
preferred: TuiAgent | 'blank' | null | undefined,
detected: Iterable<TuiAgent>
detected: Iterable<TuiAgent>,
disabled?: Iterable<unknown> | null
): TuiAgent | null {
if (preferred === 'blank') {
return null
}
const disabledSet = new Set(normalizeDisabledTuiAgents(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 TUI_AGENT_AUTO_PICK_ORDER) {
if (detectedSet.has(agent)) {
if (detectedSet.has(agent) && !disabledSet.has(agent)) {
return agent
}
}
return null
}
export function normalizeDisabledTuiAgents(value: unknown): TuiAgent[] {
if (!Array.isArray(value)) {
return []
}
const seen = new Set<TuiAgent>()
for (const item of value) {
if (isTuiAgent(item)) {
seen.add(item)
}
}
return [...seen]
}
export function isTuiAgentEnabled(agent: TuiAgent, disabled?: Iterable<unknown> | null): boolean {
return !normalizeDisabledTuiAgents(disabled).includes(agent)
}
export function filterEnabledTuiAgents<T extends TuiAgent>(
agents: Iterable<T>,
disabled?: Iterable<unknown> | null
): T[] {
const disabledSet = new Set(normalizeDisabledTuiAgents(disabled))
return [...agents].filter((agent) => !disabledSet.has(agent))
}

View File

@ -1812,6 +1812,9 @@ export type GlobalSettings = {
* - 'blank': blank terminal (no agent launched)
* - TuiAgent: a specific agent id */
defaultTuiAgent: TuiAgent | 'blank' | null
/** Agents hidden from future picker and automatic launch choices. Detection
* remains a raw PATH capability snapshot. */
disabledTuiAgents: TuiAgent[]
/** Why: worktree deletion is destructive (git worktree remove + rm -rf of the
* working directory), so Orca shows a confirmation dialog by default. Users
* who delete frequently can opt into skipping the dialog via a "Don't ask