diff --git a/docs/content/docs/providers.mdx b/docs/content/docs/providers.mdx index a7c0ac8a1..efde68eb3 100644 --- a/docs/content/docs/providers.mdx +++ b/docs/content/docs/providers.mdx @@ -55,6 +55,12 @@ Not all providers support the same features. Here's what varies: **Initial prompt**: Pass instructions when starting the agent. Most providers support this. +**Model selection** _(Claude Code only)_: Choose the Claude model to use per task. The list is fetched live from the Anthropic API. + +**Effort level** _(Claude Code only)_: Set reasoning depth at startup — low, medium, high, or max. + +**Fast mode** _(Claude Code, Opus models only)_: Enable fast streaming output. + ## Adding a Provider Providers are detected automatically when you install them. Open Emdash, go to Settings, and click "Refresh" to scan for newly installed CLIs. diff --git a/docs/content/docs/tasks.mdx b/docs/content/docs/tasks.mdx index 106f3a74c..847b55ae9 100644 --- a/docs/content/docs/tasks.mdx +++ b/docs/content/docs/tasks.mdx @@ -46,6 +46,16 @@ Each agent gets its own worktree. Compare approaches and pick the best result. S If tasks start local servers, use `EMDASH_PORT` in your Project config script fields to avoid port collisions between parallel tasks. See [Project Configuration](/project-config). +## Claude Options + +When Claude Code is selected, a **Claude options** section appears. Expand it to configure: + +- **Model**: Choose a specific Claude model (e.g. `claude-opus-4-6`, `claude-sonnet-4-6[1m]`). Models are fetched from the Anthropic API when an `ANTHROPIC_API_KEY` is set, with a hardcoded fallback list otherwise. Defaults to whatever Claude Code uses by default. +- **Effort**: Control reasoning depth — `low`, `medium`, `high`, or `max`. Leave at Default to let Claude decide. +- **Fast mode**: Enable Claude's fast streaming mode. Only shown when an Opus model is selected, as fast mode is currently supported for Opus models only. + +These options are also available in the **Add Agent to Task** dialog. + ## Advanced Options Click **Advanced Options** when creating a task to access: diff --git a/src/main/ipc/connectionsIpc.ts b/src/main/ipc/connectionsIpc.ts index 376b1b554..65c1eeb98 100644 --- a/src/main/ipc/connectionsIpc.ts +++ b/src/main/ipc/connectionsIpc.ts @@ -1,4 +1,5 @@ import { ipcMain } from 'electron'; +import https from 'https'; import { connectionsService } from '../services/ConnectionsService'; import { getProviderCustomConfig, @@ -7,6 +8,110 @@ import { type ProviderCustomConfig, } from '../settings'; +interface ClaudeModel { + id: string; + name: string; + /** + * Whether this model supports Claude Code's fast mode (--settings '{"fastMode":true}'). + * Derived from the model ID — currently only Opus models support it. + * Not returned by the Anthropic API; computed locally. + */ + supportsFast: boolean; +} + +/** + * Fast mode is documented for Opus models only. + * The Anthropic API does not expose this capability, so we infer it from the model ID. + */ +function claudeModelSupportsFast(modelId: string): boolean { + return modelId.toLowerCase().includes('opus'); +} + +/** + * Hardcoded fallback list used when the API is unavailable. + * + * Note: `claude-sonnet-4-6[1m]` is the canonical model ID returned by the + * Anthropic API for the 1M-context Sonnet variant. The square brackets are + * intentional and are safely handled at the PTY layer via quoteShellArg. + */ +const CLAUDE_FALLBACK_MODELS: ClaudeModel[] = [ + { id: 'claude-opus-4-6', name: 'Claude Opus 4.6', supportsFast: true }, + { id: 'claude-sonnet-4-6', name: 'Claude Sonnet 4.6', supportsFast: false }, + { id: 'claude-sonnet-4-6[1m]', name: 'Claude Sonnet 4.6 (1M context)', supportsFast: false }, + { id: 'claude-haiku-4-5-20251001', name: 'Claude Haiku 4.5', supportsFast: false }, +]; + +/** In-memory cache so repeated opens of the modal don't hit the API every time. */ +interface ModelCache { + models: ClaudeModel[]; + expiresAt: number; +} +let claudeModelCache: ModelCache | null = null; +let claudeModelCacheGeneration = 0; +const MODEL_CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour + +/** Fetch available models from the Anthropic API. Returns null when unavailable. */ +async function fetchAnthropicModels(): Promise { + // Prefer a key saved in the provider custom config (e.g. via Settings → Providers) + // so users who configure Claude that way also get live model lists. + const configuredKey = getProviderCustomConfig('claude')?.env?.['ANTHROPIC_API_KEY']; + const apiKey = + (typeof configuredKey === 'string' && configuredKey.trim() ? configuredKey.trim() : null) ?? + process.env.ANTHROPIC_API_KEY; + if (!apiKey) return null; + + return new Promise((resolve) => { + const req = https.request( + { + hostname: 'api.anthropic.com', + path: '/v1/models', + method: 'GET', + headers: { + 'x-api-key': apiKey, + 'anthropic-version': '2023-06-01', + }, + timeout: 5000, + }, + (res) => { + if (!res.statusCode || res.statusCode < 200 || res.statusCode >= 300) { + res.resume(); // drain body so the socket can be reused + resolve(null); + return; + } + let data = ''; + res.on('data', (chunk: Buffer) => (data += chunk.toString())); + res.on('end', () => { + try { + const parsed = JSON.parse(data) as { + data?: Array<{ id: string; display_name?: string }>; + }; + if (!Array.isArray(parsed?.data)) { + resolve(null); + return; + } + const models: ClaudeModel[] = parsed.data + .filter((m) => typeof m.id === 'string' && m.id.startsWith('claude-')) + .map((m) => ({ + id: m.id, + name: m.display_name || m.id, + supportsFast: claudeModelSupportsFast(m.id), + })); + resolve(models.length > 0 ? models : null); + } catch { + resolve(null); + } + }); + } + ); + req.on('error', () => resolve(null)); + req.on('timeout', () => { + req.destroy(); + resolve(null); + }); + req.end(); + }); +} + export function registerConnectionsIpc() { ipcMain.handle( 'providers:getStatuses', @@ -65,12 +170,50 @@ export function registerConnectionsIpc() { } }); + // List available models for a provider (currently only 'claude' is supported) + ipcMain.handle('providers:listModels', async (_event, providerId: string) => { + if (providerId !== 'claude') { + return { success: true, models: [] }; + } + + // Return fresh cache when available + if (claudeModelCache && Date.now() < claudeModelCache.expiresAt) { + return { success: true, models: claudeModelCache.models }; + } + + // Snapshot the generation before the async fetch. If the config is updated + // while we're awaiting, the generation will have changed and we discard the + // result to avoid repopulating the cache with stale models. + const generation = claudeModelCacheGeneration; + try { + const fetched = await fetchAnthropicModels(); + if (fetched && generation === claudeModelCacheGeneration) { + claudeModelCache = { models: fetched, expiresAt: Date.now() + MODEL_CACHE_TTL_MS }; + return { success: true, models: fetched }; + } + } catch { + // fall through + } + + // API unavailable — return stale cache if we have one, otherwise use hardcoded fallback + if (claudeModelCache) { + return { success: true, models: claudeModelCache.models }; + } + return { success: true, models: CLAUDE_FALLBACK_MODELS }; + }); + // Update custom config for a specific provider ipcMain.handle( 'providers:updateCustomConfig', (_event, providerId: string, config: ProviderCustomConfig | undefined) => { try { updateProviderCustomConfig(providerId, config); + // Bust the model cache when the Claude provider config changes so an + // updated ANTHROPIC_API_KEY is picked up immediately on the next fetch. + if (providerId === 'claude') { + claudeModelCache = null; + claudeModelCacheGeneration += 1; + } return { success: true }; } catch (error) { return { diff --git a/src/main/preload.ts b/src/main/preload.ts index f50116e83..263ac9fdb 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -136,6 +136,9 @@ contextBridge.exposeInMainWorld('electronAPI', { clickTime?: number; env?: Record; resume?: boolean; + model?: string; + effort?: string; + fastMode?: boolean; }) => ipcRenderer.invoke('pty:startDirect', opts), ptyScpToRemote: (args: { connectionId: string; localPaths: string[] }) => @@ -636,6 +639,8 @@ contextBridge.exposeInMainWorld('electronAPI', { getAllProviderCustomConfigs: () => ipcRenderer.invoke('providers:getAllCustomConfigs'), updateProviderCustomConfig: (providerId: string, config: any) => ipcRenderer.invoke('providers:updateCustomConfig', providerId, config), + listProviderModels: (providerId: string) => + ipcRenderer.invoke('providers:listModels', providerId), // Debug helpers debugAppendLog: (filePath: string, content: string, options?: { reset?: boolean }) => diff --git a/src/main/services/ptyIpc.ts b/src/main/services/ptyIpc.ts index 4492d739e..d69c3cedb 100644 --- a/src/main/services/ptyIpc.ts +++ b/src/main/services/ptyIpc.ts @@ -495,8 +495,11 @@ function buildRemoteProviderInvocation(args: { autoApprove?: boolean; initialPrompt?: string; resume?: boolean; + model?: string; + effort?: string; + fastMode?: boolean; }): { cli: string; cmd: string; installCommand?: string } { - const { providerId, autoApprove, initialPrompt, resume } = args; + const { providerId, autoApprove, initialPrompt, resume, model, effort, fastMode } = args; const fallbackProvider = getProvider(providerId as ProviderId); const resolvedConfig = resolveProviderCommandConfig(providerId); const provider = resolvedConfig?.provider ?? fallbackProvider; @@ -522,6 +525,15 @@ function buildRemoteProviderInvocation(args: { useKeystrokeInjection: provider?.useKeystrokeInjection, }); cliArgs.push(...getProviderRuntimeCliArgs({ providerId, target: 'remote' })); + if (model && providerId === 'claude') { + cliArgs.push('--model', model); + } + if (effort && providerId === 'claude') { + cliArgs.push('--effort', effort); + } + if (fastMode && providerId === 'claude') { + cliArgs.push('--settings', '{"fastMode":true}'); + } const cmdParts = [...cliCommandParts, ...cliArgs]; const cmd = cmdParts.map(quoteShellArg).join(' '); @@ -1172,6 +1184,9 @@ export function registerPtyIpc(): void { initialPrompt?: string; env?: Record; resume?: boolean; + model?: string; + effort?: string; + fastMode?: boolean; } ) => { if (process.env.EMDASH_DISABLE_PTY === '1') { @@ -1179,8 +1194,21 @@ export function registerPtyIpc(): void { } try { - const { id, providerId, cwd, remote, cols, rows, autoApprove, initialPrompt, env, resume } = - args; + const { + id, + providerId, + cwd, + remote, + cols, + rows, + autoApprove, + initialPrompt, + env, + resume, + model, + effort, + fastMode, + } = args; const existing = getPty(id); if (remote?.connectionId) { @@ -1204,6 +1232,9 @@ export function registerPtyIpc(): void { autoApprove, initialPrompt, resume, + model, + effort, + fastMode, }); const resolvedConfig = resolveProviderCommandConfig(providerId); @@ -1373,6 +1404,9 @@ export function registerPtyIpc(): void { env, resume: effectiveResume, tmux, + model, + effort, + fastMode, }); // Fall back to shell-based spawn when direct spawn is unavailable or shellSetup/tmux is set @@ -1399,6 +1433,9 @@ export function registerPtyIpc(): void { skipResume: !resume, shellSetup, tmux, + model, + effort, + fastMode, }); usedFallback = true; } diff --git a/src/main/services/ptyManager.ts b/src/main/services/ptyManager.ts index 1c7cdba1c..291992c5b 100644 --- a/src/main/services/ptyManager.ts +++ b/src/main/services/ptyManager.ts @@ -11,6 +11,7 @@ import { providerStatusCache } from './providerStatusCache'; import { errorTracking } from '../errorTracking'; import { LOCALE_ENV_VARS, DEFAULT_UTF8_LOCALE, isUtf8Locale } from '../utils/locale'; import { normalizeClaudeConfigDir } from '../utils/shellEnv'; +import { quoteShellArg } from '../utils/shellEscape'; /** * Suppress EPIPE/EIO errors on a PTY's underlying socket. @@ -838,6 +839,30 @@ export function resolveProviderCommandConfig( }; } +const ALLOWED_CLAUDE_EFFORTS = new Set(['low', 'medium', 'high', 'max']); + +/** + * Builds and validates Claude Code-specific runtime args. + * - Only appends --effort for known valid levels. + * - Only appends --settings fastMode when the model is Opus (or unspecified). + */ +export function buildClaudeRuntimeArgs(options: { + model?: string; + effort?: string; + fastMode?: boolean; +}): string[] { + const args: string[] = []; + const model = options.model?.trim(); + const effort = options.effort?.trim(); + const modelSupportsFast = !model || model.toLowerCase().includes('opus'); + + if (model) args.push('--model', model); + if (effort && ALLOWED_CLAUDE_EFFORTS.has(effort)) args.push('--effort', effort); + if (options.fastMode && modelSupportsFast) args.push('--settings', '{"fastMode":true}'); + + return args; +} + export function buildProviderCliArgs(options: ProviderCliArgsOptions): string[] { const args: string[] = []; @@ -1181,6 +1206,9 @@ export function startDirectPty(options: { env?: Record; resume?: boolean; tmux?: boolean; + model?: string; + effort?: string; + fastMode?: boolean; }): IPty | null { if (process.env.EMDASH_DISABLE_PTY === '1') { throw new Error('PTY disabled via EMDASH_DISABLE_PTY=1'); @@ -1204,6 +1232,9 @@ export function startDirectPty(options: { initialPrompt, env, resume, + model, + effort, + fastMode, } = options; const resolvedConfig = resolveProviderCommandConfig(providerId); @@ -1261,13 +1292,15 @@ export function startDirectPty(options: { const usedSessionIsolation = applySessionIsolation(cliArgs, provider, id, cwd, !!resume); cliArgs.push(...exactResumeArgs); + const claudeRuntimeArgs = + providerId === 'claude' ? buildClaudeRuntimeArgs({ model, effort, fastMode }) : []; cliArgs.push( ...buildProviderCliArgs({ resume: exactResumeArgs.length === 0 && !usedSessionIsolation && !!resume, resumeFlag: resolvedConfig.resumeFlag, defaultArgs: resolvedConfig.defaultArgs, extraArgs: resolvedConfig.extraArgs, - runtimeArgs: getProviderRuntimeCliArgs({ providerId }), + runtimeArgs: [...getProviderRuntimeCliArgs({ providerId }), ...claudeRuntimeArgs], autoApprove, autoApproveFlag: resolvedConfig.autoApproveFlag, initialPrompt, @@ -1379,6 +1412,9 @@ export async function startPty(options: { skipResume?: boolean; shellSetup?: string; tmux?: boolean; + model?: string; + effort?: string; + fastMode?: boolean; }): Promise { if (process.env.EMDASH_DISABLE_PTY === '1') { throw new Error('PTY disabled via EMDASH_DISABLE_PTY=1'); @@ -1395,6 +1431,9 @@ export async function startPty(options: { skipResume, shellSetup, tmux, + model, + effort, + fastMode, } = options; const defaultShell = getDefaultShell(); @@ -1515,13 +1554,18 @@ export async function startPty(options: { ); cliArgs.push(...exactResumeArgs); + const shellClaudeArgs = + provider.id === 'claude' ? buildClaudeRuntimeArgs({ model, effort, fastMode }) : []; cliArgs.push( ...buildProviderCliArgs({ resume: exactResumeArgs.length === 0 && !usedSessionIsolation && !skipResume, resumeFlag: resolvedConfig?.resumeFlag, defaultArgs: resolvedConfig?.defaultArgs, extraArgs: resolvedConfig?.extraArgs, - runtimeArgs: getProviderRuntimeCliArgs({ providerId: provider.id }), + runtimeArgs: [ + ...getProviderRuntimeCliArgs({ providerId: provider.id }), + ...shellClaudeArgs, + ], autoApprove, autoApproveFlag: resolvedConfig?.autoApproveFlag, initialPrompt, @@ -1541,12 +1585,8 @@ export async function startPty(options: { const cliCommand = resolvedCli; const commandString = cliArgs.length > 0 - ? `${cliCommand} ${cliArgs - .map((arg) => - /[\s'"\\$`\n\r\t]/.test(arg) ? `'${arg.replace(/'/g, "'\\''")}'` : arg - ) - .join(' ')}` - : cliCommand; + ? `${quoteShellArg(cliCommand)} ${cliArgs.map(quoteShellArg).join(' ')}` + : quoteShellArg(cliCommand); const shellBase = (defaultShell.split('/').pop() || '').toLowerCase(); diff --git a/src/renderer/components/ChatInterface.tsx b/src/renderer/components/ChatInterface.tsx index 220c15a51..78a3faca7 100644 --- a/src/renderer/components/ChatInterface.tsx +++ b/src/renderer/components/ChatInterface.tsx @@ -215,6 +215,18 @@ const ChatInterface: React.FC = ({ [activeConversation?.metadata] ); + // Per-conversation Claude options (model/effort/fastMode set when adding an agent via CreateChatModal) + const conversationClaudeOptions = useMemo(() => { + if (!activeConversation || activeConversation.isMain) return null; + const parsed = parseConversationMetadata(activeConversation.metadata); + if (!parsed || parsed.mode) return null; // skip review/mode conversations + return { + model: typeof parsed.model === 'string' && parsed.model ? parsed.model : undefined, + effort: typeof parsed.effort === 'string' && parsed.effort ? parsed.effort : undefined, + fastMode: typeof parsed.fastMode === 'boolean' ? parsed.fastMode : undefined, + }; + }, [activeConversation]); + // Update terminal ID to include conversation ID and agent - unique per conversation const terminalId = useMemo(() => { if (activeConversation?.isMain) { @@ -1286,6 +1298,13 @@ const ChatInterface: React.FC = ({ remote={effectiveRemote} providerId={agent} autoApprove={autoApproveEnabled} + model={conversationClaudeOptions?.model ?? task.metadata?.agentModel ?? undefined} + effort={ + conversationClaudeOptions?.effort ?? task.metadata?.agentEffort ?? undefined + } + fastMode={ + conversationClaudeOptions?.fastMode ?? task.metadata?.agentFastMode ?? false + } env={taskEnv} keepAlive={true} mapShiftEnterToCtrlJ diff --git a/src/renderer/components/ClaudeModelSelect.tsx b/src/renderer/components/ClaudeModelSelect.tsx new file mode 100644 index 000000000..0205501c6 --- /dev/null +++ b/src/renderer/components/ClaudeModelSelect.tsx @@ -0,0 +1,68 @@ +import React, { useEffect, useState } from 'react'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select'; + +export interface ModelOption { + id: string; + name: string; + supportsFast: boolean; +} + +interface Props { + value: string; + onChange: (model: string) => void; + onModelsLoaded?: (models: ModelOption[]) => void; +} + +/** Internal sentinel representing "use provider default" (no --model flag). */ +const DEFAULT_MODEL_SENTINEL = '__model_default__'; + +export function ClaudeModelSelect({ value, onChange, onModelsLoaded }: Props) { + const [models, setModels] = useState([]); + + useEffect(() => { + let cancelled = false; + const promise = window.electronAPI.listProviderModels?.('claude'); + if (!promise) return; + promise + .then((result) => { + if (cancelled) return; + if (result?.success && Array.isArray(result.models) && result.models.length > 0) { + // Normalise: older cached responses may lack supportsFast — fall back to opus check + const normalised: ModelOption[] = result.models.map((m) => ({ + ...m, + supportsFast: m.supportsFast ?? m.id.toLowerCase().includes('opus'), + })); + setModels(normalised); + onModelsLoaded?.(normalised); + } + }) + .catch(() => {}); + return () => { + cancelled = true; + }; + // onModelsLoaded is always a stable state setter from the parent, so + // including it in deps would only cause unnecessary re-fetches. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const selectValue = value || DEFAULT_MODEL_SENTINEL; + const handleChange = (v: string) => onChange(v === DEFAULT_MODEL_SENTINEL ? '' : v); + + return ( + + ); +} diff --git a/src/renderer/components/ClaudeOptionsSection.tsx b/src/renderer/components/ClaudeOptionsSection.tsx new file mode 100644 index 000000000..c9dba8081 --- /dev/null +++ b/src/renderer/components/ClaudeOptionsSection.tsx @@ -0,0 +1,121 @@ +import React, { useState } from 'react'; +import { Bot } from 'lucide-react'; +import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from './ui/accordion'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select'; +import { Checkbox } from './ui/checkbox'; +import { Label } from './ui/label'; +import { ClaudeModelSelect, type ModelOption } from './ClaudeModelSelect'; + +const EFFORT_SENTINEL = '__effort_default__'; + +const EFFORT_OPTIONS = [ + { value: 'low', label: 'Low' }, + { value: 'medium', label: 'Medium' }, + { value: 'high', label: 'High' }, + { value: 'max', label: 'Max' }, +] as const; + +interface Props { + model: string; + onModelChange: (model: string) => void; + effort: string; + onEffortChange: (effort: string) => void; + fastMode: boolean; + onFastModeChange: (fastMode: boolean) => void; +} + +export function ClaudeOptionsSection({ + model, + onModelChange, + effort, + onEffortChange, + fastMode, + onFastModeChange, +}: Props) { + const [isOpen, setIsOpen] = useState(false); + const [loadedModels, setLoadedModels] = useState([]); + + // Fast mode is only shown when a specific model is selected and that model + // supports it (currently Opus only). When "Default" is selected we don't know + // which model will run, so we hide it to avoid silently ignoring the flag. + const selectedModelMeta = loadedModels.find((m) => m.id === model); + const showFastMode = !!selectedModelMeta?.supportsFast; + + const effortSelectValue = effort || EFFORT_SENTINEL; + const handleEffortChange = (v: string) => onEffortChange(v === EFFORT_SENTINEL ? '' : v); + + return ( + + + { + e.preventDefault(); + setIsOpen((prev) => !prev); + }} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + setIsOpen((prev) => !prev); + } + }} + > + + + Claude options + + + +
+
+ + { + onModelChange(m); + // Clear fast mode if the newly selected model doesn't support it + const meta = loadedModels.find((lm) => lm.id === m); + if (meta && !meta.supportsFast) onFastModeChange(false); + }} + onModelsLoaded={setLoadedModels} + /> +
+
+ + +
+ {showFastMode && ( + + )} +
+
+
+
+ ); +} diff --git a/src/renderer/components/CreateChatModal.tsx b/src/renderer/components/CreateChatModal.tsx index 1bee425c6..b13a277b3 100644 --- a/src/renderer/components/CreateChatModal.tsx +++ b/src/renderer/components/CreateChatModal.tsx @@ -11,6 +11,7 @@ import { Button } from './ui/button'; import { Label } from './ui/label'; import { Separator } from './ui/separator'; import { AgentDropdown } from './AgentDropdown'; +import { ClaudeOptionsSection } from './ClaudeOptionsSection'; import { CreateChatReviewSection } from './CreateChatReviewSection'; import { agentConfig } from '../lib/agentConfig'; import { isValidProviderId } from '@shared/providers/registry'; @@ -44,6 +45,9 @@ export function CreateChatModal({ }: CreateChatModalProps) { const { settings } = useAppSettings(); const [selectedAgent, setSelectedAgent] = useState(DEFAULT_AGENT); + const [claudeModel, setClaudeModel] = useState(''); + const [claudeEffort, setClaudeEffort] = useState(''); + const [claudeFastMode, setClaudeFastMode] = useState(false); const [reviewEnabled, setReviewEnabled] = useState(false); const [reviewAgent, setReviewAgent] = useState(DEFAULT_AGENT); const [reviewPrompt, setReviewPrompt] = useState(''); @@ -63,6 +67,9 @@ export function CreateChatModal({ useEffect(() => { if (isOpen) { setError(null); + setClaudeModel(''); + setClaudeEffort(''); + setClaudeFastMode(false); setReviewEnabled(false); setReviewAgent(reviewSettings.agent as Agent); setReviewPrompt(reviewSettings.prompt); @@ -135,9 +142,19 @@ export function CreateChatModal({ metadata: buildReviewConversationMetadata(reviewPrompt.trim()), }); } else { + const hasClaudeOpts = + selectedAgent === 'claude' && (claudeModel || claudeEffort || claudeFastMode); + const claudeMetadata = hasClaudeOpts + ? JSON.stringify({ + ...(claudeModel ? { model: claudeModel } : {}), + ...(claudeEffort ? { effort: claudeEffort } : {}), + ...(claudeFastMode ? { fastMode: true } : {}), + }) + : null; onCreateChat({ title: `Chat ${Date.now()}`, agent: selectedAgent, + metadata: claudeMetadata, }); } onClose(); @@ -165,14 +182,26 @@ export function CreateChatModal({
{!reviewEnabled ? ( -
- - -
+ <> +
+ + +
+ {selectedAgent === 'claude' && ( + + )} + ) : null} {reviewSettings.enabled ? ( diff --git a/src/renderer/components/TaskModal.tsx b/src/renderer/components/TaskModal.tsx index 839f6ac5b..d42c14fef 100644 --- a/src/renderer/components/TaskModal.tsx +++ b/src/renderer/components/TaskModal.tsx @@ -12,6 +12,7 @@ import type { BaseModalProps } from '@/contexts/ModalProvider'; import { SlugInput } from './ui/slug-input'; import { Label } from './ui/label'; import { MultiAgentDropdown } from './MultiAgentDropdown'; +import { ClaudeOptionsSection } from './ClaudeOptionsSection'; import { TaskAdvancedSettings } from './TaskAdvancedSettings'; import { useIntegrationStatus } from './hooks/useIntegrationStatus'; import { type Agent } from '../types'; @@ -170,6 +171,9 @@ const TaskModal: React.FC = ({ onClose, initialProject, onCreate ); const [autoApprove, setAutoApprove] = useState(false); const [useWorktree, setUseWorktree] = useState(true); + const [claudeModel, setClaudeModel] = useState(''); + const [claudeEffort, setClaudeEffort] = useState(''); + const [claudeFastMode, setClaudeFastMode] = useState(false); const [useRemoteWorkspace, setUseRemoteWorkspace] = useState(false); const [workspaceProviderConfig, setWorkspaceProviderConfig] = useState<{ provisionCommand: string; @@ -234,6 +238,7 @@ const TaskModal: React.FC = ({ onClose, initialProject, onCreate // Computed values const activeAgents = useMemo(() => agentRuns.map((ar) => ar.agent), [agentRuns]); const hasAutoApproveSupport = activeAgents.every((id) => !!agentMeta[id]?.autoApproveFlag); + const hasClaudeAgent = activeAgents.some((id) => id === 'claude'); const hasInitialPromptSupport = activeAgents.every( (id) => agentMeta[id]?.initialPromptFlag !== undefined ); @@ -292,6 +297,9 @@ const TaskModal: React.FC = ({ onClose, initialProject, onCreate setSelectedForgejoIssue(null); setAutoApprove(false); setUseWorktree(true); + setClaudeModel(''); + setClaudeEffort(''); + setClaudeFastMode(false); userHasTypedRef.current = false; autoNameInitializedRef.current = false; customNameTrackedRef.current = false; @@ -440,11 +448,22 @@ const TaskModal: React.FC = ({ onClose, initialProject, onCreate setIsCreating(true); + // Inject Claude-specific options into every Claude run + const enrichedAgentRuns = agentRuns.map((ar) => { + if (ar.agent !== 'claude') return ar; + return { + ...ar, + ...(claudeModel ? { model: claudeModel } : {}), + ...(claudeEffort ? { effort: claudeEffort } : {}), + ...(claudeFastMode ? { fastMode: true } : {}), + }; + }); + try { await onCreateTask( finalName, hasInitialPromptSupport && initialPrompt.trim() ? initialPrompt.trim() : undefined, - agentRuns, + enrichedAgentRuns, selectedLinearIssue, selectedGithubIssue, selectedJiraIssue, @@ -535,6 +554,17 @@ const TaskModal: React.FC = ({ onClose, initialProject, onCreate + {hasClaudeAgent && ( + + )} + void; @@ -63,6 +66,9 @@ const TerminalPaneComponent = forwardRef( keepAlive = true, autoApprove, initialPrompt, + model, + effort, + fastMode, mapShiftEnterToCtrlJ, disableSnapshots = false, onActivity, @@ -98,6 +104,12 @@ const TerminalPaneComponent = forwardRef( autoApproveRef.current = autoApprove; const initialPromptRef = useRef(initialPrompt); initialPromptRef.current = initialPrompt; + const modelRef = useRef(model); + modelRef.current = model; + const effortRef = useRef(effort); + effortRef.current = effort; + const fastModeRef = useRef(fastMode); + fastModeRef.current = fastMode; const mapShiftEnterToCtrlJRef = useRef(mapShiftEnterToCtrlJ); mapShiftEnterToCtrlJRef.current = mapShiftEnterToCtrlJ; const disableSnapshotsRef = useRef(disableSnapshots); @@ -157,6 +169,9 @@ const TerminalPaneComponent = forwardRef( theme: themeRef.current, autoApprove: autoApproveRef.current, initialPrompt: initialPromptRef.current, + model: modelRef.current, + effort: effortRef.current, + fastMode: fastModeRef.current, mapShiftEnterToCtrlJ: mapShiftEnterToCtrlJRef.current, disableSnapshots: disableSnapshotsRef.current, onLinkClick: handleLinkClick, diff --git a/src/renderer/lib/taskCreationService.ts b/src/renderer/lib/taskCreationService.ts index 5c060c689..efd8b9587 100644 --- a/src/renderer/lib/taskCreationService.ts +++ b/src/renderer/lib/taskCreationService.ts @@ -367,6 +367,13 @@ export async function createTask(params: CreateTaskParams): Promise sum + ar.runs, 0); + const isMultiAgent = totalRuns > 1; + const primaryAgent = agentRuns[0]?.agent || 'claude'; + const primaryModel = agentRuns[0]?.model || undefined; + const primaryEffort = agentRuns[0]?.effort || undefined; + const primaryFastMode = agentRuns[0]?.fastMode || undefined; + const taskMetadata: TaskMetadata | null = linkedLinearIssue || linkedJiraIssue || @@ -376,7 +383,10 @@ export async function createTask(params: CreateTaskParams): Promise sum + ar.runs, 0); - const isMultiAgent = totalRuns > 1; - const primaryAgent = agentRuns[0]?.agent || 'claude'; - // --------------------------------------------------------------------------- // Multi-agent path // --------------------------------------------------------------------------- diff --git a/src/renderer/terminal/SessionRegistry.ts b/src/renderer/terminal/SessionRegistry.ts index 80ab10422..8482fb265 100644 --- a/src/renderer/terminal/SessionRegistry.ts +++ b/src/renderer/terminal/SessionRegistry.ts @@ -20,6 +20,9 @@ interface AttachOptions { theme: SessionTheme; autoApprove?: boolean; initialPrompt?: string; + model?: string; + effort?: string; + fastMode?: boolean; mapShiftEnterToCtrlJ?: boolean; disableSnapshots?: boolean; onLinkClick?: (url: string) => void; @@ -91,6 +94,9 @@ class SessionRegistry { telemetry: null, autoApprove: options.autoApprove, initialPrompt: options.initialPrompt, + model: options.model, + effort: options.effort, + fastMode: options.fastMode, mapShiftEnterToCtrlJ: options.mapShiftEnterToCtrlJ, disableSnapshots: options.disableSnapshots, onLinkClick: options.onLinkClick, diff --git a/src/renderer/terminal/TerminalSessionManager.ts b/src/renderer/terminal/TerminalSessionManager.ts index fa44aaa5a..af6c77b41 100644 --- a/src/renderer/terminal/TerminalSessionManager.ts +++ b/src/renderer/terminal/TerminalSessionManager.ts @@ -88,6 +88,9 @@ export interface TerminalSessionOptions { } | null; autoApprove?: boolean; initialPrompt?: string; + model?: string; + effort?: string; + fastMode?: boolean; mapShiftEnterToCtrlJ?: boolean; disableSnapshots?: boolean; onLinkClick?: (url: string) => void; @@ -1420,8 +1423,19 @@ export class TerminalSessionManager { this.ptyConnectPromise = (async () => { this.ptyConnectStartTime = performance.now(); - const { taskId, cwd, providerId, shell, env, initialSize, autoApprove, initialPrompt } = - this.options; + const { + taskId, + cwd, + providerId, + shell, + env, + initialSize, + autoApprove, + initialPrompt, + model, + effort, + fastMode, + } = this.options; const id = taskId; // Provider CLIs use direct spawn (bypasses shell config loading) @@ -1439,6 +1453,9 @@ export class TerminalSessionManager { initialPrompt, env, resume: hasExistingSession, + model, + effort, + fastMode, }) : window.electronAPI.ptyStart({ id, diff --git a/src/renderer/types/chat.ts b/src/renderer/types/chat.ts index 0f386a710..1cafbfb5e 100644 --- a/src/renderer/types/chat.ts +++ b/src/renderer/types/chat.ts @@ -10,6 +10,12 @@ import { type ForgejoIssueSummary } from './forgejo'; export interface AgentRun { agent: ProviderId; runs: number; + /** Optional model override (e.g. 'claude-opus-4-6'). Only used for providers that support --model. */ + model?: string; + /** Effort level for the session (low, medium, high, max). */ + effort?: string; + /** When true, inject /fast into the session after it starts (Opus 4.6 only). */ + fastMode?: boolean; } export interface GitHubIssueLink { @@ -36,6 +42,12 @@ export interface TaskMetadata { provisionCommand: string; terminateCommand: string; } | null; + /** Model override used when launching the agent (e.g. 'claude-opus-4-6'). null means use provider default. */ + agentModel?: string | null; + /** Effort level passed as --effort (low, medium, high, max). null means default. */ + agentEffort?: string | null; + /** When true, fast mode is enabled via --settings {"fastMode":true} (Opus 4.6 only). */ + agentFastMode?: boolean | null; /** Whether this task is pinned to the top of the sidebar */ isPinned?: boolean | null; /** The automation that created this task (if any) */ diff --git a/src/renderer/types/electron-api.d.ts b/src/renderer/types/electron-api.d.ts index 2dd571332..761126da9 100644 --- a/src/renderer/types/electron-api.d.ts +++ b/src/renderer/types/electron-api.d.ts @@ -94,6 +94,9 @@ declare global { initialPrompt?: string; env?: Record; resume?: boolean; + model?: string; + effort?: string; + fastMode?: boolean; }) => Promise<{ ok: boolean; reused?: boolean; tmux?: boolean; error?: string }>; ptyScpToRemote: (args: { connectionId: string; localPaths: string[] }) => Promise<{ success: boolean; @@ -1140,6 +1143,11 @@ declare global { success: boolean; error?: string; }>; + listProviderModels?: (providerId: string) => Promise<{ + success: boolean; + models?: Array<{ id: string; name: string; supportsFast: boolean }>; + error?: string; + }>; // Debug helpers debugAppendLog: ( @@ -1506,9 +1514,12 @@ export interface ElectronAPI { cols?: number; rows?: number; autoApprove?: boolean; + fastMode?: boolean; + effort?: string; initialPrompt?: string; env?: Record; resume?: boolean; + model?: string; }) => Promise<{ ok: boolean; reused?: boolean; tmux?: boolean; error?: string }>; ptyScpToRemote: (args: { connectionId: string; localPaths: string[] }) => Promise<{ success: boolean; @@ -1781,6 +1792,11 @@ export interface ElectronAPI { onProviderStatusUpdated?: ( listener: (data: { providerId: string; status: any }) => void ) => () => void; + listProviderModels?: (providerId: string) => Promise<{ + success: boolean; + models?: Array<{ id: string; name: string; supportsFast: boolean }>; + error?: string; + }>; // Telemetry captureTelemetry: ( event: string, diff --git a/src/renderer/types/global.d.ts b/src/renderer/types/global.d.ts index c0a489dc8..de7b2ac50 100644 --- a/src/renderer/types/global.d.ts +++ b/src/renderer/types/global.d.ts @@ -42,6 +42,9 @@ declare global { initialPrompt?: string; env?: Record; resume?: boolean; + model?: string; + effort?: string; + fastMode?: boolean; }) => Promise<{ ok: boolean; reused?: boolean; error?: string }>; ptyInput: (args: { id: string; data: string }) => void; ptyResize: (args: { id: string; cols: number; rows: number }) => void; diff --git a/src/test/main/ptyManager.modelFastMode.test.ts b/src/test/main/ptyManager.modelFastMode.test.ts new file mode 100644 index 000000000..643114c19 --- /dev/null +++ b/src/test/main/ptyManager.modelFastMode.test.ts @@ -0,0 +1,126 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +// --------------------------------------------------------------------------- +// Minimal mocks required by ptyManager (no PTY spawning needed for these tests) +// --------------------------------------------------------------------------- + +vi.mock('../../main/services/providerStatusCache', () => ({ + providerStatusCache: { get: vi.fn() }, +})); + +vi.mock('../../main/settings', () => ({ + getProviderCustomConfig: vi.fn().mockReturnValue(undefined), +})); + +vi.mock('../../main/lib/logger', () => ({ + log: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, +})); + +vi.mock('../../main/errorTracking', () => ({ + errorTracking: { captureAgentSpawnError: vi.fn(), captureCriticalError: vi.fn() }, +})); + +vi.mock('fs', () => { + const m = { + readFileSync: vi.fn(), + existsSync: vi.fn(), + writeFileSync: vi.fn(), + mkdirSync: vi.fn(), + statSync: vi.fn(() => { + throw new Error('ENOENT'); + }), + accessSync: vi.fn(), + readdirSync: vi.fn(), + constants: { X_OK: 1 }, + }; + return { ...m, default: m }; +}); + +vi.mock('electron', () => ({ app: { getPath: () => '/tmp/emdash-test' } })); +vi.mock('node-pty', () => ({ spawn: vi.fn() })); +vi.mock('node:child_process', () => ({ spawn: vi.fn() })); + +vi.mock('../../main/services/AgentEventService', () => ({ + agentEventService: { getPort: vi.fn(() => 0), getToken: vi.fn(() => '') }, +})); + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('buildProviderCliArgs — model via runtimeArgs', () => { + beforeEach(() => { + vi.resetModules(); + vi.clearAllMocks(); + }); + + it('includes --model when passed as runtimeArgs', async () => { + const { buildProviderCliArgs } = await import('../../main/services/ptyManager'); + + const args = buildProviderCliArgs({ + runtimeArgs: ['--model', 'claude-sonnet-4-6[1m]'], + }); + + expect(args).toEqual(['--model', 'claude-sonnet-4-6[1m]']); + }); + + it('model arg appears after defaultArgs', async () => { + const { buildProviderCliArgs } = await import('../../main/services/ptyManager'); + + const args = buildProviderCliArgs({ + defaultArgs: ['--verbose'], + runtimeArgs: ['--model', 'claude-haiku-4-5-20251001'], + }); + + expect(args.indexOf('--verbose')).toBeLessThan(args.indexOf('--model')); + }); + + it('model arg appears after autoApproveFlag', async () => { + const { buildProviderCliArgs } = await import('../../main/services/ptyManager'); + + const args = buildProviderCliArgs({ + autoApprove: true, + autoApproveFlag: '--dangerously-skip-permissions', + runtimeArgs: ['--model', 'claude-opus-4-6'], + }); + + expect(args.indexOf('--dangerously-skip-permissions')).toBeLessThan(args.indexOf('--model')); + }); +}); + +describe('resolveProviderCommandConfig — Claude model flag', () => { + beforeEach(() => { + vi.resetModules(); + vi.clearAllMocks(); + }); + + it('Claude provider has no fastFlag (--fast is not a real Claude Code CLI flag)', async () => { + const { resolveProviderCommandConfig } = await import('../../main/services/ptyManager'); + + const config = resolveProviderCommandConfig('claude'); + expect((config as any)?.fastFlag).toBeUndefined(); + }); + + it('resolves autoApproveFlag for Claude', async () => { + const { resolveProviderCommandConfig } = await import('../../main/services/ptyManager'); + + const config = resolveProviderCommandConfig('claude'); + expect(config?.autoApproveFlag).toBe('--dangerously-skip-permissions'); + }); +}); + +describe('provider registry — Claude model support', () => { + it('Claude provider has no fastFlag defined', async () => { + const { PROVIDERS } = await import('../../shared/providers/registry'); + + const claude = PROVIDERS.find((p) => p.id === 'claude'); + expect((claude as any)?.fastFlag).toBeUndefined(); + }); + + it('Claude provider supports --model via CLI (cli field is set)', async () => { + const { PROVIDERS } = await import('../../shared/providers/registry'); + + const claude = PROVIDERS.find((p) => p.id === 'claude'); + expect(claude?.cli).toBe('claude'); + }); +}); diff --git a/src/test/renderer/claudeModelSelect.test.ts b/src/test/renderer/claudeModelSelect.test.ts new file mode 100644 index 000000000..b6869eec2 --- /dev/null +++ b/src/test/renderer/claudeModelSelect.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest'; + +// The ClaudeModelSelect component is a straightforward Radix Select wrapper +// that uses a sentinel value for "Default (no --model flag)". +// These tests verify the sentinel round-trip so we can rely on it in +// integration without mounting a full React tree. + +const DEFAULT_MODEL_SENTINEL = '__model_default__'; + +function toSelectValue(model: string): string { + return model || DEFAULT_MODEL_SENTINEL; +} + +function fromSelectValue(v: string): string { + return v === DEFAULT_MODEL_SENTINEL ? '' : v; +} + +describe('ClaudeModelSelect sentinel round-trip', () => { + it('maps empty model to sentinel', () => { + expect(toSelectValue('')).toBe(DEFAULT_MODEL_SENTINEL); + }); + + it('maps a model ID to itself', () => { + expect(toSelectValue('claude-opus-4-6')).toBe('claude-opus-4-6'); + }); + + it('decodes sentinel back to empty string', () => { + expect(fromSelectValue(DEFAULT_MODEL_SENTINEL)).toBe(''); + }); + + it('decodes a model ID back to itself', () => { + expect(fromSelectValue('claude-sonnet-4-6')).toBe('claude-sonnet-4-6'); + }); + + it('round-trips correctly for all model IDs including 1M-context', () => { + const models = [ + '', + 'claude-opus-4-6', + 'claude-sonnet-4-6', + 'claude-sonnet-4-6[1m]', + 'claude-haiku-4-5-20251001', + ]; + for (const model of models) { + expect(fromSelectValue(toSelectValue(model))).toBe(model); + } + }); +}); diff --git a/src/test/renderer/taskCreationService.modelFastMode.test.tsx b/src/test/renderer/taskCreationService.modelFastMode.test.tsx new file mode 100644 index 000000000..ef84a784a --- /dev/null +++ b/src/test/renderer/taskCreationService.modelFastMode.test.tsx @@ -0,0 +1,133 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { AgentRun } from '../../renderer/types/chat'; +import type { Project } from '../../renderer/types/app'; + +// --------------------------------------------------------------------------- +// Mocks +// --------------------------------------------------------------------------- + +const saveTaskMock = vi.fn().mockResolvedValue(undefined); +const getOrCreateDefaultConversationMock = vi.fn().mockResolvedValue(null); + +vi.mock('../../renderer/lib/rpc', () => ({ + rpc: { + db: { + saveTask: saveTaskMock, + getOrCreateDefaultConversation: getOrCreateDefaultConversationMock, + }, + }, +})); + +vi.mock('../../renderer/lib/telemetryClient', () => ({ + captureTelemetry: vi.fn(), +})); + +vi.mock('../../renderer/lib/logger', () => ({ + log: { warn: vi.fn(), error: vi.fn(), info: vi.fn() }, +})); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const baseProject: Project = { + id: 'proj-1', + name: 'test-project', + path: '/tmp/test-project', + gitInfo: { branch: 'main', baseRef: 'main' }, + tasks: [], +} as unknown as Project; + +function makeParams(agentRuns: AgentRun[]) { + return { + project: baseProject, + taskName: 'test-task', + agentRuns, + linkedLinearIssue: null, + linkedGithubIssue: null, + linkedJiraIssue: null, + linkedPlainThread: null, + linkedGitlabIssue: null, + linkedForgejoIssue: null, + autoApprove: false, + useWorktree: false, // skip worktree API calls + }; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('taskCreationService — agentModel in metadata', () => { + beforeEach(() => { + vi.clearAllMocks(); + (window as any).electronAPI = { + lifecycleSetup: vi.fn().mockResolvedValue({ success: true }), + }; + }); + + it('stores agentModel in task metadata when a model is selected', async () => { + const { createTask } = await import('../../renderer/lib/taskCreationService'); + + await createTask(makeParams([{ agent: 'claude', runs: 1, model: 'claude-opus-4-6' }])); + + expect(saveTaskMock).toHaveBeenCalledWith( + expect.objectContaining({ + metadata: expect.objectContaining({ agentModel: 'claude-opus-4-6' }), + }) + ); + }); + + it('stores agentModel for 1M-context model', async () => { + const { createTask } = await import('../../renderer/lib/taskCreationService'); + + await createTask(makeParams([{ agent: 'claude', runs: 1, model: 'claude-sonnet-4-6[1m]' }])); + + expect(saveTaskMock).toHaveBeenCalledWith( + expect.objectContaining({ + metadata: expect.objectContaining({ agentModel: 'claude-sonnet-4-6[1m]' }), + }) + ); + }); + + it('metadata is null when no model and no other metadata', async () => { + const { createTask } = await import('../../renderer/lib/taskCreationService'); + + await createTask(makeParams([{ agent: 'claude', runs: 1 }])); + + expect(saveTaskMock).toHaveBeenCalledWith(expect.objectContaining({ metadata: null })); + }); + + it('records the correct agentId for non-Claude agents', async () => { + const { createTask } = await import('../../renderer/lib/taskCreationService'); + + await createTask(makeParams([{ agent: 'codex', runs: 1, model: 'gpt-5' } as AgentRun])); + + const call = saveTaskMock.mock.calls[0]?.[0]; + expect(call?.agentId).toBe('codex'); + }); +}); + +describe('taskCreationService — multi-agent metadata', () => { + beforeEach(() => { + vi.clearAllMocks(); + (window as any).electronAPI = { + lifecycleSetup: vi.fn().mockResolvedValue({ success: true }), + worktreeCreate: vi.fn().mockResolvedValue({ + success: true, + worktree: { id: 'wt-1', branch: 'feat', path: '/tmp/wt-1' }, + }), + worktreeRemove: vi.fn().mockResolvedValue({ success: true }), + }; + }); + + it('stores agentModel from first claude run in multi-agent metadata', async () => { + const { createTask } = await import('../../renderer/lib/taskCreationService'); + + // Two claude runs — total runs > 1 → multi-agent path + await createTask(makeParams([{ agent: 'claude', runs: 2, model: 'claude-opus-4-6' }])); + + const call = saveTaskMock.mock.calls[0]?.[0]; + expect(call?.metadata?.agentModel).toBe('claude-opus-4-6'); + }); +});