import type { TuiAgent } from './types' import { isTuiAgentEnabled } from './tui-agent-selection' /* eslint-disable max-lines -- Why: this is the single registry for non-interactive commit-message agents, their model discovery parsers, and UI capabilities. */ // 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 // the two confuses both code paths. export type ThinkingLevel = { id: string; label: string } export type CommitMessageModel = { /** Value passed to the agent CLI's --model flag. */ id: string /** Visible label in the model dropdown. */ label: string /** Omit when the model does not expose an effort selector — the UI then hides the dropdown. */ thinkingLevels?: ThinkingLevel[] /** Required when thinkingLevels is present. */ defaultThinkingLevel?: string } export type CommitMessageAgentSpec = { id: TuiAgent /** Visible label in the agent dropdown. */ label: string /** Binary spawned in non-interactive mode. */ binary: string /** 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 } export type CommitMessageModelCapability = { id: string label: string thinkingLevels?: ThinkingLevel[] defaultThinkingLevel?: string } export type CommitMessageAgentCapability = { id: TuiAgent label: string modelSource: 'static' | 'dynamic' models: CommitMessageModelCapability[] defaultModelId: string } const BASIC_THINKING_LEVELS: ThinkingLevel[] = [ { id: 'low', label: 'Low' }, { id: 'medium', label: 'Medium' }, { id: 'high', label: 'High' } ] const OPENAI_THINKING_LEVELS: ThinkingLevel[] = [ { id: 'low', label: 'Low' }, { id: 'medium', label: 'Medium' }, { id: 'high', label: 'High' }, { id: 'xhigh', label: 'Extra High' } ] const CLAUDE_THINKING_LEVELS: ThinkingLevel[] = [ { id: 'low', label: 'Low' }, { id: 'medium', label: 'Medium' }, { id: 'high', label: 'High' }, { id: 'xhigh', label: 'Extra High' }, { id: 'max', label: 'Max' } ] function labelFromModelId(id: string): string { return id .split(/[/-]/) .filter(Boolean) .map((part) => { if (/^gpt$/i.test(part)) { return 'GPT' } return part.length <= 3 && /^\d/.test(part) ? part.toUpperCase() : part.charAt(0).toUpperCase() + part.slice(1) }) .join(' ') } function uniqueModels(models: CommitMessageModel[]): CommitMessageModel[] { const seen = new Set() return models.filter((model) => { if (!model.id || seen.has(model.id)) { return false } seen.add(model.id) return true }) } function withOpenAiThinking( id: string ): Pick { return /(?:gpt-5|codex)/i.test(id) ? { thinkingLevels: OPENAI_THINKING_LEVELS, defaultThinkingLevel: 'low' } : {} } export function parseCodexModels(stdout: string): CommitMessageModel[] { try { const parsed = JSON.parse(stdout) as { models?: { slug?: string display_name?: string supported_reasoning_levels?: { effort?: string }[] default_reasoning_level?: string }[] } return uniqueModels( (parsed.models ?? []) .filter((model) => model.slug && model.display_name) .map((model) => ({ id: model.slug!, label: model.display_name!, ...(model.supported_reasoning_levels?.length ? { thinkingLevels: model.supported_reasoning_levels .map((level) => level.effort) .filter((effort): effort is string => Boolean(effort)) .map((effort) => ({ id: effort, label: effort === 'xhigh' ? 'Extra High' : labelFromModelId(effort) })), defaultThinkingLevel: model.default_reasoning_level ?? 'low' } : {}) })) ) } catch { return [] } } export function parseLineModels(stdout: string): CommitMessageModel[] { return uniqueModels( stdout .split(/\r?\n/) .map((line) => line.trim()) .filter((line) => line.length > 0 && !line.includes(' ')) .map((id) => ({ id, label: labelFromModelId(id), ...withOpenAiThinking(id) })) ) } export function parsePiModels(stdout: string): CommitMessageModel[] { return uniqueModels( stdout .split(/\r?\n/) .map((line) => line.trim().split(/\s+/)) .filter((parts) => parts.length >= 6 && parts[0] !== 'provider') .map((parts) => { const [provider, model, , , thinking] = parts const id = `${provider}/${model}` return { id, label: `${labelFromModelId(provider)} ${labelFromModelId(model)}`, ...(thinking === 'yes' ? { thinkingLevels: [ { id: 'off', label: 'Off' }, { id: 'low', label: 'Low' }, { id: 'medium', label: 'Medium' }, { id: 'high', label: 'High' }, { id: 'xhigh', label: 'Extra High' } ], defaultThinkingLevel: 'low' } : {}) } }) ) } export function parseCursorModels(stdout: string): CommitMessageModel[] { return uniqueModels( stdout .split(/\r?\n/) .map((line) => line.trim()) .map((line) => /^([^\s]+)\s+-\s+(.+)$/.exec(line)) .filter((match): match is RegExpExecArray => Boolean(match)) .map((match) => ({ id: match[1], label: match[2].replace(/\s+\((?:default|current)\)$/i, ''), ...withOpenAiThinking(match[1]) })) ) } export const COMMIT_MESSAGE_AGENT_SPECS: Partial> = { claude: { id: 'claude', label: 'Claude', binary: 'claude', // Why: diffs can be large and `claude -p` reads from stdin natively when no // positional prompt is provided. promptDelivery: 'stdin', buildArgs: ({ model, thinkingLevel }) => [ '-p', '--output-format', 'text', '--model', model, '--permission-mode', 'plan', ...(thinkingLevel ? ['--effort', thinkingLevel] : []) ], modelSource: 'static', models: [ { // 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: 'sonnet', label: 'Sonnet', thinkingLevels: CLAUDE_THINKING_LEVELS, defaultThinkingLevel: 'low' }, { id: 'opus', label: 'Opus', thinkingLevels: CLAUDE_THINKING_LEVELS, defaultThinkingLevel: 'low' } ], defaultModelId: 'sonnet' }, codex: { id: 'codex', label: 'Codex', binary: 'codex', // Why: `codex exec` reads stdin when no prompt arg is supplied. Commit // prompts include large staged diffs, so argv would exceed Windows and // some SSH/POSIX command-line limits. promptDelivery: 'stdin', buildArgs: ({ model, thinkingLevel }) => [ 'exec', // Why: commit-message generation needs text only, not a persisted agent // session or workspace writes. Match the safe git-text mode used by // local-first coding agents. '--ephemeral', '--skip-git-repo-check', '-s', 'read-only', '--model', 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: 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.3-codex', label: 'GPT-5.3 Codex', thinkingLevels: OPENAI_THINKING_LEVELS, defaultThinkingLevel: 'low' }, { // Why: Codex's Spark variant accepts `model_reasoning_effort` (the // CLI banner reports "reasoning effort: medium" by default); the // gating that surfaces "model not supported" is on the account // tier, not the effort flag. id: 'gpt-5.3-codex-spark', label: 'GPT-5.3 Codex Spark', thinkingLevels: OPENAI_THINKING_LEVELS, defaultThinkingLevel: 'low' }, { id: 'gpt-5.2', label: 'GPT-5.2', 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' } } export const DEFAULT_COMMIT_MESSAGE_AGENT_ID: TuiAgent = 'claude' // Why: the "custom" choice is not a TuiAgent — it lets the user point Orca // at any CLI by typing a command template (see customAgentCommand setting + // planCustomCommand in commit-message-prompt.ts). Keeping it as its own // sentinel avoids polluting TuiAgent (which is shared with PTY launch / // new-workspace flows that have nothing to do with this feature). export const CUSTOM_AGENT_ID = 'custom' as const export type CustomAgentId = typeof CUSTOM_AGENT_ID export type CommitMessageAgentChoice = TuiAgent | CustomAgentId export type DefaultTuiAgentPreference = TuiAgent | 'blank' | null | undefined export function isCustomAgentId(id: string | null | undefined): id is CustomAgentId { return id === CUSTOM_AGENT_ID } export function getCommitMessageAgentSpec(agentId: TuiAgent): CommitMessageAgentSpec | undefined { return COMMIT_MESSAGE_AGENT_SPECS[agentId] } export function resolveCommitMessageAgentChoice( configuredAgentId: CommitMessageAgentChoice | null | undefined, defaultTuiAgent: DefaultTuiAgentPreference, disabledTuiAgents?: Iterable | null ): CommitMessageAgentChoice | null { if (configuredAgentId) { return configuredAgentId } if ( defaultTuiAgent && defaultTuiAgent !== 'blank' && isTuiAgentEnabled(defaultTuiAgent, disabledTuiAgents) ) { return getCommitMessageAgentSpec(defaultTuiAgent) ? defaultTuiAgent : null } return isTuiAgentEnabled(DEFAULT_COMMIT_MESSAGE_AGENT_ID, disabledTuiAgents) ? DEFAULT_COMMIT_MESSAGE_AGENT_ID : null } export function getCommitMessageModel( agentId: TuiAgent, modelId: string ): CommitMessageModel | undefined { 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( spec: CommitMessageAgentSpec ): CommitMessageAgentCapability { 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 // swap this source without leaking binary/argv details into UI code. models: spec.models.map((model) => ({ id: model.id, label: model.label, ...(model.thinkingLevels ? { thinkingLevels: [...model.thinkingLevels] } : {}), ...(model.defaultThinkingLevel ? { defaultThinkingLevel: model.defaultThinkingLevel } : {}) })) } } export function getCommitMessageAgentCapability( agentId: TuiAgent ): CommitMessageAgentCapability | undefined { const spec = getCommitMessageAgentSpec(agentId) return spec ? toCommitMessageAgentCapability(spec) : undefined } export function getCommitMessageModelCapability( agentId: TuiAgent, modelId: string ): CommitMessageModelCapability | undefined { return getCommitMessageAgentCapability(agentId)?.models.find((m) => m.id === modelId) } /** Ordered list of agents that have a non-interactive mode wired up. */ export function listCommitMessageAgentIds(): TuiAgent[] { return Object.keys(COMMIT_MESSAGE_AGENT_SPECS) as TuiAgent[] } export function listCommitMessageAgentCapabilities(): CommitMessageAgentCapability[] { return listCommitMessageAgentIds() .map((id) => getCommitMessageAgentCapability(id)) .filter((capability): capability is CommitMessageAgentCapability => Boolean(capability)) }