feat: expand ai commit agent support (#1928)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: brennanb2025 <brennankbenson@gmail.com>
This commit is contained in:
Leynier Gutiérrez González 2026-05-19 03:32:53 -06:00 committed by GitHub
parent 363ddb5ef6
commit 03b889512b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
31 changed files with 2512 additions and 175 deletions

View File

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

View File

@ -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<GenerateCommitMessageResult> => {
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<DiscoverCommitMessageModelsResult> => {
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<GeneratePullRequestFieldsResult> => {
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 }
}

View File

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

View File

@ -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<GlobalSettings, 'commitMessageAi' | 'agentCmdOverrides' | 'enableGitHubAttribution'>
>
> & {
commitMessageDiscoveryHostKey?: string
}
function normalizeRuntimeGitRelativePath(filePath: string): string {
const relativePath = normalizeRuntimeRelativePath(filePath)
@ -333,15 +340,21 @@ export class RuntimeGitCommands {
worktreeSelector: string,
settingsOverride?: RuntimeCommitMessageSettingsOverride
): Promise<GenerateCommitMessageResult> {
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<GeneratePullRequestFieldsResult> {
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<RuntimeCommitMessageSettingsOverride, 'agentCmdOverrides'>
): Promise<DiscoverCommitMessageModelsResult> {
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)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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<string, (value: unknown) => 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<string, (value: unknown) => 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<string, (value: unknown) => 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<string, (value: unknown) => 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(
{

View File

@ -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<ReturnType<typeof getCommitMessageAgentSpec>>,
models = spec.models,
defaultModelId = spec.defaultModelId
): Extract<DiscoverCommitMessageModelsResult, { success: true }> {
return {
success: true,
capability: {
id: spec.id,
label: spec.label,
modelSource: spec.modelSource,
defaultModelId,
models
},
models,
defaultModelId
}
}
function finalizeModelDiscoveryOutput(
spec: NonNullable<ReturnType<typeof getCommitMessageAgentSpec>>,
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<ReturnType<typeof getCommitMessageAgentSpec>>,
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<DiscoverCommitMessageModelsResult> {
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<RemoteCommitMessageExecResult>,
agentCommandOverride?: string
): Promise<DiscoverCommitMessageModelsResult> {
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
}

View File

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

View File

@ -2029,6 +2029,11 @@ const api = {
worktreePath: string
connectionId?: string
}): Promise<unknown> => ipcRenderer.invoke('git:generateCommitMessage', args),
discoverCommitMessageModels: (args: {
agentId: string
worktreePath?: string
connectionId?: string
}): Promise<unknown> => ipcRenderer.invoke('git:discoverCommitMessageModels', args),
cancelGenerateCommitMessage: (args: {
worktreePath: string
connectionId?: string

View File

@ -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(
<AgentCombobox
agents={AGENT_CATALOG}
value="copilot"
onValueChange={vi.fn()}
triggerClassName="h-9 w-full min-w-0"
/>
)
expect(markup).toContain('GitHub Copilot')
expect(markup).toContain('!min-w-[260px]')
expect(markup).toContain('flex-1')
})
})

View File

@ -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 ? (
<span className="inline-flex min-w-0 items-center gap-1.5">
<span className="inline-flex min-w-0 flex-1 items-center gap-1.5">
<AgentIcon agent={selectedAgent.id} />
<span className="truncate">{selectedAgent.label}</span>
</span>
) : (
<span className="inline-flex min-w-0 items-center gap-1.5">
<span className="inline-flex min-w-0 flex-1 items-center gap-1.5">
<Terminal className="size-3.5" />
<span className="truncate">Blank Terminal</span>
</span>

View File

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

View File

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

View File

@ -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<CommitMessageAiSettings, 'selectedModelByAgent' | 'selectedModelByAgentByHost'> {
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<Record<TuiAgent, ModelDiscoveryState>>
>({})
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<CommitMessageAiSettings>): void => {
updateSettings({ commitMessageAi: { ...config, ...patch } })
}
const refreshModels = async (agentId: TuiAgent): Promise<void> => {
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"
>
<div className="space-y-0.5">
@ -342,7 +639,7 @@ export function CommitMessageAiPane({
</div>
<div className="flex flex-col items-end gap-1">
<Select value={activeAgentSelectValue} onValueChange={onAgentChange}>
<SelectTrigger size="sm" className="h-8 text-xs w-[180px]">
<SelectTrigger size="sm" className="h-8 w-[260px] shrink-0 text-xs">
<SelectValue placeholder="Not configured" />
</SelectTrigger>
<SelectContent>
@ -357,6 +654,17 @@ export function CommitMessageAiPane({
</SelectItem>
)
})}
{COMING_SOON_COMMIT_MESSAGE_AGENTS.filter(
(agent) => !agentCapabilities.some((capability) => capability.id === agent.id)
).map((agent) => (
<SelectItem key={agent.id} value={agent.id} disabled className="cursor-not-allowed">
<span className="flex items-center gap-2">
<AgentIcon agent={agent.id} size={14} />
<span>{agent.label}</span>
<span className="text-[11px] text-muted-foreground">Coming soon</span>
</span>
</SelectItem>
))}
<SelectItem value={CUSTOM_AGENT_ID} className="cursor-pointer">
<span className="flex items-center gap-2">
<Terminal className="size-3.5" />
@ -368,7 +676,15 @@ export function CommitMessageAiPane({
{unsupportedDefaultAgentLabel ? (
<p className="max-w-[260px] text-right text-[11px] text-muted-foreground">
Your default agent is {unsupportedDefaultAgentLabel}, which does not support commit
message generation yet. Choose Claude, Codex, or Custom.
message generation yet. Choose a supported agent or Custom.
</p>
) : null}
{unsupportedSelectedAgentLabel ? (
<p className="max-w-[260px] text-right text-[11px] text-muted-foreground">
{unsupportedSelectedAgentIsComingSoon
? `${unsupportedSelectedAgentLabel} commit message generation is coming soon.`
: `${unsupportedSelectedAgentLabel} does not support commit message generation yet.`}{' '}
Choose a supported agent or Custom.
</p>
) : null}
</div>
@ -444,22 +760,42 @@ export function CommitMessageAiPane({
<div className="space-y-0.5">
<Label>Model</Label>
<p className="text-xs text-muted-foreground">
Defaults to the strongest available model for the selected agent. Pick a smaller one if
you prefer lower latency or cost.
{activeCapability.modelSource === 'dynamic'
? 'Refreshes from the selected CLI when the CLI exposes model discovery.'
: 'This agent does not expose model discovery, so Orca uses a manual catalog.'}
</p>
{activeDiscovery?.status === 'error' && (
<p className="text-xs text-destructive">{activeDiscovery.error}</p>
)}
</div>
<div className="flex items-center gap-2">
{activeCapability.modelSource === 'dynamic' && (
<button
type="button"
onClick={() => void refreshModels(activeCapability.id)}
disabled={activeDiscovery?.status === 'loading'}
title="Refresh models"
aria-label="Refresh models"
className="inline-flex size-8 items-center justify-center rounded-md border border-border text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:cursor-not-allowed disabled:opacity-50"
>
<RefreshCw
className={`size-3.5 ${activeDiscovery?.status === 'loading' ? 'animate-spin' : ''}`}
/>
</button>
)}
<Select value={activeModel.id} onValueChange={onModelChange}>
<SelectTrigger size="sm" className="h-8 w-[260px] shrink-0 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{activeCapability.models.map((m) => (
<SelectItem key={m.id} value={m.id} className="cursor-pointer">
{m.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Select value={activeModel.id} onValueChange={onModelChange}>
<SelectTrigger size="sm" className="h-8 text-xs w-[200px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{activeCapability.models.map((m) => (
<SelectItem key={m.id} value={m.id} className="cursor-pointer">
{m.label}
</SelectItem>
))}
</SelectContent>
</Select>
</SearchableSetting>
)
}

View File

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

View File

@ -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<GlobalSettings, 'activeRuntimeEnvironmentId'> &
Partial<Pick<GlobalSettings, 'commitMessageAi' | 'agentCmdOverrides' | 'enableGitHubAttribution'>>
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<GlobalSettings, 'commitMessageAi' | 'agentCmdOverrides' | 'enableGitHubAttribution'>
> {
> & {
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<GlobalSettings, 'activeRuntimeEnvironmentId'> | 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<RuntimeDiscoverCommitMessageModelsResult> {
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<RuntimeDiscoverCommitMessageModelsResult>
}
return callRuntimeRpc<RuntimeDiscoverCommitMessageModelsResult>(
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 }
)

View File

@ -586,6 +586,10 @@ function createGitApi(): NonNullable<Partial<PreloadApi>['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,

View File

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

View File

@ -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<string>()
return models.filter((model) => {
if (!model.id || seen.has(model.id)) {
return false
}
seen.add(model.id)
return true
})
}
function withOpenAiThinking(
id: string
): Pick<CommitMessageModel, 'thinkingLevels' | 'defaultThinkingLevel'> {
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<Record<TuiAgent, CommitMessageAgentSpec>> = {
claude: {
id: 'claude',
@ -59,42 +218,32 @@ export const COMMIT_MESSAGE_AGENT_SPECS: Partial<Record<TuiAgent, CommitMessageA
'text',
'--model',
model,
'--permission-mode',
'plan',
...(thinkingLevel ? ['--effort', thinkingLevel] : [])
],
modelSource: 'static',
models: [
{
// Why: Haiku 4.5 is a non-reasoning model — `claude --effort` rejects
// any value for it. Omit thinkingLevels so the UI hides the dropdown
// and the buildArgs path skips passing --effort entirely.
id: 'claude-haiku-4-5',
label: 'Haiku 4.5'
// Why: Claude Code aliases track the account/provider's supported
// model IDs; hardcoded version IDs can be rejected by Bedrock/Vertex.
id: 'haiku',
label: 'Haiku'
},
{
id: 'claude-sonnet-4-6',
label: 'Sonnet 4.6',
thinkingLevels: [
{ id: 'low', label: 'Low' },
{ id: 'medium', label: 'Medium' },
{ id: 'high', label: 'High' },
{ id: 'xhigh', label: 'Extra High' },
{ id: 'max', label: 'Max' }
],
id: 'sonnet',
label: 'Sonnet',
thinkingLevels: CLAUDE_THINKING_LEVELS,
defaultThinkingLevel: 'low'
},
{
id: 'claude-opus-4-7',
label: 'Opus 4.7',
thinkingLevels: [
{ id: 'low', label: 'Low' },
{ id: 'medium', label: 'Medium' },
{ id: 'high', label: 'High' },
{ id: 'xhigh', label: 'Extra High' },
{ id: 'max', label: 'Max' }
],
id: 'opus',
label: 'Opus',
thinkingLevels: CLAUDE_THINKING_LEVELS,
defaultThinkingLevel: 'low'
}
],
defaultModelId: 'claude-opus-4-7'
defaultModelId: 'sonnet'
},
codex: {
id: 'codex',
@ -116,51 +265,37 @@ export const COMMIT_MESSAGE_AGENT_SPECS: Partial<Record<TuiAgent, CommitMessageA
model,
...(thinkingLevel ? ['-c', `model_reasoning_effort=${thinkingLevel}`] : [])
],
modelSource: 'dynamic',
modelDiscovery: {
binary: 'codex',
args: ['debug', 'models'],
parse: parseCodexModels
},
// Why: ordered to match the official `codex` model picker — descending
// by version so the frontier model lands on top and legacy models trail.
models: [
{
id: 'gpt-5.5',
label: 'GPT-5.5',
thinkingLevels: [
{ id: 'low', label: 'Low' },
{ id: 'medium', label: 'Medium' },
{ id: 'high', label: 'High' },
{ id: 'xhigh', label: 'Extra High' }
],
thinkingLevels: OPENAI_THINKING_LEVELS,
defaultThinkingLevel: 'low'
},
{
id: 'gpt-5.4',
label: 'GPT-5.4',
thinkingLevels: [
{ id: 'low', label: 'Low' },
{ id: 'medium', label: 'Medium' },
{ id: 'high', label: 'High' },
{ id: 'xhigh', label: 'Extra High' }
],
thinkingLevels: OPENAI_THINKING_LEVELS,
defaultThinkingLevel: 'low'
},
{
id: 'gpt-5.4-mini',
label: 'GPT-5.4 Mini',
thinkingLevels: [
{ id: 'low', label: 'Low' },
{ id: 'medium', label: 'Medium' },
{ id: 'high', label: 'High' },
{ id: 'xhigh', label: 'Extra High' }
],
thinkingLevels: OPENAI_THINKING_LEVELS,
defaultThinkingLevel: 'low'
},
{
id: 'gpt-5.3-codex',
label: 'GPT-5.3 Codex',
thinkingLevels: [
{ id: 'low', label: 'Low' },
{ id: 'medium', label: 'Medium' },
{ id: 'high', label: 'High' },
{ id: 'xhigh', label: 'Extra High' }
],
thinkingLevels: OPENAI_THINKING_LEVELS,
defaultThinkingLevel: 'low'
},
{
@ -170,27 +305,267 @@ export const COMMIT_MESSAGE_AGENT_SPECS: Partial<Record<TuiAgent, CommitMessageA
// tier, not the effort flag.
id: 'gpt-5.3-codex-spark',
label: 'GPT-5.3 Codex Spark',
thinkingLevels: [
{ id: 'low', label: 'Low' },
{ id: 'medium', label: 'Medium' },
{ id: 'high', label: 'High' },
{ id: 'xhigh', label: 'Extra High' }
],
thinkingLevels: OPENAI_THINKING_LEVELS,
defaultThinkingLevel: 'low'
},
{
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' }
],
thinkingLevels: OPENAI_THINKING_LEVELS,
defaultThinkingLevel: 'low'
}
],
defaultModelId: 'gpt-5.5'
},
opencode: {
id: 'opencode',
label: 'OpenCode',
binary: 'opencode',
promptDelivery: 'argv',
buildArgs: ({ prompt, model, thinkingLevel }) => [
'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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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<Record<TuiAgent, string>>
/** Host-scoped model selections; dynamic agents can expose different models per SSH target. */
selectedModelByAgentByHost?: Partial<Record<string, Partial<Record<TuiAgent, string>>>>
/** Per-agent dynamic models last discovered from the CLI, persisted so main can validate selections. */
discoveredModelsByAgent?: Partial<Record<TuiAgent, CommitMessageAiModelCapability[]>>
/** Host-scoped dynamic model discovery cache. */
discoveredModelsByAgentByHost?: Partial<
Record<string, Partial<Record<TuiAgent, CommitMessageAiModelCapability[]>>>
>
/** Per-model: thinking effort depends on the model, not the agent. Keyed by model id. */
selectedThinkingByModel: Record<string, string>
/** Optional user-provided suffix appended to the base prompt (style overrides, etc.). */