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:
parent
10d5c66be3
commit
bda658a364
|
|
@ -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
|
||||
]
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -101,6 +101,7 @@ function createSettings(overrides: Partial<GlobalSettings> = {}): GlobalSettings
|
|||
activeClaudeManagedAccountId: null,
|
||||
terminalScopeHistoryByWorktree: true,
|
||||
defaultTuiAgent: null,
|
||||
disabledTuiAgents: [],
|
||||
skipDeleteWorktreeConfirm: false,
|
||||
skipDeleteAutomationConfirm: false,
|
||||
defaultTaskViewPreset: 'all',
|
||||
|
|
|
|||
|
|
@ -88,6 +88,7 @@ function createSettings(overrides: Partial<GlobalSettings> = {}): GlobalSettings
|
|||
activeClaudeManagedAccountId: null,
|
||||
terminalScopeHistoryByWorktree: true,
|
||||
defaultTuiAgent: null,
|
||||
disabledTuiAgents: [],
|
||||
skipDeleteWorktreeConfirm: false,
|
||||
skipDeleteAutomationConfirm: false,
|
||||
defaultTaskViewPreset: 'all',
|
||||
|
|
|
|||
|
|
@ -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: () => {
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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!
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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'])
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}, [])
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 }))
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 &&
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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: []
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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={() => {}}
|
||||
/>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -49,7 +49,11 @@ export const AGENTS_PANE_SEARCH_ENTRIES: SettingsSearchEntry[] = [
|
|||
'command',
|
||||
'override',
|
||||
'install',
|
||||
'detected'
|
||||
'detected',
|
||||
'enable',
|
||||
'disable',
|
||||
'hide',
|
||||
'show'
|
||||
]
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -241,6 +241,7 @@ export function getDefaultSettings(homedir: string): GlobalSettings {
|
|||
activeClaudeManagedAccountId: null,
|
||||
terminalScopeHistoryByWorktree: true,
|
||||
defaultTuiAgent: null,
|
||||
disabledTuiAgents: [],
|
||||
skipDeleteWorktreeConfirm: false,
|
||||
skipDeleteAutomationConfirm: false,
|
||||
defaultTaskViewPreset: 'all',
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
])
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue