diff --git a/src/main/ipc/filesystem.test.ts b/src/main/ipc/filesystem.test.ts index adfca251b..6679e5cff 100644 --- a/src/main/ipc/filesystem.test.ts +++ b/src/main/ipc/filesystem.test.ts @@ -29,6 +29,8 @@ const { listWorktreesMock, resolveCommitMessageSettingsMock, generateCommitMessageFromContextMock, + discoverCommitMessageModelsLocalMock, + discoverCommitMessageModelsRemoteMock, cancelGenerateCommitMessageLocalMock, getSshFilesystemProviderMock, getSshGitProviderMock @@ -58,6 +60,8 @@ const { listWorktreesMock: vi.fn(), resolveCommitMessageSettingsMock: vi.fn(), generateCommitMessageFromContextMock: vi.fn(), + discoverCommitMessageModelsLocalMock: vi.fn(), + discoverCommitMessageModelsRemoteMock: vi.fn(), cancelGenerateCommitMessageLocalMock: vi.fn(), getSshFilesystemProviderMock: vi.fn(), getSshGitProviderMock: vi.fn() @@ -129,6 +133,8 @@ vi.mock('../providers/ssh-git-dispatch', () => ({ vi.mock('../text-generation/commit-message-text-generation', () => ({ resolveCommitMessageSettings: resolveCommitMessageSettingsMock, generateCommitMessageFromContext: generateCommitMessageFromContextMock, + discoverCommitMessageModelsLocal: discoverCommitMessageModelsLocalMock, + discoverCommitMessageModelsRemote: discoverCommitMessageModelsRemoteMock, cancelGenerateCommitMessageLocal: cancelGenerateCommitMessageLocalMock })) @@ -205,6 +211,8 @@ describe('registerFilesystemHandlers', () => { listWorktreesMock, resolveCommitMessageSettingsMock, generateCommitMessageFromContextMock, + discoverCommitMessageModelsLocalMock, + discoverCommitMessageModelsRemoteMock, cancelGenerateCommitMessageLocalMock, getSshFilesystemProviderMock, getSshGitProviderMock @@ -960,7 +968,7 @@ describe('registerFilesystemHandlers', () => { stagedSummary: 'M\tREADME.md', stagedPatch: '+hello' } - const params = { agentId: 'claude', model: 'claude-haiku-4-5' } + const params = { agentId: 'claude', model: 'haiku' } resolveCommitMessageSettingsMock.mockReturnValue({ ok: true, params }) getStagedCommitContextMock.mockResolvedValue(context) generateCommitMessageFromContextMock.mockResolvedValue({ @@ -1000,6 +1008,89 @@ describe('registerFilesystemHandlers', () => { } }) + it('passes per-agent command overrides into local model discovery', async () => { + discoverCommitMessageModelsLocalMock.mockResolvedValue({ + success: true, + capability: { + id: 'codex', + label: 'Codex', + modelSource: 'dynamic', + defaultModelId: 'gpt-5.5', + models: [{ id: 'gpt-5.5', label: 'GPT-5.5' }] + }, + models: [{ id: 'gpt-5.5', label: 'GPT-5.5' }], + defaultModelId: 'gpt-5.5' + }) + const storeWithOverride = { + ...store, + getSettings: () => ({ + workspaceDir: WORKSPACE_DIR, + agentCmdOverrides: { codex: 'npx codex' } + }) + } + + registerFilesystemHandlers(storeWithOverride as never) + + await handlers.get('git:discoverCommitMessageModels')!(null, { agentId: 'codex' }) + + expect(discoverCommitMessageModelsLocalMock).toHaveBeenCalledWith( + 'codex', + undefined, + 'npx codex' + ) + }) + + it('routes SSH model discovery through the remote git provider', async () => { + discoverCommitMessageModelsRemoteMock.mockResolvedValue({ + success: true, + capability: { + id: 'cursor', + label: 'Cursor', + modelSource: 'dynamic', + defaultModelId: 'auto', + models: [{ id: 'auto', label: 'Auto' }] + }, + models: [{ id: 'auto', label: 'Auto' }], + defaultModelId: 'auto' + }) + const executeCommitMessagePlan = vi.fn() + getSshGitProviderMock.mockReturnValue({ executeCommitMessagePlan }) + const storeWithOverride = { + ...store, + getSettings: () => ({ + workspaceDir: WORKSPACE_DIR, + agentCmdOverrides: { cursor: 'npx cursor-agent' } + }) + } + + registerFilesystemHandlers(storeWithOverride as never) + + await handlers.get('git:discoverCommitMessageModels')!(null, { + agentId: 'cursor', + worktreePath: '/remote/repo', + connectionId: 'conn-1' + }) + + expect(discoverCommitMessageModelsRemoteMock).toHaveBeenCalledWith( + 'cursor', + '/remote/repo', + expect.any(Function), + 'npx cursor-agent' + ) + const execute = discoverCommitMessageModelsRemoteMock.mock.calls[0]?.[2] as ( + plan: unknown, + cwd: string, + timeoutMs: number + ) => Promise + await execute({ binary: 'cursor-agent', args: ['--list-models'] }, '/remote/repo', 60_000) + expect(executeCommitMessagePlan).toHaveBeenCalledWith( + { binary: 'cursor-agent', args: ['--list-models'] }, + '/remote/repo', + 60_000 + ) + expect(discoverCommitMessageModelsLocalMock).not.toHaveBeenCalled() + }) + it('generates an SSH commit message using remote staged context and relay execution', async () => { const context = { branch: 'main', diff --git a/src/main/ipc/filesystem.ts b/src/main/ipc/filesystem.ts index cd818cb9e..1b4722d04 100644 --- a/src/main/ipc/filesystem.ts +++ b/src/main/ipc/filesystem.ts @@ -17,7 +17,8 @@ import type { GitStatusResult, MarkdownDocument, SearchOptions, - SearchResult + SearchResult, + TuiAgent } from '../../shared/types' import type { GitHistoryOptions, GitHistoryResult } from '../../shared/git-history' import { @@ -49,9 +50,12 @@ import { getHistory } from '../git/history' import { cancelGenerateCommitMessageLocal, cancelGeneratePullRequestFieldsLocal, + discoverCommitMessageModelsLocal, + discoverCommitMessageModelsRemote, generateCommitMessageFromContext, generatePullRequestFieldsFromContext, resolveCommitMessageSettings, + type DiscoverCommitMessageModelsResult, type GenerateCommitMessageResult, type GeneratePullRequestFieldsResult } from '../text-generation/commit-message-text-generation' @@ -60,6 +64,7 @@ import { getUpstreamStatus } from '../git/upstream' import { gitFetch, gitPull, gitPush } from '../git/remote' import { checkIgnoredPaths } from '../git/check-ignored-paths' import { assertGitPushTargetShape } from '../../shared/git-push-target-validation' +import { getCommitMessageModelDiscoveryHostKey } from '../../shared/commit-message-host-key' import { validateGitPushTarget } from '../git/push-target-validation' import { getRemoteFileUrl } from '../git/repo' import { @@ -623,7 +628,8 @@ export function registerFilesystemHandlers( connectionId?: string } ): Promise => { - const resolvedSettings = resolveCommitMessageSettings(store.getSettings()) + const discoveryHostKey = getCommitMessageModelDiscoveryHostKey(args.connectionId ?? null) + const resolvedSettings = resolveCommitMessageSettings(store.getSettings(), discoveryHostKey) if (!resolvedSettings.ok) { return { success: false, error: resolvedSettings.error } } @@ -701,6 +707,44 @@ export function registerFilesystemHandlers( } ) + ipcMain.handle( + 'git:discoverCommitMessageModels', + async ( + _event, + args: { agentId: string; worktreePath?: string; connectionId?: string } + ): Promise => { + const agentId = args.agentId + const agentCommandOverride = store.getSettings().agentCmdOverrides?.[agentId as TuiAgent] + if (args.connectionId) { + if (!args.worktreePath) { + return { success: false, error: 'Missing worktree path for remote model discovery.' } + } + const provider = getSshGitProvider(args.connectionId) + if (!provider) { + return { + success: false, + error: `No git provider for connection "${args.connectionId}"` + } + } + return discoverCommitMessageModelsRemote( + agentId as TuiAgent, + args.worktreePath, + (plan, cwd, timeoutMs) => provider.executeCommitMessagePlan(plan, cwd, timeoutMs), + agentCommandOverride + ) + } + const localEnv = await prepareLocalCommitMessageAgentEnv(agentId, commitMessageAgentEnv) + if (!localEnv.ok) { + return { success: false, error: localEnv.error } + } + return discoverCommitMessageModelsLocal( + agentId as TuiAgent, + localEnv.env, + agentCommandOverride + ) + } + ) + ipcMain.handle( 'git:generatePullRequestFields', async ( @@ -714,7 +758,8 @@ export function registerFilesystemHandlers( connectionId?: string } ): Promise => { - const resolvedSettings = resolveCommitMessageSettings(store.getSettings()) + const discoveryHostKey = getCommitMessageModelDiscoveryHostKey(args.connectionId ?? null) + const resolvedSettings = resolveCommitMessageSettings(store.getSettings(), discoveryHostKey) if (!resolvedSettings.ok) { return { success: false, error: resolvedSettings.error } } diff --git a/src/main/runtime/orca-runtime-git.test.ts b/src/main/runtime/orca-runtime-git.test.ts index c05c67676..b7263884b 100644 --- a/src/main/runtime/orca-runtime-git.test.ts +++ b/src/main/runtime/orca-runtime-git.test.ts @@ -10,7 +10,8 @@ import { RuntimeGitCommands, type ResolvedRuntimeGitWorktree } from './orca-runt const mocks = vi.hoisted(() => ({ getStagedCommitContext: vi.fn(), generateCommitMessageFromContext: vi.fn(), - resolveCommitMessageSettings: vi.fn() + resolveCommitMessageSettings: vi.fn(), + getSshGitProvider: vi.fn() })) vi.mock('../git/status', async () => ({ @@ -26,6 +27,10 @@ vi.mock('../text-generation/commit-message-text-generation', async () => ({ resolveCommitMessageSettings: mocks.resolveCommitMessageSettings })) +vi.mock('../providers/ssh-git-dispatch', () => ({ + getSshGitProvider: mocks.getSshGitProvider +})) + const tempDirs: string[] = [] function makeWorktree(path: string): ResolvedRuntimeGitWorktree { @@ -55,6 +60,7 @@ describe('RuntimeGitCommands', () => { mocks.getStagedCommitContext.mockReset() mocks.generateCommitMessageFromContext.mockReset() mocks.resolveCommitMessageSettings.mockReset() + mocks.getSshGitProvider.mockReset() }) afterEach(() => { @@ -109,6 +115,12 @@ describe('RuntimeGitCommands', () => { message: 'docs: update readme' }) + expect(mocks.resolveCommitMessageSettings).toHaveBeenCalledWith( + expect.objectContaining({ + commitMessageAi: { enabled: true, agentId: 'codex' } + }), + 'local' + ) expect(mocks.generateCommitMessageFromContext).toHaveBeenCalledWith( context, params, @@ -119,4 +131,56 @@ describe('RuntimeGitCommands', () => { }) ) }) + + it('resolves remote commit-message settings against the SSH host cache', async () => { + const worktreePath = '/remote/repo' + const context = { + branch: 'main', + stagedSummary: 'M\tREADME.md', + stagedPatch: '+hello' + } + const params = { agentId: 'cursor', model: 'remote-model' } + mocks.resolveCommitMessageSettings.mockReturnValue({ ok: true, params }) + mocks.generateCommitMessageFromContext.mockResolvedValue({ + success: true, + message: 'docs: update remote readme' + }) + const provider = { + getStagedCommitContext: vi.fn().mockResolvedValue(context), + executeCommitMessagePlan: vi.fn() + } + mocks.getSshGitProvider.mockReturnValue(provider) + const commands = new RuntimeGitCommands({ + resolveRuntimeGitTarget: async () => ({ + worktree: makeWorktree(worktreePath), + connectionId: 'conn-1' + }), + getRuntimeSettings: () => + ({ + commitMessageAi: { + enabled: true, + agentId: 'cursor', + selectedModelByAgentByHost: { 'ssh:conn-1': { cursor: 'remote-model' } } + } + }) as unknown as GlobalSettings + }) + + await expect(commands.generateRuntimeCommitMessage('id:wt-1')).resolves.toEqual({ + success: true, + message: 'docs: update remote readme' + }) + + expect(mocks.resolveCommitMessageSettings).toHaveBeenCalledWith( + expect.any(Object), + 'ssh:conn-1' + ) + expect(mocks.generateCommitMessageFromContext).toHaveBeenCalledWith( + context, + params, + expect.objectContaining({ + kind: 'remote', + cwd: worktreePath + }) + ) + }) }) diff --git a/src/main/runtime/orca-runtime-git.ts b/src/main/runtime/orca-runtime-git.ts index a82a2edc8..4d92be7b6 100644 --- a/src/main/runtime/orca-runtime-git.ts +++ b/src/main/runtime/orca-runtime-git.ts @@ -9,9 +9,11 @@ import type { GitUpstreamStatus, GitWorktreeInfo, GlobalSettings, + TuiAgent, Worktree } from '../../shared/types' import type { CommitMessageDraftContext } from '../../shared/commit-message-generation' +import { getCommitMessageModelDiscoveryHostKey } from '../../shared/commit-message-host-key' import type { GitHistoryOptions, GitHistoryResult } from '../../shared/git-history' import { getRemoteFileUrl } from '../git/repo' import { @@ -42,9 +44,12 @@ import { checkIgnoredPaths } from '../git/check-ignored-paths' import { cancelGenerateCommitMessageLocal, cancelGeneratePullRequestFieldsLocal, + discoverCommitMessageModelsLocal, + discoverCommitMessageModelsRemote, generateCommitMessageFromContext, generatePullRequestFieldsFromContext, resolveCommitMessageSettings, + type DiscoverCommitMessageModelsResult, type GenerateCommitMessageResult, type GeneratePullRequestFieldsResult } from '../text-generation/commit-message-text-generation' @@ -57,7 +62,9 @@ import { gitExecFileAsync } from '../git/runner' export type ResolvedRuntimeGitWorktree = Worktree & { git: GitWorktreeInfo } type RuntimeCommitMessageSettingsOverride = Partial< Pick -> +> & { + commitMessageDiscoveryHostKey?: string +} function normalizeRuntimeGitRelativePath(filePath: string): string { const relativePath = normalizeRuntimeRelativePath(filePath) @@ -333,15 +340,21 @@ export class RuntimeGitCommands { worktreeSelector: string, settingsOverride?: RuntimeCommitMessageSettingsOverride ): Promise { - const resolvedSettings = resolveCommitMessageSettings({ - ...this.host.getRuntimeSettings(), - ...settingsOverride - }) + const target = await this.host.resolveRuntimeGitTarget(worktreeSelector) + const discoveryHostKey = + settingsOverride?.commitMessageDiscoveryHostKey ?? + getCommitMessageModelDiscoveryHostKey(target.connectionId ?? null) + const resolvedSettings = resolveCommitMessageSettings( + { + ...this.host.getRuntimeSettings(), + ...settingsOverride + }, + discoveryHostKey + ) if (!resolvedSettings.ok) { return { success: false, error: resolvedSettings.error } } - const target = await this.host.resolveRuntimeGitTarget(worktreeSelector) const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null if (target.connectionId) { if (!provider) { @@ -408,15 +421,21 @@ export class RuntimeGitCommands { input: { base: string; title: string; body: string; draft: boolean }, settingsOverride?: RuntimeCommitMessageSettingsOverride ): Promise { - const resolvedSettings = resolveCommitMessageSettings({ - ...this.host.getRuntimeSettings(), - ...settingsOverride - }) + const target = await this.host.resolveRuntimeGitTarget(worktreeSelector) + const discoveryHostKey = + settingsOverride?.commitMessageDiscoveryHostKey ?? + getCommitMessageModelDiscoveryHostKey(target.connectionId ?? null) + const resolvedSettings = resolveCommitMessageSettings( + { + ...this.host.getRuntimeSettings(), + ...settingsOverride + }, + discoveryHostKey + ) if (!resolvedSettings.ok) { return { success: false, error: resolvedSettings.error } } - const target = await this.host.resolveRuntimeGitTarget(worktreeSelector) const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null if (target.connectionId && !provider) { return { @@ -478,6 +497,41 @@ export class RuntimeGitCommands { return { ok: true } } + async discoverRuntimeCommitMessageModels( + worktreeSelector: string, + agentId: string, + settingsOverride?: Pick + ): Promise { + const target = await this.host.resolveRuntimeGitTarget(worktreeSelector) + const typedAgentId = agentId as TuiAgent + const agentCommandOverride = + settingsOverride?.agentCmdOverrides?.[typedAgentId] ?? + this.host.getRuntimeSettings().agentCmdOverrides?.[typedAgentId] + if (target.connectionId) { + const provider = getSshGitProvider(target.connectionId) + if (!provider) { + return { + success: false, + error: `No git provider for connection "${target.connectionId}"` + } + } + return discoverCommitMessageModelsRemote( + typedAgentId, + target.worktree.path, + (plan, cwd, timeoutMs) => provider.executeCommitMessagePlan(plan, cwd, timeoutMs), + agentCommandOverride + ) + } + const localEnv = await prepareLocalCommitMessageAgentEnv( + typedAgentId, + this.host.getCommitMessageAgentEnvironment?.() + ) + if (!localEnv.ok) { + return { success: false, error: localEnv.error } + } + return discoverCommitMessageModelsLocal(typedAgentId, localEnv.env, agentCommandOverride) + } + async stageRuntimeGitPath(worktreeSelector: string, filePath: string): Promise<{ ok: true }> { const target = await this.host.resolveRuntimeGitTarget(worktreeSelector) const relativePath = normalizeRuntimeGitRelativePath(filePath) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 82510a2b8..9aaaef30f 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -1694,6 +1694,8 @@ export class OrcaRuntimeService { ) generateRuntimeCommitMessage: RuntimeGitCommands['generateRuntimeCommitMessage'] = this.gitCommands.generateRuntimeCommitMessage.bind(this.gitCommands) + discoverRuntimeCommitMessageModels: RuntimeGitCommands['discoverRuntimeCommitMessageModels'] = + this.gitCommands.discoverRuntimeCommitMessageModels.bind(this.gitCommands) cancelRuntimeGenerateCommitMessage: RuntimeGitCommands['cancelRuntimeGenerateCommitMessage'] = this.gitCommands.cancelRuntimeGenerateCommitMessage.bind(this.gitCommands) generateRuntimePullRequestFields: RuntimeGitCommands['generateRuntimePullRequestFields'] = diff --git a/src/main/runtime/rpc/methods/git-params.ts b/src/main/runtime/rpc/methods/git-params.ts index 41f1207bc..db6b5cbce 100644 --- a/src/main/runtime/rpc/methods/git-params.ts +++ b/src/main/runtime/rpc/methods/git-params.ts @@ -78,10 +78,22 @@ export const GitCommit = WorktreeSelector.extend({ .pipe(z.string().min(1, 'Missing commit message')) }) +const CommitMessageModelCapability = z.object({ + id: z.string(), + label: z.string(), + thinkingLevels: z.array(z.object({ id: z.string(), label: z.string() })).optional(), + defaultThinkingLevel: z.string().optional() +}) + const CommitMessageAiSettings = z.object({ enabled: z.boolean(), agentId: z.string().nullable(), selectedModelByAgent: z.record(z.string(), z.string()), + selectedModelByAgentByHost: z.record(z.string(), z.record(z.string(), z.string())).optional(), + discoveredModelsByAgent: z.record(z.string(), z.array(CommitMessageModelCapability)).optional(), + discoveredModelsByAgentByHost: z + .record(z.string(), z.record(z.string(), z.array(CommitMessageModelCapability))) + .optional(), selectedThinkingByModel: z.record(z.string(), z.string()), customPrompt: z.string(), customAgentCommand: z.string() @@ -90,7 +102,13 @@ const CommitMessageAiSettings = z.object({ export const GitGenerateCommitMessage = WorktreeSelector.extend({ commitMessageAi: CommitMessageAiSettings.optional(), agentCmdOverrides: z.record(z.string(), z.string()).optional(), - enableGitHubAttribution: z.boolean().optional() + enableGitHubAttribution: z.boolean().optional(), + commitMessageDiscoveryHostKey: z.string().optional() +}) + +export const GitDiscoverCommitMessageModels = WorktreeSelector.extend({ + agentId: z.string().min(1, 'Missing agent id'), + agentCmdOverrides: z.record(z.string(), z.string()).optional() }) export const GitGeneratePullRequestFields = GitGenerateCommitMessage.extend({ diff --git a/src/main/runtime/rpc/methods/git.test.ts b/src/main/runtime/rpc/methods/git.test.ts index 5a23d49f0..60d2c9542 100644 --- a/src/main/runtime/rpc/methods/git.test.ts +++ b/src/main/runtime/rpc/methods/git.test.ts @@ -191,6 +191,11 @@ describe('git RPC methods', () => { generateRuntimeCommitMessage: vi .fn() .mockResolvedValue({ success: true, message: 'feat: test' }), + discoverRuntimeCommitMessageModels: vi.fn().mockResolvedValue({ + success: true, + models: [{ id: 'auto', label: 'Auto' }], + defaultModelId: 'auto' + }), cancelRuntimeGenerateCommitMessage: vi.fn().mockResolvedValue({ ok: true }), pushRuntimeGit: vi.fn().mockResolvedValue({ ok: true }), getRuntimeGitRemoteFileUrl: vi.fn().mockResolvedValue('https://example.com/file#L3') @@ -201,6 +206,13 @@ describe('git RPC methods', () => { makeRequest('git.commit', { worktree: 'id:wt-1', message: 'feat: test' }) ) await dispatcher.dispatch(makeRequest('git.generateCommitMessage', { worktree: 'id:wt-1' })) + await dispatcher.dispatch( + makeRequest('git.discoverCommitMessageModels', { + worktree: 'id:wt-1', + agentId: 'cursor', + agentCmdOverrides: { cursor: 'cursor-agent' } + }) + ) await dispatcher.dispatch( makeRequest('git.cancelGenerateCommitMessage', { worktree: 'id:wt-1' }) ) @@ -221,6 +233,9 @@ describe('git RPC methods', () => { expect(runtime.commitRuntimeGit).toHaveBeenCalledWith('id:wt-1', 'feat: test') expect(runtime.generateRuntimeCommitMessage).toHaveBeenCalledWith('id:wt-1') + expect(runtime.discoverRuntimeCommitMessageModels).toHaveBeenCalledWith('id:wt-1', 'cursor', { + agentCmdOverrides: { cursor: 'cursor-agent' } + }) expect(runtime.cancelRuntimeGenerateCommitMessage).toHaveBeenCalledWith('id:wt-1') expect(runtime.pushRuntimeGit).toHaveBeenCalledWith('id:wt-1', true, { remote: 'origin' }) expect(response).toMatchObject({ ok: true, result: 'https://example.com/file#L3' }) @@ -231,6 +246,15 @@ describe('git RPC methods', () => { enabled: true, agentId: 'codex', selectedModelByAgent: { codex: 'gpt-5.3-codex-spark' }, + selectedModelByAgentByHost: { 'ssh:conn-1': { cursor: 'remote-model' } }, + discoveredModelsByAgent: { + cursor: [{ id: 'local-model', label: 'Local Model' }] + }, + discoveredModelsByAgentByHost: { + 'ssh:conn-1': { + cursor: [{ id: 'remote-model', label: 'Remote Model' }] + } + }, selectedThinkingByModel: { 'gpt-5.3-codex-spark': 'medium' }, customPrompt: '', customAgentCommand: '' @@ -247,14 +271,16 @@ describe('git RPC methods', () => { worktree: 'id:wt-1', commitMessageAi, agentCmdOverrides, - enableGitHubAttribution: true + enableGitHubAttribution: true, + commitMessageDiscoveryHostKey: 'runtime:env-1' }) ) expect(runtime.generateRuntimeCommitMessage).toHaveBeenCalledWith('id:wt-1', { commitMessageAi, agentCmdOverrides, - enableGitHubAttribution: true + enableGitHubAttribution: true, + commitMessageDiscoveryHostKey: 'runtime:env-1' }) }) diff --git a/src/main/runtime/rpc/methods/git.ts b/src/main/runtime/rpc/methods/git.ts index 64e7c63c8..2fdc80f01 100644 --- a/src/main/runtime/rpc/methods/git.ts +++ b/src/main/runtime/rpc/methods/git.ts @@ -8,6 +8,7 @@ import { GitCommit, GitCommitCompare, GitCommitDiff, + GitDiscoverCommitMessageModels, GitDiff, GitFilePath, GitGenerateCommitMessage, @@ -127,7 +128,8 @@ export const GIT_METHODS: RpcMethod[] = [ if ( params.commitMessageAi === undefined && params.agentCmdOverrides === undefined && - params.enableGitHubAttribution === undefined + params.enableGitHubAttribution === undefined && + params.commitMessageDiscoveryHostKey === undefined ) { return runtime.generateRuntimeCommitMessage(params.worktree) } @@ -142,10 +144,27 @@ export const GIT_METHODS: RpcMethod[] = [ : {}), ...(params.enableGitHubAttribution !== undefined ? { enableGitHubAttribution: params.enableGitHubAttribution } + : {}), + ...(params.commitMessageDiscoveryHostKey !== undefined + ? { commitMessageDiscoveryHostKey: params.commitMessageDiscoveryHostKey } : {}) }) } }), + defineMethod({ + name: 'git.discoverCommitMessageModels', + params: GitDiscoverCommitMessageModels, + handler: async (params, { runtime }) => + runtime.discoverRuntimeCommitMessageModels( + params.worktree, + params.agentId, + params.agentCmdOverrides !== undefined + ? { + agentCmdOverrides: params.agentCmdOverrides as GlobalSettings['agentCmdOverrides'] + } + : {} + ) + }), defineMethod({ name: 'git.cancelGenerateCommitMessage', params: WorktreeSelector, @@ -165,7 +184,8 @@ export const GIT_METHODS: RpcMethod[] = [ if ( params.commitMessageAi === undefined && params.agentCmdOverrides === undefined && - params.enableGitHubAttribution === undefined + params.enableGitHubAttribution === undefined && + params.commitMessageDiscoveryHostKey === undefined ) { return runtime.generateRuntimePullRequestFields(params.worktree, input) } @@ -180,6 +200,9 @@ export const GIT_METHODS: RpcMethod[] = [ : {}), ...(params.enableGitHubAttribution !== undefined ? { enableGitHubAttribution: params.enableGitHubAttribution } + : {}), + ...(params.commitMessageDiscoveryHostKey !== undefined + ? { commitMessageDiscoveryHostKey: params.commitMessageDiscoveryHostKey } : {}) }) } diff --git a/src/main/text-generation/commit-message-agent-environment.test.ts b/src/main/text-generation/commit-message-agent-environment.test.ts new file mode 100644 index 000000000..96f50bcd0 --- /dev/null +++ b/src/main/text-generation/commit-message-agent-environment.test.ts @@ -0,0 +1,107 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { prepareLocalCommitMessageAgentEnv } from './commit-message-agent-environment' + +const originalEnv = { ...process.env } +const tempDirs: string[] = [] + +afterEach(() => { + for (const key of Object.keys(process.env)) { + if (!(key in originalEnv)) { + delete process.env[key] + } + } + Object.assign(process.env, originalEnv) + while (tempDirs.length > 0) { + rmSync(tempDirs.pop()!, { recursive: true, force: true }) + } +}) + +function makeHome(): string { + const dir = mkdtempSync(join(tmpdir(), 'orca-commit-env-')) + tempDirs.push(dir) + process.env.HOME = dir + process.env.SHELL = '/bin/zsh' + delete process.env.ORCA_OPENCODE_SOURCE_CONFIG_DIR + delete process.env.ORCA_PI_SOURCE_AGENT_DIR + return dir +} + +describe('prepareLocalCommitMessageAgentEnv', () => { + it('hydrates OpenCode config dir from shell startup files for headless generation', async () => { + const home = makeHome() + delete process.env.OPENCODE_CONFIG_DIR + writeFileSync(join(home, '.zshrc'), 'export OPENCODE_CONFIG_DIR="$HOME/company/opencode"\n') + + const result = await prepareLocalCommitMessageAgentEnv('opencode', undefined) + + expect(result).toEqual({ + ok: true, + env: expect.objectContaining({ + OPENCODE_CONFIG_DIR: join(home, 'company/opencode') + }) + }) + }) + + it('prefers the original OpenCode config root over inherited PTY overlays', async () => { + process.env.OPENCODE_CONFIG_DIR = '/tmp/orca-opencode-overlay' + process.env.ORCA_OPENCODE_SOURCE_CONFIG_DIR = '/Users/tester/company/opencode' + + const result = await prepareLocalCommitMessageAgentEnv('opencode', undefined) + + expect(result).toEqual({ + ok: true, + env: expect.objectContaining({ + OPENCODE_CONFIG_DIR: '/Users/tester/company/opencode' + }) + }) + }) + + it('hydrates Pi agent dir from shell startup files for headless generation', async () => { + const home = makeHome() + delete process.env.PI_CODING_AGENT_DIR + writeFileSync(join(home, '.zshrc'), 'export PI_CODING_AGENT_DIR="$HOME/.config/pi-agent"\n') + + const result = await prepareLocalCommitMessageAgentEnv('pi', undefined) + + expect(result).toEqual({ + ok: true, + env: expect.objectContaining({ + PI_CODING_AGENT_DIR: join(home, '.config/pi-agent') + }) + }) + }) + + it('prefers the original Pi agent root over inherited PTY overlays', async () => { + process.env.PI_CODING_AGENT_DIR = '/tmp/orca-pi-overlay' + process.env.ORCA_PI_SOURCE_AGENT_DIR = '/Users/tester/.pi/agent' + + const result = await prepareLocalCommitMessageAgentEnv('pi', undefined) + + expect(result).toEqual({ + ok: true, + env: expect.objectContaining({ + PI_CODING_AGENT_DIR: '/Users/tester/.pi/agent' + }) + }) + }) + + it('does not synthesize env for agents without shell-scoped auth or config roots', async () => { + makeHome() + + await expect(prepareLocalCommitMessageAgentEnv('cursor', undefined)).resolves.toEqual({ + ok: true + }) + }) + + it('falls back to inherited env when managed account resolvers are unavailable', async () => { + await expect(prepareLocalCommitMessageAgentEnv('codex', undefined)).resolves.toEqual({ + ok: true + }) + await expect(prepareLocalCommitMessageAgentEnv('claude', undefined)).resolves.toEqual({ + ok: true + }) + }) +}) diff --git a/src/main/text-generation/commit-message-agent-environment.ts b/src/main/text-generation/commit-message-agent-environment.ts index f4ba624b7..4581168b5 100644 --- a/src/main/text-generation/commit-message-agent-environment.ts +++ b/src/main/text-generation/commit-message-agent-environment.ts @@ -1,5 +1,6 @@ import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-auth-service' import { applyClaudeEnvPatch } from '../claude-accounts/environment' +import { readShellStartupEnvVar } from '../pty/shell-startup-env' export type CommitMessageAgentEnvironmentResolvers = { prepareForCodexLaunch?: () => string | null @@ -16,10 +17,46 @@ function cloneProcessEnv(): Record { return env } +function readInheritedOrShellEnvVar(name: string, sourceName?: string): string | undefined { + return ( + (sourceName ? process.env[sourceName] : undefined) ?? + process.env[name] ?? + readShellStartupEnvVar(name, process.env.HOME, process.env.SHELL) + ) +} + +function prepareShellConfigDirEnv(agentId: string): { ok: true; env?: NodeJS.ProcessEnv } | null { + const configVar = + agentId === 'opencode' ? 'OPENCODE_CONFIG_DIR' : agentId === 'pi' ? 'PI_CODING_AGENT_DIR' : null + if (!configVar) { + return null + } + const sourceVar = + agentId === 'opencode' + ? 'ORCA_OPENCODE_SOURCE_CONFIG_DIR' + : agentId === 'pi' + ? 'ORCA_PI_SOURCE_AGENT_DIR' + : undefined + + const value = readInheritedOrShellEnvVar(configVar, sourceVar) + if (!value) { + return { ok: true } + } + + // Why: GUI-launched Orca may not inherit shell startup exports, but these + // vars point the headless CLI at the user's auth/config root. Nested Orca + // launches inherit PTY overlays, so prefer ORCA_*_SOURCE_* when present. + return { ok: true, env: { ...cloneProcessEnv(), [configVar]: value } } +} + export async function prepareLocalCommitMessageAgentEnv( agentId: string, resolvers: CommitMessageAgentEnvironmentResolvers | undefined ): Promise<{ ok: true; env?: NodeJS.ProcessEnv } | { ok: false; error: string }> { + const shellConfigEnv = prepareShellConfigDirEnv(agentId) + if (shellConfigEnv) { + return shellConfigEnv + } if (!resolvers) { return { ok: true } } diff --git a/src/main/text-generation/commit-message-text-generation.test.ts b/src/main/text-generation/commit-message-text-generation.test.ts index c18f91bad..46f938317 100644 --- a/src/main/text-generation/commit-message-text-generation.test.ts +++ b/src/main/text-generation/commit-message-text-generation.test.ts @@ -8,6 +8,8 @@ import { getDefaultSettings } from '../../shared/constants' import { cancelGenerateCommitMessageLocal, cancelGeneratePullRequestFieldsLocal, + discoverCommitMessageModelsLocal, + discoverCommitMessageModelsRemote, generateCommitMessageFromContext, generatePullRequestFieldsFromContext, resolveCommitMessageSettings, @@ -39,7 +41,7 @@ beforeEach(() => { }) describe('resolveCommitMessageSettings', () => { - it('falls back to the agent default model when a persisted model is stale', () => { + it('falls back when a dynamic persisted model was not discovered', () => { const settings = getDefaultSettings('/tmp') settings.enableGitHubAttribution = true settings.commitMessageAi = { @@ -64,6 +66,29 @@ describe('resolveCommitMessageSettings', () => { }) }) + it('falls back from stale Claude version ids to the CLI alias default', () => { + const settings = getDefaultSettings('/tmp') + settings.commitMessageAi = { + enabled: true, + agentId: 'claude', + selectedModelByAgent: { claude: 'claude-sonnet-4-6' }, + selectedThinkingByModel: { sonnet: 'low' }, + customPrompt: '', + customAgentCommand: '' + } + + const result = resolveCommitMessageSettings(settings) + + expect(result).toMatchObject({ + ok: true, + params: { + agentId: 'claude', + model: 'sonnet', + thinkingLevel: 'low' + } + }) + }) + it("uses the user's default agent when the AI setting has no explicit agent", () => { const settings = getDefaultSettings('/tmp') settings.defaultTuiAgent = 'codex' @@ -80,6 +105,66 @@ describe('resolveCommitMessageSettings', () => { }) }) + it('preserves dynamic persisted models that were discovered by the CLI', () => { + const settings = getDefaultSettings('/tmp') + settings.commitMessageAi = { + enabled: true, + agentId: 'cursor', + selectedModelByAgent: { cursor: 'gpt-5.2' }, + discoveredModelsByAgent: { + cursor: [ + { + id: 'gpt-5.2', + label: 'GPT 5.2', + thinkingLevels: [{ id: 'xhigh', label: 'Extra High' }], + defaultThinkingLevel: 'xhigh' + } + ] + }, + selectedThinkingByModel: { 'gpt-5.2': 'xhigh' }, + customPrompt: '', + customAgentCommand: '' + } + + const result = resolveCommitMessageSettings(settings) + + expect(result).toMatchObject({ + ok: true, + params: { + agentId: 'cursor', + model: 'gpt-5.2', + thinkingLevel: 'xhigh' + } + }) + }) + + it('uses host-scoped discovered models for SSH worktrees', () => { + const settings = getDefaultSettings('/tmp') + settings.commitMessageAi = { + enabled: true, + agentId: 'cursor', + selectedModelByAgent: { cursor: 'auto' }, + selectedModelByAgentByHost: { 'ssh:conn-1': { cursor: 'remote-only' } }, + discoveredModelsByAgent: { cursor: [{ id: 'auto', label: 'Auto' }] }, + discoveredModelsByAgentByHost: { + 'ssh:conn-1': { cursor: [{ id: 'remote-only', label: 'Remote Only' }] } + }, + selectedThinkingByModel: {}, + customPrompt: '', + customAgentCommand: '' + } + + const result = resolveCommitMessageSettings(settings, 'ssh:conn-1') + + expect(result).toMatchObject({ + ok: true, + params: { + agentId: 'cursor', + model: 'remote-only' + } + }) + }) + it('falls back to the model default thinking level when a persisted level is stale', () => { const settings = getDefaultSettings('/tmp') settings.commitMessageAi = { @@ -126,6 +211,28 @@ describe('resolveCommitMessageSettings', () => { }) }) + it('falls back when persisted thinking belongs to an undiscovered dynamic model', () => { + const settings = getDefaultSettings('/tmp') + settings.commitMessageAi = { + enabled: true, + agentId: 'cursor', + selectedModelByAgent: { cursor: 'gpt-5.2' }, + selectedThinkingByModel: { 'gpt-5.2': 'xhigh' }, + customPrompt: '', + customAgentCommand: '' + } + + const result = resolveCommitMessageSettings(settings) + + expect(result).toMatchObject({ + ok: true, + params: { + agentId: 'cursor', + model: 'auto' + } + }) + }) + it('requires a non-empty custom command for custom agents', () => { const settings = getDefaultSettings('/tmp') settings.commitMessageAi = { @@ -144,7 +251,185 @@ describe('resolveCommitMessageSettings', () => { }) }) +describe('discoverCommitMessageModelsLocal', () => { + it('returns static catalog models without spawning for static agents', async () => { + const result = await discoverCommitMessageModelsLocal('amp', undefined) + + expect(result).toMatchObject({ + success: true, + defaultModelId: 'smart' + }) + expect(spawnMock).not.toHaveBeenCalled() + }) + + it('discovers dynamic models through the agent CLI', async () => { + const listeners = new Map void>() + const child = { + pid: 123, + kill: vi.fn(), + stdout: { on: vi.fn((event, callback) => listeners.set(`stdout:${event}`, callback)) }, + stderr: { on: vi.fn((event, callback) => listeners.set(`stderr:${event}`, callback)) }, + stdin: { end: vi.fn() }, + on: vi.fn((event, callback) => listeners.set(event, callback)) + } + spawnMock.mockReturnValue(child as never) + + const pending = discoverCommitMessageModelsLocal('cursor', undefined) + + listeners.get('stdout:data')?.(Buffer.from('auto - Auto\ngpt-5.2 - GPT-5.2\n')) + listeners.get('close')?.(0) + + await expect(pending).resolves.toMatchObject({ + success: true, + defaultModelId: 'auto', + models: [ + { id: 'auto', label: 'Auto' }, + { id: 'gpt-5.2', label: 'GPT-5.2' } + ] + }) + expect(spawnMock).toHaveBeenCalledWith( + 'cursor-agent', + ['--list-models'], + expect.objectContaining({ windowsHide: true }) + ) + }) + + it('discovers dynamic models through the configured agent command override', async () => { + const listeners = new Map void>() + const child = { + pid: 123, + kill: vi.fn(), + stdout: { on: vi.fn((event, callback) => listeners.set(`stdout:${event}`, callback)) }, + stderr: { on: vi.fn((event, callback) => listeners.set(`stderr:${event}`, callback)) }, + stdin: { end: vi.fn() }, + on: vi.fn((event, callback) => listeners.set(event, callback)) + } + spawnMock.mockReturnValue(child as never) + + const pending = discoverCommitMessageModelsLocal('cursor', undefined, 'npx cursor-agent') + + listeners.get('stdout:data')?.(Buffer.from('auto - Auto\n')) + listeners.get('close')?.(0) + + await expect(pending).resolves.toMatchObject({ + success: true, + defaultModelId: 'auto' + }) + expect(spawnMock).toHaveBeenCalledWith( + 'npx', + ['cursor-agent', '--list-models'], + expect.objectContaining({ windowsHide: true }) + ) + }) + + it('falls back to static models when dynamic discovery returns no parseable models', async () => { + const listeners = new Map void>() + const child = { + pid: 123, + kill: vi.fn(), + stdout: { on: vi.fn((event, callback) => listeners.set(`stdout:${event}`, callback)) }, + stderr: { on: vi.fn((event, callback) => listeners.set(`stderr:${event}`, callback)) }, + stdin: { end: vi.fn() }, + on: vi.fn((event, callback) => listeners.set(event, callback)) + } + spawnMock.mockReturnValue(child as never) + + const pending = discoverCommitMessageModelsLocal('pi', undefined) + + listeners.get('stdout:data')?.(Buffer.from('provider model\n')) + listeners.get('close')?.(0) + + await expect(pending).resolves.toMatchObject({ + success: true, + defaultModelId: 'github-copilot/gpt-5.4-mini', + models: [{ id: 'github-copilot/gpt-5.4-mini' }] + }) + }) + + it('parses Pi model discovery from stderr when the CLI exits successfully', async () => { + const listeners = new Map void>() + const child = { + pid: 123, + kill: vi.fn(), + stdout: { on: vi.fn((event, callback) => listeners.set(`stdout:${event}`, callback)) }, + stderr: { on: vi.fn((event, callback) => listeners.set(`stderr:${event}`, callback)) }, + stdin: { end: vi.fn() }, + on: vi.fn((event, callback) => listeners.set(event, callback)) + } + spawnMock.mockReturnValue(child as never) + + const pending = discoverCommitMessageModelsLocal('pi', undefined) + + listeners.get('stderr:data')?.( + Buffer.from( + [ + 'provider model context max-out thinking images', + 'github-copilot gpt-5.4-mini 400K 128K yes yes', + 'openai-codex gpt-5.5 272K 128K yes yes' + ].join('\n') + ) + ) + listeners.get('close')?.(0) + + await expect(pending).resolves.toMatchObject({ + success: true, + defaultModelId: 'github-copilot/gpt-5.4-mini', + models: [{ id: 'github-copilot/gpt-5.4-mini' }, { id: 'openai-codex/gpt-5.5' }] + }) + }) +}) + describe('generateCommitMessageFromContext', () => { + it('discovers dynamic models through a remote execution plan', async () => { + const execute = vi.fn(async (plan, cwd, timeoutMs) => { + expect(plan).toEqual({ + binary: 'npx', + args: ['cursor-agent', '--list-models'], + stdinPayload: null, + label: 'Cursor' + }) + expect(cwd).toBe('/remote/repo') + expect(timeoutMs).toBe(60_000) + return { + stdout: 'auto - Auto\ngpt-5.2 - GPT-5.2\n', + stderr: '', + exitCode: 0, + timedOut: false + } + }) + + const result = await discoverCommitMessageModelsRemote( + 'cursor', + '/remote/repo', + execute, + 'npx cursor-agent' + ) + + expect(result).toMatchObject({ + success: true, + defaultModelId: 'auto', + models: [ + { id: 'auto', label: 'Auto' }, + { id: 'gpt-5.2', label: 'GPT-5.2' } + ] + }) + }) + + it('reports remote model discovery spawn failures with remote install guidance', async () => { + const result = await discoverCommitMessageModelsRemote('cursor', '/remote/repo', async () => ({ + stdout: '', + stderr: '', + exitCode: null, + timedOut: false, + spawnError: 'ENOENT' + })) + + expect(result).toEqual({ + success: false, + error: 'cursor-agent not found on the remote PATH. Install Cursor there.' + }) + }) + it('uses a prepared remote execution plan instead of running git on the remote side', async () => { const result = await generateCommitMessageFromContext( { @@ -247,6 +532,37 @@ describe('generateCommitMessageFromContext', () => { }) }) + it('treats empty stdout plus an error on stderr as an agent failure', async () => { + const result = await generateCommitMessageFromContext( + { + branch: 'main', + stagedSummary: 'M\tREADME.md', + stagedPatch: '+hello' + }, + { + agentId: 'custom', + model: '', + customAgentCommand: 'agent' + }, + { + kind: 'remote', + cwd: '/repo', + missingBinaryLocation: 'remote PATH', + execute: async () => ({ + stdout: '', + stderr: '\u001b[91m\u001b[1mError: \u001b[0mNo payment method', + exitCode: 0, + timedOut: false + }) + } + ) + + expect(result).toEqual({ + success: false, + error: 'agent failed. Check the agent CLI configuration and try again.' + }) + }) + it('preserves the structured subject and body when formatting the final response', async () => { const result = await generateCommitMessageFromContext( { diff --git a/src/main/text-generation/commit-message-text-generation.ts b/src/main/text-generation/commit-message-text-generation.ts index 09949e281..06817ab0b 100644 --- a/src/main/text-generation/commit-message-text-generation.ts +++ b/src/main/text-generation/commit-message-text-generation.ts @@ -24,12 +24,16 @@ import { getCommitMessageAgentSpec, getCommitMessageModel, isCustomAgentId, - resolveCommitMessageAgentChoice + resolveCommitMessageAgentChoice, + type CommitMessageAgentCapability, + type CommitMessageModelCapability } from '../../shared/commit-message-agent-spec' import { + planAgentBinary, planCommitMessageGeneration, type CommitMessagePlan } from '../../shared/commit-message-plan' +import { LOCAL_COMMIT_MESSAGE_HOST_KEY } from '../../shared/commit-message-host-key' import { resolveCliCommand } from '../codex-cli/command' import { getSpawnArgsForWindows, @@ -53,6 +57,15 @@ export type GenerateCommitMessageResult = | { success: true; message: string; agentLabel?: string } | { success: false; error: string; canceled?: boolean } +export type DiscoverCommitMessageModelsResult = + | { + success: true + capability: CommitMessageAgentCapability + models: CommitMessageModelCapability[] + defaultModelId: string + } + | { success: false; error: string } + export type GeneratePullRequestFieldsResult = | { success: true; fields: GeneratedPullRequestFields; agentLabel?: string } | { success: false; error: string; canceled?: boolean } @@ -92,7 +105,8 @@ export function trimGeneratedCommitMessage(message: string): string { } export function resolveCommitMessageSettings( - settings: GlobalSettings + settings: GlobalSettings, + discoveryHostKey = LOCAL_COMMIT_MESSAGE_HOST_KEY ): ResolveCommitMessageSettingsResult { const config = settings.commitMessageAi if (!config?.enabled) { @@ -134,9 +148,19 @@ export function resolveCommitMessageSettings( return { ok: false, error: `Agent "${agentId}" does not support AI commit messages.` } } - const persistedModelId = config.selectedModelByAgent[agentId] ?? spec.defaultModelId + const hostSelectedModels = config.selectedModelByAgentByHost?.[discoveryHostKey] + const legacySelectedModels = + discoveryHostKey === LOCAL_COMMIT_MESSAGE_HOST_KEY ? config.selectedModelByAgent : undefined + const persistedModelId = + hostSelectedModels?.[agentId] ?? legacySelectedModels?.[agentId] ?? spec.defaultModelId + const discoveredModels = + config.discoveredModelsByAgentByHost?.[discoveryHostKey]?.[agentId] ?? + (discoveryHostKey === LOCAL_COMMIT_MESSAGE_HOST_KEY + ? (config.discoveredModelsByAgent?.[agentId] ?? []) + : []) const model = - getCommitMessageModel(agentId, persistedModelId) ?? + spec.models.find((candidate) => candidate.id === persistedModelId) ?? + discoveredModels.find((candidate) => candidate.id === persistedModelId) ?? getCommitMessageModel(agentId, spec.defaultModelId) if (!model) { return { ok: false, error: `No model is available for ${spec.label}.` } @@ -176,6 +200,251 @@ function userFacingUnsafeWindowsBatchArgs(label: string): string { return `${label} cannot be run as a Windows batch command with the prompt in argv. Remove {prompt} so Orca sends the prompt on stdin.` } +function toModelDiscoveryCapability( + spec: NonNullable>, + models = spec.models, + defaultModelId = spec.defaultModelId +): Extract { + return { + success: true, + capability: { + id: spec.id, + label: spec.label, + modelSource: spec.modelSource, + defaultModelId, + models + }, + models, + defaultModelId + } +} + +function finalizeModelDiscoveryOutput( + spec: NonNullable>, + stdout: string, + stderr: string, + code: number | null +): DiscoverCommitMessageModelsResult { + if (code !== 0) { + const safeDetail = sanitizeAgentFailureDetail(extractAgentErrorMessage(stdout, stderr)) + console.error('[commit-message] Model discovery failed:', { + label: spec.label, + exitCode: code, + safeDetail, + stdout, + stderr + }) + return { + success: false, + error: `${spec.label} model discovery failed. Check the agent CLI configuration and try again.` + } + } + let models = spec.modelDiscovery?.parse(stdout) ?? [] + if (models.length === 0 && stderr.trim()) { + // Why: Pi currently writes its successful `--list-models` table to stderr, + // so exit code 0 must still allow stderr-backed discovery. + models = spec.modelDiscovery?.parse(stderr) ?? [] + } + if (models.length === 0) { + if (spec.models.length > 0) { + console.warn('[commit-message] Model discovery returned no models; using static fallback:', { + label: spec.label + }) + return toModelDiscoveryCapability(spec, spec.models, spec.defaultModelId) + } + return { success: false, error: `${spec.label} returned no available models.` } + } + const defaultModelId = models.some((model) => model.id === spec.defaultModelId) + ? spec.defaultModelId + : models[0].id + return toModelDiscoveryCapability(spec, models, defaultModelId) +} + +function planModelDiscovery( + spec: NonNullable>, + agentCommandOverride?: string +): { ok: true; plan: CommitMessagePlan } | { ok: false; error: string } { + const modelDiscovery = spec.modelDiscovery + if (!modelDiscovery) { + return { ok: false, error: `${spec.label} does not support dynamic model discovery.` } + } + const command = planAgentBinary(modelDiscovery.binary, agentCommandOverride) + if (!command.ok) { + return command + } + return { + ok: true, + plan: { + binary: command.binary, + args: [...command.prefixArgs, ...modelDiscovery.args], + stdinPayload: null, + label: spec.label + } + } +} + +export async function discoverCommitMessageModelsLocal( + agentId: TuiAgent, + env: NodeJS.ProcessEnv | undefined, + agentCommandOverride?: string +): Promise { + const spec = getCommitMessageAgentSpec(agentId) + if (!spec) { + return { success: false, error: `Agent "${agentId}" does not support AI commit messages.` } + } + + if (spec.modelSource === 'static' || !spec.modelDiscovery) { + return toModelDiscoveryCapability(spec) + } + + return new Promise((resolve) => { + let child: ChildProcess + const spawnEnv = env ?? process.env + try { + const planned = planModelDiscovery(spec, agentCommandOverride) + if (!planned.ok) { + resolve({ success: false, error: planned.error }) + return + } + const resolvedBinary = + process.platform === 'win32' + ? resolveCliCommand(planned.plan.binary, { + pathEnv: spawnEnv.PATH ?? spawnEnv.Path ?? null + }) + : planned.plan.binary + const { spawnCmd, spawnArgs } = getSpawnArgsForWindows(resolvedBinary, planned.plan.args) + child = spawn(spawnCmd, spawnArgs, { + env: spawnEnv, + stdio: ['ignore', 'pipe', 'pipe'], + windowsHide: true + }) + } catch (error) { + console.error('[commit-message] Failed to spawn model discovery:', error) + resolve({ + success: false, + error: `${spec.label} model discovery could not be started. Check the agent CLI configuration and try again.` + }) + return + } + + let stdout = '' + let stderr = '' + let outputLimitExceeded = false + let settled = false + const finish = (result: DiscoverCommitMessageModelsResult): void => { + if (settled) { + return + } + settled = true + resolve(result) + } + const timer = setTimeout(() => { + killProcessTree(child) + finish({ + success: false, + error: `${spec.label} model discovery timed out after ${GENERATION_TIMEOUT_MS / 1000}s.` + }) + }, GENERATION_TIMEOUT_MS) + + const onData = (chunk: Buffer, append: (text: string) => void): void => { + if (stdout.length + stderr.length + chunk.byteLength > MAX_AGENT_OUTPUT_BYTES) { + outputLimitExceeded = true + killProcessTree(child) + return + } + append(chunk.toString('utf-8')) + } + + child.stdout?.on('data', (chunk: Buffer) => onData(chunk, (text) => (stdout += text))) + child.stderr?.on('data', (chunk: Buffer) => onData(chunk, (text) => (stderr += text))) + child.on('error', (error) => { + clearTimeout(timer) + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + finish({ + success: false, + error: `${spec.modelDiscovery?.binary ?? spec.binary} not found on PATH. Install ${spec.label} to discover models.` + }) + return + } + finish({ + success: false, + error: `${spec.label} model discovery failed to start. Check the agent CLI configuration and try again.` + }) + }) + child.on('close', (code) => { + clearTimeout(timer) + if (outputLimitExceeded) { + finish({ success: false, error: `${spec.label} returned too much model data.` }) + return + } + if (code !== 0) { + finish(finalizeModelDiscoveryOutput(spec, stdout, stderr, code)) + return + } + finish(finalizeModelDiscoveryOutput(spec, stdout, stderr, code)) + }) + }) +} + +export async function discoverCommitMessageModelsRemote( + agentId: TuiAgent, + cwd: string, + execute: ( + plan: CommitMessagePlan, + cwd: string, + timeoutMs: number + ) => Promise, + agentCommandOverride?: string +): Promise { + const spec = getCommitMessageAgentSpec(agentId) + if (!spec) { + return { success: false, error: `Agent "${agentId}" does not support AI commit messages.` } + } + if (spec.modelSource === 'static' || !spec.modelDiscovery) { + return toModelDiscoveryCapability(spec) + } + const planned = planModelDiscovery(spec, agentCommandOverride) + if (!planned.ok) { + return { success: false, error: planned.error } + } + let result: RemoteCommitMessageExecResult + try { + result = await execute(planned.plan, cwd, GENERATION_TIMEOUT_MS) + } catch (error) { + console.error('[commit-message] Remote model discovery request failed:', error) + return { + success: false, + error: `${spec.label} model discovery could not be reached on the remote PATH. Try again after the SSH connection recovers.` + } + } + if (result.spawnError) { + if (result.spawnError === WINDOWS_BATCH_UNSAFE_ARGUMENTS_ERROR) { + return { success: false, error: userFacingUnsafeWindowsBatchArgs(spec.label) } + } + if (/ENOENT/i.test(result.spawnError)) { + return { + success: false, + error: `${planned.plan.binary} not found on the remote PATH. Install ${spec.label} there.` + } + } + console.error('[commit-message] Remote model discovery spawn failed:', result.spawnError) + return { + success: false, + error: `${spec.label} model discovery could not be started on the remote PATH. Check the agent command there and try again.` + } + } + if (result.canceled) { + return { success: false, error: 'Model discovery canceled.' } + } + if (result.timedOut) { + return { + success: false, + error: `${spec.label} model discovery timed out after ${GENERATION_TIMEOUT_MS / 1000}s.` + } + } + return finalizeModelDiscoveryOutput(spec, result.stdout, result.stderr, result.exitCode) +} + // Why: on Windows, npm-installed CLIs like `claude` and `codex` are usually // `.cmd` shims. We route those through cmd.exe so Node can launch them, and // `child.kill()` would only terminate the wrapper. `taskkill /T /F` walks the @@ -361,6 +630,18 @@ function finalizeFromAgentOutput(args: { } const cleaned = cleanGeneratedCommitMessage(stdout) if (!cleaned) { + const safeDetail = sanitizeAgentFailureDetail(extractAgentErrorMessage(stdout, stderr)) + if (safeDetail) { + console.error('[commit-message] Generator returned no stdout but reported an error:', { + label, + exitCode: code, + safeDetail, + stdout, + stderr + }) + finalize({ success: false, error: userFacingAgentFailure(label) }) + return + } finalize({ success: false, error: `${label} returned an empty ${emptyResultName}.` }) return } diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 919cf1610..46266c229 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -167,6 +167,10 @@ import type { RuntimeSyncWindowGraph, RuntimeTerminalDriverState } from '../shared/runtime-types' +import type { + CommitMessageAgentCapability, + CommitMessageModelCapability +} from '../shared/commit-message-agent-spec' import type { ShellOpenLocalPathResult } from '../shared/shell-open-types' import type { SkillDiscoveryResult } from '../shared/skills' import type { @@ -1481,6 +1485,19 @@ export type PreloadApi = { | { success: true; message: string; agentLabel?: string } | { success: false; error: string; canceled?: boolean } > + discoverCommitMessageModels: (args: { + agentId: string + worktreePath?: string + connectionId?: string + }) => Promise< + | { + success: true + capability: CommitMessageAgentCapability + models: CommitMessageModelCapability[] + defaultModelId: string + } + | { success: false; error: string } + > cancelGenerateCommitMessage: (args: { worktreePath: string connectionId?: string diff --git a/src/preload/index.ts b/src/preload/index.ts index 0cca2e304..a24e5867c 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -2029,6 +2029,11 @@ const api = { worktreePath: string connectionId?: string }): Promise => ipcRenderer.invoke('git:generateCommitMessage', args), + discoverCommitMessageModels: (args: { + agentId: string + worktreePath?: string + connectionId?: string + }): Promise => ipcRenderer.invoke('git:discoverCommitMessageModels', args), cancelGenerateCommitMessage: (args: { worktreePath: string connectionId?: string diff --git a/src/renderer/src/components/agent/AgentCombobox.test.tsx b/src/renderer/src/components/agent/AgentCombobox.test.tsx new file mode 100644 index 000000000..d0051587d --- /dev/null +++ b/src/renderer/src/components/agent/AgentCombobox.test.tsx @@ -0,0 +1,21 @@ +import { renderToStaticMarkup } from 'react-dom/server' +import { describe, expect, it, vi } from 'vitest' +import { AGENT_CATALOG } from '@/lib/agent-catalog' +import AgentCombobox from './AgentCombobox' + +describe('AgentCombobox', () => { + it('keeps enough trigger width for GitHub Copilot when callers pass min-w-0', () => { + const markup = renderToStaticMarkup( + + ) + + expect(markup).toContain('GitHub Copilot') + expect(markup).toContain('!min-w-[260px]') + expect(markup).toContain('flex-1') + }) +}) diff --git a/src/renderer/src/components/agent/AgentCombobox.tsx b/src/renderer/src/components/agent/AgentCombobox.tsx index 9ee35bcf6..9c22089a0 100644 --- a/src/renderer/src/components/agent/AgentCombobox.tsx +++ b/src/renderer/src/components/agent/AgentCombobox.tsx @@ -42,6 +42,7 @@ type AgentComboboxProps = { } const BLANK_VALUE = '__none__' +const TRIGGER_MIN_WIDTH_CLASS = '!min-w-[260px]' type ItemRenderArgs = { key: string @@ -239,18 +240,21 @@ export default function AgentCombobox({ aria-expanded={open} onKeyDown={handleTriggerKeyDown} className={cn( - 'h-8 min-w-[184px] justify-between px-3 text-xs font-normal', - triggerClassName + // Why: callers sometimes pass `min-w-0` for grid layouts, but + // the compact trigger still needs room for "GitHub Copilot". + 'h-8 justify-between px-3 text-xs font-normal', + triggerClassName, + TRIGGER_MIN_WIDTH_CLASS )} data-agent-combobox-root="true" > {selectedAgent ? ( - + {selectedAgent.label} ) : ( - + Blank Terminal diff --git a/src/renderer/src/components/editor/useEditorPanelContentState.ts b/src/renderer/src/components/editor/useEditorPanelContentState.ts index 76045a630..3da0717b3 100644 --- a/src/renderer/src/components/editor/useEditorPanelContentState.ts +++ b/src/renderer/src/components/editor/useEditorPanelContentState.ts @@ -162,7 +162,7 @@ export function useEditorPanelContentState({ const compareAgainstHead = file.mode === 'edit' const key = inFlightDiffKey( { ...file, diffSource: effectiveDiffSource }, - gitScope, + gitScope ?? undefined, compareAgainstHead ) let pending = inFlightDiffReads.get(key) diff --git a/src/renderer/src/components/settings/CommitMessageAiPane.test.tsx b/src/renderer/src/components/settings/CommitMessageAiPane.test.tsx index dd1c16b22..4ab4a38b4 100644 --- a/src/renderer/src/components/settings/CommitMessageAiPane.test.tsx +++ b/src/renderer/src/components/settings/CommitMessageAiPane.test.tsx @@ -1,9 +1,17 @@ import React from 'react' import { renderToStaticMarkup } from 'react-dom/server' import { beforeEach, describe, expect, it } from 'vitest' -import type { GlobalSettings } from '../../../../shared/types' +import type { CommitMessageAiSettings, GlobalSettings } from '../../../../shared/types' +import { + getCommitMessageModelDiscoveryHostKey, + getCommitMessageModelDiscoveryHostKeyForScope +} from '../../../../shared/commit-message-host-key' import { useAppStore } from '../../store' -import { CommitMessageAiPane } from './CommitMessageAiPane' +import { + CommitMessageAiPane, + getCommitMessageSettingsPaneDiscoveryHostKey, + mergeDiscoveredModelsIntoCommitMessageConfig +} from './CommitMessageAiPane' import { COMMIT_MESSAGE_AI_PANE_SEARCH_ENTRIES } from './commit-message-ai-search' function renderPane(settings: GlobalSettings): string { @@ -68,6 +76,24 @@ describe('CommitMessageAiPane', () => { expect(markup).toContain('Saved') }) + it('keeps the agent and model selectors aligned for long labels', () => { + const markup = renderPane( + buildSettings({ + commitMessageAi: { + enabled: true, + agentId: 'copilot', + selectedModelByAgent: { copilot: 'gpt-5.5' }, + selectedThinkingByModel: {}, + customPrompt: '', + customAgentCommand: '' + } + }) + ) + + expect(markup.match(/w-\[260px\]/g)).toHaveLength(2) + expect(markup.match(/shrink-0/g)?.length ?? 0).toBeGreaterThanOrEqual(2) + }) + it('renders custom command settings for custom agents', () => { const markup = renderPane( buildSettings({ @@ -90,7 +116,7 @@ describe('CommitMessageAiPane', () => { it('shows an unconfigured state when the default agent is unsupported', () => { const markup = renderPane( buildSettings({ - defaultTuiAgent: 'gemini', + defaultTuiAgent: 'aider', commitMessageAi: { enabled: true, agentId: null, @@ -103,12 +129,31 @@ describe('CommitMessageAiPane', () => { ) expect(markup).toContain('Not configured') - expect(markup).toContain('Your default agent is Gemini') - expect(markup).toContain('Choose Claude, Codex, or Custom') + expect(markup).toContain('Your default agent is Aider') + expect(markup).toContain('Choose a supported agent or Custom') expect(markup).not.toContain('Which model the selected agent uses') expect(markup).not.toContain('Thinking effort') }) + it('shows Gemini as coming soon instead of a selectable generator', () => { + const markup = renderPane( + buildSettings({ + commitMessageAi: { + enabled: true, + agentId: 'gemini', + selectedModelByAgent: {}, + selectedThinkingByModel: {}, + customPrompt: '', + customAgentCommand: '' + } + }) + ) + + expect(markup).toContain('Gemini') + expect(markup).toContain('Gemini commit message generation is coming soon') + expect(markup).not.toContain('Which model the selected agent uses') + }) + it('keeps custom command discoverable in settings search metadata', () => { const customCommandEntry = COMMIT_MESSAGE_AI_PANE_SEARCH_ENTRIES.find( (entry) => entry.title === 'Custom command' @@ -118,4 +163,85 @@ describe('CommitMessageAiPane', () => { expect.arrayContaining(['custom', 'command', 'ollama']) ) }) + + it('merges discovered models without clobbering newer settings fields', () => { + const config: CommitMessageAiSettings = { + enabled: true, + agentId: 'cursor', + selectedModelByAgent: { cursor: 'stale-model', codex: 'gpt-5.5' }, + selectedThinkingByModel: { 'gpt-5.5': 'low' }, + customPrompt: 'Use Conventional Commits.', + customAgentCommand: '', + discoveredModelsByAgent: {} + } + + const merged = mergeDiscoveredModelsIntoCommitMessageConfig( + config, + 'cursor', + [{ id: 'auto', label: 'Auto' }], + 'auto' + ) + + expect(merged.customPrompt).toBe('Use Conventional Commits.') + expect(merged.agentId).toBe('cursor') + expect(merged.selectedModelByAgent).toEqual({ + cursor: 'auto', + codex: 'gpt-5.5' + }) + expect(merged.discoveredModelsByAgent?.cursor).toEqual([{ id: 'auto', label: 'Auto' }]) + expect(merged.discoveredModelsByAgentByHost?.local?.cursor).toEqual([ + { id: 'auto', label: 'Auto' } + ]) + }) + + it('keeps SSH discovered models out of the legacy local cache', () => { + const config: CommitMessageAiSettings = { + enabled: true, + agentId: 'cursor', + selectedModelByAgent: { cursor: 'auto' }, + selectedThinkingByModel: {}, + customPrompt: '', + customAgentCommand: '', + discoveredModelsByAgent: { cursor: [{ id: 'auto', label: 'Auto' }] }, + selectedModelByAgentByHost: {}, + discoveredModelsByAgentByHost: {} + } + + const merged = mergeDiscoveredModelsIntoCommitMessageConfig( + config, + 'cursor', + [{ id: 'remote-only', label: 'Remote Only' }], + 'remote-only', + 'ssh:conn-1' + ) + + expect(merged.selectedModelByAgent.cursor).toBe('auto') + expect(merged.discoveredModelsByAgent?.cursor).toEqual([{ id: 'auto', label: 'Auto' }]) + expect(merged.selectedModelByAgentByHost?.['ssh:conn-1']?.cursor).toBe('remote-only') + expect(merged.discoveredModelsByAgentByHost?.['ssh:conn-1']?.cursor).toEqual([ + { id: 'remote-only', label: 'Remote Only' } + ]) + }) + + it('keys model discovery cache by execution host', () => { + expect(getCommitMessageModelDiscoveryHostKey(null)).toBe('local') + expect(getCommitMessageModelDiscoveryHostKey('ssh-1')).toBe('ssh:ssh-1') + expect(getCommitMessageModelDiscoveryHostKey(undefined)).toBe('unknown') + expect(getCommitMessageModelDiscoveryHostKeyForScope('runtime:env-1')).toBe('runtime:env-1') + expect(getCommitMessageModelDiscoveryHostKeyForScope('ssh-1')).toBe('ssh:ssh-1') + }) + + it('keeps local active worktree discovery scoped to local, not unknown', () => { + expect(getCommitMessageSettingsPaneDiscoveryHostKey(buildSettings(), null, true)).toBe('local') + expect(getCommitMessageSettingsPaneDiscoveryHostKey(buildSettings(), undefined, true)).toBe( + 'unknown' + ) + expect( + getCommitMessageSettingsPaneDiscoveryHostKey( + buildSettings({ activeRuntimeEnvironmentId: 'env-1' }), + null, + true + ) + ).toBe('runtime:env-1') + }) }) diff --git a/src/renderer/src/components/settings/CommitMessageAiPane.tsx b/src/renderer/src/components/settings/CommitMessageAiPane.tsx index 58887223b..45ee2fff6 100644 --- a/src/renderer/src/components/settings/CommitMessageAiPane.tsx +++ b/src/renderer/src/components/settings/CommitMessageAiPane.tsx @@ -3,7 +3,7 @@ a SearchableSetting block, and splitting the pane across files would scatter the ~6 conditional render branches without making any of them clearer. */ import { useEffect, useMemo, useRef, useState } from 'react' -import { Terminal } from 'lucide-react' +import { RefreshCw, Terminal } from 'lucide-react' import type { CommitMessageAiSettings, GlobalSettings, TuiAgent } from '../../../../shared/types' import { CUSTOM_AGENT_ID, @@ -15,11 +15,21 @@ import { type CommitMessageModelCapability } from '../../../../shared/commit-message-agent-spec' import { CUSTOM_PROMPT_PLACEHOLDER } from '../../../../shared/commit-message-prompt' +import { + getCommitMessageModelDiscoveryHostKeyForScope, + LOCAL_COMMIT_MESSAGE_HOST_KEY +} from '../../../../shared/commit-message-host-key' import { AGENT_CATALOG, AgentIcon } from '@/lib/agent-catalog' +import { getConnectionId } from '@/lib/connection-context' import { Button } from '../ui/button' import { Label } from '../ui/label' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select' +import { + discoverRuntimeCommitMessageModels, + getRuntimeGitScope +} from '../../runtime/runtime-git-client' import { useAppStore } from '../../store' +import { useActiveWorktree } from '../../store/selectors' import { SearchableSetting } from './SearchableSetting' import { matchesSettingsSearch } from './settings-search' @@ -34,12 +44,24 @@ const EMPTY_SETTINGS: CommitMessageAiSettings = { enabled: false, agentId: null, selectedModelByAgent: {}, + discoveredModelsByAgent: {}, selectedThinkingByModel: {}, customPrompt: '', customAgentCommand: '' } +type ModelDiscoveryState = { + status: 'idle' | 'loading' | 'ready' | 'error' + hostKey: string + models: CommitMessageModelCapability[] + defaultModelId?: string + error?: string +} + const UNCONFIGURED_AGENT_SELECT_VALUE = '' +const COMING_SOON_COMMIT_MESSAGE_AGENTS: readonly { id: TuiAgent; label: string }[] = [ + { id: 'gemini', label: 'Gemini' } +] function readSettings(settings: GlobalSettings): CommitMessageAiSettings { return settings.commitMessageAi ?? EMPTY_SETTINGS @@ -49,11 +71,23 @@ function agentLabel(agentId: TuiAgent, capability: CommitMessageAgentCapability) return AGENT_CATALOG.find((a) => a.id === agentId)?.label ?? capability.label } +function readSelectedModelId( + config: CommitMessageAiSettings, + hostKey: string, + agentId: TuiAgent +): string | undefined { + return ( + config.selectedModelByAgentByHost?.[hostKey]?.[agentId] ?? + (hostKey === LOCAL_COMMIT_MESSAGE_HOST_KEY ? config.selectedModelByAgent[agentId] : undefined) + ) +} + function resolveSelectedModel( config: CommitMessageAiSettings, - capability: CommitMessageAgentCapability + capability: CommitMessageAgentCapability, + hostKey: string ): CommitMessageModelCapability { - const persisted = config.selectedModelByAgent[capability.id] + const persisted = readSelectedModelId(config, hostKey, capability.id) if (persisted) { const found = capability.models.find((m) => m.id === persisted) if (found) { @@ -78,6 +112,91 @@ function resolveSelectedThinking( return model.defaultThinkingLevel } +export function mergeDiscoveredModelsIntoCommitMessageConfig( + config: CommitMessageAiSettings, + agentId: TuiAgent, + models: CommitMessageModelCapability[], + defaultModelId: string, + hostKey = LOCAL_COMMIT_MESSAGE_HOST_KEY +): CommitMessageAiSettings { + const hostSelectedModels = config.selectedModelByAgentByHost?.[hostKey] ?? {} + const persisted = readSelectedModelId(config, hostKey, agentId) + const nextModelId = models.some((model) => model.id === persisted) ? persisted : defaultModelId + const nextHostSelectedModels = + nextModelId && nextModelId !== persisted + ? { + ...hostSelectedModels, + [agentId]: nextModelId + } + : hostSelectedModels + const nextHostDiscoveredModels = { + ...config.discoveredModelsByAgentByHost?.[hostKey], + [agentId]: models + } + return { + ...config, + ...(hostKey === LOCAL_COMMIT_MESSAGE_HOST_KEY + ? { + discoveredModelsByAgent: { + ...config.discoveredModelsByAgent, + [agentId]: models + }, + selectedModelByAgent: + nextModelId && nextModelId !== persisted + ? { + ...config.selectedModelByAgent, + [agentId]: nextModelId + } + : config.selectedModelByAgent + } + : {}), + discoveredModelsByAgentByHost: { + ...config.discoveredModelsByAgentByHost, + [hostKey]: nextHostDiscoveredModels + }, + selectedModelByAgentByHost: { + ...config.selectedModelByAgentByHost, + [hostKey]: nextHostSelectedModels + } + } +} + +function selectModelForHost( + config: CommitMessageAiSettings, + hostKey: string, + agentId: TuiAgent, + modelId: string +): Pick { + const hostSelectedModels = config.selectedModelByAgentByHost?.[hostKey] ?? {} + return { + selectedModelByAgent: + hostKey === LOCAL_COMMIT_MESSAGE_HOST_KEY + ? { + ...config.selectedModelByAgent, + [agentId]: modelId + } + : config.selectedModelByAgent, + selectedModelByAgentByHost: { + ...config.selectedModelByAgentByHost, + [hostKey]: { + ...hostSelectedModels, + [agentId]: modelId + } + } + } +} + +export function getCommitMessageSettingsPaneDiscoveryHostKey( + settings: GlobalSettings, + activeConnectionId: string | null | undefined, + hasActiveWorktree: boolean +): string { + const runtimeScope = hasActiveWorktree + ? getRuntimeGitScope(settings, activeConnectionId) + : activeConnectionId + return getCommitMessageModelDiscoveryHostKeyForScope(runtimeScope) +} + export function CommitMessageAiPane({ settings, updateSettings, @@ -85,7 +204,19 @@ export function CommitMessageAiPane({ customPromptDiscardSignal }: CommitMessageAiPaneProps): React.JSX.Element { const searchQuery = useAppStore((s) => s.settingsSearchQuery) + const activeWorktree = useActiveWorktree() + const activeConnectionId = getConnectionId(activeWorktree?.id ?? null) + const discoveryHostKey = getCommitMessageSettingsPaneDiscoveryHostKey( + settings, + activeConnectionId, + Boolean(activeWorktree?.id) + ) const config = readSettings(settings) + const latestConfigRef = useRef(config) + latestConfigRef.current = config + const [modelDiscoveryByAgent, setModelDiscoveryByAgent] = useState< + Partial> + >({}) const persistedCustomPrompt = config.customPrompt const [customPromptDraft, setCustomPromptDraft] = useState(persistedCustomPrompt) const [isSavingCustomPrompt, setIsSavingCustomPrompt] = useState(false) @@ -119,9 +250,36 @@ export function CommitMessageAiPane({ [onCustomPromptDirtyChange] ) - const agentCapabilities = useMemo(listCommitMessageAgentCapabilities, []) + const baseAgentCapabilities = useMemo(listCommitMessageAgentCapabilities, []) + const agentCapabilities = useMemo( + () => + baseAgentCapabilities.map((capability) => { + const discovery = modelDiscoveryByAgent[capability.id] + if ( + capability.modelSource !== 'dynamic' || + discovery?.status !== 'ready' || + discovery.hostKey !== discoveryHostKey + ) { + return capability + } + return { + ...capability, + models: discovery.models, + defaultModelId: discovery.defaultModelId ?? capability.defaultModelId + } + }), + [baseAgentCapabilities, discoveryHostKey, modelDiscoveryByAgent] + ) const resolvedAgentId = resolveCommitMessageAgentChoice(config.agentId, settings.defaultTuiAgent) - const activeAgentSelectValue = resolvedAgentId ?? UNCONFIGURED_AGENT_SELECT_VALUE + const unsupportedSelectedAgent = + config.agentId && + !isCustomAgentId(config.agentId) && + !getCommitMessageAgentCapability(config.agentId) + ? config.agentId + : null + const activeAgentSelectValue = unsupportedSelectedAgent + ? UNCONFIGURED_AGENT_SELECT_VALUE + : (resolvedAgentId ?? UNCONFIGURED_AGENT_SELECT_VALUE) const unsupportedDefaultAgent = resolvedAgentId === null && !config.agentId && @@ -133,18 +291,139 @@ export function CommitMessageAiPane({ ? (AGENT_CATALOG.find((a) => a.id === unsupportedDefaultAgent)?.label ?? unsupportedDefaultAgent) : null + const unsupportedSelectedAgentIsComingSoon = COMING_SOON_COMMIT_MESSAGE_AGENTS.some( + (agent) => agent.id === unsupportedSelectedAgent + ) + const unsupportedSelectedAgentLabel = unsupportedSelectedAgent + ? (COMING_SOON_COMMIT_MESSAGE_AGENTS.find((a) => a.id === unsupportedSelectedAgent)?.label ?? + AGENT_CATALOG.find((a) => a.id === unsupportedSelectedAgent)?.label ?? + unsupportedSelectedAgent) + : null const isCustom = isCustomAgentId(resolvedAgentId) - const activeCapability = - resolvedAgentId && !isCustomAgentId(resolvedAgentId) - ? getCommitMessageAgentCapability(resolvedAgentId) - : undefined - const activeModel = activeCapability ? resolveSelectedModel(config, activeCapability) : null + const activeAgentId = resolvedAgentId && !isCustom ? resolvedAgentId : null + const activeCapability = activeAgentId + ? (agentCapabilities.find((capability) => capability.id === activeAgentId) ?? + getCommitMessageAgentCapability(activeAgentId)) + : undefined + const activeModel = activeCapability + ? resolveSelectedModel(config, activeCapability, discoveryHostKey) + : null const activeThinking = activeModel ? resolveSelectedThinking(config, activeModel) : undefined + const rawActiveDiscovery = activeAgentId ? modelDiscoveryByAgent[activeAgentId] : undefined + const activeDiscovery = + rawActiveDiscovery?.hostKey === discoveryHostKey ? rawActiveDiscovery : undefined const writeConfig = (patch: Partial): void => { updateSettings({ commitMessageAi: { ...config, ...patch } }) } + const refreshModels = async (agentId: TuiAgent): Promise => { + const capability = + agentCapabilities.find((candidate) => candidate.id === agentId) ?? + getCommitMessageAgentCapability(agentId) + if (!capability || capability.modelSource !== 'dynamic') { + return + } + setModelDiscoveryByAgent((prev) => ({ + ...prev, + [agentId]: { + status: 'loading', + hostKey: discoveryHostKey, + models: + prev[agentId]?.hostKey === discoveryHostKey + ? (prev[agentId]?.models ?? capability.models) + : capability.models + } + })) + try { + const result = await discoverRuntimeCommitMessageModels( + { + settings, + worktreeId: activeWorktree?.id, + worktreePath: activeWorktree?.path ?? '', + connectionId: activeConnectionId ?? undefined + }, + agentId + ) + if (!result.success) { + setModelDiscoveryByAgent((prev) => ({ + ...prev, + [agentId]: { + status: 'error', + hostKey: discoveryHostKey, + models: + prev[agentId]?.hostKey === discoveryHostKey + ? (prev[agentId]?.models ?? capability.models) + : capability.models, + error: result.error + } + })) + return + } + setModelDiscoveryByAgent((prev) => ({ + ...prev, + [agentId]: { + status: 'ready', + hostKey: discoveryHostKey, + models: result.models, + defaultModelId: result.defaultModelId + } + })) + const latestConfig = latestConfigRef.current + updateSettings({ + commitMessageAi: mergeDiscoveredModelsIntoCommitMessageConfig( + latestConfig, + agentId, + result.models, + result.defaultModelId, + discoveryHostKey + ) + }) + } catch (error) { + setModelDiscoveryByAgent((prev) => ({ + ...prev, + [agentId]: { + status: 'error', + hostKey: discoveryHostKey, + models: + prev[agentId]?.hostKey === discoveryHostKey + ? (prev[agentId]?.models ?? capability.models) + : capability.models, + error: error instanceof Error ? error.message : 'Failed to discover models' + } + })) + } + } + + useEffect(() => { + if ( + !config.enabled || + isCustom || + !activeCapability || + activeCapability.modelSource !== 'dynamic' + ) { + return + } + const discovery = modelDiscoveryByAgent[activeCapability.id] + if ( + discovery?.hostKey === discoveryHostKey && + (discovery.status === 'loading' || discovery.status === 'ready') + ) { + return + } + void refreshModels(activeCapability.id) + // Why: auto-refresh should run once when a dynamic agent becomes active. + // Including the discovery map would retry immediately after an error and + // turn a visible CLI failure into a request loop. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ + activeCapability?.id, + activeCapability?.modelSource, + config.enabled, + discoveryHostKey, + isCustom + ]) + const onToggleEnabled = (): void => { const next = !config.enabled if (!next) { @@ -164,13 +443,23 @@ export function CommitMessageAiPane({ const seedCapability = isCustomAgentId(seedAgentId) ? undefined : getCommitMessageAgentCapability(seedAgentId) - const seedModel = seedCapability ? resolveSelectedModel(config, seedCapability) : null + const seedModel = seedCapability + ? resolveSelectedModel(config, seedCapability, discoveryHostKey) + : null const seedThinking = seedModel ? resolveSelectedThinking(config, seedModel) : undefined - const nextSelectedModelByAgent = { ...config.selectedModelByAgent } - if (seedCapability && !nextSelectedModelByAgent[seedCapability.id]) { - nextSelectedModelByAgent[seedCapability.id] = seedCapability.defaultModelId - } + const selectedModelPatch = seedCapability + ? selectModelForHost( + config, + discoveryHostKey, + seedCapability.id, + readSelectedModelId(config, discoveryHostKey, seedCapability.id) ?? + seedCapability.defaultModelId + ) + : { + selectedModelByAgent: config.selectedModelByAgent, + selectedModelByAgentByHost: config.selectedModelByAgentByHost + } const nextSelectedThinkingByModel = { ...config.selectedThinkingByModel } if (seedModel && seedThinking && !nextSelectedThinkingByModel[seedModel.id]) { nextSelectedThinkingByModel[seedModel.id] = seedThinking @@ -178,7 +467,7 @@ export function CommitMessageAiPane({ writeConfig({ enabled: true, agentId: seedAgentId, - selectedModelByAgent: nextSelectedModelByAgent, + ...selectedModelPatch, selectedThinkingByModel: nextSelectedThinkingByModel }) } @@ -195,11 +484,17 @@ export function CommitMessageAiPane({ if (!capability) { return } - const nextSelectedModelByAgent = { ...config.selectedModelByAgent } - if (!nextSelectedModelByAgent[capability.id]) { - nextSelectedModelByAgent[capability.id] = capability.defaultModelId - } - const newModel = resolveSelectedModel({ ...config, agentId: capability.id }, capability) + const selectedModelPatch = selectModelForHost( + config, + discoveryHostKey, + capability.id, + readSelectedModelId(config, discoveryHostKey, capability.id) ?? capability.defaultModelId + ) + const newModel = resolveSelectedModel( + { ...config, ...selectedModelPatch, agentId: capability.id }, + capability, + discoveryHostKey + ) const nextSelectedThinkingByModel = { ...config.selectedThinkingByModel } if ( newModel.thinkingLevels && @@ -210,7 +505,7 @@ export function CommitMessageAiPane({ } writeConfig({ agentId: capability.id, - selectedModelByAgent: nextSelectedModelByAgent, + ...selectedModelPatch, selectedThinkingByModel: nextSelectedThinkingByModel }) } @@ -227,10 +522,12 @@ export function CommitMessageAiPane({ if (!model) { return } - const nextSelectedModelByAgent = { - ...config.selectedModelByAgent, - [activeCapability.id]: model.id - } + const selectedModelPatch = selectModelForHost( + config, + discoveryHostKey, + activeCapability.id, + model.id + ) const nextSelectedThinkingByModel = { ...config.selectedThinkingByModel } if ( model.thinkingLevels && @@ -240,7 +537,7 @@ export function CommitMessageAiPane({ nextSelectedThinkingByModel[model.id] = model.defaultThinkingLevel } writeConfig({ - selectedModelByAgent: nextSelectedModelByAgent, + ...selectedModelPatch, selectedThinkingByModel: nextSelectedThinkingByModel }) } @@ -321,7 +618,7 @@ export function CommitMessageAiPane({ matchesSettingsSearch(searchQuery, { title: 'Agent', description: 'Which agent to invoke when generating a commit message.', - keywords: ['agent', 'claude', 'codex'] + keywords: ['agent', 'claude', 'codex', 'opencode', 'gemini', 'cursor'] }) ) { sections.push( @@ -329,7 +626,7 @@ export function CommitMessageAiPane({ key="agent" title="Agent" description="Which agent to invoke when generating a commit message." - keywords={['agent', 'claude', 'codex']} + keywords={['agent', 'claude', 'codex', 'opencode', 'gemini', 'cursor']} className="flex items-center justify-between gap-4 px-1 py-2" >
@@ -342,7 +639,7 @@ export function CommitMessageAiPane({
+ + + + + {activeCapability.models.map((m) => ( + + {m.label} + + ))} + +
- ) } diff --git a/src/renderer/src/runtime/runtime-git-client.test.ts b/src/renderer/src/runtime/runtime-git-client.test.ts index d58c2431c..a6f9c75c6 100644 --- a/src/renderer/src/runtime/runtime-git-client.test.ts +++ b/src/renderer/src/runtime/runtime-git-client.test.ts @@ -5,6 +5,7 @@ import { bulkStageRuntimeGitPaths, cancelRuntimeGenerateCommitMessage, commitRuntimeGit, + discoverRuntimeCommitMessageModels, generateRuntimeCommitMessage, getRuntimeGitDiff, getRuntimeGitHistory, @@ -27,6 +28,7 @@ const gitBulkDiscard = vi.fn() const gitCommit = vi.fn() const gitPush = vi.fn() const gitGenerateCommitMessage = vi.fn() +const gitDiscoverCommitMessageModels = vi.fn() const gitCancelGenerateCommitMessage = vi.fn() const runtimeEnvironmentCall = vi.fn() const runtimeEnvironmentTransportCall = vi.fn() @@ -43,6 +45,7 @@ beforeEach(() => { gitCommit.mockReset() gitPush.mockReset() gitGenerateCommitMessage.mockReset() + gitDiscoverCommitMessageModels.mockReset() gitCancelGenerateCommitMessage.mockReset() runtimeEnvironmentCall.mockReset() runtimeEnvironmentTransportCall.mockReset() @@ -62,6 +65,7 @@ beforeEach(() => { commit: gitCommit, push: gitPush, generateCommitMessage: gitGenerateCommitMessage, + discoverCommitMessageModels: gitDiscoverCommitMessageModels, cancelGenerateCommitMessage: gitCancelGenerateCommitMessage }, runtime: { call: runtimeCall }, @@ -313,7 +317,7 @@ describe('runtime git client', () => { expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(4, { selector: 'env-1', method: 'git.generateCommitMessage', - params: { worktree: 'wt-1' }, + params: { worktree: 'wt-1', commitMessageDiscoveryHostKey: 'runtime:env-1' }, timeoutMs: 75_000 }) expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(5, { @@ -365,9 +369,37 @@ describe('runtime git client', () => { worktree: 'wt-1', commitMessageAi, agentCmdOverrides, - enableGitHubAttribution: true + enableGitHubAttribution: true, + commitMessageDiscoveryHostKey: 'runtime:env-1' }, timeoutMs: 75_000 }) }) + + it('discovers commit-message models through the active runtime', async () => { + const agentCmdOverrides = { cursor: 'cursor-agent' } + runtimeEnvironmentCall.mockResolvedValue({ + id: 'rpc-1', + ok: true, + result: { success: true, models: [{ id: 'auto', label: 'Auto' }], defaultModelId: 'auto' }, + _meta: { runtimeId: 'remote-runtime' } + }) + + await discoverRuntimeCommitMessageModels( + { + settings: { activeRuntimeEnvironmentId: 'env-1', agentCmdOverrides }, + worktreeId: 'wt-1', + worktreePath: '/repo' + }, + 'cursor' + ) + + expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'git.discoverCommitMessageModels', + params: { worktree: 'wt-1', agentId: 'cursor', agentCmdOverrides }, + timeoutMs: 75_000 + }) + expect(gitDiscoverCommitMessageModels).not.toHaveBeenCalled() + }) }) diff --git a/src/renderer/src/runtime/runtime-git-client.ts b/src/renderer/src/runtime/runtime-git-client.ts index 31c1fb038..c804149b7 100644 --- a/src/renderer/src/runtime/runtime-git-client.ts +++ b/src/renderer/src/runtime/runtime-git-client.ts @@ -11,6 +11,11 @@ import type { GitUpstreamStatus, GlobalSettings } from '../../../shared/types' +import type { + CommitMessageAgentCapability, + CommitMessageModelCapability +} from '../../../shared/commit-message-agent-spec' +import { getCommitMessageModelDiscoveryHostKeyForScope } from '../../../shared/commit-message-host-key' import type { GitHistoryOptions, GitHistoryResult } from '../../../shared/git-history' import { callRuntimeRpc, getActiveRuntimeTarget } from './runtime-rpc-client' @@ -29,6 +34,15 @@ export type RuntimeGeneratePullRequestFieldsResult = type RuntimeGitSettings = Pick & Partial> +type RuntimeDiscoverCommitMessageModelsResult = + | { + success: true + capability: CommitMessageAgentCapability + models: CommitMessageModelCapability[] + defaultModelId: string + } + | { success: false; error: string } + export type RuntimeGitContext = { settings: RuntimeGitSettings | null | undefined worktreeId: string | null | undefined @@ -37,13 +51,17 @@ export type RuntimeGitContext = { } function getRuntimeCommitMessageSettings( - settings: RuntimeGitSettings | null | undefined + settings: RuntimeGitSettings | null | undefined, + connectionId?: string ): Partial< Pick -> { +> & { + commitMessageDiscoveryHostKey?: string +} { if (!settings) { return {} } + const scope = getRuntimeGitScope(settings, connectionId) return { ...(settings.commitMessageAi !== undefined ? { commitMessageAi: settings.commitMessageAi } @@ -53,14 +71,15 @@ function getRuntimeCommitMessageSettings( : {}), ...(settings.enableGitHubAttribution !== undefined ? { enableGitHubAttribution: settings.enableGitHubAttribution } - : {}) + : {}), + commitMessageDiscoveryHostKey: getCommitMessageModelDiscoveryHostKeyForScope(scope) } } export function getRuntimeGitScope( settings: Pick | null | undefined, - connectionId: string | undefined -): string | undefined { + connectionId: string | null | undefined +): string | null | undefined { const target = getActiveRuntimeTarget(settings) return target.kind === 'environment' ? `runtime:${target.environmentId}` : connectionId } @@ -362,7 +381,33 @@ export async function generateRuntimeCommitMessage( 'git.generateCommitMessage', { worktree: context.worktreeId, - ...getRuntimeCommitMessageSettings(context.settings) + ...getRuntimeCommitMessageSettings(context.settings, context.connectionId) + }, + { timeoutMs: 75_000 } + ) +} + +export async function discoverRuntimeCommitMessageModels( + context: RuntimeGitContext, + agentId: string +): Promise { + const target = getActiveRuntimeTarget(context.settings) + if (target.kind === 'local' || !context.worktreeId) { + return window.api.git.discoverCommitMessageModels({ + agentId, + worktreePath: context.worktreePath, + connectionId: context.connectionId + }) as Promise + } + return callRuntimeRpc( + target, + 'git.discoverCommitMessageModels', + { + worktree: context.worktreeId, + agentId, + ...(context.settings?.agentCmdOverrides + ? { agentCmdOverrides: context.settings.agentCmdOverrides } + : {}) }, { timeoutMs: 75_000 } ) @@ -405,7 +450,7 @@ export async function generateRuntimePullRequestFields( { worktree: context.worktreeId, ...input, - ...getRuntimeCommitMessageSettings(context.settings) + ...getRuntimeCommitMessageSettings(context.settings, context.connectionId) }, { timeoutMs: 75_000 } ) diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index fa7034c91..fa421019d 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -586,6 +586,10 @@ function createGitApi(): NonNullable['git']> { success: false, error: 'Commit message generation is unavailable in the web client.' }), + discoverCommitMessageModels: async () => ({ + success: false, + error: 'Commit message model discovery is unavailable in the web client.' + }), cancelGenerateCommitMessage: () => Promise.resolve(), generatePullRequestFields: async () => ({ success: false, diff --git a/src/shared/commit-message-agent-spec.test.ts b/src/shared/commit-message-agent-spec.test.ts index e08f591fe..f5e44b639 100644 --- a/src/shared/commit-message-agent-spec.test.ts +++ b/src/shared/commit-message-agent-spec.test.ts @@ -9,33 +9,67 @@ import { getCommitMessageModel, isCustomAgentId, listCommitMessageAgentCapabilities, - listCommitMessageAgentIds + listCommitMessageAgentIds, + parseCodexModels, + parseCursorModels, + parseLineModels, + parsePiModels } from './commit-message-agent-spec' describe('COMMIT_MESSAGE_AGENT_SPECS', () => { - it('exposes Claude and Codex as the v1 agents', () => { + it('exposes the installed local agents as commit-message agents', () => { const ids = listCommitMessageAgentIds().sort() - expect(ids).toEqual(['claude', 'codex']) + expect(ids).toEqual(['amp', 'claude', 'codex', 'copilot', 'cursor', 'kimi', 'opencode', 'pi']) }) - it('uses the smartest model as the default for each agent', () => { - expect(COMMIT_MESSAGE_AGENT_SPECS.claude?.defaultModelId).toBe('claude-opus-4-7') + it('uses the strongest available defaults for core agents', () => { + expect(COMMIT_MESSAGE_AGENT_SPECS.claude?.defaultModelId).toBe('sonnet') expect(COMMIT_MESSAGE_AGENT_SPECS.codex?.defaultModelId).toBe('gpt-5.5') + expect(COMMIT_MESSAGE_AGENT_SPECS.pi?.defaultModelId).toBe('github-copilot/gpt-5.4-mini') + }) + + it('uses the provider-qualified Kimi model id accepted by the CLI', () => { + expect(COMMIT_MESSAGE_AGENT_SPECS.kimi?.models.map((m) => m.id)).toEqual([ + 'default', + 'kimi-code/kimi-for-coding' + ]) + }) + + it('lists Copilot hosted CLI models even when account policy filters the picker', () => { + expect(COMMIT_MESSAGE_AGENT_SPECS.copilot?.defaultModelId).toBe('gpt-5.4') + expect(COMMIT_MESSAGE_AGENT_SPECS.copilot?.models.map((m) => m.id)).toEqual([ + 'auto', + 'claude-haiku-4.5', + 'claude-sonnet-4.5', + 'claude-sonnet-4.6', + 'claude-opus-4.5', + 'claude-opus-4.6', + 'claude-opus-4.6-fast', + 'claude-opus-4.7', + 'gpt-4.1', + 'gpt-5-mini', + 'gpt-5.2', + 'gpt-5.2-codex', + 'gpt-5.3-codex', + 'gpt-5.4', + 'gpt-5.4-mini', + 'gpt-5.5' + ]) }) it('defaults the agent picker to Claude', () => { expect(DEFAULT_COMMIT_MESSAGE_AGENT_ID).toBe('claude') }) - it('defaults every model with thinking levels to "low"', () => { + it('gives every model with thinking levels a valid default', () => { for (const spec of Object.values(COMMIT_MESSAGE_AGENT_SPECS)) { if (!spec) { continue } for (const model of spec.models) { if (model.thinkingLevels) { - expect(model.defaultThinkingLevel).toBe('low') - expect(model.thinkingLevels.some((l) => l.id === 'low')).toBe(true) + expect(model.defaultThinkingLevel).toBeDefined() + expect(model.thinkingLevels.some((l) => l.id === model.defaultThinkingLevel)).toBe(true) } } } @@ -48,8 +82,8 @@ describe('COMMIT_MESSAGE_AGENT_SPECS', () => { expect(spark?.defaultThinkingLevel).toBe('low') }) - it('omits thinking levels on Claude Haiku 4.5 (non-reasoning model)', () => { - const haiku = getCommitMessageModel('claude', 'claude-haiku-4-5') + it('omits thinking levels on Claude Haiku (non-reasoning model)', () => { + const haiku = getCommitMessageModel('claude', 'haiku') expect(haiku).toBeDefined() expect(haiku?.thinkingLevels).toBeUndefined() expect(haiku?.defaultThinkingLevel).toBeUndefined() @@ -81,11 +115,12 @@ describe('COMMIT_MESSAGE_AGENT_SPECS', () => { it('exposes UI capabilities without spawn details', () => { const capabilities = listCommitMessageAgentCapabilities() - expect(capabilities.map((capability) => capability.id).sort()).toEqual(['claude', 'codex']) + expect(capabilities.map((capability) => capability.id)).toContain('opencode') const codex = getCommitMessageAgentCapability('codex') expect(codex).toMatchObject({ id: 'codex', label: 'Codex', + modelSource: 'dynamic', defaultModelId: 'gpt-5.5' }) expect(codex).not.toHaveProperty('binary') @@ -98,14 +133,22 @@ describe('buildArgs (Claude)', () => { const spec = getCommitMessageAgentSpec('claude')! it('passes -p, output format, and model on every call', () => { - const args = spec.buildArgs({ prompt: '', model: 'claude-haiku-4-5' }) - expect(args).toEqual(['-p', '--output-format', 'text', '--model', 'claude-haiku-4-5']) + const args = spec.buildArgs({ prompt: '', model: 'haiku' }) + expect(args).toEqual([ + '-p', + '--output-format', + 'text', + '--model', + 'haiku', + '--permission-mode', + 'plan' + ]) }) it('appends --effort when a thinking level is supplied', () => { const args = spec.buildArgs({ prompt: '', - model: 'claude-sonnet-4-6', + model: 'sonnet', thinkingLevel: 'high' }) expect(args).toEqual([ @@ -113,18 +156,100 @@ describe('buildArgs (Claude)', () => { '--output-format', 'text', '--model', - 'claude-sonnet-4-6', + 'sonnet', + '--permission-mode', + 'plan', '--effort', 'high' ]) }) it('omits --effort when thinkingLevel is not provided', () => { - const args = spec.buildArgs({ prompt: '', model: 'claude-opus-4-7' }) + const args = spec.buildArgs({ prompt: '', model: 'opus' }) expect(args).not.toContain('--effort') }) }) +describe('model discovery parsers', () => { + it('parses Codex model JSON', () => { + expect( + parseCodexModels( + JSON.stringify({ + models: [ + { + slug: 'gpt-5.5', + display_name: 'GPT-5.5', + default_reasoning_level: 'low', + supported_reasoning_levels: [{ effort: 'low' }, { effort: 'high' }] + } + ] + }) + ) + ).toEqual([ + { + id: 'gpt-5.5', + label: 'GPT-5.5', + thinkingLevels: [ + { id: 'low', label: 'Low' }, + { id: 'high', label: 'High' } + ], + defaultThinkingLevel: 'low' + } + ]) + }) + + it('parses one-model-per-line output', () => { + expect(parseLineModels('opencode/gpt-5.4-mini\n\nopenai/gpt-5.5\n').map((m) => m.id)).toEqual([ + 'opencode/gpt-5.4-mini', + 'openai/gpt-5.5' + ]) + }) + + it('parses Pi model table output with provider-qualified ids', () => { + const output = [ + 'provider model context max-out thinking images', + 'github-copilot gpt-5.4-mini 400K 128K yes yes', + 'github-copilot gpt-4o 128K 4.1K no yes' + ].join('\n') + + expect(parsePiModels(output)).toEqual([ + { + id: 'github-copilot/gpt-5.4-mini', + label: 'Github Copilot GPT 5.4 Mini', + thinkingLevels: [ + { id: 'off', label: 'Off' }, + { id: 'low', label: 'Low' }, + { id: 'medium', label: 'Medium' }, + { id: 'high', label: 'High' }, + { id: 'xhigh', label: 'Extra High' } + ], + defaultThinkingLevel: 'low' + }, + { + id: 'github-copilot/gpt-4o', + label: 'Github Copilot GPT 4O' + } + ]) + }) + + it('parses Cursor model output', () => { + expect(parseCursorModels('auto - Auto\ngpt-5.2 - GPT-5.2\n')).toEqual([ + { id: 'auto', label: 'Auto' }, + { + id: 'gpt-5.2', + label: 'GPT-5.2', + thinkingLevels: [ + { id: 'low', label: 'Low' }, + { id: 'medium', label: 'Medium' }, + { id: 'high', label: 'High' }, + { id: 'xhigh', label: 'Extra High' } + ], + defaultThinkingLevel: 'low' + } + ]) + }) +}) + describe('buildArgs (Codex)', () => { const spec = getCommitMessageAgentSpec('codex')! diff --git a/src/shared/commit-message-agent-spec.ts b/src/shared/commit-message-agent-spec.ts index 6556ae854..2baadf796 100644 --- a/src/shared/commit-message-agent-spec.ts +++ b/src/shared/commit-message-agent-spec.ts @@ -1,5 +1,7 @@ import type { TuiAgent } from './types' +/* eslint-disable max-lines -- Why: this is the single registry for non-interactive commit-message agents, their model discovery parsers, and UI capabilities. */ + // Why: this file is the source of truth for non-interactive agent invocation // (commit-message generation). It is intentionally separate from // `tui-agent-config.ts`, which describes interactive PTY launching — mixing @@ -27,6 +29,14 @@ export type CommitMessageAgentSpec = { /** Where the prompt is delivered. Large diffs go via stdin to avoid argv limits. */ promptDelivery: 'argv' | 'stdin' buildArgs: (params: { prompt: string; model: string; thinkingLevel?: string }) => string[] + /** Whether the model list is static or discovered from the agent CLI. */ + modelSource: 'static' | 'dynamic' + /** Command used by the main process to discover models when modelSource is dynamic. */ + modelDiscovery?: { + binary: string + args: string[] + parse: (stdout: string) => CommitMessageModel[] + } models: CommitMessageModel[] defaultModelId: string } @@ -41,10 +51,159 @@ export type CommitMessageModelCapability = { export type CommitMessageAgentCapability = { id: TuiAgent label: string + modelSource: 'static' | 'dynamic' models: CommitMessageModelCapability[] defaultModelId: string } +const BASIC_THINKING_LEVELS: ThinkingLevel[] = [ + { id: 'low', label: 'Low' }, + { id: 'medium', label: 'Medium' }, + { id: 'high', label: 'High' } +] + +const OPENAI_THINKING_LEVELS: ThinkingLevel[] = [ + { id: 'low', label: 'Low' }, + { id: 'medium', label: 'Medium' }, + { id: 'high', label: 'High' }, + { id: 'xhigh', label: 'Extra High' } +] + +const CLAUDE_THINKING_LEVELS: ThinkingLevel[] = [ + { id: 'low', label: 'Low' }, + { id: 'medium', label: 'Medium' }, + { id: 'high', label: 'High' }, + { id: 'xhigh', label: 'Extra High' }, + { id: 'max', label: 'Max' } +] + +function labelFromModelId(id: string): string { + return id + .split(/[/-]/) + .filter(Boolean) + .map((part) => { + if (/^gpt$/i.test(part)) { + return 'GPT' + } + return part.length <= 3 && /^\d/.test(part) + ? part.toUpperCase() + : part.charAt(0).toUpperCase() + part.slice(1) + }) + .join(' ') +} + +function uniqueModels(models: CommitMessageModel[]): CommitMessageModel[] { + const seen = new Set() + return models.filter((model) => { + if (!model.id || seen.has(model.id)) { + return false + } + seen.add(model.id) + return true + }) +} + +function withOpenAiThinking( + id: string +): Pick { + return /(?:gpt-5|codex)/i.test(id) + ? { thinkingLevels: OPENAI_THINKING_LEVELS, defaultThinkingLevel: 'low' } + : {} +} + +export function parseCodexModels(stdout: string): CommitMessageModel[] { + try { + const parsed = JSON.parse(stdout) as { + models?: { + slug?: string + display_name?: string + supported_reasoning_levels?: { effort?: string }[] + default_reasoning_level?: string + }[] + } + return uniqueModels( + (parsed.models ?? []) + .filter((model) => model.slug && model.display_name) + .map((model) => ({ + id: model.slug!, + label: model.display_name!, + ...(model.supported_reasoning_levels?.length + ? { + thinkingLevels: model.supported_reasoning_levels + .map((level) => level.effort) + .filter((effort): effort is string => Boolean(effort)) + .map((effort) => ({ + id: effort, + label: effort === 'xhigh' ? 'Extra High' : labelFromModelId(effort) + })), + defaultThinkingLevel: model.default_reasoning_level ?? 'low' + } + : {}) + })) + ) + } catch { + return [] + } +} + +export function parseLineModels(stdout: string): CommitMessageModel[] { + return uniqueModels( + stdout + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => line.length > 0 && !line.includes(' ')) + .map((id) => ({ + id, + label: labelFromModelId(id), + ...withOpenAiThinking(id) + })) + ) +} + +export function parsePiModels(stdout: string): CommitMessageModel[] { + return uniqueModels( + stdout + .split(/\r?\n/) + .map((line) => line.trim().split(/\s+/)) + .filter((parts) => parts.length >= 6 && parts[0] !== 'provider') + .map((parts) => { + const [provider, model, , , thinking] = parts + const id = `${provider}/${model}` + return { + id, + label: `${labelFromModelId(provider)} ${labelFromModelId(model)}`, + ...(thinking === 'yes' + ? { + thinkingLevels: [ + { id: 'off', label: 'Off' }, + { id: 'low', label: 'Low' }, + { id: 'medium', label: 'Medium' }, + { id: 'high', label: 'High' }, + { id: 'xhigh', label: 'Extra High' } + ], + defaultThinkingLevel: 'low' + } + : {}) + } + }) + ) +} + +export function parseCursorModels(stdout: string): CommitMessageModel[] { + return uniqueModels( + stdout + .split(/\r?\n/) + .map((line) => line.trim()) + .map((line) => /^([^\s]+)\s+-\s+(.+)$/.exec(line)) + .filter((match): match is RegExpExecArray => Boolean(match)) + .map((match) => ({ + id: match[1], + label: match[2].replace(/\s+\((?:default|current)\)$/i, ''), + ...withOpenAiThinking(match[1]) + })) + ) +} + export const COMMIT_MESSAGE_AGENT_SPECS: Partial> = { claude: { id: 'claude', @@ -59,42 +218,32 @@ export const COMMIT_MESSAGE_AGENT_SPECS: Partial [ + 'run', + '--model', + model, + '--agent', + 'build', + '--format', + 'default', + ...(thinkingLevel ? ['--variant', thinkingLevel] : []), + prompt + ], + modelSource: 'dynamic', + modelDiscovery: { binary: 'opencode', args: ['models'], parse: parseLineModels }, + models: [ + { + // Why: OpenCode's hosted GPT models can require workspace billing even + // when `opencode models` lists them. This free model is available in + // discovery and works as a usable out-of-the-box default. + id: 'opencode/deepseek-v4-flash-free', + label: 'OpenCode DeepSeek V4 Flash Free' + }, + { + id: 'opencode/gpt-5.4-mini', + label: 'OpenCode GPT 5.4 Mini', + ...withOpenAiThinking('gpt-5.4-mini') + } + ], + defaultModelId: 'opencode/deepseek-v4-flash-free' + }, + pi: { + id: 'pi', + label: 'Pi', + binary: 'pi', + promptDelivery: 'stdin', + buildArgs: ({ model, thinkingLevel }) => [ + '--print', + '--no-session', + '--no-tools', + '--no-extensions', + '--no-skills', + '--no-context-files', + '--mode', + 'text', + '--model', + model, + ...(thinkingLevel ? ['--thinking', thinkingLevel] : []) + ], + modelSource: 'dynamic', + modelDiscovery: { binary: 'pi', args: ['--list-models'], parse: parsePiModels }, + models: [ + { + // Why: Pi commonly authenticates through GitHub Copilot locally; using + // that provider avoids selecting a raw OpenAI model when no key exists. + id: 'github-copilot/gpt-5.4-mini', + label: 'Github Copilot GPT 5.4 Mini', + ...withOpenAiThinking('gpt-5.4-mini') + } + ], + defaultModelId: 'github-copilot/gpt-5.4-mini' + }, + amp: { + id: 'amp', + label: 'Amp', + binary: 'amp', + promptDelivery: 'stdin', + buildArgs: ({ model, thinkingLevel }) => [ + '--execute', + '--archive', + '--no-notifications', + '--no-ide', + '--no-jetbrains', + '--mode', + model, + ...(thinkingLevel ? ['--effort', thinkingLevel] : []) + ], + modelSource: 'static', + models: [ + { id: 'smart', label: 'Smart' }, + { id: 'rush', label: 'Rush' }, + { + id: 'large', + label: 'Large', + thinkingLevels: BASIC_THINKING_LEVELS, + defaultThinkingLevel: 'low' + }, + { + id: 'deep', + label: 'Deep', + thinkingLevels: BASIC_THINKING_LEVELS, + defaultThinkingLevel: 'low' + } + ], + defaultModelId: 'smart' + }, + cursor: { + id: 'cursor', + label: 'Cursor', + binary: 'cursor-agent', + promptDelivery: 'argv', + buildArgs: ({ prompt, model }) => [ + '--print', + '--mode', + 'ask', + '--trust', + '--output-format', + 'text', + '--model', + model, + prompt + ], + modelSource: 'dynamic', + modelDiscovery: { binary: 'cursor-agent', args: ['--list-models'], parse: parseCursorModels }, + models: [{ id: 'auto', label: 'Auto' }], + defaultModelId: 'auto' + }, + kimi: { + id: 'kimi', + label: 'Kimi', + binary: 'kimi', + promptDelivery: 'stdin', + buildArgs: ({ model, thinkingLevel }) => [ + '--print', + '--quiet', + ...(model && model !== 'default' ? ['--model', model] : []), + ...(thinkingLevel === 'on' + ? ['--thinking'] + : thinkingLevel === 'off' + ? ['--no-thinking'] + : []) + ], + modelSource: 'static', + models: [ + { id: 'default', label: 'Config default' }, + { + // Why: Kimi resolves its managed model by provider/model; bare model + // names are rejected by the CLI with "LLM not set". + id: 'kimi-code/kimi-for-coding', + label: 'Kimi K2.6', + thinkingLevels: [ + { id: 'on', label: 'On' }, + { id: 'off', label: 'Off' } + ], + defaultThinkingLevel: 'on' + } + ], + defaultModelId: 'default' + }, + copilot: { + id: 'copilot', + label: 'GitHub Copilot', + binary: 'copilot', + promptDelivery: 'argv', + buildArgs: ({ prompt, model, thinkingLevel }) => [ + '--prompt', + prompt, + '--silent', + '--stream', + 'off', + '--no-custom-instructions', + '--model', + model, + ...(thinkingLevel ? ['--effort', thinkingLevel] : []) + ], + modelSource: 'static', + // Why: Copilot CLI's picker is policy-filtered per account/org. Keep the + // full hosted CLI catalog here so users can select models enabled for them. + models: [ + { id: 'auto', label: 'Auto' }, + { + id: 'claude-haiku-4.5', + label: 'Claude Haiku 4.5' + }, + { + id: 'claude-sonnet-4.5', + label: 'Claude Sonnet 4.5' + }, + { + id: 'claude-sonnet-4.6', + label: 'Claude Sonnet 4.6' + }, + { + id: 'claude-opus-4.5', + label: 'Claude Opus 4.5' + }, + { + id: 'claude-opus-4.6', + label: 'Claude Opus 4.6' + }, + { + id: 'claude-opus-4.6-fast', + label: 'Claude Opus 4.6 Fast' + }, + { + id: 'claude-opus-4.7', + label: 'Claude Opus 4.7' + }, + { + id: 'gpt-4.1', + label: 'GPT-4.1' + }, + { + id: 'gpt-5-mini', + label: 'GPT-5 Mini', + thinkingLevels: OPENAI_THINKING_LEVELS, + defaultThinkingLevel: 'low' + }, + { + id: 'gpt-5.2', + label: 'GPT-5.2', + thinkingLevels: OPENAI_THINKING_LEVELS, + defaultThinkingLevel: 'low' + }, + { + id: 'gpt-5.2-codex', + label: 'GPT-5.2 Codex', + thinkingLevels: OPENAI_THINKING_LEVELS, + defaultThinkingLevel: 'low' + }, + { + id: 'gpt-5.3-codex', + label: 'GPT-5.3 Codex', + thinkingLevels: OPENAI_THINKING_LEVELS, + defaultThinkingLevel: 'low' + }, + { + id: 'gpt-5.4', + label: 'GPT-5.4', + thinkingLevels: OPENAI_THINKING_LEVELS, + defaultThinkingLevel: 'low' + }, + { + id: 'gpt-5.4-mini', + label: 'GPT-5.4 Mini', + thinkingLevels: OPENAI_THINKING_LEVELS, + defaultThinkingLevel: 'low' + }, + { + id: 'gpt-5.5', + label: 'GPT-5.5', + thinkingLevels: OPENAI_THINKING_LEVELS, + defaultThinkingLevel: 'low' + } + ], + defaultModelId: 'gpt-5.4' } } @@ -231,7 +606,16 @@ export function getCommitMessageModel( agentId: TuiAgent, modelId: string ): CommitMessageModel | undefined { - return getCommitMessageAgentSpec(agentId)?.models.find((m) => m.id === modelId) + const spec = getCommitMessageAgentSpec(agentId) + const model = spec?.models.find((m) => m.id === modelId) + if (model || !spec || spec.modelSource !== 'dynamic' || modelId.trim().length === 0) { + return model + } + return { + id: modelId, + label: labelFromModelId(modelId), + ...withOpenAiThinking(modelId) + } } function toCommitMessageAgentCapability( @@ -240,6 +624,7 @@ function toCommitMessageAgentCapability( return { id: spec.id, label: spec.label, + modelSource: spec.modelSource, defaultModelId: spec.defaultModelId, // Why: renderer/settings should consume provider capabilities, not the // spawn contract. Copy the model metadata so future dynamic probes can diff --git a/src/shared/commit-message-host-key.ts b/src/shared/commit-message-host-key.ts new file mode 100644 index 000000000..6edf51a9b --- /dev/null +++ b/src/shared/commit-message-host-key.ts @@ -0,0 +1,27 @@ +export const LOCAL_COMMIT_MESSAGE_HOST_KEY = 'local' +export const UNKNOWN_COMMIT_MESSAGE_HOST_KEY = 'unknown' +export const RUNTIME_COMMIT_MESSAGE_HOST_KEY_PREFIX = 'runtime:' + +export function getCommitMessageModelDiscoveryHostKey( + connectionId: string | null | undefined +): string { + if (connectionId === undefined) { + return UNKNOWN_COMMIT_MESSAGE_HOST_KEY + } + return connectionId ? `ssh:${connectionId}` : LOCAL_COMMIT_MESSAGE_HOST_KEY +} + +export function getCommitMessageModelDiscoveryHostKeyForScope( + scope: string | null | undefined +): string { + if (scope === undefined) { + return UNKNOWN_COMMIT_MESSAGE_HOST_KEY + } + if (!scope) { + return LOCAL_COMMIT_MESSAGE_HOST_KEY + } + if (scope.startsWith(RUNTIME_COMMIT_MESSAGE_HOST_KEY_PREFIX)) { + return scope + } + return getCommitMessageModelDiscoveryHostKey(scope) +} diff --git a/src/shared/commit-message-plan.test.ts b/src/shared/commit-message-plan.test.ts index d8294278e..3c891c067 100644 --- a/src/shared/commit-message-plan.test.ts +++ b/src/shared/commit-message-plan.test.ts @@ -6,7 +6,7 @@ describe('planCommitMessageGeneration', () => { const result = planCommitMessageGeneration( { agentId: 'claude', - model: 'claude-sonnet-4-6', + model: 'sonnet', thinkingLevel: 'high' }, 'PROMPT' @@ -16,13 +16,86 @@ describe('planCommitMessageGeneration', () => { ok: true, plan: { binary: 'claude', - args: ['-p', '--output-format', 'text', '--model', 'claude-sonnet-4-6', '--effort', 'high'], + args: [ + '-p', + '--output-format', + 'text', + '--model', + 'sonnet', + '--permission-mode', + 'plan', + '--effort', + 'high' + ], stdinPayload: 'PROMPT', label: 'Claude' } }) }) + it('plans OpenCode run with prompt in argv and model variant', () => { + const result = planCommitMessageGeneration( + { + agentId: 'opencode', + model: 'opencode/gpt-5.4-mini', + thinkingLevel: 'high' + }, + 'PROMPT' + ) + + expect(result).toEqual({ + ok: true, + plan: { + binary: 'opencode', + args: [ + 'run', + '--model', + 'opencode/gpt-5.4-mini', + '--agent', + 'build', + '--format', + 'default', + '--variant', + 'high', + 'PROMPT' + ], + stdinPayload: null, + label: 'OpenCode' + } + }) + }) + + it('allows discovered dynamic models that are not in the seed catalog', () => { + const result = planCommitMessageGeneration( + { + agentId: 'cursor', + model: 'gpt-5.2', + thinkingLevel: 'xhigh' + }, + 'PROMPT' + ) + + expect(result).toEqual({ + ok: true, + plan: { + binary: 'cursor-agent', + args: [ + '--print', + '--mode', + 'ask', + '--trust', + '--output-format', + 'text', + '--model', + 'gpt-5.2', + 'PROMPT' + ], + stdinPayload: null, + label: 'Cursor' + } + }) + }) + it('plans Codex exec as non-interactive read-only generation with the prompt on stdin only', () => { const result = planCommitMessageGeneration( { @@ -87,7 +160,7 @@ describe('planCommitMessageGeneration', () => { const result = planCommitMessageGeneration( { agentId: 'claude', - model: 'claude-haiku-4-5', + model: 'haiku', agentCommandOverride: 'claude "unterminated' }, 'PROMPT' diff --git a/src/shared/commit-message-plan.ts b/src/shared/commit-message-plan.ts index 7dd7ccf61..b7c4c783e 100644 --- a/src/shared/commit-message-plan.ts +++ b/src/shared/commit-message-plan.ts @@ -33,7 +33,7 @@ export type CommitMessagePlanResult = | { ok: true; plan: CommitMessagePlan } | { ok: false; error: string } -function planAgentBinary( +export function planAgentBinary( defaultBinary: string, commandOverride: string | undefined ): { ok: true; binary: string; prefixArgs: string[] } | { ok: false; error: string } { @@ -91,13 +91,13 @@ export function planCommitMessageGeneration( return { ok: false, error: `Model "${input.model}" is not available for ${spec.label}.` } } if (input.thinkingLevel) { - if (!model.thinkingLevels) { + if (!model.thinkingLevels && spec.modelSource !== 'dynamic') { return { ok: false, error: `Model "${model.label}" does not support a thinking effort level.` } } - if (!model.thinkingLevels.some((l) => l.id === input.thinkingLevel)) { + if (model.thinkingLevels && !model.thinkingLevels.some((l) => l.id === input.thinkingLevel)) { return { ok: false, error: `Thinking level "${input.thinkingLevel}" is not valid for ${model.label}.` diff --git a/src/shared/commit-message-prompt.test.ts b/src/shared/commit-message-prompt.test.ts index dfaa39fa1..abd84fe64 100644 --- a/src/shared/commit-message-prompt.test.ts +++ b/src/shared/commit-message-prompt.test.ts @@ -73,6 +73,13 @@ describe('cleanGeneratedCommitMessage', () => { expect(cleanGeneratedCommitMessage('feat: a\r\nbody line\r\n')).toBe('feat: a\nbody line') }) + it('strips a leading list marker from the commit subject', () => { + expect(cleanGeneratedCommitMessage('● Add Copilot entry to agent results')).toBe( + 'Add Copilot entry to agent results' + ) + expect(cleanGeneratedCommitMessage('1. Add numbered entry')).toBe('Add numbered entry') + }) + it('returns empty string when input is whitespace', () => { expect(cleanGeneratedCommitMessage(' \n\t')).toBe('') }) @@ -111,6 +118,32 @@ describe('extractAgentErrorMessage', () => { expect(extractAgentErrorMessage('Error: model unavailable\n', '')).toBe('model unavailable') }) + it('matches ANSI-colored `Error:` lines emitted by CLIs', () => { + expect( + extractAgentErrorMessage('', '\u001b[91m\u001b[1mError: \u001b[0mNo payment method\n') + ).toBe('No payment method') + }) + + it('matches tool-specific `Error during ...:` lines', () => { + expect( + extractAgentErrorMessage( + '', + 'Error during droid execution: Authentication failed. Please log into Factory.\n' + ) + ).toBe('Authentication failed. Please log into Factory.') + }) + + it('matches wrapped provider error-code payloads with quoted message fields', () => { + const stdout = [ + "Error code: 401 - {'error': {'message': 'The API Key appears to be invalid or ma", + "y have expired. Please verify your credentials and try again.', 'type': 'invalid", + "_authentication_error'}}" + ].join('\n') + expect(extractAgentErrorMessage(stdout, '')).toBe( + 'The API Key appears to be invalid or may have expired. Please verify your credentials and try again.' + ) + }) + it('returns null when no ERROR line is present', () => { expect(extractAgentErrorMessage('plain log\nmore log\n', '')).toBeNull() }) diff --git a/src/shared/commit-message-prompt.ts b/src/shared/commit-message-prompt.ts index cb12b5aeb..975dfbf39 100644 --- a/src/shared/commit-message-prompt.ts +++ b/src/shared/commit-message-prompt.ts @@ -66,9 +66,17 @@ export function cleanGeneratedCommitMessage(raw: string): string { text = fenced[1].trim() } + // Why: some CLIs format a one-shot answer as a list item even when the + // prompt asks for raw text; a Git subject should not carry that marker. + text = text.replace(/^(\s*)(?:[-*•●]\s+|\d+[.)]\s+)/, '$1').trim() + return text } +function stripAnsiControlSequences(value: string): string { + return value.replace(new RegExp(`${String.fromCharCode(27)}\\[[0-?]*[ -/]*[@-~]`, 'g'), '') +} + export const CUSTOM_PROMPT_PLACEHOLDER = '{prompt}' export type TokenizeCustomCommandResult = @@ -195,7 +203,7 @@ export function planCustomCommand(template: string, prompt: string): CustomComma // out the real message so the user sees something legible instead of a // dump of the agent's runtime state. export function extractAgentErrorMessage(stdout: string, stderr: string): string | null { - const combined = `${stdout}\n${stderr}` + const combined = stripAnsiControlSequences(`${stdout}\n${stderr}`) const lines = combined.split(/\r?\n/) // Pass 1: look for an `ERROR:`/`Error:` line carrying a JSON payload. @@ -203,7 +211,7 @@ export function extractAgentErrorMessage(stdout: string, stderr: string): string // error wins when an agent prints multiple. for (let i = lines.length - 1; i >= 0; i--) { const line = lines[i] - const match = /^\s*(?:ERROR|Error)\s*:\s*(.+)$/.exec(line) + const match = /^\s*(?:ERROR|Error(?:\s+during\s+[^:]+)?)\s*:\s*(.+)$/i.exec(line) if (!match) { continue } @@ -227,5 +235,18 @@ export function extractAgentErrorMessage(stdout: string, stderr: string): string } } + const compact = combined.replace(/([A-Za-z])\r?\n\s*([A-Za-z_])/g, '$1$2').replace(/\s+/g, ' ') + const errorCodeMatch = /\bError code:\s*\d+\s*-\s*(.+)$/i.exec(compact) + if (errorCodeMatch) { + const payload = errorCodeMatch[1].trim() + const messageMatch = /['"]message['"]\s*:\s*['"]([^'"]+)['"]/i.exec(payload) + if (messageMatch?.[1]?.trim()) { + return messageMatch[1].trim() + } + if (payload.length > 0) { + return payload + } + } + return null } diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 5e8de4e09..a39fb4bbb 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -1,3 +1,4 @@ +/* eslint-disable max-lines -- Why: default persisted settings live in one schema-shaped object so migrations and tests compare against one source of truth. */ import type { GlobalSettings, NotificationSettings, @@ -275,6 +276,9 @@ export function getDefaultSettings(homedir: string): GlobalSettings { enabled: true, agentId: null, selectedModelByAgent: {}, + discoveredModelsByAgent: {}, + selectedModelByAgentByHost: {}, + discoveredModelsByAgentByHost: {}, selectedThinkingByModel: {}, customPrompt: '', customAgentCommand: '' diff --git a/src/shared/types.ts b/src/shared/types.ts index f8c1f9684..2b8109739 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -1654,12 +1654,27 @@ export type GlobalSettings = { voice?: VoiceSettings } +export type CommitMessageAiModelCapability = { + id: string + label: string + thinkingLevels?: { id: string; label: string }[] + defaultThinkingLevel?: string +} + export type CommitMessageAiSettings = { enabled: boolean /** A TuiAgent id, the literal `'custom'` for a user-supplied command, or null. */ agentId: TuiAgent | 'custom' | null /** Per-agent: switching agents preserves the previously-picked model. */ selectedModelByAgent: Partial> + /** Host-scoped model selections; dynamic agents can expose different models per SSH target. */ + selectedModelByAgentByHost?: Partial>>> + /** Per-agent dynamic models last discovered from the CLI, persisted so main can validate selections. */ + discoveredModelsByAgent?: Partial> + /** Host-scoped dynamic model discovery cache. */ + discoveredModelsByAgentByHost?: Partial< + Record>> + > /** Per-model: thinking effort depends on the model, not the agent. Keyed by model id. */ selectedThinkingByModel: Record /** Optional user-provided suffix appended to the base prompt (style overrides, etc.). */