feat: add opencode support

This commit is contained in:
Haileyesus
2026-05-13 17:43:10 +03:00
parent 10f721cf14
commit 421bdd2f0f
53 changed files with 2691 additions and 130 deletions

View File

@@ -42,6 +42,7 @@ interface UseChatComposerStateArgs {
claudeModel: string;
codexModel: string;
geminiModel: string;
opencodeModel: string;
isLoading: boolean;
canAbortSession: boolean;
tokenBudget: Record<string, unknown> | null;
@@ -111,6 +112,7 @@ export function useChatComposerState({
claudeModel,
codexModel,
geminiModel,
opencodeModel,
isLoading,
canAbortSession,
tokenBudget,
@@ -285,7 +287,15 @@ export function useChatComposerState({
projectId: selectedProject.projectId,
sessionId: currentSessionId,
provider,
model: provider === 'cursor' ? cursorModel : provider === 'codex' ? codexModel : provider === 'gemini' ? geminiModel : claudeModel,
model: provider === 'cursor'
? cursorModel
: provider === 'codex'
? codexModel
: provider === 'gemini'
? geminiModel
: provider === 'opencode'
? opencodeModel
: claudeModel,
tokenUsage: tokenBudget,
};
@@ -337,6 +347,7 @@ export function useChatComposerState({
currentSessionId,
cursorModel,
geminiModel,
opencodeModel,
handleBuiltInCommand,
handleCustomCommand,
input,
@@ -577,6 +588,8 @@ export function useChatComposerState({
? 'codex-settings'
: provider === 'gemini'
? 'gemini-settings'
: provider === 'opencode'
? 'opencode-settings'
: 'claude-settings';
const savedSettings = safeLocalStorage.getItem(settingsKey);
if (savedSettings) {
@@ -644,6 +657,20 @@ export function useChatComposerState({
toolsSettings,
},
});
} else if (provider === 'opencode') {
sendMessage({
type: 'opencode-command',
command: messageContent,
sessionId: effectiveSessionId,
options: {
cwd: resolvedProjectPath,
projectPath: resolvedProjectPath,
sessionId: effectiveSessionId,
resume: Boolean(effectiveSessionId),
model: opencodeModel,
sessionSummary,
},
});
} else {
sendMessage({
type: 'claude-command',
@@ -686,6 +713,7 @@ export function useChatComposerState({
cursorModel,
executeCommand,
geminiModel,
opencodeModel,
isLoading,
onSessionActive,
onSessionProcessing,

View File

@@ -1,8 +1,15 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { authenticatedFetch } from '../../../utils/api';
import { CLAUDE_MODELS, CODEX_MODELS, CURSOR_MODELS, GEMINI_MODELS } from '../../../../shared/modelConstants';
import type { PendingPermissionRequest, PermissionMode } from '../types/types';
import type { ProjectSession, LLMProvider } from '../../../types/app';
import type { ProjectSession, LLMProvider, Project, ProviderModelsDefinition } from '../../../types/app';
const FALLBACK_DEFAULT_MODEL: Record<LLMProvider, string> = {
claude: 'opus',
cursor: 'gpt-5.3-codex',
codex: 'gpt-5.4',
gemini: 'gemini-3.1-pro-preview',
opencode: 'anthropic/claude-sonnet-4-5',
};
const getPermissionModesForProvider = (provider: LLMProvider): PermissionMode[] => {
if (provider === 'codex') {
@@ -11,34 +18,180 @@ const getPermissionModesForProvider = (provider: LLMProvider): PermissionMode[]
if (provider === 'claude') {
return ['default', 'auto', 'acceptEdits', 'bypassPermissions', 'plan'];
}
if (provider === 'opencode') {
return ['default'];
}
return ['default', 'acceptEdits', 'bypassPermissions', 'plan'];
};
interface UseChatProviderStateArgs {
selectedSession: ProjectSession | null;
selectedProject: Project | null;
}
export function useChatProviderState({ selectedSession }: UseChatProviderStateArgs) {
export function useChatProviderState({ selectedSession, selectedProject }: UseChatProviderStateArgs) {
const [permissionMode, setPermissionMode] = useState<PermissionMode>('default');
const [pendingPermissionRequests, setPendingPermissionRequests] = useState<PendingPermissionRequest[]>([]);
const [provider, setProvider] = useState<LLMProvider>(() => {
return (localStorage.getItem('selected-provider') as LLMProvider) || 'claude';
});
const [cursorModel, setCursorModel] = useState<string>(() => {
return localStorage.getItem('cursor-model') || CURSOR_MODELS.DEFAULT;
return localStorage.getItem('cursor-model') || FALLBACK_DEFAULT_MODEL.cursor;
});
const [claudeModel, setClaudeModel] = useState<string>(() => {
return localStorage.getItem('claude-model') || CLAUDE_MODELS.DEFAULT;
return localStorage.getItem('claude-model') || FALLBACK_DEFAULT_MODEL.claude;
});
const [codexModel, setCodexModel] = useState<string>(() => {
return localStorage.getItem('codex-model') || CODEX_MODELS.DEFAULT;
return localStorage.getItem('codex-model') || FALLBACK_DEFAULT_MODEL.codex;
});
const [geminiModel, setGeminiModel] = useState<string>(() => {
return localStorage.getItem('gemini-model') || GEMINI_MODELS.DEFAULT;
return localStorage.getItem('gemini-model') || FALLBACK_DEFAULT_MODEL.gemini;
});
const [opencodeModel, setOpenCodeModel] = useState<string>(() => {
return localStorage.getItem('opencode-model') || FALLBACK_DEFAULT_MODEL.opencode;
});
const [providerModelCatalog, setProviderModelCatalog] = useState<
Partial<Record<LLMProvider, ProviderModelsDefinition>>
>({});
const [providerModelsLoading, setProviderModelsLoading] = useState(true);
const lastProviderRef = useRef(provider);
const workspacePath = selectedProject?.fullPath || selectedProject?.path || '';
useEffect(() => {
let cancelled = false;
const providers: LLMProvider[] = ['claude', 'cursor', 'codex', 'gemini', 'opencode'];
const load = async () => {
setProviderModelsLoading(true);
try {
const results = await Promise.all(
providers.map(async (p) => {
const qs =
p === 'opencode' && workspacePath
? `?workspacePath=${encodeURIComponent(workspacePath)}`
: '';
const response = await authenticatedFetch(`/api/providers/${p}/models${qs}`);
const body = (await response.json()) as {
success?: boolean;
data?: { models?: ProviderModelsDefinition };
};
if (!body.success || !body.data?.models) {
return null;
}
return body.data.models;
}),
);
if (cancelled) {
return;
}
const next: Partial<Record<LLMProvider, ProviderModelsDefinition>> = {};
providers.forEach((p, i) => {
const entry = results[i];
if (entry) {
next[p] = entry;
}
});
setProviderModelCatalog(next);
} catch (error) {
console.error('Error loading provider models:', error);
} finally {
if (!cancelled) {
setProviderModelsLoading(false);
}
}
};
void load();
return () => {
cancelled = true;
};
}, [workspacePath]);
const pickStoredOrCurrent = (
storageKey: string,
current: string,
def: ProviderModelsDefinition,
): string => {
const stored = localStorage.getItem(storageKey);
if (stored && def.OPTIONS.some((o) => o.value === stored)) {
return stored;
}
if (current && def.OPTIONS.some((o) => o.value === current)) {
return current;
}
return def.DEFAULT;
};
useEffect(() => {
const claude = providerModelCatalog.claude;
if (claude) {
const next = pickStoredOrCurrent('claude-model', claudeModel, claude);
if (next !== claudeModel) {
setClaudeModel(next);
}
if (localStorage.getItem('claude-model') !== next) {
localStorage.setItem('claude-model', next);
}
}
}, [providerModelCatalog.claude, claudeModel]);
useEffect(() => {
const cursor = providerModelCatalog.cursor;
if (cursor) {
const next = pickStoredOrCurrent('cursor-model', cursorModel, cursor);
if (next !== cursorModel) {
setCursorModel(next);
}
if (localStorage.getItem('cursor-model') !== next) {
localStorage.setItem('cursor-model', next);
}
}
}, [providerModelCatalog.cursor, cursorModel]);
useEffect(() => {
const codex = providerModelCatalog.codex;
if (codex) {
const next = pickStoredOrCurrent('codex-model', codexModel, codex);
if (next !== codexModel) {
setCodexModel(next);
}
if (localStorage.getItem('codex-model') !== next) {
localStorage.setItem('codex-model', next);
}
}
}, [providerModelCatalog.codex, codexModel]);
useEffect(() => {
const gemini = providerModelCatalog.gemini;
if (gemini) {
const next = pickStoredOrCurrent('gemini-model', geminiModel, gemini);
if (next !== geminiModel) {
setGeminiModel(next);
}
if (localStorage.getItem('gemini-model') !== next) {
localStorage.setItem('gemini-model', next);
}
}
}, [providerModelCatalog.gemini, geminiModel]);
useEffect(() => {
const opencode = providerModelCatalog.opencode;
if (opencode) {
const next = pickStoredOrCurrent('opencode-model', opencodeModel, opencode);
if (next !== opencodeModel) {
setOpenCodeModel(next);
}
if (localStorage.getItem('opencode-model') !== next) {
localStorage.setItem('opencode-model', next);
}
}
}, [providerModelCatalog.opencode, opencodeModel]);
useEffect(() => {
if (!selectedSession?.id) {
return;
@@ -118,10 +271,14 @@ export function useChatProviderState({ selectedSession }: UseChatProviderStateAr
setCodexModel,
geminiModel,
setGeminiModel,
opencodeModel,
setOpenCodeModel,
permissionMode,
setPermissionMode,
pendingPermissionRequests,
setPendingPermissionRequests,
cyclePermissionMode,
providerModelCatalog,
providerModelsLoading,
};
}

View File

@@ -72,12 +72,17 @@ function ChatInterface({
setCodexModel,
geminiModel,
setGeminiModel,
opencodeModel,
setOpenCodeModel,
permissionMode,
pendingPermissionRequests,
setPendingPermissionRequests,
cyclePermissionMode,
providerModelCatalog,
providerModelsLoading,
} = useChatProviderState({
selectedSession,
selectedProject,
});
const {
@@ -182,6 +187,7 @@ function ChatInterface({
claudeModel,
codexModel,
geminiModel,
opencodeModel,
isLoading,
canAbortSession,
tokenBudget,
@@ -280,6 +286,8 @@ function ChatInterface({
? t('messageTypes.codex')
: provider === 'gemini'
? t('messageTypes.gemini')
: provider === 'opencode'
? t('messageTypes.opencode', { defaultValue: 'OpenCode' })
: t('messageTypes.claude');
return (
@@ -318,6 +326,10 @@ function ChatInterface({
setCodexModel={setCodexModel}
geminiModel={geminiModel}
setGeminiModel={setGeminiModel}
opencodeModel={opencodeModel}
setOpenCodeModel={setOpenCodeModel}
providerModelCatalog={providerModelCatalog}
providerModelsLoading={providerModelsLoading}
tasksEnabled={tasksEnabled}
isTaskMasterInstalled={isTaskMasterInstalled}
onShowAllTasks={onShowAllTasks}
@@ -406,6 +418,8 @@ function ChatInterface({
? t('messageTypes.codex')
: provider === 'gemini'
? t('messageTypes.gemini')
: provider === 'opencode'
? t('messageTypes.opencode', { defaultValue: 'OpenCode' })
: t('messageTypes.claude'),
})}
isTextareaExpanded={isTextareaExpanded}

View File

@@ -2,7 +2,7 @@ import { useTranslation } from 'react-i18next';
import { useCallback, useRef } from 'react';
import type { Dispatch, RefObject, SetStateAction } from 'react';
import type { ChatMessage } from '../../types/types';
import type { Project, ProjectSession, LLMProvider } from '../../../../types/app';
import type { Project, ProjectSession, LLMProvider, ProviderModelsDefinition } from '../../../../types/app';
import { getIntrinsicMessageKey } from '../../utils/messageKeys';
import MessageComponent from './MessageComponent';
import ProviderSelectionEmptyState from './ProviderSelectionEmptyState';
@@ -26,6 +26,10 @@ interface ChatMessagesPaneProps {
setCodexModel: (model: string) => void;
geminiModel: string;
setGeminiModel: (model: string) => void;
opencodeModel: string;
setOpenCodeModel: (model: string) => void;
providerModelCatalog: Partial<Record<LLMProvider, ProviderModelsDefinition>>;
providerModelsLoading: boolean;
tasksEnabled: boolean;
isTaskMasterInstalled: boolean | null;
onShowAllTasks?: (() => void) | null;
@@ -71,6 +75,10 @@ export default function ChatMessagesPane({
setCodexModel,
geminiModel,
setGeminiModel,
opencodeModel,
setOpenCodeModel,
providerModelCatalog,
providerModelsLoading,
tasksEnabled,
isTaskMasterInstalled,
onShowAllTasks,
@@ -154,6 +162,10 @@ export default function ChatMessagesPane({
setCodexModel={setCodexModel}
geminiModel={geminiModel}
setGeminiModel={setGeminiModel}
opencodeModel={opencodeModel}
setOpenCodeModel={setOpenCodeModel}
providerModelCatalog={providerModelCatalog}
providerModelsLoading={providerModelsLoading}
tasksEnabled={tasksEnabled}
isTaskMasterInstalled={isTaskMasterInstalled}
onShowAllTasks={onShowAllTasks}

View File

@@ -29,6 +29,7 @@ const PROVIDER_LABEL_KEYS: Record<string, string> = {
codex: 'messageTypes.codex',
cursor: 'messageTypes.cursor',
gemini: 'messageTypes.gemini',
opencode: 'messageTypes.opencode',
};
function formatElapsedTime(totalSeconds: number) {
@@ -126,4 +127,4 @@ export default function ClaudeStatus({
</div>
</div>
);
}
}

View File

@@ -176,7 +176,19 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, o
</div>
)}
<div className="text-sm font-medium text-gray-900 dark:text-white">
{message.type === 'error' ? t('messageTypes.error') : message.type === 'tool' ? t('messageTypes.tool') : (provider === 'cursor' ? t('messageTypes.cursor') : provider === 'codex' ? t('messageTypes.codex') : provider === 'gemini' ? t('messageTypes.gemini') : t('messageTypes.claude'))}
{message.type === 'error'
? t('messageTypes.error')
: message.type === 'tool'
? t('messageTypes.tool')
: (provider === 'cursor'
? t('messageTypes.cursor')
: provider === 'codex'
? t('messageTypes.codex')
: provider === 'gemini'
? t('messageTypes.gemini')
: provider === 'opencode'
? t('messageTypes.opencode', { defaultValue: 'OpenCode' })
: t('messageTypes.claude'))}
</div>
</div>
)}

View File

@@ -3,15 +3,8 @@ import { Check, ChevronDown } from "lucide-react";
import { Trans, useTranslation } from "react-i18next";
import { useServerPlatform } from "../../../../hooks/useServerPlatform";
import type { ProjectSession, LLMProvider, ProviderModelsDefinition } from "../../../../types/app";
import SessionProviderLogo from "../../../llm-logo-provider/SessionProviderLogo";
import {
CLAUDE_MODELS,
CURSOR_MODELS,
CODEX_MODELS,
GEMINI_MODELS,
PROVIDERS,
} from "../../../../../shared/modelConstants";
import type { ProjectSession, LLMProvider } from "../../../../types/app";
import { NextTaskBanner } from "../../../task-master";
import {
Dialog,
@@ -27,6 +20,14 @@ import {
Card,
} from "../../../../shared/view/ui";
const PROVIDER_META: { id: LLMProvider; name: string }[] = [
{ id: "claude", name: "Anthropic" },
{ id: "codex", name: "OpenAI" },
{ id: "gemini", name: "Google" },
{ id: "cursor", name: "Cursor" },
{ id: "opencode", name: "OpenCode" },
];
const MOD_KEY =
typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.platform) ? "⌘" : "Ctrl";
@@ -44,6 +45,10 @@ type ProviderSelectionEmptyStateProps = {
setCodexModel: (model: string) => void;
geminiModel: string;
setGeminiModel: (model: string) => void;
opencodeModel: string;
setOpenCodeModel: (model: string) => void;
providerModelCatalog: Partial<Record<LLMProvider, ProviderModelsDefinition>>;
providerModelsLoading: boolean;
tasksEnabled: boolean;
isTaskMasterInstalled: boolean | null;
onShowAllTasks?: (() => void) | null;
@@ -56,17 +61,12 @@ type ProviderGroup = {
models: { value: string; label: string }[];
};
const PROVIDER_GROUPS: ProviderGroup[] = PROVIDERS.map((p) => ({
id: p.id as LLMProvider,
name: p.name,
models: p.models.OPTIONS,
}));
function getModelConfig(p: LLMProvider) {
if (p === "claude") return CLAUDE_MODELS;
if (p === "codex") return CODEX_MODELS;
if (p === "gemini") return GEMINI_MODELS;
return CURSOR_MODELS;
function getModelConfig(
p: LLMProvider,
catalog: Partial<Record<LLMProvider, ProviderModelsDefinition>>,
): ProviderModelsDefinition {
const entry = catalog[p];
return entry ?? { OPTIONS: [], DEFAULT: "" };
}
function getCurrentModel(
@@ -75,10 +75,12 @@ function getCurrentModel(
cu: string,
co: string,
g: string,
o: string,
) {
if (p === "claude") return c;
if (p === "codex") return co;
if (p === "gemini") return g;
if (p === "opencode") return o;
return cu;
}
@@ -86,6 +88,7 @@ function getProviderDisplayName(p: LLMProvider) {
if (p === "claude") return "Claude";
if (p === "cursor") return "Cursor";
if (p === "codex") return "Codex";
if (p === "opencode") return "OpenCode";
return "Gemini";
}
@@ -103,6 +106,10 @@ export default function ProviderSelectionEmptyState({
setCodexModel,
geminiModel,
setGeminiModel,
opencodeModel,
setOpenCodeModel,
providerModelCatalog,
providerModelsLoading,
tasksEnabled,
isTaskMasterInstalled,
onShowAllTasks,
@@ -112,10 +119,14 @@ export default function ProviderSelectionEmptyState({
const { isWindowsServer } = useServerPlatform();
const [dialogOpen, setDialogOpen] = useState(false);
const visibleProviderGroups = useMemo(
() => (isWindowsServer ? PROVIDER_GROUPS.filter((p) => p.id !== "cursor") : PROVIDER_GROUPS),
[isWindowsServer],
);
const visibleProviderGroups = useMemo(() => {
const groups: ProviderGroup[] = PROVIDER_META.map((p) => ({
id: p.id,
name: p.name,
models: providerModelCatalog[p.id]?.OPTIONS ?? [],
}));
return isWindowsServer ? groups.filter((p) => p.id !== "cursor") : groups;
}, [isWindowsServer, providerModelCatalog]);
useEffect(() => {
if (isWindowsServer && provider === "cursor") {
@@ -134,15 +145,16 @@ export default function ProviderSelectionEmptyState({
cursorModel,
codexModel,
geminiModel,
opencodeModel,
);
const currentModelLabel = useMemo(() => {
const config = getModelConfig(provider);
const config = getModelConfig(provider, providerModelCatalog);
const found = config.OPTIONS.find(
(o: { value: string; label: string }) => o.value === currentModel,
);
return found?.label || currentModel;
}, [provider, currentModel]);
}, [provider, currentModel, providerModelCatalog]);
const setModelForProvider = useCallback(
(providerId: LLMProvider, modelValue: string) => {
@@ -155,12 +167,15 @@ export default function ProviderSelectionEmptyState({
} else if (providerId === "gemini") {
setGeminiModel(modelValue);
localStorage.setItem("gemini-model", modelValue);
} else if (providerId === "opencode") {
setOpenCodeModel(modelValue);
localStorage.setItem("opencode-model", modelValue);
} else {
setCursorModel(modelValue);
localStorage.setItem("cursor-model", modelValue);
}
},
[setClaudeModel, setCursorModel, setCodexModel, setGeminiModel],
[setClaudeModel, setCursorModel, setCodexModel, setGeminiModel, setOpenCodeModel],
);
const handleModelSelect = useCallback(
@@ -249,6 +264,11 @@ export default function ProviderSelectionEmptyState({
</span>
}
>
{group.models.length === 0 && providerModelsLoading ? (
<CommandItem disabled className="ml-4 border-l border-border/40 pl-4 text-muted-foreground">
{t("providerSelection.loadingModels", { defaultValue: "Loading models…" })}
</CommandItem>
) : null}
{group.models.map((model) => {
const isSelected = provider === group.id && currentModel === model.value;
return (
@@ -287,6 +307,10 @@ export default function ProviderSelectionEmptyState({
gemini: t("providerSelection.readyPrompt.gemini", {
model: geminiModel,
}),
opencode: t("providerSelection.readyPrompt.opencode", {
model: opencodeModel,
defaultValue: "Ready with OpenCode {{model}}",
}),
}[provider]
}
</p>

View File

@@ -14,6 +14,7 @@ interface SessionsResponse {
cursorSessions?: ProjectSession[];
codexSessions?: ProjectSession[];
geminiSessions?: ProjectSession[];
opencodeSessions?: ProjectSession[];
}
export function useSessionsSource(projectId: string | undefined, enabled: boolean) {
@@ -33,6 +34,7 @@ export function useSessionsSource(projectId: string | undefined, enabled: boolea
...(data.cursorSessions ?? []),
...(data.codexSessions ?? []),
...(data.geminiSessions ?? []),
...(data.opencodeSessions ?? []),
];
return all.map<SessionResult>((s) => ({
id: s.id,

View File

@@ -0,0 +1,25 @@
type OpenCodeLogoProps = {
className?: string;
};
const OpenCodeLogo = ({ className = 'w-5 h-5' }: OpenCodeLogoProps) => (
<svg
viewBox="0 0 24 24"
role="img"
aria-label="OpenCode"
className={className}
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<rect x="2.5" y="2.5" width="19" height="19" rx="4" className="fill-foreground" />
<path
d="M8.1 8.1 4.9 12l3.2 3.9M15.9 8.1l3.2 3.9-3.2 3.9M13.2 6.9l-2.4 10.2"
className="stroke-background"
strokeWidth="1.9"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
export default OpenCodeLogo;

View File

@@ -3,6 +3,7 @@ import ClaudeLogo from './ClaudeLogo';
import CodexLogo from './CodexLogo';
import CursorLogo from './CursorLogo';
import GeminiLogo from './GeminiLogo';
import OpenCodeLogo from './OpenCodeLogo';
type SessionProviderLogoProps = {
provider?: LLMProvider | string | null;
@@ -25,5 +26,9 @@ export default function SessionProviderLogo({
return <GeminiLogo className={className} />;
}
if (provider === 'opencode') {
return <OpenCodeLogo className={className} />;
}
return <ClaudeLogo className={className} />;
}

View File

@@ -5,6 +5,7 @@ export const MCP_PROVIDER_NAMES: Record<McpProvider, string> = {
cursor: 'Cursor',
codex: 'Codex',
gemini: 'Gemini',
opencode: 'OpenCode',
};
export const MCP_SUPPORTED_SCOPES: Record<McpProvider, McpScope[]> = {
@@ -12,6 +13,7 @@ export const MCP_SUPPORTED_SCOPES: Record<McpProvider, McpScope[]> = {
cursor: ['user', 'project'],
codex: ['user', 'project'],
gemini: ['user', 'project'],
opencode: ['user', 'project'],
};
export const MCP_SUPPORTED_TRANSPORTS: Record<McpProvider, McpTransport[]> = {
@@ -19,6 +21,7 @@ export const MCP_SUPPORTED_TRANSPORTS: Record<McpProvider, McpTransport[]> = {
cursor: ['stdio', 'http'],
codex: ['stdio', 'http'],
gemini: ['stdio', 'http', 'sse'],
opencode: ['stdio', 'http'],
};
export const MCP_GLOBAL_SUPPORTED_SCOPES: McpScope[] = ['user', 'project'];
@@ -30,6 +33,7 @@ export const MCP_PROVIDER_BUTTON_CLASSES: Record<McpProvider, string> = {
cursor: 'bg-purple-600 text-white hover:bg-purple-700',
codex: 'bg-gray-800 text-white hover:bg-gray-900 dark:bg-gray-700 dark:hover:bg-gray-600',
gemini: 'bg-blue-600 text-white hover:bg-blue-700',
opencode: 'bg-zinc-900 text-white hover:bg-zinc-800 dark:bg-zinc-700 dark:hover:bg-zinc-600',
};
export const MCP_SUPPORTS_WORKING_DIRECTORY: Record<McpProvider, boolean> = {
@@ -37,6 +41,7 @@ export const MCP_SUPPORTS_WORKING_DIRECTORY: Record<McpProvider, boolean> = {
cursor: false,
codex: true,
gemini: true,
opencode: false,
};
export const DEFAULT_MCP_FORM: McpFormState = {

View File

@@ -10,13 +10,14 @@ export type ProviderAuthStatus = {
export type ProviderAuthStatusMap = Record<LLMProvider, ProviderAuthStatus>;
export const CLI_PROVIDERS: LLMProvider[] = ['claude', 'cursor', 'codex', 'gemini'];
export const CLI_PROVIDERS: LLMProvider[] = ['claude', 'cursor', 'codex', 'gemini', 'opencode'];
export const PROVIDER_AUTH_STATUS_ENDPOINTS: Record<LLMProvider, string> = {
claude: '/api/providers/claude/auth/status',
cursor: '/api/providers/cursor/auth/status',
codex: '/api/providers/codex/auth/status',
gemini: '/api/providers/gemini/auth/status',
opencode: '/api/providers/opencode/auth/status',
};
export const createInitialProviderAuthStatusMap = (loading = true): ProviderAuthStatusMap => ({
@@ -24,4 +25,5 @@ export const createInitialProviderAuthStatusMap = (loading = true): ProviderAuth
cursor: { authenticated: false, email: null, method: null, error: null, loading },
codex: { authenticated: false, email: null, method: null, error: null, loading },
gemini: { authenticated: false, email: null, method: null, error: null, loading },
opencode: { authenticated: false, email: null, method: null, error: null, loading },
});

View File

@@ -37,6 +37,10 @@ const getProviderCommand = ({
return IS_PLATFORM ? 'codex login --device-auth' : 'codex login';
}
if (provider === 'opencode') {
return 'opencode auth login';
}
return 'gemini status';
};
@@ -44,6 +48,7 @@ const getProviderTitle = (provider: LLMProvider) => {
if (provider === 'claude') return 'Claude CLI Login';
if (provider === 'cursor') return 'Cursor CLI Login';
if (provider === 'codex') return 'Codex CLI Login';
if (provider === 'opencode') return 'OpenCode CLI Login';
return 'Gemini CLI Configuration';
};

View File

@@ -37,7 +37,7 @@ export const SETTINGS_MAIN_TABS: SettingsMainTabMeta[] = [
{ id: 'about', label: 'About', keywords: 'about version info', icon: Info },
];
export const AGENT_PROVIDERS: AgentProvider[] = ['claude', 'cursor', 'codex', 'gemini'];
export const AGENT_PROVIDERS: AgentProvider[] = ['claude', 'cursor', 'codex', 'gemini', 'opencode'];
export const AGENT_CATEGORIES: AgentCategory[] = ['account', 'permissions', 'mcp'];
export const DEFAULT_PROJECT_SORT_ORDER: ProjectSortOrder = 'name';

View File

@@ -12,7 +12,7 @@ type AgentListItemProps = {
type AgentConfig = {
name: string;
color: 'blue' | 'purple' | 'gray' | 'indigo';
color: 'blue' | 'purple' | 'gray' | 'indigo' | 'zinc';
};
const agentConfig: Record<AgentProvider, AgentConfig> = {
@@ -31,7 +31,11 @@ const agentConfig: Record<AgentProvider, AgentConfig> = {
gemini: {
name: 'Gemini',
color: 'indigo',
}
},
opencode: {
name: 'OpenCode',
color: 'zinc',
},
};
const colorClasses = {
@@ -47,6 +51,9 @@ const colorClasses = {
indigo: {
dot: 'bg-indigo-500',
},
zinc: {
dot: 'bg-zinc-500',
},
} as const;
export default function AgentListItem({

View File

@@ -26,7 +26,7 @@ export default function AgentsSettingsTab({
const { isWindowsServer } = useServerPlatform();
const visibleAgents = useMemo<AgentProvider[]>(() => {
const all: AgentProvider[] = ['claude', 'cursor', 'codex', 'gemini'];
const all: AgentProvider[] = ['claude', 'cursor', 'codex', 'gemini', 'opencode'];
if (isWindowsServer) {
return all.filter((id) => id !== 'cursor');
}
@@ -57,12 +57,17 @@ export default function AgentsSettingsTab({
authStatus: providerAuthStatus.gemini,
onLogin: () => onProviderLogin('gemini'),
},
opencode: {
authStatus: providerAuthStatus.opencode,
onLogin: () => onProviderLogin('opencode'),
},
}), [
onProviderLogin,
providerAuthStatus.claude,
providerAuthStatus.codex,
providerAuthStatus.cursor,
providerAuthStatus.gemini,
providerAuthStatus.opencode,
]);
return (

View File

@@ -8,6 +8,7 @@ const AGENT_NAMES: Record<AgentProvider, string> = {
cursor: 'Cursor',
codex: 'Codex',
gemini: 'Gemini',
opencode: 'OpenCode',
};
export default function AgentSelectorSection({
@@ -23,7 +24,8 @@ export default function AgentSelectorSection({
const dotColor =
agent === 'claude' ? 'bg-blue-500' :
agent === 'cursor' ? 'bg-purple-500' :
agent === 'gemini' ? 'bg-indigo-500' : 'bg-foreground/60';
agent === 'gemini' ? 'bg-indigo-500' :
agent === 'opencode' ? 'bg-zinc-500' : 'bg-foreground/60';
return (
<Pill

View File

@@ -54,6 +54,15 @@ const agentConfig: Record<AgentProvider, AgentVisualConfig> = {
subtextClass: 'text-indigo-700 dark:text-indigo-300',
buttonClass: 'bg-indigo-600 hover:bg-indigo-700 active:bg-indigo-800',
},
opencode: {
name: 'OpenCode',
description: 'OpenCode CLI assistant',
bgClass: 'bg-zinc-50 dark:bg-zinc-900/20',
borderClass: 'border-zinc-200 dark:border-zinc-700',
textClass: 'text-zinc-900 dark:text-zinc-100',
subtextClass: 'text-zinc-700 dark:text-zinc-300',
buttonClass: 'bg-zinc-900 hover:bg-zinc-800 active:bg-zinc-950 dark:bg-zinc-700 dark:hover:bg-zinc-600',
},
};
export default function AccountContent({ agent, authStatus, onLogin }: AccountContentProps) {
@@ -66,7 +75,11 @@ export default function AccountContent({ agent, authStatus, onLogin }: AccountCo
<SessionProviderLogo provider={agent} className="h-6 w-6" />
<div>
<h3 className="text-lg font-medium text-foreground">{config.name}</h3>
<p className="text-sm text-muted-foreground">{t(`agents.account.${agent}.description`)}</p>
<p className="text-sm text-muted-foreground">
{t(`agents.account.${agent}.description`, {
defaultValue: config.description || `${config.name} CLI assistant`,
})}
</p>
</div>
</div>

View File

@@ -62,6 +62,7 @@ export type SessionViewModel = {
isCursorSession: boolean;
isCodexSession: boolean;
isGeminiSession: boolean;
isOpenCodeSession: boolean;
isActive: boolean;
sessionName: string;
sessionTime: string;

View File

@@ -85,6 +85,7 @@ export const createSessionViewModel = (
isCursorSession: session.__provider === 'cursor',
isCodexSession: session.__provider === 'codex',
isGeminiSession: session.__provider === 'gemini',
isOpenCodeSession: session.__provider === 'opencode',
isActive: diffInMinutes < 10,
sessionName: getSessionName(session, t),
sessionTime: getSessionTime(session),
@@ -113,7 +114,12 @@ export const getAllSessions = (project: Project): SessionWithProvider[] => {
__provider: 'gemini' as const,
}));
return [...claudeSessions, ...cursorSessions, ...codexSessions, ...geminiSessions].sort(
const opencodeSessions = (project.opencodeSessions || []).map((session) => ({
...session,
__provider: 'opencode' as const,
}));
return [...claudeSessions, ...cursorSessions, ...codexSessions, ...geminiSessions, ...opencodeSessions].sort(
(a, b) => getSessionDate(b).getTime() - getSessionDate(a).getTime(),
);
};

View File

@@ -61,7 +61,8 @@ const projectsHaveChanges = (
return (
serialize(nextProject.cursorSessions) !== serialize(prevProject.cursorSessions) ||
serialize(nextProject.codexSessions) !== serialize(prevProject.codexSessions) ||
serialize(nextProject.geminiSessions) !== serialize(prevProject.geminiSessions)
serialize(nextProject.geminiSessions) !== serialize(prevProject.geminiSessions) ||
serialize(nextProject.opencodeSessions) !== serialize(prevProject.opencodeSessions)
);
});
};
@@ -98,6 +99,7 @@ const getProjectSessions = (project: Project): ProjectSession[] => {
...(project.codexSessions ?? []),
...(project.cursorSessions ?? []),
...(project.geminiSessions ?? []),
...(project.opencodeSessions ?? []),
];
};
@@ -145,6 +147,7 @@ const mergeExpandedSessionPages = (previousProjects: Project[], incomingProjects
cursorSessions: mergeSessionProviderLists(incomingProject.cursorSessions ?? [], previousProject.cursorSessions ?? []),
codexSessions: mergeSessionProviderLists(incomingProject.codexSessions ?? [], previousProject.codexSessions ?? []),
geminiSessions: mergeSessionProviderLists(incomingProject.geminiSessions ?? [], previousProject.geminiSessions ?? []),
opencodeSessions: mergeSessionProviderLists(incomingProject.opencodeSessions ?? [], previousProject.opencodeSessions ?? []),
};
const totalSessions = Number(incomingProject.sessionMeta?.total ?? previousLoadedCount);
@@ -160,7 +163,7 @@ const mergeExpandedSessionPages = (previousProjects: Project[], incomingProjects
const mergeProjectSessionPage = (
existingProject: Project,
sessionsPage: Pick<Project, 'sessions' | 'cursorSessions' | 'codexSessions' | 'geminiSessions' | 'sessionMeta'>,
sessionsPage: Pick<Project, 'sessions' | 'cursorSessions' | 'codexSessions' | 'geminiSessions' | 'opencodeSessions' | 'sessionMeta'>,
): Project => {
const mergedProject: Project = {
...existingProject,
@@ -168,6 +171,7 @@ const mergeProjectSessionPage = (
cursorSessions: mergeSessionProviderLists(existingProject.cursorSessions ?? [], sessionsPage.cursorSessions ?? []),
codexSessions: mergeSessionProviderLists(existingProject.codexSessions ?? [], sessionsPage.codexSessions ?? []),
geminiSessions: mergeSessionProviderLists(existingProject.geminiSessions ?? [], sessionsPage.geminiSessions ?? []),
opencodeSessions: mergeSessionProviderLists(existingProject.opencodeSessions ?? [], sessionsPage.opencodeSessions ?? []),
};
const totalSessions = Number(sessionsPage.sessionMeta?.total ?? existingProject.sessionMeta?.total ?? 0);
@@ -555,6 +559,21 @@ export function useProjectsState({
}
return;
}
const opencodeSession = project.opencodeSessions?.find((session) => session.id === sessionId);
if (opencodeSession) {
const shouldUpdateProject = selectedProject?.projectId !== project.projectId;
const shouldUpdateSession =
selectedSession?.id !== sessionId || selectedSession.__provider !== 'opencode';
if (shouldUpdateProject) {
setSelectedProject(project);
}
if (shouldUpdateSession) {
setSelectedSession({ ...opencodeSession, __provider: 'opencode' });
}
return;
}
}
// Session id is in the URL but not yet present on any project payload (common
@@ -583,6 +602,8 @@ export function useProjectsState({
? 'codex'
: providerFromStorage === 'gemini'
? 'gemini'
: providerFromStorage === 'opencode'
? 'opencode'
: 'claude';
setSelectedSession({
@@ -665,12 +686,14 @@ export function useProjectsState({
const cursorSessions = project.cursorSessions?.filter((session) => session.id !== sessionIdToDelete) ?? [];
const codexSessions = project.codexSessions?.filter((session) => session.id !== sessionIdToDelete) ?? [];
const geminiSessions = project.geminiSessions?.filter((session) => session.id !== sessionIdToDelete) ?? [];
const opencodeSessions = project.opencodeSessions?.filter((session) => session.id !== sessionIdToDelete) ?? [];
const removedFromProject = (
sessions.length !== (project.sessions?.length ?? 0)
|| cursorSessions.length !== (project.cursorSessions?.length ?? 0)
|| codexSessions.length !== (project.codexSessions?.length ?? 0)
|| geminiSessions.length !== (project.geminiSessions?.length ?? 0)
|| opencodeSessions.length !== (project.opencodeSessions?.length ?? 0)
);
if (!removedFromProject) {
@@ -683,6 +706,7 @@ export function useProjectsState({
cursorSessions,
codexSessions,
geminiSessions,
opencodeSessions,
};
const totalSessions = Math.max(0, Number(project.sessionMeta?.total ?? 0) - 1);
@@ -776,7 +800,7 @@ export function useProjectsState({
throw new Error(message);
}
const sessionsPage = (await response.json()) as Pick<Project, 'sessions' | 'cursorSessions' | 'codexSessions' | 'geminiSessions' | 'sessionMeta'>;
const sessionsPage = (await response.json()) as Pick<Project, 'sessions' | 'cursorSessions' | 'codexSessions' | 'geminiSessions' | 'opencodeSessions' | 'sessionMeta'>;
let mergedProjectForSelection: Project | null = null;
setProjects((previousProjects) =>

View File

@@ -18,7 +18,8 @@
"claude": "Claude",
"cursor": "Cursor",
"codex": "Codex",
"gemini": "Gemini"
"gemini": "Gemini",
"opencode": "OpenCode"
},
"tools": {
"settings": "Tool Settings",
@@ -189,6 +190,7 @@
"cursor": "Ready to use Cursor with {{model}}. Start typing your message below.",
"codex": "Ready to use Codex with {{model}}. Start typing your message below.",
"gemini": "Ready to use Gemini with {{model}}. Start typing your message below.",
"opencode": "Ready to use OpenCode with {{model}}. Start typing your message below.",
"default": "Select a provider above to begin"
},
"pressToSearch": "Press <kbd>{{shortcut}}</kbd> to search sessions, files, and commits"

View File

@@ -322,6 +322,9 @@
},
"gemini": {
"description": "Google Gemini AI assistant"
},
"opencode": {
"description": "OpenCode CLI assistant"
}
},
"connectionStatus": "Connection Status",
@@ -416,7 +419,8 @@
"description": {
"claude": "Model Context Protocol servers provide additional tools and data sources to Claude",
"cursor": "Model Context Protocol servers provide additional tools and data sources to Cursor",
"codex": "Model Context Protocol servers provide additional tools and data sources to Codex"
"codex": "Model Context Protocol servers provide additional tools and data sources to Codex",
"opencode": "Model Context Protocol servers provide additional tools and data sources to OpenCode"
},
"addButton": "Add MCP Server",
"empty": "No MCP servers configured",

View File

@@ -1,4 +1,14 @@
export type LLMProvider = 'claude' | 'cursor' | 'codex' | 'gemini';
export type LLMProvider = 'claude' | 'cursor' | 'codex' | 'gemini' | 'opencode';
export type ProviderModelOption = {
value: string;
label: string;
};
export type ProviderModelsDefinition = {
OPTIONS: ProviderModelOption[];
DEFAULT: string;
};
export type AppTab = 'chat' | 'files' | 'shell' | 'git' | 'tasks' | 'preview' | `plugin:${string}`;
@@ -46,6 +56,7 @@ export interface Project {
cursorSessions?: ProjectSession[];
codexSessions?: ProjectSession[];
geminiSessions?: ProjectSession[];
opencodeSessions?: ProjectSession[];
sessionMeta?: ProjectSessionMeta;
taskmaster?: ProjectTaskmasterInfo;
[key: string]: unknown;