merge: resolve conflicts with upstream OpenCode support

- Combined CLI disconnection markers with OpenCode support
- Added OpenCode auth/deauth routes and API methods
- Resolved merge conflicts between feature branch and upstream v0.9.0rc
This commit is contained in:
DhanushSantosh
2026-01-09 22:25:46 +05:30
45 changed files with 6975 additions and 958 deletions

View File

@@ -0,0 +1,306 @@
import { Button } from '@/components/ui/button';
import { CheckCircle2, AlertCircle, RefreshCw, XCircle, Bot } from 'lucide-react';
import { cn } from '@/lib/utils';
import type { CliStatus } from '../shared/types';
export type OpencodeAuthMethod =
| 'api_key_env' // ANTHROPIC_API_KEY or other provider env vars
| 'api_key' // Manually stored API key
| 'oauth' // OAuth authentication
| 'config_file' // Config file with credentials
| 'none';
export interface OpencodeAuthStatus {
authenticated: boolean;
method: OpencodeAuthMethod;
hasApiKey?: boolean;
hasEnvApiKey?: boolean;
hasOAuthToken?: boolean;
error?: string;
}
function getAuthMethodLabel(method: OpencodeAuthMethod): string {
switch (method) {
case 'api_key':
return 'API Key';
case 'api_key_env':
return 'API Key (Environment)';
case 'oauth':
return 'OAuth Authentication';
case 'config_file':
return 'Configuration File';
default:
return method || 'Unknown';
}
}
interface OpencodeCliStatusProps {
status: CliStatus | null;
authStatus?: OpencodeAuthStatus | null;
isChecking: boolean;
onRefresh: () => void;
}
function SkeletonPulse({ className }: { className?: string }) {
return <div className={cn('animate-pulse bg-muted/50 rounded', className)} />;
}
export function OpencodeCliStatusSkeleton() {
return (
<div
className={cn(
'rounded-2xl overflow-hidden',
'border border-border/50',
'bg-gradient-to-br from-card/90 via-card/70 to-card/80 backdrop-blur-xl',
'shadow-sm shadow-black/5'
)}
>
<div className="p-6 border-b border-border/50 bg-gradient-to-r from-transparent via-accent/5 to-transparent">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-3">
<SkeletonPulse className="w-9 h-9 rounded-xl" />
<SkeletonPulse className="h-6 w-36" />
</div>
<SkeletonPulse className="w-9 h-9 rounded-lg" />
</div>
<div className="ml-12">
<SkeletonPulse className="h-4 w-80" />
</div>
</div>
<div className="p-6 space-y-4">
{/* Installation status skeleton */}
<div className="flex items-center gap-3 p-4 rounded-xl border border-border/30 bg-muted/10">
<SkeletonPulse className="w-10 h-10 rounded-xl" />
<div className="flex-1 space-y-2">
<SkeletonPulse className="h-4 w-40" />
<SkeletonPulse className="h-3 w-32" />
<SkeletonPulse className="h-3 w-48" />
</div>
</div>
{/* Auth status skeleton */}
<div className="flex items-center gap-3 p-4 rounded-xl border border-border/30 bg-muted/10">
<SkeletonPulse className="w-10 h-10 rounded-xl" />
<div className="flex-1 space-y-2">
<SkeletonPulse className="h-4 w-28" />
<SkeletonPulse className="h-3 w-36" />
</div>
</div>
</div>
</div>
);
}
export function OpencodeModelConfigSkeleton() {
return (
<div
className={cn(
'rounded-2xl overflow-hidden',
'border border-border/50',
'bg-gradient-to-br from-card/90 via-card/70 to-card/80 backdrop-blur-xl',
'shadow-sm shadow-black/5'
)}
>
<div className="p-6 border-b border-border/50 bg-gradient-to-r from-transparent via-accent/5 to-transparent">
<div className="flex items-center gap-3 mb-2">
<SkeletonPulse className="w-9 h-9 rounded-xl" />
<SkeletonPulse className="h-6 w-40" />
</div>
<div className="ml-12">
<SkeletonPulse className="h-4 w-72" />
</div>
</div>
<div className="p-6 space-y-6">
{/* Default Model skeleton */}
<div className="space-y-2">
<SkeletonPulse className="h-4 w-24" />
<SkeletonPulse className="h-10 w-full rounded-md" />
</div>
{/* Available Models skeleton */}
<div className="space-y-3">
<SkeletonPulse className="h-4 w-32" />
{/* Provider group skeleton */}
<div className="space-y-2">
<div className="flex items-center gap-2">
<SkeletonPulse className="w-4 h-4 rounded" />
<SkeletonPulse className="h-4 w-20" />
</div>
<div className="grid gap-2">
{[1, 2, 3].map((i) => (
<div
key={i}
className="flex items-center justify-between p-3 rounded-xl border border-border/30 bg-muted/10"
>
<div className="flex items-center gap-3">
<SkeletonPulse className="w-5 h-5 rounded" />
<div className="space-y-1.5">
<SkeletonPulse className="h-4 w-32" />
<SkeletonPulse className="h-3 w-48" />
</div>
</div>
<SkeletonPulse className="h-5 w-12 rounded-full" />
</div>
))}
</div>
</div>
</div>
</div>
</div>
);
}
export function OpencodeCliStatus({
status,
authStatus,
isChecking,
onRefresh,
}: OpencodeCliStatusProps) {
if (!status) return <OpencodeCliStatusSkeleton />;
return (
<div
className={cn(
'rounded-2xl overflow-hidden',
'border border-border/50',
'bg-gradient-to-br from-card/90 via-card/70 to-card/80 backdrop-blur-xl',
'shadow-sm shadow-black/5'
)}
>
<div className="p-6 border-b border-border/50 bg-gradient-to-r from-transparent via-accent/5 to-transparent">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-brand-500/20 to-brand-600/10 flex items-center justify-center border border-brand-500/20">
<Bot className="w-5 h-5 text-brand-500" />
</div>
<h2 className="text-lg font-semibold text-foreground tracking-tight">OpenCode CLI</h2>
</div>
<Button
variant="ghost"
size="icon"
onClick={onRefresh}
disabled={isChecking}
data-testid="refresh-opencode-cli"
title="Refresh OpenCode CLI detection"
className={cn(
'h-9 w-9 rounded-lg',
'hover:bg-accent/50 hover:scale-105',
'transition-all duration-200'
)}
>
<RefreshCw className={cn('w-4 h-4', isChecking && 'animate-spin')} />
</Button>
</div>
<p className="text-sm text-muted-foreground/80 ml-12">
OpenCode CLI provides multi-provider AI support with Claude, GPT, and Gemini models.
</p>
</div>
<div className="p-6 space-y-4">
{status.success && status.status === 'installed' ? (
<div className="space-y-3">
<div className="flex items-center gap-3 p-4 rounded-xl bg-emerald-500/10 border border-emerald-500/20">
<div className="w-10 h-10 rounded-xl bg-emerald-500/15 flex items-center justify-center border border-emerald-500/20 shrink-0">
<CheckCircle2 className="w-5 h-5 text-emerald-500" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-emerald-400">OpenCode CLI Installed</p>
<div className="text-xs text-emerald-400/70 mt-1.5 space-y-0.5">
{status.method && (
<p>
Method: <span className="font-mono">{status.method}</span>
</p>
)}
{status.version && (
<p>
Version: <span className="font-mono">{status.version}</span>
</p>
)}
{status.path && (
<p className="truncate" title={status.path}>
Path: <span className="font-mono text-[10px]">{status.path}</span>
</p>
)}
</div>
</div>
</div>
{/* Authentication Status */}
{authStatus?.authenticated ? (
<div className="flex items-center gap-3 p-4 rounded-xl bg-emerald-500/10 border border-emerald-500/20">
<div className="w-10 h-10 rounded-xl bg-emerald-500/15 flex items-center justify-center border border-emerald-500/20 shrink-0">
<CheckCircle2 className="w-5 h-5 text-emerald-500" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-emerald-400">Authenticated</p>
<div className="text-xs text-emerald-400/70 mt-1.5">
<p>
Method:{' '}
<span className="font-mono">{getAuthMethodLabel(authStatus.method)}</span>
</p>
</div>
</div>
</div>
) : (
<div className="flex items-start gap-3 p-4 rounded-xl bg-amber-500/10 border border-amber-500/20">
<div className="w-10 h-10 rounded-xl bg-amber-500/15 flex items-center justify-center border border-amber-500/20 shrink-0 mt-0.5">
<XCircle className="w-5 h-5 text-amber-500" />
</div>
<div className="flex-1">
<p className="text-sm font-medium text-amber-400">Not Authenticated</p>
<p className="text-xs text-amber-400/70 mt-1">
Run{' '}
<code className="font-mono bg-amber-500/10 px-1 rounded">opencode auth</code> or
set an API key to authenticate.
</p>
</div>
</div>
)}
{status.recommendation && (
<p className="text-xs text-muted-foreground/70 ml-1">{status.recommendation}</p>
)}
</div>
) : (
<div className="space-y-4">
<div className="flex items-start gap-3 p-4 rounded-xl bg-amber-500/10 border border-amber-500/20">
<div className="w-10 h-10 rounded-xl bg-amber-500/15 flex items-center justify-center border border-amber-500/20 shrink-0 mt-0.5">
<AlertCircle className="w-5 h-5 text-amber-500" />
</div>
<div className="flex-1">
<p className="text-sm font-medium text-amber-400">OpenCode CLI Not Detected</p>
<p className="text-xs text-amber-400/70 mt-1">
{status.recommendation || 'Install OpenCode CLI to use multi-provider AI models.'}
</p>
</div>
</div>
{status.installCommands && (
<div className="space-y-3">
<p className="text-xs font-medium text-foreground/80">Installation Commands:</p>
<div className="space-y-2">
{status.installCommands.npm && (
<div className="p-3 rounded-xl bg-accent/30 border border-border/50">
<p className="text-[10px] text-muted-foreground mb-1.5 font-medium uppercase tracking-wider">
npm
</p>
<code className="text-xs text-foreground/80 font-mono break-all">
{status.installCommands.npm}
</code>
</div>
)}
{status.installCommands.macos && (
<div className="p-3 rounded-xl bg-accent/30 border border-border/50">
<p className="text-[10px] text-muted-foreground mb-1.5 font-medium uppercase tracking-wider">
macOS/Linux
</p>
<code className="text-xs text-foreground/80 font-mono break-all">
{status.installCommands.macos}
</code>
</div>
)}
</div>
</div>
)}
</div>
)}
</div>
</div>
);
}

View File

@@ -14,6 +14,7 @@ import {
MessageSquareText,
User,
Shield,
Cpu,
} from 'lucide-react';
import { AnthropicIcon, CursorIcon, OpenAIIcon } from '@/components/ui/provider-icon';
import type { SettingsViewId } from '../hooks/use-settings-view';
@@ -41,6 +42,7 @@ export const GLOBAL_NAV_ITEMS: NavigationItem[] = [
{ id: 'claude-provider', label: 'Claude', icon: AnthropicIcon },
{ id: 'cursor-provider', label: 'Cursor', icon: CursorIcon },
{ id: 'codex-provider', label: 'Codex', icon: OpenAIIcon },
{ id: 'opencode-provider', label: 'OpenCode', icon: Cpu },
],
},
{ id: 'mcp-servers', label: 'MCP Servers', icon: Plug },

View File

@@ -7,6 +7,7 @@ export type SettingsViewId =
| 'claude-provider'
| 'cursor-provider'
| 'codex-provider'
| 'opencode-provider'
| 'mcp-servers'
| 'prompts'
| 'model-defaults'

View File

@@ -5,6 +5,7 @@ import type {
ModelAlias,
CursorModelId,
CodexModelId,
OpencodeModelId,
GroupedModel,
PhaseModelEntry,
ThinkingLevel,
@@ -23,13 +24,14 @@ import {
CLAUDE_MODELS,
CURSOR_MODELS,
CODEX_MODELS,
OPENCODE_MODELS,
THINKING_LEVELS,
THINKING_LEVEL_LABELS,
REASONING_EFFORT_LEVELS,
REASONING_EFFORT_LABELS,
} from '@/components/views/board-view/shared/model-constants';
import { Check, ChevronsUpDown, Star, ChevronRight } from 'lucide-react';
import { AnthropicIcon, CursorIcon, OpenAIIcon } from '@/components/ui/provider-icon';
import { AnthropicIcon, CursorIcon, OpenAIIcon, OpenCodeIcon } from '@/components/ui/provider-icon';
import { Button } from '@/components/ui/button';
import {
Command,
@@ -199,6 +201,10 @@ export function PhaseModelSelector({
const codexModel = CODEX_MODELS.find((m) => m.id === selectedModel);
if (codexModel) return { ...codexModel, icon: OpenAIIcon };
// Check OpenCode models
const opencodeModel = OPENCODE_MODELS.find((m) => m.id === selectedModel);
if (opencodeModel) return { ...opencodeModel, icon: OpenCodeIcon };
return null;
}, [selectedModel, selectedThinkingLevel, availableCursorModels]);
@@ -236,11 +242,12 @@ export function PhaseModelSelector({
}, [availableCursorModels, enabledCursorModels]);
// Group models
const { favorites, claude, cursor, codex } = React.useMemo(() => {
const { favorites, claude, cursor, codex, opencode } = React.useMemo(() => {
const favs: typeof CLAUDE_MODELS = [];
const cModels: typeof CLAUDE_MODELS = [];
const curModels: typeof CURSOR_MODELS = [];
const codModels: typeof CODEX_MODELS = [];
const ocModels: typeof OPENCODE_MODELS = [];
// Process Claude Models
CLAUDE_MODELS.forEach((model) => {
@@ -269,7 +276,22 @@ export function PhaseModelSelector({
}
});
return { favorites: favs, claude: cModels, cursor: curModels, codex: codModels };
// Process OpenCode Models
OPENCODE_MODELS.forEach((model) => {
if (favoriteModels.includes(model.id)) {
favs.push(model);
} else {
ocModels.push(model);
}
});
return {
favorites: favs,
claude: cModels,
cursor: curModels,
codex: codModels,
opencode: ocModels,
};
}, [favoriteModels, availableCursorModels]);
// Render Codex model item with secondary popover for reasoning effort (only for models that support it)
@@ -453,6 +475,64 @@ export function PhaseModelSelector({
);
};
// Render OpenCode model item (simple selector, no thinking/reasoning options)
const renderOpencodeModelItem = (model: (typeof OPENCODE_MODELS)[0]) => {
const isSelected = selectedModel === model.id;
const isFavorite = favoriteModels.includes(model.id);
return (
<CommandItem
key={model.id}
value={model.label}
onSelect={() => {
onChange({ model: model.id as OpencodeModelId });
setOpen(false);
}}
className="group flex items-center justify-between py-2"
>
<div className="flex items-center gap-3 overflow-hidden">
<OpenCodeIcon
className={cn(
'h-4 w-4 shrink-0',
isSelected ? 'text-primary' : 'text-muted-foreground'
)}
/>
<div className="flex flex-col truncate">
<span className={cn('truncate font-medium', isSelected && 'text-primary')}>
{model.label}
</span>
<span className="truncate text-xs text-muted-foreground">{model.description}</span>
</div>
</div>
<div className="flex items-center gap-1 ml-2">
{model.badge && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-muted text-muted-foreground mr-1">
{model.badge}
</span>
)}
<Button
variant="ghost"
size="icon"
className={cn(
'h-6 w-6 hover:bg-transparent hover:text-yellow-500 focus:ring-0',
isFavorite
? 'text-yellow-500 opacity-100'
: 'opacity-0 group-hover:opacity-100 text-muted-foreground'
)}
onClick={(e) => {
e.stopPropagation();
toggleFavoriteModel(model.id);
}}
>
<Star className={cn('h-3.5 w-3.5', isFavorite && 'fill-current')} />
</Button>
{isSelected && <Check className="h-4 w-4 text-primary shrink-0" />}
</div>
</CommandItem>
);
};
// Render Cursor model item (no thinking level needed)
const renderCursorModelItem = (model: (typeof CURSOR_MODELS)[0]) => {
const modelValue = stripProviderPrefix(model.id);
@@ -835,6 +915,10 @@ export function PhaseModelSelector({
if (model.provider === 'codex') {
return renderCodexModelItem(model);
}
// OpenCode model
if (model.provider === 'opencode') {
return renderOpencodeModelItem(model);
}
// Claude model
return renderClaudeModelItem(model);
});
@@ -864,6 +948,12 @@ export function PhaseModelSelector({
{codex.map((model) => renderCodexModelItem(model))}
</CommandGroup>
)}
{opencode.length > 0 && (
<CommandGroup heading="OpenCode Models">
{opencode.map((model) => renderOpencodeModelItem(model))}
</CommandGroup>
)}
</CommandList>
</Command>
</PopoverContent>

View File

@@ -8,10 +8,8 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Cpu } from 'lucide-react';
import { cn } from '@/lib/utils';
import type { CodexModelId } from '@automaker/types';
import { CODEX_MODEL_MAP } from '@automaker/types';
import { OpenAIIcon } from '@/components/ui/provider-icon';
interface CodexModelConfigurationProps {
@@ -160,17 +158,6 @@ export function CodexModelConfiguration({
);
}
function getModelDisplayName(modelId: string): string {
const displayNames: Record<string, string> = {
'codex-gpt-5.2-codex': 'GPT-5.2-Codex',
'codex-gpt-5.1-codex-max': 'GPT-5.1-Codex-Max',
'codex-gpt-5.1-codex-mini': 'GPT-5.1-Codex-Mini',
'codex-gpt-5.2': 'GPT-5.2',
'codex-gpt-5.1': 'GPT-5.1',
};
return displayNames[modelId] || modelId;
}
function supportsReasoningEffort(modelId: string): boolean {
const reasoningModels = [
'codex-gpt-5.2-codex',

View File

@@ -2,3 +2,4 @@ export { ProviderTabs } from './provider-tabs';
export { ClaudeSettingsTab } from './claude-settings-tab';
export { CursorSettingsTab } from './cursor-settings-tab';
export { CodexSettingsTab } from './codex-settings-tab';
export { OpencodeSettingsTab } from './opencode-settings-tab';

View File

@@ -0,0 +1,231 @@
import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
import { Checkbox } from '@/components/ui/checkbox';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Terminal, Cloud, Cpu, Brain, Sparkles, Zap } from 'lucide-react';
import { cn } from '@/lib/utils';
import type { OpencodeModelId, OpencodeProvider, OpencodeModelConfig } from '@automaker/types';
import { OPENCODE_MODELS, OPENCODE_MODEL_CONFIG_MAP } from '@automaker/types';
import { AnthropicIcon } from '@/components/ui/provider-icon';
import type { ComponentType } from 'react';
interface OpencodeModelConfigurationProps {
enabledOpencodeModels: OpencodeModelId[];
opencodeDefaultModel: OpencodeModelId;
isSaving: boolean;
onDefaultModelChange: (model: OpencodeModelId) => void;
onModelToggle: (model: OpencodeModelId, enabled: boolean) => void;
}
/**
* Returns the appropriate icon component for a given OpenCode provider
*/
function getProviderIcon(provider: OpencodeProvider): ComponentType<{ className?: string }> {
switch (provider) {
case 'opencode':
return Terminal;
case 'amazon-bedrock-anthropic':
return AnthropicIcon;
case 'amazon-bedrock-deepseek':
return Brain;
case 'amazon-bedrock-amazon':
return Cloud;
case 'amazon-bedrock-meta':
return Cpu;
case 'amazon-bedrock-mistral':
return Sparkles;
case 'amazon-bedrock-qwen':
return Zap;
default:
return Terminal;
}
}
/**
* Returns a formatted provider label for display
*/
function getProviderLabel(provider: OpencodeProvider): string {
switch (provider) {
case 'opencode':
return 'OpenCode (Free)';
case 'amazon-bedrock-anthropic':
return 'Claude (Bedrock)';
case 'amazon-bedrock-deepseek':
return 'DeepSeek (Bedrock)';
case 'amazon-bedrock-amazon':
return 'Amazon Nova (Bedrock)';
case 'amazon-bedrock-meta':
return 'Meta Llama (Bedrock)';
case 'amazon-bedrock-mistral':
return 'Mistral (Bedrock)';
case 'amazon-bedrock-qwen':
return 'Qwen (Bedrock)';
default:
return provider;
}
}
export function OpencodeModelConfiguration({
enabledOpencodeModels,
opencodeDefaultModel,
isSaving,
onDefaultModelChange,
onModelToggle,
}: OpencodeModelConfigurationProps) {
// Group models by provider for organized display
const modelsByProvider = OPENCODE_MODELS.reduce(
(acc, model) => {
if (!acc[model.provider]) {
acc[model.provider] = [];
}
acc[model.provider].push(model);
return acc;
},
{} as Record<OpencodeProvider, OpencodeModelConfig[]>
);
// Order: Free tier first, then Claude, then others
const providerOrder: OpencodeProvider[] = [
'opencode',
'amazon-bedrock-anthropic',
'amazon-bedrock-deepseek',
'amazon-bedrock-amazon',
'amazon-bedrock-meta',
'amazon-bedrock-mistral',
'amazon-bedrock-qwen',
];
return (
<div
className={cn(
'rounded-2xl overflow-hidden',
'border border-border/50',
'bg-gradient-to-br from-card/90 via-card/70 to-card/80 backdrop-blur-xl',
'shadow-sm shadow-black/5'
)}
>
<div className="p-6 border-b border-border/50 bg-gradient-to-r from-transparent via-accent/5 to-transparent">
<div className="flex items-center gap-3 mb-2">
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-brand-500/20 to-brand-600/10 flex items-center justify-center border border-brand-500/20">
<Terminal className="w-5 h-5 text-brand-500" />
</div>
<h2 className="text-lg font-semibold text-foreground tracking-tight">
Model Configuration
</h2>
</div>
<p className="text-sm text-muted-foreground/80 ml-12">
Configure which OpenCode models are available in the feature modal
</p>
</div>
<div className="p-6 space-y-6">
{/* Default Model Selection */}
<div className="space-y-2">
<Label>Default Model</Label>
<Select
value={opencodeDefaultModel}
onValueChange={(v) => onDefaultModelChange(v as OpencodeModelId)}
disabled={isSaving}
>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{enabledOpencodeModels.map((modelId) => {
const model = OPENCODE_MODEL_CONFIG_MAP[modelId];
if (!model) return null;
const ProviderIconComponent = getProviderIcon(model.provider);
return (
<SelectItem key={modelId} value={modelId}>
<div className="flex items-center gap-2">
<ProviderIconComponent className="w-4 h-4" />
<span>{model.label}</span>
</div>
</SelectItem>
);
})}
</SelectContent>
</Select>
</div>
{/* Available Models grouped by provider */}
<div className="space-y-4">
<Label>Available Models</Label>
{providerOrder.map((provider) => {
const models = modelsByProvider[provider];
if (!models || models.length === 0) return null;
const ProviderIconComponent = getProviderIcon(provider);
return (
<div key={provider} className="space-y-2">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<ProviderIconComponent className="w-4 h-4" />
<span className="font-medium">{getProviderLabel(provider)}</span>
{provider === 'opencode' && (
<Badge
variant="outline"
className="text-xs bg-green-500/10 text-green-500 border-green-500/30"
>
Free
</Badge>
)}
</div>
<div className="grid gap-2">
{models.map((model) => {
const isEnabled = enabledOpencodeModels.includes(model.id);
const isDefault = model.id === opencodeDefaultModel;
return (
<div
key={model.id}
className="flex items-center justify-between p-3 rounded-xl border border-border/50 bg-card/50 hover:bg-accent/30 transition-colors"
>
<div className="flex items-center gap-3">
<Checkbox
checked={isEnabled}
onCheckedChange={(checked) => onModelToggle(model.id, !!checked)}
disabled={isSaving || isDefault}
/>
<div>
<div className="flex items-center gap-2">
<span className="text-sm font-medium">{model.label}</span>
{model.supportsVision && (
<Badge variant="outline" className="text-xs">
Vision
</Badge>
)}
{model.tier === 'free' && (
<Badge
variant="outline"
className="text-xs bg-green-500/10 text-green-500 border-green-500/30"
>
Free
</Badge>
)}
{isDefault && (
<Badge variant="secondary" className="text-xs">
Default
</Badge>
)}
</div>
<p className="text-xs text-muted-foreground">{model.description}</p>
</div>
</div>
</div>
);
})}
</div>
</div>
);
})}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,180 @@
import { useState, useCallback, useEffect } from 'react';
import { toast } from 'sonner';
import { useAppStore } from '@/store/app-store';
import {
OpencodeCliStatus,
OpencodeCliStatusSkeleton,
OpencodeModelConfigSkeleton,
} from '../cli-status/opencode-cli-status';
import { OpencodeModelConfiguration } from './opencode-model-configuration';
import { getElectronAPI } from '@/lib/electron';
import { createLogger } from '@automaker/utils/logger';
import type { CliStatus as SharedCliStatus } from '../shared/types';
import type { OpencodeModelId } from '@automaker/types';
import type { OpencodeAuthStatus } from '../cli-status/opencode-cli-status';
const logger = createLogger('OpencodeSettings');
export function OpencodeSettingsTab() {
const {
enabledOpencodeModels,
opencodeDefaultModel,
setOpencodeDefaultModel,
toggleOpencodeModel,
} = useAppStore();
const [isCheckingOpencodeCli, setIsCheckingOpencodeCli] = useState(false);
const [isInitialLoading, setIsInitialLoading] = useState(true);
const [cliStatus, setCliStatus] = useState<SharedCliStatus | null>(null);
const [authStatus, setAuthStatus] = useState<OpencodeAuthStatus | null>(null);
const [isSaving, setIsSaving] = useState(false);
// Load OpenCode CLI status on mount
useEffect(() => {
const checkOpencodeStatus = async () => {
setIsCheckingOpencodeCli(true);
try {
const api = getElectronAPI();
if (api?.setup?.getOpencodeStatus) {
const result = await api.setup.getOpencodeStatus();
setCliStatus({
success: result.success,
status: result.installed ? 'installed' : 'not_installed',
method: result.auth?.method,
version: result.version,
path: result.path,
recommendation: result.recommendation,
installCommands: result.installCommands,
});
// Set auth status if available
if (result.auth) {
setAuthStatus({
authenticated: result.auth.authenticated,
method: (result.auth.method as OpencodeAuthStatus['method']) || 'none',
hasApiKey: result.auth.hasApiKey,
hasEnvApiKey: result.auth.hasEnvApiKey,
hasOAuthToken: result.auth.hasOAuthToken,
});
}
} else {
// Fallback for web mode or when API is not available
setCliStatus({
success: false,
status: 'not_installed',
recommendation: 'OpenCode CLI detection is only available in desktop mode.',
});
}
} catch (error) {
logger.error('Failed to check OpenCode CLI status:', error);
setCliStatus({
success: false,
status: 'not_installed',
error: error instanceof Error ? error.message : 'Unknown error',
});
} finally {
setIsCheckingOpencodeCli(false);
setIsInitialLoading(false);
}
};
checkOpencodeStatus();
}, []);
const handleRefreshOpencodeCli = useCallback(async () => {
setIsCheckingOpencodeCli(true);
try {
const api = getElectronAPI();
if (api?.setup?.getOpencodeStatus) {
const result = await api.setup.getOpencodeStatus();
setCliStatus({
success: result.success,
status: result.installed ? 'installed' : 'not_installed',
method: result.auth?.method,
version: result.version,
path: result.path,
recommendation: result.recommendation,
installCommands: result.installCommands,
});
// Update auth status if available
if (result.auth) {
setAuthStatus({
authenticated: result.auth.authenticated,
method: (result.auth.method as OpencodeAuthStatus['method']) || 'none',
hasApiKey: result.auth.hasApiKey,
hasEnvApiKey: result.auth.hasEnvApiKey,
hasOAuthToken: result.auth.hasOAuthToken,
});
}
}
} catch (error) {
logger.error('Failed to refresh OpenCode CLI status:', error);
toast.error('Failed to refresh OpenCode CLI status');
} finally {
setIsCheckingOpencodeCli(false);
}
}, []);
const handleDefaultModelChange = useCallback(
(model: OpencodeModelId) => {
setIsSaving(true);
try {
setOpencodeDefaultModel(model);
toast.success('Default model updated');
} catch (error) {
toast.error('Failed to update default model');
} finally {
setIsSaving(false);
}
},
[setOpencodeDefaultModel]
);
const handleModelToggle = useCallback(
(model: OpencodeModelId, enabled: boolean) => {
setIsSaving(true);
try {
toggleOpencodeModel(model, enabled);
} catch (error) {
toast.error('Failed to update models');
} finally {
setIsSaving(false);
}
},
[toggleOpencodeModel]
);
// Show loading skeleton during initial load
if (isInitialLoading) {
return (
<div className="space-y-6">
<OpencodeCliStatusSkeleton />
<OpencodeModelConfigSkeleton />
</div>
);
}
const isCliInstalled = cliStatus?.success && cliStatus?.status === 'installed';
return (
<div className="space-y-6">
<OpencodeCliStatus
status={cliStatus}
authStatus={authStatus}
isChecking={isCheckingOpencodeCli}
onRefresh={handleRefreshOpencodeCli}
/>
{/* Model Configuration - Only show when CLI is installed */}
{isCliInstalled && (
<OpencodeModelConfiguration
enabledOpencodeModels={enabledOpencodeModels}
opencodeDefaultModel={opencodeDefaultModel}
isSaving={isSaving}
onDefaultModelChange={handleDefaultModelChange}
onModelToggle={handleModelToggle}
/>
)}
</div>
);
}
export default OpencodeSettingsTab;

View File

@@ -1,18 +1,20 @@
import React from 'react';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { AnthropicIcon, CursorIcon, OpenAIIcon } from '@/components/ui/provider-icon';
import { Cpu } from 'lucide-react';
import { CursorSettingsTab } from './cursor-settings-tab';
import { ClaudeSettingsTab } from './claude-settings-tab';
import { CodexSettingsTab } from './codex-settings-tab';
import { OpencodeSettingsTab } from './opencode-settings-tab';
interface ProviderTabsProps {
defaultTab?: 'claude' | 'cursor' | 'codex';
defaultTab?: 'claude' | 'cursor' | 'codex' | 'opencode';
}
export function ProviderTabs({ defaultTab = 'claude' }: ProviderTabsProps) {
return (
<Tabs defaultValue={defaultTab} className="w-full">
<TabsList className="grid w-full grid-cols-3 mb-6">
<TabsList className="grid w-full grid-cols-4 mb-6">
<TabsTrigger value="claude" className="flex items-center gap-2">
<AnthropicIcon className="w-4 h-4" />
Claude
@@ -25,6 +27,10 @@ export function ProviderTabs({ defaultTab = 'claude' }: ProviderTabsProps) {
<OpenAIIcon className="w-4 h-4" />
Codex
</TabsTrigger>
<TabsTrigger value="opencode" className="flex items-center gap-2">
<Cpu className="w-4 h-4" />
OpenCode
</TabsTrigger>
</TabsList>
<TabsContent value="claude">
@@ -38,6 +44,10 @@ export function ProviderTabs({ defaultTab = 'claude' }: ProviderTabsProps) {
<TabsContent value="codex">
<CodexSettingsTab />
</TabsContent>
<TabsContent value="opencode">
<OpencodeSettingsTab />
</TabsContent>
</Tabs>
);
}