diff --git a/src/renderer/src/components/settings/CommitMessageAiPane.tsx b/src/renderer/src/components/settings/CommitMessageAiPane.tsx index 096ccd34d51..b1a28ebf13e 100644 --- a/src/renderer/src/components/settings/CommitMessageAiPane.tsx +++ b/src/renderer/src/components/settings/CommitMessageAiPane.tsx @@ -1,3 +1,5 @@ +import { PiConfiguredModelReset } from './PiConfiguredModelReset' +import { getConnectionIdFromState } from '@/lib/connection-context' import { useRef } from 'react' import type React from 'react' import type { GlobalSettings } from '../../../../shared/global-settings-types' @@ -103,6 +105,11 @@ export function CommitMessageAiPane({ const storeSearchQuery = useAppStore((s) => s.settingsSearchQuery) const searchQuery = settingsSearchQuery ?? storeSearchQuery const config = readSettings(settings) + const piHostKey = useAppStore((s) => + getCommitMessageModelDiscoveryHostKeyForScope( + getRuntimeGitScope(settings, getConnectionIdFromState(s, s.activeWorktreeId)) + ) + ) const ownership = getSettingOwnershipSummary('sourceControlAiDefaults') const settingsWriteQueueRef = useRef>(undefined!) settingsWriteQueueRef.current ??= Promise.resolve() @@ -214,6 +221,24 @@ export function CommitMessageAiPane({ ) } + if ( + config.enabled && + matchesSettingsSearch(searchQuery, { + title: translate('settings.piConfiguredModelReset.title', 'Saved Pi model choices'), + description: translate('settings.piConfiguredModelReset.action', 'Use Pi’s configured model'), + keywords: ['pi', 'model', 'copilot'] + }) + ) { + sections.push( + + ) + } + sections.push( Promise +}): React.JSX.Element | null { + const [saving, setSaving] = useState(false) + if (!hasSavedPiSourceControlModel(config, hostKey)) { + return null + } + const reset = async (): Promise => { + setSaving(true) + try { + await writeConfig((current) => resetPiSourceControlModelsForHost(current, hostKey)) + toast.success( + translate( + 'settings.piConfiguredModelReset.success', + 'Pi now uses its configured model on this host.' + ) + ) + } catch { + toast.error( + translate( + 'settings.piConfiguredModelReset.error', + 'Could not reset saved Pi model choices.' + ) + ) + } finally { + setSaving(false) + } + } + return ( +
+
+ +

+ {translate( + 'settings.piConfiguredModelReset.description', + 'Reset saved Pi model choices for commit messages, pull requests, and branch names on this host. Models in recipe CLI arguments still apply.' + )} +

+

+ {translate('settings.piConfiguredModelReset.host', 'Host: {{host}}', { + host: hostKey === 'local' ? 'This computer' : 'Current workspace host' + })} +

+
+ +
+ ) +} diff --git a/src/renderer/src/components/settings/commit-message-ai-search.ts b/src/renderer/src/components/settings/commit-message-ai-search.ts index 8ec0d820c0d..1d2c6be14c9 100644 --- a/src/renderer/src/components/settings/commit-message-ai-search.ts +++ b/src/renderer/src/components/settings/commit-message-ai-search.ts @@ -3,6 +3,11 @@ import { translateSearchKeyword } from './settings-search-keywords' import { createLocalizedCatalog } from '@/i18n/localized-catalog' export const getCommitMessageAiPaneSearchEntries = createLocalizedCatalog(() => [ + { + title: translate('settings.piConfiguredModelReset.title', 'Saved Pi model choices'), + description: translate('settings.piConfiguredModelReset.action', 'Use Pi’s configured model'), + keywords: ['pi', 'model', 'copilot', 'reset'] + }, { title: translate( 'auto.components.settings.commit.message.ai.search.24dbdfca78', diff --git a/src/shared/pi-source-control-model-reset.test.ts b/src/shared/pi-source-control-model-reset.test.ts new file mode 100644 index 00000000000..88887897d40 --- /dev/null +++ b/src/shared/pi-source-control-model-reset.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from 'vitest' +import { getDefaultSettings } from './constants' +import { + hasSavedPiSourceControlModel, + resetPiSourceControlModelsForHost +} from './pi-source-control-model-reset' +import { + resolveSourceControlAiForOperation, + projectSourceControlAiToLegacyCommitMessageAi, + mergeLegacyCommitMessageAiIntoSourceControlAi +} from './source-control-ai' + +const copilot = 'github-copilot/gpt-5.4-mini' +describe('Pi model reset', () => { + it.each(['local', 'ssh:fixture', 'wsl:Ubuntu', 'runtime:fixture'])( + 'resets only Pi choices for %s through save/reload', + (hostKey) => { + const settings = getDefaultSettings('/tmp') + const config = settings.sourceControlAi! + config.agentId = 'pi' + config.selectedModelByAgent = { pi: copilot, codex: 'gpt-5.5' } + config.selectedModelByAgentByHost = { + [hostKey]: { pi: copilot, codex: 'gpt-5.5' }, + 'ssh:other': { pi: 'other/model' } + } + config.modelOverridesByOperation = { + branchName: { + selectedModelByAgentByHost: { + [hostKey]: { pi: copilot }, + 'ssh:other': { pi: 'other/branch' } + } + } + } + config.modelOverridesByOperation.branchName!.selectedThinkingByModel = { 'gpt-5.5': 'high' } + config.actions = { branchName: { agentId: 'pi', agentArgs: '--thinking low' } } + expect(hasSavedPiSourceControlModel(config, hostKey)).toBe(true) + const reset = resetPiSourceControlModelsForHost(config, hostKey) + expect(hasSavedPiSourceControlModel(reset, hostKey)).toBe(false) + expect(reset.actions).toEqual(config.actions) + expect(reset.modelOverridesByOperation?.branchName?.selectedThinkingByModel).toEqual({ + 'gpt-5.5': 'high' + }) + expect(reset.selectedModelByAgentByHost?.['ssh:other']).toEqual({ pi: 'other/model' }) + expect(reset.selectedModelByAgentByHost?.[hostKey]?.codex).toBe('gpt-5.5') + expect( + reset.modelOverridesByOperation?.branchName?.selectedModelByAgentByHost?.['ssh:other'] + ).toEqual({ pi: 'other/branch' }) + expect(config.selectedModelByAgentByHost?.[hostKey]?.pi).toBe(copilot) + if (hostKey !== 'local') { + expect(reset.selectedModelByAgent.pi).toBe(copilot) + } + settings.commitMessageAi = projectSourceControlAiToLegacyCommitMessageAi(reset) + settings.sourceControlAi = mergeLegacyCommitMessageAiIntoSourceControlAi( + reset, + settings.commitMessageAi + ) + for (const operation of ['commitMessage', 'pullRequest', 'branchName'] as const) { + const resolved = resolveSourceControlAiForOperation({ + settings, + operation, + discoveryHostKey: hostKey + }) + expect(resolved.ok && resolved.value.params.useConfiguredDefaultModel).toBe(true) + } + } + ) + it('does not reset an unresolved host', () => { + const config = getDefaultSettings('/tmp').sourceControlAi! + config.selectedModelByAgent.pi = copilot + expect(hasSavedPiSourceControlModel(config, 'unknown')).toBe(false) + expect(resetPiSourceControlModelsForHost(config, 'unknown')).toBe(config) + }) +}) diff --git a/src/shared/pi-source-control-model-reset.ts b/src/shared/pi-source-control-model-reset.ts new file mode 100644 index 00000000000..eeaaa220d12 --- /dev/null +++ b/src/shared/pi-source-control-model-reset.ts @@ -0,0 +1,44 @@ +import { + clearSourceControlAiModelChoiceForHost, + readSourceControlAiModelChoiceForHost +} from './source-control-ai-model-selection' +import { PI_DEFAULT_MODEL_ID } from './pi-configured-default-model' +import { UNKNOWN_COMMIT_MESSAGE_HOST_KEY } from './commit-message-host-key' +import type { SourceControlAiSettings } from './source-control-ai-types' + +export function hasSavedPiSourceControlModel( + settings: SourceControlAiSettings, + hostKey: string +): boolean { + if (hostKey === UNKNOWN_COMMIT_MESSAGE_HOST_KEY) { + return false + } + return [settings, ...Object.values(settings.modelOverridesByOperation ?? {})].some((choice) => { + const model = readSourceControlAiModelChoiceForHost(choice, hostKey, 'pi') + return model !== undefined && model !== PI_DEFAULT_MODEL_ID + }) +} + +export function resetPiSourceControlModelsForHost( + settings: SourceControlAiSettings, + hostKey: string +): SourceControlAiSettings { + if (hostKey === UNKNOWN_COMMIT_MESSAGE_HOST_KEY) { + return settings + } + const choice = clearSourceControlAiModelChoiceForHost(settings, hostKey, 'pi') + const overrides = { ...settings.modelOverridesByOperation } + for (const operation of Object.keys(overrides) as (keyof typeof overrides)[]) { + const previous = overrides[operation] + const cleared = clearSourceControlAiModelChoiceForHost(previous, hostKey, 'pi') + overrides[operation] = previous?.selectedThinkingByModel + ? { ...cleared, selectedThinkingByModel: previous.selectedThinkingByModel } + : cleared + } + return { + ...settings, + selectedModelByAgent: choice?.selectedModelByAgent ?? {}, + selectedModelByAgentByHost: choice?.selectedModelByAgentByHost ?? {}, + modelOverridesByOperation: overrides + } +} diff --git a/tests/tools/pi-default-runtime-smoke.mjs b/tests/tools/pi-default-runtime-smoke.mjs index cd0368cc2db..e25af7bfb6b 100644 --- a/tests/tools/pi-default-runtime-smoke.mjs +++ b/tests/tools/pi-default-runtime-smoke.mjs @@ -1,6 +1,6 @@ import assert from 'node:assert/strict' import { once } from 'node:events' -import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises' +import { mkdtemp, mkdir, writeFile, readFile, rm } from 'node:fs/promises' import { createServer } from 'node:http' import { createRequire } from 'node:module' import { tmpdir } from 'node:os' @@ -88,7 +88,9 @@ try { join(dir, 'settings.json'), JSON.stringify({ defaultProvider: 'orca-proof', defaultModel: 'local' }) ) - const settings = getDefaultSettings(scratch) + const settings = process.argv[3] + ? JSON.parse(await readFile(resolve(process.argv[3]), 'utf8')).settings + : getDefaultSettings(scratch) settings.defaultTuiAgent = 'pi' settings.sourceControlAi.agentId = 'pi' settings.commitMessageAi.agentId = 'pi'